This commit is contained in:
pj committed 2026-08-28 09:22:58 +05:30
commit 852cba1840
252 files changed
+55050

No files matched your search

+597
View File
@@ -0,0 +1,597 @@
// The filesystem layer is the only part of this app that can lose somebody's work, so the tests
// here are about the promises rather than the plumbing: a failed write leaves the old file whole,
// a conflict writes nothing at all, a name is never taken from a file that already has it, a path
// from the frontend cannot reach outside the folders the user opened, and a delete is always
// something Finder can undo.
use std::collections::BTreeSet;
use std::fs;
use std::os::unix::fs::{symlink, PermissionsExt};
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::{SystemTime, UNIX_EPOCH};
use margin_docs_lib::dto::FileNode;
use margin_docs_lib::fs::{
atomic_write, create_file, create_folder, duplicate_entry, free_path, move_entry, read_document,
rename_entry, resolve_in_roots, root_id_for, scan_tree, trash_entry, write_asset,
write_document,
};
use tempfile::TempDir;
fn root() -> TempDir {
TempDir::new().expect("a temp dir")
}
fn write(path: &Path, text: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, text).unwrap();
}
fn names(node: &FileNode) -> Vec<String> {
node.children.iter().map(|c| c.name.clone()).collect()
}
fn find<'a>(node: &'a FileNode, name: &str) -> Option<&'a FileNode> {
node.children.iter().find(|c| c.name == name)
}
fn flat(node: &FileNode, into: &mut Vec<String>) {
into.push(node.name.clone());
for child in &node.children {
flat(child, into);
}
}
#[test]
fn a_failed_write_leaves_the_original_whole() {
let dir = root();
let doc = dir.path().join("doc.md");
write(&doc, "the original");
// A folder that cannot be written to makes the write fail at its first step, which is the
// moment the original is most at risk. The temp name cannot be blocked from outside any more,
// since it is unique per call, so the folder is what gets taken away instead.
let open = fs::metadata(dir.path()).unwrap().permissions();
fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o500)).unwrap();
let result = atomic_write(&doc, b"the replacement");
fs::set_permissions(dir.path(), open).unwrap();
assert!(result.is_err());
assert_eq!(fs::read_to_string(&doc).unwrap(), "the original");
}
/// A `.bak` or a `.tmp` named after a document is a file somebody may have written on purpose, and
/// a save of the document it is named after is not permission to unlink it.
#[test]
fn a_write_leaves_the_users_own_bak_and_tmp_siblings_alone() {
let dir = root();
let doc = dir.path().join("doc.md");
let bak = dir.path().join("doc.md.bak");
let tmp = dir.path().join("doc.md.tmp");
write(&doc, "one");
write(&bak, "a revision the author kept on purpose");
write(&tmp, "a scratch file the author kept on purpose");
atomic_write(&doc, b"two").unwrap();
assert_eq!(fs::read_to_string(&doc).unwrap(), "two");
assert_eq!(
fs::read_to_string(&bak).unwrap(),
"a revision the author kept on purpose",
"the save deleted a backup the user owned"
);
assert_eq!(
fs::read_to_string(&tmp).unwrap(),
"a scratch file the author kept on purpose",
"the save deleted a scratch file the user owned"
);
}
/// Nothing named after the document may appear beside it, not even for the length of one save.
/// Anything else watching the folder, git included, sees whatever is there while the write runs, and
/// a crash halfway through strands it for good.
#[test]
fn a_save_puts_nothing_document_shaped_beside_the_document() {
let dir = root();
let doc = dir.path().join("doc.md");
write(&doc, "one");
let watched = dir.path().to_path_buf();
let (stop_tx, stop_rx) = mpsc::channel::<()>();
let poller = std::thread::spawn(move || {
let mut seen: BTreeSet<String> = BTreeSet::new();
while stop_rx.try_recv().is_err() {
if let Ok(entries) = fs::read_dir(&watched) {
for entry in entries.flatten() {
seen.insert(entry.file_name().to_string_lossy().into_owned());
}
}
}
seen
});
// Big enough that the copy, the write and the fsync are a window a poller can see into.
atomic_write(&doc, &vec![b'z'; 32 * 1024 * 1024]).unwrap();
stop_tx.send(()).ok();
let seen = poller.join().unwrap();
let strays: Vec<&String> = seen
.iter()
.filter(|name| name.as_str() != "doc.md")
.filter(|name| !(name.starts_with(".doc.md.") && name.ends_with(".tmp")))
.collect();
assert!(strays.is_empty(), "a save put these beside the document: {strays:?}");
assert!(
seen.iter().any(|name| name.starts_with(".doc.md.")),
"the poller never caught the temp file, so this proved nothing: {seen:?}"
);
let left: Vec<String> = fs::read_dir(dir.path())
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(left, vec!["doc.md".to_string()]);
}
/// A debounced autosave and a Cmd+S both in flight are two saves of one document. Naming the temp
/// file after the target made them race on one path, and the loser took the document with it.
#[test]
fn concurrent_saves_of_one_document_all_land_and_none_loses_it() {
let dir = root();
let doc = dir.path().join("doc.md");
write(&doc, "the original");
let payloads: Vec<String> = (0..4)
.map(|n| format!("save number {n}\n{}\n", "z".repeat(1024 * 1024)))
.collect();
let writers: Vec<_> = payloads
.iter()
.cloned()
.map(|text| {
let doc = doc.clone();
std::thread::spawn(move || atomic_write(&doc, text.as_bytes()))
})
.collect();
for writer in writers {
writer
.join()
.expect("a writer thread")
.expect("every concurrent save succeeds");
}
let landed = fs::read_to_string(&doc).expect("the document is still there");
assert!(
payloads.contains(&landed),
"the document holds neither writer's text"
);
let left: Vec<String> = fs::read_dir(dir.path())
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(left, vec!["doc.md".to_string()]);
}
#[test]
fn a_write_leaves_no_temp_and_no_backup_behind() {
let dir = root();
let doc = dir.path().join("doc.md");
write(&doc, "one");
atomic_write(&doc, b"two").unwrap();
assert_eq!(fs::read_to_string(&doc).unwrap(), "two");
let mut left: Vec<String> = fs::read_dir(dir.path())
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
left.sort();
assert_eq!(left, vec!["doc.md".to_string()]);
}
#[test]
fn a_write_keeps_the_permissions_the_file_had() {
let dir = root();
let doc = dir.path().join("doc.md");
write(&doc, "one");
fs::set_permissions(&doc, fs::Permissions::from_mode(0o600)).unwrap();
atomic_write(&doc, b"two").unwrap();
let mode = fs::metadata(&doc).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
#[test]
fn a_conflict_writes_nothing() {
let dir = root();
let doc = dir.path().join("doc.md");
write(&doc, "what is on disk");
let seen = read_document(&doc).unwrap();
let result = write_document(&doc, "what the buffer holds", Some(seen.modified_ms - 5_000))
.expect("a conflict is a result and not an error");
assert!(result.conflict);
assert_eq!(fs::read_to_string(&doc).unwrap(), "what is on disk");
}
#[test]
fn the_timestamp_the_caller_saw_lets_the_write_through() {
let dir = root();
let doc = dir.path().join("doc.md");
write(&doc, "one");
let seen = read_document(&doc).unwrap();
let result = write_document(&doc, "two", Some(seen.modified_ms)).unwrap();
assert!(!result.conflict);
assert_eq!(fs::read_to_string(&doc).unwrap(), "two");
}
#[test]
fn reading_a_document_writes_nothing() {
let dir = root();
let doc = dir.path().join("doc.md");
write(&doc, "# Heading\n");
let before = fs::metadata(&doc).unwrap().modified().unwrap();
let read = read_document(&doc).unwrap();
assert_eq!(read.text, "# Heading\n");
assert_eq!(fs::metadata(&doc).unwrap().modified().unwrap(), before);
assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1);
}
#[test]
fn a_document_that_is_not_utf8_is_an_error_and_not_a_lossy_read() {
let dir = root();
let doc = dir.path().join("doc.md");
fs::write(&doc, [0xff, 0xfe, 0x00, 0x41]).unwrap();
assert!(read_document(&doc).is_err());
}
#[test]
fn untitled_naming_does_not_collide() {
let dir = root();
let first = create_file(dir.path(), "untitled.md").unwrap();
let second = create_file(dir.path(), "untitled.md").unwrap();
let third = create_file(dir.path(), "untitled.md").unwrap();
assert_eq!(first.name, "untitled.md");
assert_eq!(second.name, "untitled-2.md");
assert_eq!(third.name, "untitled-3.md");
assert!(dir.path().join("untitled.md").exists());
assert!(dir.path().join("untitled-2.md").exists());
assert!(dir.path().join("untitled-3.md").exists());
assert!(first.editable);
assert_eq!(first.kind, "markdown");
}
#[test]
fn a_free_name_never_lands_on_a_file_that_is_there() {
let dir = root();
write(&dir.path().join("note.md"), "one");
write(&dir.path().join("note-2.md"), "two");
assert_eq!(free_path(dir.path(), "note.md"), dir.path().join("note-3.md"));
}
#[test]
fn a_new_folder_follows_the_same_rule() {
let dir = root();
let first = create_folder(dir.path(), "untitled").unwrap();
let second = create_folder(dir.path(), "untitled").unwrap();
assert_eq!(first.name, "untitled");
assert_eq!(first.kind, "dir");
assert!(!first.editable);
assert_eq!(second.name, "untitled-2");
}
#[test]
fn the_tree_skips_node_modules_and_the_rest() {
let dir = root();
write(&dir.path().join("note.md"), "note");
write(&dir.path().join("node_modules/left-pad/index.js"), "js");
write(&dir.path().join(".git/config"), "config");
write(&dir.path().join("target/debug/thing"), "binary");
write(&dir.path().join("dist/bundle.js"), "bundle");
let tree = scan_tree(dir.path(), false).unwrap();
assert_eq!(names(&tree), vec!["note.md".to_string()]);
}
#[test]
fn the_tree_respects_a_gitignore() {
let dir = root();
write(&dir.path().join(".gitignore"), "drafts/\nsecret.md\n");
write(&dir.path().join("note.md"), "note");
write(&dir.path().join("secret.md"), "secret");
write(&dir.path().join("drafts/half.md"), "half");
let tree = scan_tree(dir.path(), false).unwrap();
assert_eq!(names(&tree), vec!["note.md".to_string()]);
}
#[test]
fn showing_ignored_files_brings_them_back() {
let dir = root();
write(&dir.path().join(".gitignore"), "secret.md\n");
write(&dir.path().join("note.md"), "note");
write(&dir.path().join("secret.md"), "secret");
write(&dir.path().join("node_modules/left-pad/index.js"), "js");
let tree = scan_tree(dir.path(), true).unwrap();
let mut all = Vec::new();
flat(&tree, &mut all);
assert!(all.contains(&"secret.md".to_string()));
assert!(all.contains(&"node_modules".to_string()));
}
#[test]
fn the_tree_classifies_every_kind_and_sorts_folders_first() {
let dir = root();
write(&dir.path().join("zebra.md"), "md");
write(&dir.path().join("Apple.txt"), "txt");
write(&dir.path().join("photo.png"), "png");
write(&dir.path().join("beta/inner.markdown"), "md");
let tree = scan_tree(dir.path(), false).unwrap();
assert_eq!(
names(&tree),
vec![
"beta".to_string(),
"Apple.txt".to_string(),
"photo.png".to_string(),
"zebra.md".to_string(),
]
);
assert_eq!(find(&tree, "zebra.md").unwrap().kind, "markdown");
assert!(find(&tree, "Apple.txt").unwrap().editable);
assert_eq!(find(&tree, "Apple.txt").unwrap().kind, "text");
assert_eq!(find(&tree, "photo.png").unwrap().kind, "other");
assert!(!find(&tree, "photo.png").unwrap().editable);
assert_eq!(find(&tree, "beta").unwrap().children.len(), 1);
}
#[test]
fn a_symlink_loop_does_not_hang_the_scan() {
let dir = root();
write(&dir.path().join("note.md"), "note");
fs::create_dir(dir.path().join("inner")).unwrap();
symlink(dir.path(), dir.path().join("inner/loop")).unwrap();
let tree = scan_tree(dir.path(), false).unwrap();
let mut all = Vec::new();
flat(&tree, &mut all);
assert!(all.contains(&"note.md".to_string()));
}
#[test]
fn path_validation_rejects_an_escape() {
let dir = root();
let roots = vec![dir.path().to_string_lossy().into_owned()];
let escape = dir.path().join("../escaped.md");
assert!(resolve_in_roots(&roots, &escape.to_string_lossy()).is_err());
assert!(resolve_in_roots(&roots, "/etc/hosts").is_err());
assert!(resolve_in_roots(&roots, "notes/relative.md").is_err());
assert!(resolve_in_roots(&[], &dir.path().join("note.md").to_string_lossy()).is_err());
}
#[test]
fn path_validation_rejects_a_symlink_pointing_out_of_the_root() {
let inside = root();
let outside = root();
let secret = outside.path().join("secret.md");
write(&secret, "secret");
symlink(&secret, inside.path().join("link.md")).unwrap();
let roots = vec![inside.path().to_string_lossy().into_owned()];
let link = inside.path().join("link.md");
assert!(resolve_in_roots(&roots, &link.to_string_lossy()).is_err());
}
#[test]
fn path_validation_does_not_treat_a_sibling_as_a_child() {
let dir = root();
fs::create_dir(dir.path().join("notes")).unwrap();
fs::create_dir(dir.path().join("notes-old")).unwrap();
write(&dir.path().join("notes-old/note.md"), "note");
let roots = vec![dir.path().join("notes").to_string_lossy().into_owned()];
let sibling = dir.path().join("notes-old/note.md");
assert!(resolve_in_roots(&roots, &sibling.to_string_lossy()).is_err());
}
#[test]
fn path_validation_accepts_what_is_really_inside() {
let dir = root();
let doc = dir.path().join("folder/note.md");
write(&doc, "note");
let roots = vec![dir.path().to_string_lossy().into_owned()];
let resolved = resolve_in_roots(&roots, &doc.to_string_lossy()).unwrap();
assert_eq!(resolved, fs::canonicalize(&doc).unwrap());
// A file being created has no canonical path of its own, and it still has to be checked.
let unborn = dir.path().join("folder/new.md");
let resolved = resolve_in_roots(&roots, &unborn.to_string_lossy()).unwrap();
assert_eq!(
resolved,
fs::canonicalize(dir.path().join("folder")).unwrap().join("new.md")
);
}
#[test]
fn trash_does_not_hard_delete() {
let dir = root();
let unique = format!(
"margin-docs-trash-test-{}.md",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
);
let doc = dir.path().join(&unique);
write(&doc, "recoverable");
trash_entry(&doc).unwrap();
assert!(!doc.exists(), "the file is gone from where it was");
let trashed = PathBuf::from(std::env::var("HOME").unwrap())
.join(".Trash")
.join(&unique);
assert!(trashed.exists(), "and it is sitting in the Trash instead");
// Best effort, because the contents of the Trash are not always readable or writable by a
// process that is not Finder. The test above is the assertion; this is only tidying up.
let _ = fs::remove_file(&trashed);
}
#[test]
fn a_rename_will_not_move() {
let dir = root();
let doc = dir.path().join("note.md");
write(&doc, "note");
assert!(rename_entry(&doc, "../elsewhere.md").is_err());
assert!(rename_entry(&doc, "sub/other.md").is_err());
assert!(rename_entry(&doc, " ").is_err());
assert!(doc.exists());
let renamed = rename_entry(&doc, "other.md").unwrap();
assert_eq!(renamed.name, "other.md");
assert!(dir.path().join("other.md").exists());
assert!(!doc.exists());
}
#[test]
fn a_rename_onto_a_name_that_is_taken_is_refused() {
let dir = root();
write(&dir.path().join("one.md"), "one");
write(&dir.path().join("two.md"), "two");
assert!(rename_entry(&dir.path().join("one.md"), "two.md").is_err());
assert_eq!(fs::read_to_string(dir.path().join("two.md")).unwrap(), "two");
}
#[test]
fn a_move_into_the_folder_it_is_already_in_does_nothing() {
let dir = root();
let doc = dir.path().join("note.md");
write(&doc, "note");
let node = move_entry(&doc, dir.path()).unwrap();
assert_eq!(node.name, "note.md");
assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1);
}
#[test]
fn a_move_will_not_put_a_folder_inside_itself() {
let dir = root();
let outer = dir.path().join("outer");
let inner = outer.join("inner");
fs::create_dir_all(&inner).unwrap();
assert!(move_entry(&outer, &inner).is_err());
assert!(move_entry(&outer, &outer).is_err());
}
#[test]
fn a_move_keeps_the_name_unless_it_is_taken() {
let dir = root();
let from = dir.path().join("from");
let to = dir.path().join("to");
fs::create_dir_all(&from).unwrap();
fs::create_dir_all(&to).unwrap();
write(&from.join("note.md"), "moved");
write(&to.join("note.md"), "already here");
let node = move_entry(&from.join("note.md"), &to).unwrap();
assert_eq!(node.name, "note-2.md");
assert_eq!(fs::read_to_string(to.join("note.md")).unwrap(), "already here");
assert_eq!(fs::read_to_string(to.join("note-2.md")).unwrap(), "moved");
}
#[test]
fn duplicating_copies_a_folder_and_everything_under_it() {
let dir = root();
write(&dir.path().join("project/note.md"), "note");
write(&dir.path().join("project/deep/inner.md"), "inner");
let node = duplicate_entry(&dir.path().join("project")).unwrap();
assert_eq!(node.name, "project-2");
assert_eq!(
fs::read_to_string(dir.path().join("project-2/deep/inner.md")).unwrap(),
"inner"
);
assert_eq!(fs::read_to_string(dir.path().join("project/note.md")).unwrap(), "note");
}
#[test]
fn duplicating_a_document_is_byte_for_byte() {
let dir = root();
let doc = dir.path().join("note.md");
write(&doc, "---\ntitle: x\n---\n\n# ragged heading\n");
let node = duplicate_entry(&doc).unwrap();
assert_eq!(node.name, "note-2.md");
assert_eq!(
fs::read_to_string(dir.path().join("note-2.md")).unwrap(),
"---\ntitle: x\n---\n\n# ragged heading\n"
);
}
#[test]
fn a_pasted_image_lands_in_assets_beside_the_document() {
let dir = root();
let doc = dir.path().join("folder/note.md");
write(&doc, "note");
let first = write_asset(&doc, &[0x89, 0x50, 0x4e, 0x47], "image.png").unwrap();
let second = write_asset(&doc, &[0x89, 0x50, 0x4e, 0x47], "image.png").unwrap();
assert_eq!(first.rel_path, "assets/image.png");
assert_eq!(second.rel_path, "assets/image-2.png");
assert_eq!(
fs::read(dir.path().join("folder/assets/image.png")).unwrap(),
vec![0x89, 0x50, 0x4e, 0x47]
);
assert!(Path::new(&first.path).is_absolute());
}
#[test]
fn a_suggested_asset_name_is_a_name_and_not_a_path() {
let dir = root();
let doc = dir.path().join("note.md");
write(&doc, "note");
let result = write_asset(&doc, &[1, 2, 3], "../../../evil.png").unwrap();
assert_eq!(result.rel_path, "assets/evil.png");
assert!(dir.path().join("assets/evil.png").exists());
}
#[test]
fn a_root_id_is_the_same_folder_every_time() {
assert_eq!(root_id_for("/Users/x/notes"), root_id_for("/Users/x/notes"));
assert_ne!(root_id_for("/Users/x/notes"), root_id_for("/Users/x/other"));
assert_eq!(root_id_for("/Users/x/notes").len(), 16);
}
+18
View File
@@ -0,0 +1,18 @@
// Full text search is load bearing for the index in M3, and it arrives through libsqlite3-sys's
// bundled build rather than through a cargo feature we can see in Cargo.toml. A dependency bump
// could drop it silently, so the assumption is asserted rather than assumed.
#[test]
fn fts5_is_compiled_in() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE VIRTUAL TABLE probe USING fts5(path, body);
INSERT INTO probe(path, body) VALUES ('a.md', 'the quick brown fox');
INSERT INTO probe(path, body) VALUES ('b.md', 'lazy dog sleeping');",
)
.expect("fts5 virtual table must be creatable");
let hit: String = conn
.query_row("SELECT path FROM probe WHERE probe MATCH 'brown'", [], |r| r.get(0))
.unwrap();
assert_eq!(hit, "a.md");
}
File diff suppressed because it is too large. Load diff
+428
View File
@@ -0,0 +1,428 @@
// The watcher against a real folder and real filesystem calls, because every interesting thing it
// does is a reaction to what the kernel actually reports rather than to what notify's documentation
// says it reports. FSEvents sets `ItemCreated` on every event it ever emits for a path, describes
// an atomic save as a rename with no relation to the file it replaced, and spells every path
// through /private. None of that is visible from the types.
//
// Waiting is done by writing a probe file and waiting for its event, not by sleeping. Batches are
// delivered oldest first, so the probe's event arriving is proof that everything caused before it
// has already been delivered, which is what makes "nothing was reported" a bounded assertion rather
// than a guess at how long to wait. The one deliberate sleep is in the fixture helper, where a file
// has to be older than the watcher's own idea of newly born for the test to mean anything.
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::mpsc::{self, Receiver};
use std::time::{Duration, Instant};
use margin_docs_lib::dto::WatchEvent;
use margin_docs_lib::fs::write_document;
use margin_docs_lib::watch::{note_self_write, spawn_watcher};
use notify::RecommendedWatcher;
use notify_debouncer_full::{Debouncer, NoCache};
use tempfile::TempDir;
/// How long a test waits for the watcher to prove it is running before calling it broken.
const DEADLINE: Duration = Duration::from_secs(15);
/// The debouncer holds an event for 300ms and ticks every quarter of that, so a batch that has not
/// arrived in this long is not on its way.
const QUIET: Duration = Duration::from_millis(900);
/// Comfortably more than the 250ms within which the watcher counts a file as newly born, so that a
/// fixture written by the test is unambiguously a file that was already there.
const AGE: Duration = Duration::from_millis(600);
const ROOT: &str = "root-1";
struct Harness {
/// Declared first so the watcher stops before the folder it is watching is deleted.
_watcher: Debouncer<RecommendedWatcher, NoCache>,
dir: TempDir,
rx: Receiver<WatchEvent>,
probes: AtomicU32,
}
impl Harness {
fn path(&self, name: &str) -> PathBuf {
self.dir.path().join(name)
}
/// Waits until the watcher is up and throws away whatever it has reported so far.
fn sync(&self) {
self.drain();
}
/// Everything reported up to a fresh probe file, the probe events themselves left out.
fn drain(&self) -> Vec<WatchEvent> {
let give_up = Instant::now() + DEADLINE;
let mut seen = Vec::new();
loop {
let n = self.probes.fetch_add(1, Ordering::Relaxed);
let probe = self.path(&format!("probe-{n}.md"));
fs::write(&probe, format!("probe {n}\n")).unwrap();
let want = probe.to_string_lossy().into_owned();
loop {
match self.rx.recv_timeout(QUIET) {
Ok(event) if event.path == want => return seen,
Ok(event) => {
if !is_probe(&event.path) {
seen.push(event);
}
}
// Nothing is arriving at all, so the watcher was not yet up when the probe was
// written. Write another one.
Err(_) => break,
}
}
assert!(
Instant::now() < give_up,
"the watcher never reported anything"
);
}
}
fn events_for(&self, path: &Path) -> Vec<WatchEvent> {
let want = path.to_string_lossy().into_owned();
self.drain()
.into_iter()
.filter(|event| event.path == want)
.collect()
}
}
/// A watched folder holding `fixtures`, all of them old enough to count as files that were already
/// there when the watch started.
fn harness(fixtures: &[&str]) -> Harness {
let dir = tempfile::tempdir().unwrap();
for name in fixtures {
let path = dir.path().join(name);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&path, format!("# {name}\n")).unwrap();
}
if !fixtures.is_empty() {
std::thread::sleep(AGE);
}
let (tx, rx) = mpsc::channel();
let watcher = spawn_watcher(
ROOT.to_string(),
dir.path().to_string_lossy().into_owned(),
move |events| {
for event in events {
tx.send(event).ok();
}
},
)
.unwrap();
Harness {
_watcher: watcher,
dir,
rx,
probes: AtomicU32::new(0),
}
}
fn is_probe(path: &str) -> bool {
Path::new(path)
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("probe-"))
}
fn one<'a>(events: &'a [WatchEvent], what: &str) -> &'a WatchEvent {
assert_eq!(events.len(), 1, "{what}, got {events:?}");
&events[0]
}
#[test]
fn a_created_file_is_reported_created() {
let harness = harness(&[]);
harness.sync();
let note = harness.path("note.md");
fs::write(&note, "# Note\n").unwrap();
let events = harness.events_for(&note);
let event = one(&events, "a new file is one event");
assert_eq!(event.kind, "created");
assert_eq!(event.root, ROOT);
assert_eq!(event.old_path, None);
assert_eq!(event.path, note.to_string_lossy());
}
#[test]
fn a_modified_file_is_reported_modified() {
let harness = harness(&["note.md"]);
let note = harness.path("note.md");
harness.sync();
fs::write(&note, "# Note\n\nA second paragraph.\n").unwrap();
let events = harness.events_for(&note);
let event = one(&events, "a write to an existing file is one event");
assert_eq!(event.kind, "modified");
assert_eq!(event.old_path, None);
}
#[test]
fn a_deleted_file_is_reported_removed() {
let harness = harness(&["note.md"]);
let note = harness.path("note.md");
harness.sync();
fs::remove_file(&note).unwrap();
let events = harness.events_for(&note);
assert_eq!(one(&events, "a delete is one event").kind, "removed");
}
#[test]
fn a_self_write_is_reported_as_nothing() {
let harness = harness(&["note.md"]);
let note = harness.path("note.md");
harness.sync();
note_self_write(&note);
fs::write(&note, "# Note\n\nWritten by the app itself.\n").unwrap();
let events = harness.events_for(&note);
assert!(events.is_empty(), "the app's own write echoed: {events:?}");
}
#[test]
fn the_apps_own_atomic_save_is_reported_as_nothing() {
let harness = harness(&["note.md"]);
let note = harness.path("note.md");
let temp = harness.path("note.md.tmp");
harness.sync();
// Both halves have to be registered. The rename names the temp file as well as the document,
// and either name getting through would let the echo through with it.
note_self_write(&temp);
note_self_write(&note);
fs::write(&temp, "# Note\n\nWritten by the app itself.\n").unwrap();
fs::rename(&temp, &note).unwrap();
let events = harness.drain();
assert!(
events.is_empty(),
"the app's own atomic save echoed: {events:?}"
);
}
/// The two tests above register the paths by hand, which proves the mechanism and not that anything
/// uses it. This one goes through the real write path: a save of a document reaches the frontend as
/// nothing at all, neither the document nor the temp file it went through.
#[test]
fn a_real_save_is_reported_as_nothing() {
let harness = harness(&["note.md"]);
let note = harness.path("note.md");
harness.sync();
write_document(&note, "# Note\n\nSaved by the app itself.\n", None).unwrap();
let events = harness.drain();
assert!(
events.is_empty(),
"the app's own save came back as an external change: {events:?}"
);
}
#[test]
fn a_self_write_stops_suppressing_once_the_window_is_up() {
let harness = harness(&["note.md"]);
let note = harness.path("note.md");
harness.sync();
note_self_write(&note);
fs::write(&note, "one\n").unwrap();
assert!(harness.events_for(&note).is_empty());
// The suppression is a window and not a switch: a later write to the same path, by the app or
// by anything else, has to come through again once the window is up.
std::thread::sleep(Duration::from_millis(2_100));
fs::write(&note, "two\n").unwrap();
let events = harness.events_for(&note);
assert_eq!(
one(&events, "the write after the window is one event").kind,
"modified"
);
}
#[test]
fn a_burst_of_writes_is_not_a_burst_of_events() {
let harness = harness(&["note.md"]);
let note = harness.path("note.md");
harness.sync();
for n in 0..10 {
fs::write(&note, format!("# Note\n\nRevision {n}.\n")).unwrap();
}
let events = harness.events_for(&note);
assert!(
(1..=2).contains(&events.len()),
"ten writes should coalesce, got {events:?}"
);
assert!(events.iter().all(|event| event.kind == "modified"));
}
#[test]
fn a_rename_is_reported_at_both_ends() {
let harness = harness(&["before.md"]);
let before = harness.path("before.md");
let after = harness.path("after.md");
harness.sync();
fs::rename(&before, &after).unwrap();
let events = harness.drain();
let gone = events
.iter()
.find(|event| event.path == before.to_string_lossy())
.unwrap_or_else(|| panic!("the old name was not reported: {events:?}"));
assert_eq!(gone.kind, "removed");
// Not `renamed`. FSEvents reports the two ends as unrelated events and marks both of them
// created, which defeats the debouncer's attempt to pair them up, so the honest report is that
// one name went away and another appeared.
let arrived = events
.iter()
.find(|event| event.path == after.to_string_lossy())
.unwrap_or_else(|| panic!("the new name was not reported: {events:?}"));
assert_ne!(arrived.kind, "removed");
if let Some(old) = &arrived.old_path {
assert_eq!(old, &*before.to_string_lossy());
}
}
#[test]
fn another_editors_atomic_save_is_one_event_on_the_document() {
let harness = harness(&["note.md"]);
let note = harness.path("note.md");
let temp = harness.path(".note.md.tmp");
harness.sync();
fs::write(&temp, "# Note\n\nSaved by something else.\n").unwrap();
fs::rename(&temp, &note).unwrap();
let events = harness.drain();
assert!(
events
.iter()
.all(|event| event.path != temp.to_string_lossy()),
"the temp file was reported as if it were a document: {events:?}"
);
let on_note: Vec<_> = events
.into_iter()
.filter(|event| event.path == note.to_string_lossy())
.collect();
let event = one(&on_note, "an atomic save is one event on the document");
assert_ne!(event.kind, "removed");
assert_eq!(
event.old_path, None,
"the document was reported as renamed from a temp file it never was"
);
}
#[test]
fn hidden_paths_are_not_reported() {
let harness = harness(&[]);
harness.sync();
fs::create_dir_all(harness.path(".git")).unwrap();
fs::write(harness.path(".git/index"), "not a document").unwrap();
fs::write(harness.path(".DS_Store"), "not a document either").unwrap();
let note = harness.path("note.md");
fs::write(&note, "# Note\n").unwrap();
let events = harness.drain();
assert!(
events
.iter()
.all(|event| event.path == note.to_string_lossy()),
"hidden paths reached the frontend: {events:?}"
);
}
#[test]
fn nested_changes_are_reported_under_the_path_the_root_was_opened_as() {
let harness = harness(&["sub/deep.md"]);
let nested = harness.path("sub/deep.md");
harness.sync();
fs::write(&nested, "# Deep\n\nEdited.\n").unwrap();
let events = harness.events_for(&nested);
let event = one(&events, "a nested file is one event");
assert_eq!(event.kind, "modified");
// Not the /private form FSEvents hands out, which nothing else in the app spells that way.
assert!(event
.path
.starts_with(&*harness.dir.path().to_string_lossy()));
}
#[test]
fn a_deleted_root_is_reported_removed() {
let outer = tempfile::tempdir().unwrap();
let root = outer.path().join("notes");
fs::create_dir(&root).unwrap();
fs::write(root.join("note.md"), "# Note\n").unwrap();
let (tx, rx) = mpsc::channel();
let watcher = spawn_watcher(
ROOT.to_string(),
root.to_string_lossy().into_owned(),
move |events| {
for event in events {
tx.send(event).ok();
}
},
)
.unwrap();
let give_up = Instant::now() + DEADLINE;
let mut live = false;
let mut probe = 0;
while !live {
fs::write(root.join(format!("probe-{probe}.md")), "probe\n").unwrap();
probe += 1;
live = rx.recv_timeout(QUIET).is_ok();
assert!(
live || Instant::now() < give_up,
"the watcher never reported anything"
);
}
fs::remove_dir_all(&root).unwrap();
let want = root.to_string_lossy().into_owned();
let give_up = Instant::now() + DEADLINE;
let mut removed = false;
while !removed && Instant::now() < give_up {
match rx.recv_timeout(QUIET) {
Ok(event) => removed = event.path == want && event.kind == "removed",
Err(_) => break,
}
}
assert!(removed, "deleting the root reported nothing");
drop(watcher);
}
#[test]
fn a_watch_on_a_folder_that_is_not_there_is_an_error() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("gone");
let started = spawn_watcher(
ROOT.to_string(),
missing.to_string_lossy().into_owned(),
|_| {},
);
assert!(started.is_err());
}
+214
View File
@@ -0,0 +1,214 @@
// The shape of a `watch-event` as the frontend receives it, rather than as Rust holds it.
//
// src-tauri/tests/watch.rs proves the watcher reports the right things about the right paths, but
// it asserts against `WatchEvent`'s Rust fields, and the frontend never sees those. What crosses
// the IPC boundary is serde's JSON, and the frontend reads `event.payload.oldPath` off it. A
// missing `#[serde(rename_all = "camelCase")]` would leave every Rust test green and hand the
// frontend `old_path`, which reads as `undefined`, which is neither the string nor the null the
// TypeScript type promises. Nothing else in the suite would notice.
//
// So this file drives the same real watcher over a real folder and asserts the serialized object
// exactly: every key, no extra keys, and the literal values the TypeScript union in src/ipc.ts
// lists. Between this and the Playwright suite in tests/external-changes.spec.ts, which feeds
// payloads of this shape through the real Tauri listener into the real UI, both ends of the wire
// are pinned to the same object.
//
// The waiting strategy is the one watch.rs uses and for the same reason: a probe file whose event
// proves everything caused before it has already been delivered.
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::mpsc::{self, Receiver};
use std::time::{Duration, Instant};
use margin_docs_lib::dto::WatchEvent;
use margin_docs_lib::watch::spawn_watcher;
use notify::RecommendedWatcher;
use notify_debouncer_full::{Debouncer, NoCache};
use serde_json::{json, Value};
use tempfile::TempDir;
const DEADLINE: Duration = Duration::from_secs(15);
const QUIET: Duration = Duration::from_millis(900);
const AGE: Duration = Duration::from_millis(600);
const ROOT: &str = "root-1";
struct Harness {
_watcher: Debouncer<RecommendedWatcher, NoCache>,
dir: TempDir,
rx: Receiver<WatchEvent>,
probes: AtomicU32,
}
impl Harness {
fn path(&self, name: &str) -> PathBuf {
self.dir.path().join(name)
}
fn sync(&self) {
self.drain();
}
fn drain(&self) -> Vec<WatchEvent> {
let give_up = Instant::now() + DEADLINE;
let mut seen = Vec::new();
loop {
let n = self.probes.fetch_add(1, Ordering::Relaxed);
let probe = self.path(&format!("probe-{n}.md"));
fs::write(&probe, format!("probe {n}\n")).unwrap();
let want = probe.to_string_lossy().into_owned();
loop {
match self.rx.recv_timeout(QUIET) {
Ok(event) if event.path == want => return seen,
Ok(event) => {
if !is_probe(&event.path) {
seen.push(event);
}
}
Err(_) => break,
}
}
assert!(
Instant::now() < give_up,
"the watcher never reported anything"
);
}
}
/// The one event for `path`, as the JSON object the frontend will be handed.
fn payload_for(&self, path: &Path) -> Value {
let want = path.to_string_lossy().into_owned();
let events: Vec<WatchEvent> = self
.drain()
.into_iter()
.filter(|event| event.path == want)
.collect();
assert_eq!(events.len(), 1, "expected one event, got {events:?}");
serde_json::to_value(&events[0]).unwrap()
}
}
fn harness(fixtures: &[&str]) -> Harness {
let dir = tempfile::tempdir().unwrap();
for name in fixtures {
let path = dir.path().join(name);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&path, format!("# {name}\n")).unwrap();
}
if !fixtures.is_empty() {
std::thread::sleep(AGE);
}
let (tx, rx) = mpsc::channel();
let watcher = spawn_watcher(
ROOT.to_string(),
dir.path().to_string_lossy().into_owned(),
move |events| {
for event in events {
tx.send(event).ok();
}
},
)
.unwrap();
Harness {
_watcher: watcher,
dir,
rx,
probes: AtomicU32::new(0),
}
}
fn is_probe(path: &str) -> bool {
Path::new(path)
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("probe-"))
}
/// Exactly these four keys, spelled the way `WatchEvent` in src/ipc.ts spells them.
fn expect_payload(actual: &Value, path: &Path, kind: &str) {
assert_eq!(
actual,
&json!({
"root": ROOT,
"path": path.to_string_lossy(),
"kind": kind,
"oldPath": Value::Null,
}),
"the payload the frontend receives is not the object it is typed as"
);
}
#[test]
fn an_external_edit_serialises_as_the_frontend_reads_it() {
let harness = harness(&["note.md"]);
let note = harness.path("note.md");
harness.sync();
fs::write(&note, "# Note\n\nEdited by another program.\n").unwrap();
expect_payload(&harness.payload_for(&note), &note, "modified");
}
#[test]
fn a_created_file_serialises_as_the_frontend_reads_it() {
let harness = harness(&[]);
harness.sync();
let note = harness.path("note.md");
fs::write(&note, "# Note\n").unwrap();
expect_payload(&harness.payload_for(&note), &note, "created");
}
#[test]
fn a_deleted_file_serialises_as_the_frontend_reads_it() {
let harness = harness(&["note.md"]);
let note = harness.path("note.md");
harness.sync();
fs::remove_file(&note).unwrap();
expect_payload(&harness.payload_for(&note), &note, "removed");
}
/// `old_path` is the one field whose name differs between the two languages, and the only one that
/// is ever anything but a plain string. A rename is where it would be filled in if it ever were,
/// so this is where a wrong spelling would do its damage.
#[test]
fn old_path_is_spelled_the_way_the_frontend_reads_it() {
let renamed = WatchEvent {
root: ROOT.to_string(),
path: "/tmp/after.md".to_string(),
kind: "renamed".to_string(),
old_path: Some("/tmp/before.md".to_string()),
};
assert_eq!(
serde_json::to_value(&renamed).unwrap(),
json!({
"root": ROOT,
"path": "/tmp/after.md",
"kind": "renamed",
"oldPath": "/tmp/before.md",
})
);
}
/// A source-literal check and nothing more: it cannot see a running app. What it does catch is the
/// one silent break the runtime tests on either side cannot, because each side is internally
/// consistent with its own constant. Rename the event on one side and the frontend simply stops
/// hearing anything, with every test still green.
#[test]
fn both_sides_name_the_event_the_same_string() {
assert!(
include_str!("../src/watch.rs").contains(r#"const WATCH_EVENT: &str = "watch-event";"#),
"the backend no longer emits under `watch-event`"
);
assert!(
include_str!("../../src/ipc.ts").contains(r#"export const WATCH_EVENT = "watch-event";"#),
"the frontend no longer listens for `watch-event`"
);
}