mirror of
https://github.com/priyanshujain/margin-docs.git
synced 2026-10-02 19:17:05 +00:00
features
This commit is contained in:
commit
852cba1840
252 files changed
+55050
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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(¬e, "# Note\n").unwrap();
|
||||
|
||||
let events = harness.events_for(¬e);
|
||||
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(¬e, "# Note\n\nA second paragraph.\n").unwrap();
|
||||
|
||||
let events = harness.events_for(¬e);
|
||||
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(¬e).unwrap();
|
||||
|
||||
let events = harness.events_for(¬e);
|
||||
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(¬e);
|
||||
fs::write(¬e, "# Note\n\nWritten by the app itself.\n").unwrap();
|
||||
|
||||
let events = harness.events_for(¬e);
|
||||
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(¬e);
|
||||
fs::write(&temp, "# Note\n\nWritten by the app itself.\n").unwrap();
|
||||
fs::rename(&temp, ¬e).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(¬e, "# 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(¬e);
|
||||
fs::write(¬e, "one\n").unwrap();
|
||||
assert!(harness.events_for(¬e).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(¬e, "two\n").unwrap();
|
||||
|
||||
let events = harness.events_for(¬e);
|
||||
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(¬e, format!("# Note\n\nRevision {n}.\n")).unwrap();
|
||||
}
|
||||
|
||||
let events = harness.events_for(¬e);
|
||||
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, ¬e).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(¬e, "# 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());
|
||||
}
|
||||
@@ -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(¬e, "# Note\n\nEdited by another program.\n").unwrap();
|
||||
|
||||
expect_payload(&harness.payload_for(¬e), ¬e, "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(¬e, "# Note\n").unwrap();
|
||||
|
||||
expect_payload(&harness.payload_for(¬e), ¬e, "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(¬e).unwrap();
|
||||
|
||||
expect_payload(&harness.payload_for(¬e), ¬e, "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`"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user