#![cfg(test)]
use crate::{
ArcPath, EventBatch, EventKind, Id, Interest, Watcher, WatcherConfigBuilder,
};
use anyhow::{bail, Result};
use enumflags2::BitFlags;
use fxhash::FxHashMap;
use std::{
path::PathBuf,
time::{Duration, Instant},
};
use tempfile::TempDir;
use tokio::{fs, sync::mpsc, time::timeout};
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone)]
#[allow(unused)]
pub enum Expectation {
Event { watch: &'static str, kind: BitFlags<Interest>, path: &'static str },
Error { watch: &'static str },
}
#[derive(Debug, Clone)]
#[allow(unused)]
pub enum Step {
Watch { name: &'static str, path: &'static str, interest: BitFlags<Interest> },
Unwatch { name: &'static str },
CreateFile { path: &'static str },
CreateDir { path: &'static str },
WriteFile { path: &'static str, content: &'static str },
DeleteFile { path: &'static str },
DeleteDir { path: &'static str },
Rename { from: &'static str, to: &'static str },
Sleep { ms: u64 },
AtLeast(&'static [Expectation]),
Exactly(&'static [Expectation]),
ExactlyWithOptional {
required: &'static [Expectation],
optional: &'static [Expectation],
},
Drain,
}
use Step::*;
struct TestContext {
_temp_dir: TempDir,
canonical_path: PathBuf,
watcher: Watcher,
rx: mpsc::Receiver<EventBatch>,
watches: FxHashMap<&'static str, (Id, crate::Watched)>,
pending_events: Vec<(Id, Interest, ArcPath)>,
pending_errors: Vec<Id>,
}
impl TestContext {
async fn new() -> Result<Self> {
let _temp_dir = TempDir::new()?;
let canonical_path = _temp_dir.path().canonicalize()?;
eprintln!("running in {}", canonical_path.display());
let (tx, rx) = mpsc::channel(100);
let watcher =
WatcherConfigBuilder::default().event_handler(tx).build()?.start()?;
Ok(Self {
_temp_dir,
canonical_path,
watcher,
rx,
watches: FxHashMap::default(),
pending_events: Vec::new(),
pending_errors: Vec::new(),
})
}
fn resolve_path(&self, path: &str) -> PathBuf {
self.canonical_path.join(path)
}
fn process_batch(&mut self, batch: EventBatch) {
for (id, event) in batch.iter() {
match &event.event {
EventKind::Event(interest) => {
for path in event.paths.iter() {
self.pending_events.push((*id, *interest, path.clone()));
}
}
EventKind::Error(_) => {
self.pending_errors.push(*id);
}
}
}
}
async fn recv_events(&mut self) -> Result<()> {
let wait = Duration::from_millis(500);
let ts = Instant::now();
while let Ok(Some(batch)) = timeout(wait - ts.elapsed(), self.rx.recv()).await {
self.process_batch(batch);
}
Ok(())
}
fn find_event(
&mut self,
watch_id: Id,
kind: BitFlags<Interest>,
path: &PathBuf,
) -> Option<usize> {
self.pending_events.iter().position(|(id, k, p)| {
*id == watch_id && kind.contains(*k) && p.as_path() == path
})
}
fn find_error(&mut self, watch_id: Id) -> Option<usize> {
self.pending_errors.iter().position(|id| *id == watch_id)
}
async fn wait_for_events(
&mut self,
single_pass: bool,
expectations: &[Expectation],
) -> Result<()> {
let deadline = tokio::time::Instant::now() + DEFAULT_TIMEOUT;
let mut remaining: Vec<_> = expectations.iter().collect();
while !remaining.is_empty() {
self.recv_events().await?;
remaining.retain(|ex| match ex {
Expectation::Event { watch, kind, path } => {
let watch_id = match self.watches.get(watch) {
Some((id, _)) => *id,
None => return true,
};
let full_path = self.resolve_path(path);
match self.find_event(watch_id, *kind, &full_path) {
Some(idx) => {
self.pending_events.remove(idx);
false
}
None => true,
}
}
Expectation::Error { watch } => {
let watch_id = match self.watches.get(watch) {
Some((id, _)) => *id,
None => return true,
};
match self.find_error(watch_id) {
Some(idx) => {
self.pending_errors.remove(idx);
false
}
None => true,
}
}
});
if single_pass || remaining.is_empty() {
break;
}
if tokio::time::Instant::now() >= deadline {
bail!(
"timeout waiting for events: {:?}\n\
pending events: {:?}\n\
pending errors: {:?}",
remaining,
self.pending_events,
self.pending_errors
);
}
}
Ok(())
}
async fn wait_for_exactly(
&mut self,
required: &[Expectation],
optional: &[Expectation],
) -> Result<()> {
self.wait_for_events(false, required).await?;
if !optional.is_empty() {
let _ = self.wait_for_events(true, optional).await;
}
tokio::time::sleep(Duration::from_millis(100)).await;
self.recv_events().await?;
if !self.pending_events.is_empty() || !self.pending_errors.is_empty() {
bail!(
"unexpected events remaining:\n\
pending events: {:?}\n\
pending errors: {:?}",
self.pending_events,
self.pending_errors
);
}
Ok(())
}
async fn run_step(&mut self, step: &Step) -> Result<()> {
eprintln!("run step {step:?}");
match step {
Watch { name, path, interest } => {
let full_path = self.resolve_path(path);
let watched = self.watcher.add(ArcPath::from(full_path), *interest)?;
let id = watched.id();
self.watches.insert(name, (id, watched));
}
Unwatch { name } => {
if self.watches.remove(name).is_none() {
bail!("unknown watch: {name}");
}
}
CreateFile { path } => {
let full_path = self.resolve_path(path);
if let Some(parent) = full_path.parent() {
fs::create_dir_all(parent).await?;
}
fs::write(&full_path, b"").await?;
}
CreateDir { path } => {
let full_path = self.resolve_path(path);
fs::create_dir_all(&full_path).await?;
}
WriteFile { path, content } => {
let full_path = self.resolve_path(path);
if let Some(parent) = full_path.parent() {
fs::create_dir_all(parent).await?;
}
fs::write(&full_path, content.as_bytes()).await?;
}
DeleteFile { path } => {
let full_path = self.resolve_path(path);
fs::remove_file(&full_path).await?;
}
DeleteDir { path } => {
let full_path = self.resolve_path(path);
fs::remove_dir_all(&full_path).await?;
}
Rename { from, to } => {
let from_path = self.resolve_path(from);
let to_path = self.resolve_path(to);
if let Some(parent) = to_path.parent() {
fs::create_dir_all(parent).await?;
}
fs::rename(&from_path, &to_path).await?;
}
Sleep { ms } => {
tokio::time::sleep(Duration::from_millis(*ms)).await;
}
AtLeast(expectations) => {
self.wait_for_events(false, expectations).await?;
}
Exactly(expectations) => self.wait_for_exactly(expectations, &[]).await?,
ExactlyWithOptional { required, optional } => {
self.wait_for_exactly(required, optional).await?
}
Drain => {
tokio::time::sleep(Duration::from_millis(200)).await;
self.recv_events().await?;
self.pending_events.clear();
self.pending_errors.clear();
}
}
Ok(())
}
}
pub async fn run_test(steps: &[Step]) -> Result<()> {
let _ = env_logger::try_init();
let mut ctx = TestContext::new().await?;
for (i, step) in steps.iter().enumerate() {
ctx.run_step(step)
.await
.map_err(|e| anyhow::anyhow!("step {i} ({step:?}): {e}"))?;
}
Ok(())
}
macro_rules! ex {
($($e:expr),+$(,)?) => {
&*Box::leak(Box::new([
$($e),+
]))
}
}
#[tokio::test]
async fn nested_nonexistent() -> Result<()> {
run_test(&[
Watch {
name: "w",
path: "a/b/c.txt",
interest: Interest::Established | Interest::Create,
},
Exactly(ex![Expectation::Event {
watch: "w",
kind: Interest::Established.into(),
path: "a/b/c.txt",
}]),
CreateFile { path: "a/b/c.txt" },
Exactly(ex![Expectation::Event {
watch: "w",
kind: Interest::Create.into(),
path: "a/b/c.txt",
}]),
])
.await
}
#[tokio::test]
async fn multiple_watches_same_file() -> Result<()> {
run_test(&[
CreateFile { path: "shared.txt" },
Watch {
name: "w1",
path: "shared.txt",
interest: Interest::Established | Interest::ModifyData | Interest::CreateFile,
},
Watch {
name: "w2",
path: "shared.txt",
interest: Interest::Established | Interest::ModifyData | Interest::CreateFile,
},
ExactlyWithOptional {
required: ex![
Expectation::Event {
watch: "w1",
kind: Interest::Established.into(),
path: "shared.txt",
},
Expectation::Event {
watch: "w2",
kind: Interest::Established.into(),
path: "shared.txt",
},
],
optional: ex![
Expectation::Event {
watch: "w1",
kind: Interest::CreateFile.into(),
path: "shared.txt",
},
Expectation::Event {
watch: "w2",
kind: Interest::CreateFile.into(),
path: "shared.txt",
},
],
},
WriteFile { path: "shared.txt", content: "test" },
ExactlyWithOptional {
required: ex![
Expectation::Event {
watch: "w1",
kind: Interest::Modify
| Interest::ModifyData
| Interest::ModifyDataContent
| Interest::CreateFile,
path: "shared.txt"
},
Expectation::Event {
watch: "w2",
kind: Interest::Modify
| Interest::ModifyData
| Interest::ModifyDataContent
| Interest::CreateFile,
path: "shared.txt"
}
],
optional: ex![
Expectation::Event {
watch: "w1",
kind: Interest::Modify
| Interest::ModifyData
| Interest::ModifyDataContent
| Interest::CreateFile,
path: "shared.txt"
},
Expectation::Event {
watch: "w2",
kind: Interest::Modify
| Interest::ModifyData
| Interest::ModifyDataContent
| Interest::CreateFile,
path: "shared.txt"
}
],
},
])
.await
}
#[tokio::test]
async fn unwatch() -> Result<()> {
run_test(&[
CreateFile { path: "file.txt" },
Watch {
name: "w",
path: "file.txt",
interest: Interest::Established | Interest::Modify,
},
Exactly(ex![Expectation::Event {
watch: "w",
kind: Interest::Established.into(),
path: "file.txt",
}]),
Unwatch { name: "w" },
Sleep { ms: 200 },
WriteFile { path: "file.txt", content: "ignored" },
Exactly(&[]), ])
.await
}
#[tokio::test]
async fn nonexistent_create() -> Result<()> {
run_test(&[
Watch {
name: "w",
path: "test.txt",
interest: Interest::Established | Interest::Create,
},
Exactly(ex![Expectation::Event {
watch: "w",
kind: Interest::Established.into(),
path: "test.txt",
}]),
CreateFile { path: "test.txt" },
Exactly(ex![Expectation::Event {
watch: "w",
kind: Interest::Create.into(),
path: "test.txt",
}]),
])
.await
}
#[tokio::test]
async fn delete_file() -> Result<()> {
run_test(&[
CreateFile { path: "test.txt" },
Watch {
name: "w",
path: "test.txt",
interest: Interest::Established | Interest::Delete,
},
Exactly(ex![Expectation::Event {
watch: "w",
kind: Interest::Established.into(),
path: "test.txt",
}]),
DeleteFile { path: "test.txt" },
Exactly(ex![Expectation::Event {
watch: "w",
kind: (Interest::Delete | Interest::DeleteFile).into(),
path: "test.txt",
}]),
])
.await
}
#[tokio::test]
async fn existing_file() -> Result<()> {
run_test(&[
CreateFile { path: "test.txt" },
Watch {
name: "w",
path: "test.txt",
interest: Interest::Established | Interest::Modify | Interest::Create,
},
ExactlyWithOptional {
required: ex![Expectation::Event {
watch: "w",
kind: Interest::Established.into(),
path: "test.txt",
}],
optional: ex![Expectation::Event {
watch: "w",
kind: Interest::CreateFile.into(),
path: "test.txt",
}],
},
WriteFile { path: "test.txt", content: "hello" },
ExactlyWithOptional {
required: ex![Expectation::Event {
watch: "w",
kind: Interest::Modify
| Interest::ModifyData
| Interest::ModifyDataContent
| Interest::CreateFile
| Interest::Create,
path: "test.txt",
}],
optional: ex![Expectation::Event {
watch: "w",
kind: Interest::Modify
| Interest::ModifyData
| Interest::CreateFile
| Interest::Create
| Interest::ModifyMetadata,
path: "test.txt",
}],
},
])
.await
}
#[tokio::test]
async fn dir_and_nonexistent_child() -> Result<()> {
run_test(&[
CreateDir { path: "dir" },
Watch {
name: "dir",
path: "dir",
interest: Interest::Established | Interest::Create | Interest::Modify,
},
Watch {
name: "file",
path: "dir/file.txt",
interest: Interest::Established | Interest::Create,
},
Exactly(ex![
Expectation::Event {
watch: "dir",
kind: Interest::Established.into(),
path: "dir",
},
Expectation::Event {
watch: "file",
kind: Interest::Established.into(),
path: "dir/file.txt",
},
]),
CreateFile { path: "dir/file.txt" },
#[cfg(unix)]
Exactly(ex![
Expectation::Event {
watch: "dir",
kind: Interest::CreateFile.into(),
path: "dir/file.txt",
},
Expectation::Event {
watch: "file",
kind: Interest::Create.into(),
path: "dir/file.txt",
},
]),
#[cfg(windows)]
Exactly(ex![
Expectation::Event {
watch: "dir",
kind: Interest::Create.into(),
path: "dir/file.txt",
},
Expectation::Event {
watch: "file",
kind: Interest::Create.into(),
path: "dir/file.txt",
},
Expectation::Event {
watch: "dir",
kind: Interest::Create.into(),
path: "dir/file.txt",
},
Expectation::Event {
watch: "file",
kind: Interest::Create.into(),
path: "dir/file.txt",
},
]),
])
.await
}
#[tokio::test]
async fn multiple_watches_nonexistent() -> Result<()> {
run_test(&[
Watch {
name: "w1",
path: "ghost.txt",
interest: Interest::Established | Interest::Create,
},
Watch {
name: "w2",
path: "ghost.txt",
interest: Interest::Established | Interest::Create,
},
Exactly(ex![
Expectation::Event {
watch: "w1",
kind: Interest::Established.into(),
path: "ghost.txt",
},
Expectation::Event {
watch: "w2",
kind: Interest::Established.into(),
path: "ghost.txt",
},
]),
CreateFile { path: "ghost.txt" },
#[cfg(unix)]
Exactly(ex![
Expectation::Event {
watch: "w1",
kind: Interest::Create.into(),
path: "ghost.txt",
},
Expectation::Event {
watch: "w2",
kind: Interest::Create.into(),
path: "ghost.txt",
},
]),
#[cfg(windows)]
Exactly(ex![
Expectation::Event {
watch: "w1",
kind: Interest::Create.into(),
path: "ghost.txt",
},
Expectation::Event {
watch: "w2",
kind: Interest::Create.into(),
path: "ghost.txt",
},
Expectation::Event {
watch: "w1",
kind: Interest::Create.into(),
path: "ghost.txt",
},
Expectation::Event {
watch: "w2",
kind: Interest::Create.into(),
path: "ghost.txt",
},
]),
])
.await
}
#[tokio::test]
async fn rename_to_watched_path() -> Result<()> {
run_test(&[
CreateFile { path: "source.txt" },
Watch {
name: "w",
path: "target.txt",
interest: Interest::Established | Interest::Create,
},
Exactly(ex![Expectation::Event {
watch: "w",
kind: Interest::Established.into(),
path: "target.txt",
}]),
Rename { from: "source.txt", to: "target.txt" },
Exactly(ex![Expectation::Event {
watch: "w",
kind: Interest::Create.into(),
path: "target.txt",
}]),
])
.await
}
#[tokio::test]
async fn atomic_file_replacement() -> Result<()> {
run_test(&[
CreateFile { path: "config.txt" },
WriteFile { path: "config.txt", content: "v1" },
Watch {
name: "w",
path: "config.txt",
interest: Interest::Established | Interest::Delete | Interest::ModifyRename,
},
Exactly(ex![Expectation::Event {
watch: "w",
kind: Interest::Established.into(),
path: "config.txt",
}]),
WriteFile { path: "config.txt.tmp", content: "v2" },
Rename { from: "config.txt.tmp", to: "config.txt" },
Exactly(ex![Expectation::Event {
watch: "w",
kind: Interest::Delete | Interest::DeleteFile | Interest::ModifyRename,
path: "config.txt",
}]),
])
.await
}
#[tokio::test]
async fn repeated_atomic_file_replacement() -> Result<()> {
const REPLACED: BitFlags<Interest> = enumflags2::make_bitflags!(Interest::{
Delete | DeleteFile | ModifyRename | Create | CreateFile
});
run_test(&[
CreateFile { path: "config.txt" },
WriteFile { path: "config.txt", content: "v0" },
Watch {
name: "w",
path: "config.txt",
interest: Interest::Established
| Interest::Delete
| Interest::DeleteFile
| Interest::ModifyRename
| Interest::Create,
},
Drain, WriteFile { path: "config.txt.tmp", content: "v1" },
Rename { from: "config.txt.tmp", to: "config.txt" },
AtLeast(ex![Expectation::Event {
watch: "w",
kind: REPLACED,
path: "config.txt",
}]),
WriteFile { path: "config.txt.tmp", content: "v2" },
Rename { from: "config.txt.tmp", to: "config.txt" },
AtLeast(ex![Expectation::Event {
watch: "w",
kind: REPLACED,
path: "config.txt",
}]),
WriteFile { path: "config.txt.tmp", content: "v3" },
Rename { from: "config.txt.tmp", to: "config.txt" },
AtLeast(ex![Expectation::Event {
watch: "w",
kind: REPLACED,
path: "config.txt",
}]),
])
.await
}
#[tokio::test]
async fn pending_watch_survives_unrelated_siblings() -> Result<()> {
run_test(&[
CreateDir { path: "base" },
Watch {
name: "w",
path: "base/sub/target.txt", interest: Interest::Established | Interest::Create,
},
Exactly(ex![Expectation::Event {
watch: "w",
kind: Interest::Established.into(),
path: "base/sub/target.txt",
}]),
CreateFile { path: "base/noise1" },
CreateFile { path: "base/noise2" },
CreateDir { path: "base/other" },
Exactly(&[]),
CreateDir { path: "base/sub" },
WriteFile { path: "base/sub/target.txt", content: "hi" },
AtLeast(ex![Expectation::Event {
watch: "w",
kind: Interest::Create.into(),
path: "base/sub/target.txt",
}]),
])
.await
}
#[tokio::test]
async fn delete_parent_directory() -> Result<()> {
run_test(&[
CreateFile { path: "parent/child.txt" },
Watch {
name: "w",
path: "parent/child.txt",
interest: Interest::Established | Interest::Delete,
},
Exactly(ex![Expectation::Event {
watch: "w",
kind: Interest::Established.into(),
path: "parent/child.txt",
}]),
DeleteDir { path: "parent" },
Exactly(ex![Expectation::Event {
watch: "w",
kind: (Interest::Delete | Interest::DeleteFile).into(),
path: "parent/child.txt",
}]),
])
.await
}
#[tokio::test]
async fn rapid_create_delete_cycles() -> Result<()> {
run_test(&[
Watch {
name: "w",
path: "ephemeral.txt",
interest: Interest::Established | Interest::Create | Interest::Delete,
},
Exactly(ex![Expectation::Event {
watch: "w",
kind: Interest::Established.into(),
path: "ephemeral.txt",
}]),
CreateFile { path: "ephemeral.txt" },
ExactlyWithOptional {
required: ex![Expectation::Event {
watch: "w",
kind: Interest::Create | Interest::CreateFile,
path: "ephemeral.txt",
}],
optional: ex![Expectation::Event {
watch: "w",
kind: Interest::Create | Interest::CreateFile,
path: "ephemeral.txt",
}],
},
DeleteFile { path: "ephemeral.txt" },
Exactly(ex![Expectation::Event {
watch: "w",
kind: Interest::Delete | Interest::DeleteFile,
path: "ephemeral.txt",
}]),
CreateFile { path: "ephemeral.txt" },
ExactlyWithOptional {
required: ex![Expectation::Event {
watch: "w",
kind: Interest::Create | Interest::CreateFile,
path: "ephemeral.txt",
}],
optional: ex![Expectation::Event {
watch: "w",
kind: Interest::Create | Interest::CreateFile,
path: "ephemeral.txt",
}],
},
DeleteFile { path: "ephemeral.txt" },
Exactly(ex![Expectation::Event {
watch: "w",
kind: (Interest::Delete | Interest::DeleteFile).into(),
path: "ephemeral.txt",
}]),
])
.await
}
#[tokio::test]
async fn create_intermediate_dirs_slowly() -> Result<()> {
run_test(&[
Watch {
name: "w",
path: "a/b/c/d/e.txt",
interest: Interest::Established | Interest::Create,
},
Exactly(ex![Expectation::Event {
watch: "w",
kind: Interest::Established.into(),
path: "a/b/c/d/e.txt",
}]),
CreateDir { path: "a" },
Sleep { ms: 100 },
CreateDir { path: "a/b" },
Sleep { ms: 100 },
CreateDir { path: "a/b/c" },
Sleep { ms: 100 },
CreateDir { path: "a/b/c/d" },
Sleep { ms: 100 },
CreateFile { path: "a/b/c/d/e.txt" },
Exactly(ex![Expectation::Event {
watch: "w",
kind: Interest::Create.into(),
path: "a/b/c/d/e.txt",
}]),
])
.await
}
#[tokio::test]
#[cfg(unix)]
async fn rename_parent_directory() -> Result<()> {
run_test(&[
CreateFile { path: "a/b/c/d.txt" },
Watch {
name: "w",
path: "a/b/c/d.txt",
interest: Interest::Established | Interest::Delete | Interest::Modify,
},
Exactly(ex![Expectation::Event {
watch: "w",
kind: Interest::Established.into(),
path: "a/b/c/d.txt",
}]),
Rename { from: "a/b", to: "a/x" },
Exactly(ex![Expectation::Event {
watch: "w",
kind: Interest::Delete.into(),
path: "a/b/c/d.txt",
}]),
])
.await
}
#[tokio::test]
async fn replace_file_with_directory() -> Result<()> {
run_test(&[
CreateFile { path: "thing" },
Watch {
name: "w",
path: "thing",
interest: Interest::Established | Interest::Delete | Interest::Create,
},
ExactlyWithOptional {
required: ex![Expectation::Event {
watch: "w",
kind: Interest::Established.into(),
path: "thing",
}],
optional: ex![Expectation::Event {
watch: "w",
kind: Interest::CreateFile.into(),
path: "thing",
}],
},
DeleteFile { path: "thing" },
Exactly(ex![Expectation::Event {
watch: "w",
kind: Interest::Delete | Interest::DeleteFile,
path: "thing",
}]),
CreateDir { path: "thing" },
ExactlyWithOptional {
required: ex![Expectation::Event {
watch: "w",
kind: Interest::CreateFolder | Interest::Create,
path: "thing",
}],
optional: ex![Expectation::Event {
watch: "w",
kind: Interest::CreateFolder | Interest::Create,
path: "thing",
}],
},
])
.await
}