diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 1eea57e..c014174 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -5,4 +5,4 @@ patreon: d2phap open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel -custom: ["https://donorbox.org/imageglass", "https://www.paypal.me/d2phap"] +custom: ["https://donate.stripe.com/6oE15Kab3740du828a", "https://www.paypal.me/d2phap"] diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml new file mode 100644 index 0000000..783009a --- /dev/null +++ b/.github/workflows/dotnet.yml @@ -0,0 +1,32 @@ +# This workflow will build a .NET project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net + +name: .NET + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +defaults: + run: + working-directory: Source + +jobs: + build: + + runs-on: windows-latest + + steps: + - uses: actions/checkout@v3 + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 8.0.x + - name: Restore dependencies + run: dotnet restore + - name: Build + run: dotnet build --no-restore + - name: Test + run: dotnet test --no-build --verbosity normal diff --git a/LICENSE b/LICENSE index e693ab6..0447c45 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ -MIT License +MIT License -Copyright (c) 2022 Duong Dieu Phap +Copyright (c) 2018-2026 Duong Dieu Phap Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 67c3169..7a5c4be 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# FileWatcherEx for Windows +# D2Phap.FileWatcherEx for Windows A wrapper of `System.IO.FileSystemWatcher` to standardize the events and avoid false change notifications. It has been being used in [ImageGlass - A lightweight, versatile image viewer](https://github.com/d2phap/ImageGlass) project. This project is based on the *VSCode FileWatcher*: https://github.com/Microsoft/vscode-filewatcher-windows @@ -7,28 +7,28 @@ This project is based on the *VSCode FileWatcher*: https://github.com/Microsoft/ ## Resource links -- Nuget package: [https://www.nuget.org/packages/FileWatcherEx](https://www.nuget.org/packages/FileWatcherEx/) +- Nuget package: [https://www.nuget.org/packages/D2Phap.FileWatcherEx](https://www.nuget.org/packages/D2Phap.FileWatcherEx/) - Project url: [https://github.com/d2phap/FileWatcherEx](https://github.com/d2phap/FileWatcherEx) - Website: [https://imageglass.org](https://imageglass.org) ## Features - Standardizes the events of `System.IO.FileSystemWatcher`. - No false change notifications when a file system item is created, deleted, changed or renamed. -- Supports .NET 6.0, 7.0. +- Supports .NET 8.0, 10.0 ## Installation Run the command: ```bash # Nuget package -Install-Package FileWatcherEx +Install-Package D2Phap.FileWatcherEx ``` ## Usage See Demo project for full details! ```cs -using FileWatcherEx; +using D2Phap.FileWatcherEx; var _fw = new FileSystemWatcherEx(@"C:\path\to\watch"); @@ -63,6 +63,5 @@ void FW_OnRenamed(object sender, FileChangedEvent e) - [GitHub sponsor](https://github.com/sponsors/d2phap) - [Patreon](https://www.patreon.com/d2phap) - [PayPal](https://www.paypal.me/d2phap) -- [Wire Transfers](https://donorbox.org/imageglass) Thanks for your gratitude and finance help! diff --git a/Source/FileWatcherEx/FileWatcherEx.csproj b/Source/D2Phap.FileWatcherEx/D2Phap.FileWatcherEx.csproj similarity index 69% rename from Source/FileWatcherEx/FileWatcherEx.csproj rename to Source/D2Phap.FileWatcherEx/D2Phap.FileWatcherEx.csproj index e755cd5..bf555e3 100644 --- a/Source/FileWatcherEx/FileWatcherEx.csproj +++ b/Source/D2Phap.FileWatcherEx/D2Phap.FileWatcherEx.csproj @@ -1,10 +1,10 @@ - net6.0;net7.0 + net8.0;net10.0 enable enable - Copyright © 2018-2023 Duong Dieu Phap + Copyright © 2018-2026 Duong Dieu Phap https://github.com/d2phap/FileWatcherEx README.md https://github.com/d2phap/FileWatcherEx @@ -13,16 +13,28 @@ A wrapper of FileSystemWatcher to standardize the events and avoid false change notifications, used in ImageGlass project (https://imageglass.org). This project is based on the VSCode FileWatcher: https://github.com/Microsoft/vscode-filewatcher-windows. True $(Version) - 2.2.0 - MIT + 3.0.0 See https://github.com/d2phap/FileWatcherEx/releases d2phap - FileWatcherEx - A file system watcher + D2Phap.FileWatcherEx - A file system watcher True snupkg + LICENSE + False + latest + + true + true + true + false + latest + + True + \ + True \ diff --git a/Source/FileWatcherEx/FileEvents.cs b/Source/D2Phap.FileWatcherEx/FileEvents.cs similarity index 93% rename from Source/FileWatcherEx/FileEvents.cs rename to Source/D2Phap.FileWatcherEx/FileEvents.cs index 1ce5d5d..871dcbb 100644 --- a/Source/FileWatcherEx/FileEvents.cs +++ b/Source/D2Phap.FileWatcherEx/FileEvents.cs @@ -1,4 +1,4 @@ -namespace FileWatcherEx; +namespace D2Phap.FileWatcherEx; public enum ChangeType { diff --git a/Source/FileWatcherEx/FileSystemWatcherEx.cs b/Source/D2Phap.FileWatcherEx/FileSystemWatcherEx.cs similarity index 76% rename from Source/FileWatcherEx/FileSystemWatcherEx.cs rename to Source/D2Phap.FileWatcherEx/FileSystemWatcherEx.cs index 8bc5e02..e382dd3 100644 --- a/Source/FileWatcherEx/FileSystemWatcherEx.cs +++ b/Source/D2Phap.FileWatcherEx/FileSystemWatcherEx.cs @@ -1,30 +1,41 @@  +using D2Phap.FileWatcherEx.Helpers; using System.Collections.Concurrent; using System.ComponentModel; -namespace FileWatcherEx; +namespace D2Phap.FileWatcherEx; + /// /// A wrapper of to standardize the events /// and avoid false change notifications. /// -public class FileSystemWatcherEx : IDisposable +/// +/// Optional Action to log out library internals +public class FileSystemWatcherEx(string folderPath = "", Action? logger = null) : IDisposable, IFileSystemWatcherEx { - #region Private Properties private Thread? _thread; private EventProcessor? _processor; - private readonly BlockingCollection _fileEventQueue = new(); + private readonly BlockingCollection _fileEventQueue = []; - private FileWatcher? _watcher; - private FileSystemWatcher? _fsw; + private SymlinkAwareFileWatcher? _watcher; + private Func? _fswFactory; + private readonly Action _logger = logger ?? (_ => { }); // Define the cancellation token. private CancellationTokenSource? _cancelSource; - #endregion + // allow injection of FileSystemWatcherWrapper + internal Func FileSystemWatcherFactory + { + // default to production FileSystemWatcherWrapper (which wrapped the native FileSystemWatcher) + get { return _fswFactory ?? (() => new FileSystemWatcherWrapper()); } + set => _fswFactory = value; + } + #endregion #region Public Properties @@ -32,13 +43,13 @@ public class FileSystemWatcherEx : IDisposable /// /// Gets or sets the path of the directory to watch. /// - public string FolderPath { get; set; } = ""; + public string FolderPath { get; set; } = folderPath; /// /// Gets the collection of all the filters used to determine what files are monitored in a directory. /// - public System.Collections.ObjectModel.Collection Filters { get; } = new(); + public System.Collections.ObjectModel.Collection Filters { get; } = []; /// @@ -79,7 +90,6 @@ public string Filter #endregion - #region Public Events /// @@ -125,22 +135,13 @@ public string Filter - /// - /// Initialize new instance of - /// - /// - public FileSystemWatcherEx(string folderPath = "") - { - FolderPath = folderPath; - } - - /// /// Start watching files /// public void Start() { if (!Directory.Exists(FolderPath)) return; + Stop(); _processor = new EventProcessor((e) => @@ -155,15 +156,13 @@ void InvokeChangedEvent(object? sender, FileChangedEvent fileEvent) { if (SynchronizingObject != null && SynchronizingObject.InvokeRequired) { - SynchronizingObject.Invoke(new Action(InvokeChangedEvent), new object[] { SynchronizingObject, e }); + SynchronizingObject.Invoke(new Action(InvokeChangedEvent), [SynchronizingObject, e]); } else { OnChanged?.Invoke(SynchronizingObject, e); } } - - break; case ChangeType.CREATED: @@ -174,15 +173,13 @@ void InvokeCreatedEvent(object? sender, FileChangedEvent fileEvent) { if (SynchronizingObject != null && SynchronizingObject.InvokeRequired) { - SynchronizingObject.Invoke(new Action(InvokeCreatedEvent), new object[] { SynchronizingObject, e }); + SynchronizingObject.Invoke(new Action(InvokeCreatedEvent), args: [SynchronizingObject, e]); } else { OnCreated?.Invoke(SynchronizingObject, e); } } - - break; case ChangeType.DELETED: @@ -193,15 +190,13 @@ void InvokeDeletedEvent(object? sender, FileChangedEvent fileEvent) { if (SynchronizingObject != null && SynchronizingObject.InvokeRequired) { - SynchronizingObject.Invoke(new Action(InvokeDeletedEvent), new object[] { SynchronizingObject, e }); + SynchronizingObject.Invoke(new Action(InvokeDeletedEvent), [SynchronizingObject, e]); } else { OnDeleted?.Invoke(SynchronizingObject, e); } } - - break; case ChangeType.RENAMED: @@ -212,15 +207,13 @@ void InvokeRenamedEvent(object? sender, FileChangedEvent fileEvent) { if (SynchronizingObject != null && SynchronizingObject.InvokeRequired) { - SynchronizingObject.Invoke(new Action(InvokeRenamedEvent), new object[] { SynchronizingObject, e }); + SynchronizingObject.Invoke(new Action(InvokeRenamedEvent), [SynchronizingObject, e]); } else { OnRenamed?.Invoke(SynchronizingObject, e); } } - - break; default: @@ -228,10 +221,10 @@ void InvokeRenamedEvent(object? sender, FileChangedEvent fileEvent) } }, (log) => { - Console.WriteLine(string.Format("{0} | {1}", Enum.GetName(typeof(ChangeType), ChangeType.LOG), log)); + Console.WriteLine($"{Enum.GetName(ChangeType.LOG)} | {log}"); }); - _cancelSource = new(); + _cancelSource = new CancellationTokenSource(); _thread = new Thread(() => Thread_DoingWork(_cancelSource.Token)) { // this ensures the thread does not block the process from terminating! @@ -242,38 +235,40 @@ void InvokeRenamedEvent(object? sender, FileChangedEvent fileEvent) // Log each event in our special format to output queue - void onEvent(FileChangedEvent e) + void OnEvent(FileChangedEvent e) { _fileEventQueue.Add(e); } - // OnError - void onError(ErrorEventArgs e) + void OnError(ErrorEventArgs e) { if (e != null) { - OnError?.Invoke(this, e); + this.OnError?.Invoke(this, e); } } - - // Start watcher - _watcher = new FileWatcher(); - - _fsw = _watcher.Create(FolderPath, onEvent, onError); - - foreach (var filter in Filters) + _watcher = new SymlinkAwareFileWatcher(FolderPath, OnEvent, OnError, FileSystemWatcherFactory, _logger) { - _fsw.Filters.Add(filter); - } + NotifyFilter = NotifyFilter, + IncludeSubdirectories = IncludeSubdirectories, + SynchronizingObject = SynchronizingObject, + EnableRaisingEvents = true + }; + Filters.ToList().ForEach(_watcher.Filters.Add); + _watcher.Init(); + } - _fsw.NotifyFilter = NotifyFilter; - _fsw.IncludeSubdirectories = IncludeSubdirectories; - _fsw.SynchronizingObject = SynchronizingObject; - // Start watching - _fsw.EnableRaisingEvents = true; + internal void StartForTesting( + Func getFileAttributesFunc, + Func getDirectoryInfosFunc) + { + Start(); + if (_watcher is null) return; + _watcher.GetFileAttributesFunc = getFileAttributesFunc; + _watcher.GetDirectoryInfosFunc = getDirectoryInfosFunc; } @@ -282,17 +277,12 @@ void onError(ErrorEventArgs e) /// public void Stop() { - if (_fsw != null) - { - _fsw.EnableRaisingEvents = false; - _fsw.Dispose(); - } - _watcher?.Dispose(); + _watcher = null; // stop the thread - _cancelSource?.Cancel(); _cancelSource?.Dispose(); + _cancelSource = null; } @@ -301,14 +291,12 @@ public void Stop() /// public void Dispose() { - _fsw?.Dispose(); - _watcher?.Dispose(); - _cancelSource?.Dispose(); + Stop(); + GC.SuppressFinalize(this); } - private void Thread_DoingWork(CancellationToken cancelToken) { while (true) @@ -327,6 +315,5 @@ private void Thread_DoingWork(CancellationToken cancelToken) } } } - - } + diff --git a/Source/D2Phap.FileWatcherEx/Helpers/EventNormalizer.cs b/Source/D2Phap.FileWatcherEx/Helpers/EventNormalizer.cs new file mode 100644 index 0000000..77fc675 --- /dev/null +++ b/Source/D2Phap.FileWatcherEx/Helpers/EventNormalizer.cs @@ -0,0 +1,170 @@ +namespace D2Phap.FileWatcherEx.Helpers; + +/// +/// Tries to fix the real life oddities of the underlying FileSystemWatcher class. +/// The code here got refactored from the original Microsoft sources. +/// For real scenario, see EventNormalizerTest.cs +/// +internal class EventNormalizer +{ + private readonly FileEventRepository _eventRepo = new(); + + internal IEnumerable Normalize(FileChangedEvent[] events) + { + NormalizeDuplicates(events); + return FilterDeleted(_eventRepo.Events()); + } + + private void NormalizeDuplicates(FileChangedEvent[] events) + { + foreach (var newEvent in events) + { + var oldEvent = _eventRepo.Find(newEvent.FullPath); + // original file event from which we renamed, only applicable for RENAMED event + var renameFromEvent = newEvent.ChangeType == ChangeType.RENAMED + ? _eventRepo.Find(newEvent.OldFullPath) + : null; + + switch (newEvent.ChangeType) + { + // CREATED followed by CHANGED => CREATED + case ChangeType.CHANGED when oldEvent?.ChangeType == ChangeType.CREATED: + // Do nothing + break; + + // CREATED followed by DELETED => remove + case ChangeType.DELETED when oldEvent?.ChangeType == ChangeType.CREATED: + _eventRepo.Remove(oldEvent); + break; + + // DELETED followed by CREATED => CHANGED + case ChangeType.CREATED when oldEvent?.ChangeType == ChangeType.DELETED: + oldEvent.ChangeType = ChangeType.CHANGED; + break; + + // Scenario: + // - file foo is created + // - file bar is deleted + // - now foo is renamed to the just deleted bar + // - this results into a bar changed event + case ChangeType.RENAMED when oldEvent?.ChangeType == ChangeType.DELETED && renameFromEvent?.ChangeType == ChangeType.CREATED: + newEvent.ChangeType = ChangeType.CHANGED; + newEvent.OldFullPath = null; + _eventRepo.AddOrUpdate(newEvent); + + // Remove data about the CREATED file + _eventRepo.Remove(renameFromEvent); + break; + + // rename from CREATED file, all other cases + case ChangeType.RENAMED when renameFromEvent?.ChangeType == ChangeType.CREATED: + newEvent.ChangeType = ChangeType.CREATED; + newEvent.OldFullPath = null; + _eventRepo.AddOrUpdate(newEvent); + + // Remove data about the CREATED file + _eventRepo.Remove(renameFromEvent); + break; + + case ChangeType.RENAMED when renameFromEvent?.ChangeType == ChangeType.RENAMED: + newEvent.OldFullPath = renameFromEvent.OldFullPath; + _eventRepo.AddOrUpdate(newEvent); + + // Remove data about the RENAMED file + _eventRepo.Remove(renameFromEvent); + break; + + // the LOG event is not coming from the filesystem, hence it is ignored. + // ideally, LOG would disappear completely but unfortunately it is part of the public API of this lib + case ChangeType.LOG: + // ignore + break; + + default: + _eventRepo.AddOrUpdate(newEvent); + break; + } + } + } + + // This algorithm will remove all DELETE events up to the root folder + // that got deleted if any. This ensures that we are not producing + // DELETE events for each file inside a folder that gets deleted. + // + // 1.) split ADD/CHANGE and DELETED events + // 2.) sort short deleted paths to the top + // 3.) for each DELETE, check if there is a deleted parent and ignore the event in that case + internal static IEnumerable FilterDeleted(IEnumerable eventsWithoutDuplicates) + { + // Handle deletes + var deletedPaths = new List(); + return eventsWithoutDuplicates + .Select((e, n) => new KeyValuePair(n, e)) // store original position value + .OrderBy(e => e.Value.FullPath.Length) // shortest path first + .Where(e => IsParent(e.Value, deletedPaths)) + .OrderBy(e => e.Key) // restore original position + .Select(e => e.Value); + } + + internal static bool IsParent(FileChangedEvent e, List deletedPaths) + { + if (e.ChangeType == ChangeType.DELETED) + { + if (deletedPaths.Any(d => IsParent(e.FullPath, d))) + { + return false; // DELETE is ignored if parent is deleted already + } + + // otherwise mark as deleted + deletedPaths.Add(e.FullPath); + } + + return true; + } + + + internal static bool IsParent(string path, string candidatePath) + { + // if exists, remove trailing "\" for both paths + candidatePath = candidatePath.TrimEnd('\\'); + path = path.TrimEnd('\\'); + return path.StartsWith(candidatePath + '\\', StringComparison.Ordinal); + } + + + private class FileEventRepository + { + private readonly Dictionary _mapPathToEvents = []; + + public void AddOrUpdate(FileChangedEvent newEvent) + { + if (_mapPathToEvents.TryGetValue(newEvent.FullPath, out var oldEvent)) + { + // update existing + oldEvent.ChangeType = newEvent.ChangeType; + oldEvent.OldFullPath = newEvent.OldFullPath; + } + else + { + // add + _mapPathToEvents[newEvent.FullPath] = newEvent; + } + } + + public void Remove(FileChangedEvent ev) + { + _mapPathToEvents.Remove(ev.FullPath); + } + + public FileChangedEvent? Find(string? path) + { + _mapPathToEvents.TryGetValue(path ?? "", out var oldEvent); + return oldEvent; + } + + public List Events() + { + return [.. _mapPathToEvents.Values]; + } + } +} diff --git a/Source/D2Phap.FileWatcherEx/Helpers/EventProcessor.cs b/Source/D2Phap.FileWatcherEx/Helpers/EventProcessor.cs new file mode 100644 index 0000000..e661f29 --- /dev/null +++ b/Source/D2Phap.FileWatcherEx/Helpers/EventProcessor.cs @@ -0,0 +1,101 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +namespace D2Phap.FileWatcherEx.Helpers; + +internal class EventProcessor(Action onEvent, Action onLogging) +{ + /// + /// Aggregate and only emit events when changes have stopped for this duration (in ms) + /// + private const int EventDelay = 50; + + /// + /// Warn after certain time span of event spam + /// + private readonly TimeSpan _eventSpamWarningThreshold = TimeSpan.FromMinutes(1); + +#if NET9_0_OR_GREATER + private readonly Lock _lock = new(); +#else + private readonly object _lock = new(); +#endif + private Task? _delayTask = null; + + private readonly List _events = []; + private readonly Action _handleEvent = onEvent; + private readonly Action _logger = onLogging; + + private long _lastEventTime = 0; + private long _delayStarted = 0; + + private long _spamCheckStartTime = 0; + private bool _spamWarningLogged = false; + + + public void ProcessEvent(FileChangedEvent fileEvent) + { + lock (_lock) + { + var now = DateTime.Now.Ticks; + WarnForSpam(fileEvent, now); + + // Add into our queue + _events.Add(fileEvent); + _lastEventTime = now; + + // Process queue after delay + if (_delayTask == null) + { + // Start function after delay + _delayStarted = _lastEventTime; + _delayTask = Task.Delay(EventDelay).ContinueWith(HandleEventsFunc); + } + } + } + + private void HandleEventsFunc(Task _) + { + lock (_lock) + { + // Check if another event has been received in the meantime + if (_delayStarted == _lastEventTime) + { + // Normalize and handle + var normalized = new EventNormalizer().Normalize([.. _events]); + foreach (var ev in normalized) + { + _handleEvent(ev); + } + + // Reset + _events.Clear(); + _delayTask = null; + } + + // Otherwise we have received a new event while this task was + // delayed and we reschedule it. + else + { + _delayStarted = _lastEventTime; + _delayTask = Task.Delay(EventDelay).ContinueWith(HandleEventsFunc); + } + } + } + + private void WarnForSpam(FileChangedEvent fileEvent, long now) + { + if (_events.Count == 0) + { + _spamWarningLogged = false; + _spamCheckStartTime = now; + } + else if (!_spamWarningLogged && _spamCheckStartTime + _eventSpamWarningThreshold.Ticks < now) + { + _spamWarningLogged = true; + _logger($"Warning: Watcher is busy catching up with {_events.Count} file changes " + + $"in {_eventSpamWarningThreshold.TotalSeconds} seconds. Latest path is '{fileEvent.FullPath}'"); + } + } +} \ No newline at end of file diff --git a/Source/D2Phap.FileWatcherEx/Helpers/FileSystemWatcherWrapper.cs b/Source/D2Phap.FileWatcherEx/Helpers/FileSystemWatcherWrapper.cs new file mode 100644 index 0000000..5fa8b82 --- /dev/null +++ b/Source/D2Phap.FileWatcherEx/Helpers/FileSystemWatcherWrapper.cs @@ -0,0 +1,39 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; + +namespace D2Phap.FileWatcherEx.Helpers; + +/// +/// Interface around .NET FileSystemWatcher to be able to replace it with a fake implementation +/// +public interface IFileSystemWatcherWrapper +{ + string Path { get; set; } + + Collection Filters { get; } + bool IncludeSubdirectories { get; set; } + bool EnableRaisingEvents { get; set; } + NotifyFilters NotifyFilter { get; set; } + + event FileSystemEventHandler Created; + event FileSystemEventHandler Deleted; + event FileSystemEventHandler Changed; + event RenamedEventHandler Renamed; + event ErrorEventHandler Error; + + int InternalBufferSize { get; set; } + + public ISynchronizeInvoke? SynchronizingObject { get; set; } + + void Dispose(); +} + +/// +/// Production implementation of IFileSystemWrapper interface. +/// Backed by the existing FileSystemWatcher +/// +public class FileSystemWatcherWrapper : FileSystemWatcher, IFileSystemWatcherWrapper +{ + // intentionally empty +} + diff --git a/Source/D2Phap.FileWatcherEx/Helpers/SymlinkAwareFileWatcher.cs b/Source/D2Phap.FileWatcherEx/Helpers/SymlinkAwareFileWatcher.cs new file mode 100644 index 0000000..033bdec --- /dev/null +++ b/Source/D2Phap.FileWatcherEx/Helpers/SymlinkAwareFileWatcher.cs @@ -0,0 +1,231 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +using System.Collections.ObjectModel; +using System.ComponentModel; + +namespace D2Phap.FileWatcherEx.Helpers; + + +/// +/// Create new instance of . +/// Object creation follows this order: +/// +/// 1) create new instance +/// 2) set properties (optional) +/// 3) call (mandatory) +/// +/// +/// Full folder path to watcher +/// onEvent callback +/// onError callback +/// how to create a FileSystemWatcher +/// logging callback +internal class SymlinkAwareFileWatcher(string path, + Action onEvent, + Action onError, + Func watcherFactory, + Action logger) : IDisposable +{ + private readonly string _watchPath = path; + private readonly Action? _eventCallback = onEvent; + private readonly Action? _onError = onError; + private Func? _getFileAttributesFunc; + private Func? _getDirectoryInfosFunc; + private readonly Func _watcherFactory = watcherFactory; + private readonly Action _logger = logger; + + + internal Func GetFileAttributesFunc + { + get => _getFileAttributesFunc ?? File.GetAttributes; + set => _getFileAttributesFunc = value; + } + + internal Func GetDirectoryInfosFunc + { + get + { + static DirectoryInfo[] DefaultFunc(string p) => new DirectoryInfo(p).GetDirectories(); + return _getDirectoryInfosFunc ?? DefaultFunc; + } + set => _getDirectoryInfosFunc = value; + } + + internal Dictionary FileWatchers { get; } = []; + + // defaults from: + // https://learn.microsoft.com/en-us/dotnet/api/system.io.filesystemwatcher.notifyfilter?view=net-7.0#property-value + public NotifyFilters NotifyFilter { get; set; } = NotifyFilters.LastWrite + | NotifyFilters.FileName + | NotifyFilters.DirectoryName; + public bool EnableRaisingEvents { get; set; } + + public bool IncludeSubdirectories { get; set; } + + public Collection Filters { get; } = []; + + public ISynchronizeInvoke? SynchronizingObject { get; set; } + + + public void Init() + { + RegisterFileWatcher(_watchPath); + RegisterAdditionalFileWatchersForSymLinkDirs(_watchPath); + } + + private void RegisterFileWatcher(string path) + { + _logger($"Registering file watcher for {path}"); + var fileWatcher = _watcherFactory(); + SetFileWatcherProperties(fileWatcher, path); + RegisterFileWatcherEventHandlers(fileWatcher); + + FileWatchers.Add(path, fileWatcher); + } + + private void SetFileWatcherProperties(IFileSystemWatcherWrapper fileWatcher, string path) + { + fileWatcher.Path = path; + fileWatcher.NotifyFilter = NotifyFilter; + fileWatcher.IncludeSubdirectories = IncludeSubdirectories; + fileWatcher.EnableRaisingEvents = EnableRaisingEvents; + Filters.ToList().ForEach(fileWatcher.Filters.Add); + + // currently the sync object is only registered for the root file watcher. + // this preserves the old behaviour + if (IsRootPath(path)) + { + fileWatcher.SynchronizingObject = SynchronizingObject; + } + + //changing this to a higher value can lead into issues when watching UNC drives + fileWatcher.InternalBufferSize = 32768; + } + + + private bool IsRootPath(string path) + { + return _watchPath == path; + } + + private void RegisterFileWatcherEventHandlers(IFileSystemWatcherWrapper fileWatcher) + { + fileWatcher.Created += (_, e) => ProcessEvent(e, ChangeType.CREATED); + fileWatcher.Changed += (_, e) => ProcessEvent(e, ChangeType.CHANGED); + fileWatcher.Deleted += (_, e) => ProcessEvent(e, ChangeType.DELETED); + fileWatcher.Renamed += (_, e) => ProcessRenamedEvent(e); + fileWatcher.Error += (_, e) => _onError?.Invoke(e); + + // extra measures to handle symbolic link directories + fileWatcher.Created += (_, e) => TryRegisterFileWatcherForSymbolicLinkDir(e.FullPath); + fileWatcher.Deleted += UnregisterFileWatcherForSymbolicLinkDir; + } + + /// + /// Recursively find sym link dir and register them. + /// Background: the native filewatcher does not follow symlinks so they need to be treated separately. + /// + private void RegisterAdditionalFileWatchersForSymLinkDirs(string path) + { + TryRegisterFileWatcherForSymbolicLinkDir(path); + + if (!IncludeSubdirectories || !Directory.Exists(path)) + { + return; + } + + foreach (var dirInfo in GetDirectoryInfosFunc(path)) + { + RegisterAdditionalFileWatchersForSymLinkDirs(dirInfo.FullName); + } + } + + + /// + /// Process event for type = [CHANGED; DELETED; CREATED] + /// + private void ProcessEvent(FileSystemEventArgs e, ChangeType changeType) + { + _eventCallback?.Invoke(new() + { + ChangeType = changeType, + FullPath = e.FullPath, + }); + } + + + private void ProcessRenamedEvent(RenamedEventArgs e) + { + _eventCallback?.Invoke(new() + { + ChangeType = ChangeType.RENAMED, + FullPath = e.FullPath, + OldFullPath = e.OldFullPath, + }); + } + + + /// + /// Safely register a file watcher for a symbolic link directory. Used at startup as well as callback on file creation. + /// + /// + internal void TryRegisterFileWatcherForSymbolicLinkDir(string path) + { + try + { + if (IsSymbolicLinkDirectory(path) && IncludeSubdirectories && !FileWatchers.ContainsKey(path)) + { + _logger($"Directory {path} is a symbolic link dir. Will register additional file watcher."); + RegisterFileWatcher(path); + } + } + catch (Exception ex) + { + // IG Issue #405: throws exception on Windows 10 + // for "c:\users\user\application data" folder and sub-folders. + _logger($"Error registering file system watcher for directory '{path}'. Error was: {ex.Message}"); + } + } + + + /// + /// Cleanup filewatcher if a symbolic link dir is deleted + /// + internal void UnregisterFileWatcherForSymbolicLinkDir(object? _, FileSystemEventArgs e) + { + if (FileWatchers.TryGetValue(e.FullPath, out IFileSystemWatcherWrapper? value)) + { + value.Dispose(); + FileWatchers.Remove(e.FullPath); + } + } + + + private bool IsSymbolicLinkDirectory(string path) + { + var attrs = GetFileAttributesFunc(path); + return attrs.HasFlag(FileAttributes.Directory) + && attrs.HasFlag(FileAttributes.ReparsePoint); + } + + // for testing + internal List GetFileWatchers() + { + return [.. FileWatchers.Values]; + } + + + /// + /// Stop raising events and Dispose all filewatchers + /// + public void Dispose() + { + foreach (var watcher in FileWatchers.Select(pair => pair.Value)) + { + watcher.EnableRaisingEvents = false; + watcher.Dispose(); + } + } +} diff --git a/Source/D2Phap.FileWatcherEx/IFileSystemWatcherEx.cs b/Source/D2Phap.FileWatcherEx/IFileSystemWatcherEx.cs new file mode 100644 index 0000000..b794171 --- /dev/null +++ b/Source/D2Phap.FileWatcherEx/IFileSystemWatcherEx.cs @@ -0,0 +1,85 @@ +using System.ComponentModel; + +namespace D2Phap.FileWatcherEx; + +public interface IFileSystemWatcherEx +{ + /// + /// Gets or sets the path of the directory to watch. + /// + string FolderPath { get; set; } + + /// + /// Gets the collection of all the filters used to determine what files are monitored in a directory. + /// + System.Collections.ObjectModel.Collection Filters { get; } + + /// + /// Gets or sets the filter string used to determine what files are monitored in a directory. + /// + string Filter { get; set; } + + /// + /// Gets or sets the type of changes to watch for. + /// The default is the bitwise OR combination of + /// , + /// , + /// and . + /// + NotifyFilters NotifyFilter { get; set; } + + /// + /// Gets or sets a value indicating whether subdirectories within the specified path should be monitored. + /// + bool IncludeSubdirectories { get; set; } + + /// + /// Gets or sets the object used to marshal the event handler calls issued as a result of a directory change. + /// + ISynchronizeInvoke? SynchronizingObject { get; set; } + + /// + /// Occurs when a file or directory in the specified + /// is changed. + /// + event FileSystemWatcherEx.DelegateOnChanged? OnChanged; + + /// + /// Occurs when a file or directory in the specified + /// is deleted. + /// + event FileSystemWatcherEx.DelegateOnDeleted? OnDeleted; + + /// + /// Occurs when a file or directory in the specified + /// is created. + /// + event FileSystemWatcherEx.DelegateOnCreated? OnCreated; + + /// + /// Occurs when a file or directory in the specified + /// is renamed. + /// + event FileSystemWatcherEx.DelegateOnRenamed? OnRenamed; + + /// + /// Occurs when the instance of is unable to continue + /// monitoring changes or when the internal buffer overflows. + /// + event FileSystemWatcherEx.DelegateOnError? OnError; + + /// + /// Start watching files + /// + void Start(); + + /// + /// Stop watching files + /// + void Stop(); + + /// + /// Dispose the FileWatcherEx instance + /// + void Dispose(); +} diff --git a/Source/Demo/Demo.WinForms.csproj b/Source/Demo/Demo.WinForms.csproj index 592a245..100d947 100644 --- a/Source/Demo/Demo.WinForms.csproj +++ b/Source/Demo/Demo.WinForms.csproj @@ -2,14 +2,14 @@ WinExe - net6.0-windows + net10.0-windows enable true enable - + \ No newline at end of file diff --git a/Source/Demo/Form1.Designer.cs b/Source/Demo/Form1.Designer.cs index 797ed49..5046d93 100644 --- a/Source/Demo/Form1.Designer.cs +++ b/Source/Demo/Form1.Designer.cs @@ -74,7 +74,7 @@ private void InitializeComponent() this.txtPath.Name = "txtPath"; this.txtPath.Size = new System.Drawing.Size(508, 57); this.txtPath.TabIndex = 2; - this.txtPath.Text = "C:\\Users\\d2pha\\Desktop\\New folder"; + this.txtPath.Text = "C:\\"; // // btnStart // diff --git a/Source/Demo/Form1.cs b/Source/Demo/Form1.cs index bb5f772..5f1388b 100644 --- a/Source/Demo/Form1.cs +++ b/Source/Demo/Form1.cs @@ -1,111 +1,120 @@ +using D2Phap.FileWatcherEx; -using FileWatcherEx; +namespace Demo; -namespace Demo +public partial class Form1 : Form { - public partial class Form1 : Form - { - private FileSystemWatcherEx _fw = new(); + private FileSystemWatcherEx _fw = new(); - public Form1() - { - InitializeComponent(); - } + public Form1() + { + InitializeComponent(); + } - private void BtnStart_Click(object sender, EventArgs e) - { - _fw = new FileSystemWatcherEx(txtPath.Text.Trim()); + private void BtnStart_Click(object sender, EventArgs e) + { + _fw = new FileSystemWatcherEx(txtPath.Text.Trim(), FW_OnLog); - _fw.OnRenamed += FW_OnRenamed; - _fw.OnCreated += FW_OnCreated; - _fw.OnDeleted += FW_OnDeleted; - _fw.OnChanged += FW_OnChanged; - _fw.OnError += FW_OnError; + _fw.OnRenamed += FW_OnRenamed; + _fw.OnCreated += FW_OnCreated; + _fw.OnDeleted += FW_OnDeleted; + _fw.OnChanged += FW_OnChanged; + _fw.OnError += FW_OnError; - _fw.SynchronizingObject = this; + _fw.SynchronizingObject = this; + _fw.IncludeSubdirectories = true; + try + { _fw.Start(); - btnStart.Enabled = false; + btnStart.Enabled = true; btnSelectFolder.Enabled = false; txtPath.Enabled = false; btnStop.Enabled = true; } - - private void FW_OnError(object? sender, ErrorEventArgs e) + catch (Exception ex) { - if (txtConsole.InvokeRequired) - { - txtConsole.Invoke(FW_OnError, sender, e); - } - else - { - txtConsole.Text += "[ERROR]: " + e.GetException().Message + "\r\n"; - } - } - - private void FW_OnChanged(object? sender, FileChangedEvent e) - { - txtConsole.Text += string.Format("[cha] {0} | {1}", - Enum.GetName(typeof(ChangeType), e.ChangeType), - e.FullPath) + "\r\n"; + MessageBox.Show(ex.Message); } + } - private void FW_OnDeleted(object? sender, FileChangedEvent e) + private void FW_OnError(object? sender, ErrorEventArgs e) + { + if (txtConsole.InvokeRequired) { - txtConsole.Text += string.Format("[del] {0} | {1}", - Enum.GetName(typeof(ChangeType), e.ChangeType), - e.FullPath) + "\r\n"; + txtConsole.Invoke(FW_OnError, sender, e); } - - private void FW_OnCreated(object? sender, FileChangedEvent e) + else { - txtConsole.Text += string.Format("[cre] {0} | {1}", - Enum.GetName(typeof(ChangeType), e.ChangeType), - e.FullPath) + "\r\n"; + txtConsole.Text += "[ERROR]: " + e.GetException().Message + "\r\n"; } + } - private void FW_OnRenamed(object? sender, FileChangedEvent e) - { - txtConsole.Text += string.Format("[ren] {0} | {1} ----> {2}", - Enum.GetName(typeof(ChangeType), e.ChangeType), - e.OldFullPath, - e.FullPath) + "\r\n"; - } + private void FW_OnChanged(object? sender, FileChangedEvent e) + { + txtConsole.Text += string.Format("[cha] {0} | {1}", + Enum.GetName(e.ChangeType), + e.FullPath) + "\r\n"; + } + private void FW_OnDeleted(object? sender, FileChangedEvent e) + { + txtConsole.Text += string.Format("[del] {0} | {1}", + Enum.GetName(e.ChangeType), + e.FullPath) + "\r\n"; + } + private void FW_OnCreated(object? sender, FileChangedEvent e) + { + txtConsole.Text += string.Format("[cre] {0} | {1}", + Enum.GetName(e.ChangeType), + e.FullPath) + "\r\n"; + } - private void BtnStop_Click(object sender, EventArgs e) - { - _fw.Stop(); + private void FW_OnRenamed(object? sender, FileChangedEvent e) + { + txtConsole.Text += string.Format("[ren] {0} | {1} ----> {2}", + Enum.GetName(e.ChangeType), + e.OldFullPath, + e.FullPath) + "\r\n"; + } - btnStart.Enabled = true; - btnSelectFolder.Enabled = true; - txtPath.Enabled = true; - btnStop.Enabled = false; - } + private void FW_OnLog(string value) + { + txtConsole.Text += $@"[log] {value}" + "\r\n"; + } + private void BtnStop_Click(object sender, EventArgs e) + { + _fw.Stop(); - private void BtnSelectFolder_Click(object sender, EventArgs e) - { - var fb = new FolderBrowserDialog(); + btnStart.Enabled = true; + btnSelectFolder.Enabled = true; + txtPath.Enabled = true; + btnStop.Enabled = false; + btnStop.Enabled = true; + } - if (fb.ShowDialog() == DialogResult.OK) - { - txtPath.Text = fb.SelectedPath; + private void BtnSelectFolder_Click(object sender, EventArgs e) + { + var fb = new FolderBrowserDialog(); - _fw.Stop(); - _fw.Dispose(); - } - } - private void Form1_FormClosing(object sender, FormClosingEventArgs e) + if (fb.ShowDialog() == DialogResult.OK) { + txtPath.Text = fb.SelectedPath; + _fw.Stop(); _fw.Dispose(); } } + + private void Form1_FormClosing(object sender, FormClosingEventArgs e) + { + _fw.Dispose(); + } } \ No newline at end of file diff --git a/Source/Demo/README.md b/Source/Demo/README.md new file mode 100644 index 0000000..bff6a63 --- /dev/null +++ b/Source/Demo/README.md @@ -0,0 +1,9 @@ +Manual Testing +-------------- + +This little application is helpful for interactive testing of the library. + +Symlinks +-------- +To create a symlink directory on Windows, use `mklink`. +Example: `mklink /D my-symbolic-link c:\temp\target-directory` diff --git a/Source/FileSystemEventRecorder/FileSystemEventRecorder.cs b/Source/FileSystemEventRecorder/FileSystemEventRecorder.cs new file mode 100644 index 0000000..be5fe3e --- /dev/null +++ b/Source/FileSystemEventRecorder/FileSystemEventRecorder.cs @@ -0,0 +1,150 @@ +using CsvHelper; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Globalization; + +namespace FileSystemEventRecorder; + +// event received from C# FileSystemWatcher +internal record EventRecord( + string FullPath, + string EventName, + string? OldFullPath, // only provided by "rename" event + long NowInTicks +); + +// post processed. Calculated before closing program. +// data is written to CSV already in the format FileSystemEventArgs requires it (separate dir + filename) +internal record EventRecordWithDiff( + string Directory, + string FileName, + string EventName, + string? OldFileName, + long DiffInTicks, // ticks between passed by from the previous event to now + double DiffInMilliseconds // milliseconds between previous event and now. +); + +/// +/// Command line tool to capture the raw events of the native FileSystemWatcher class in a CSV file +/// +public static class FileSystemEventRecords +{ + private static readonly ConcurrentQueue EventRecords = new(); + + public static void Main(string[] args) + { + var (watchedDirectory, csvOutputFile) = ProcessArguments(args); + + var watcher = new FileSystemWatcher + { + Path = watchedDirectory, + IncludeSubdirectories = true, + NotifyFilter = NotifyFilters.LastWrite + | NotifyFilters.FileName + | NotifyFilters.DirectoryName + }; + + watcher.Created += (_, ev) => + EventRecords.Enqueue(new EventRecord(ev.FullPath, "created", null, Stopwatch.GetTimestamp())); + watcher.Deleted += (_, ev) => + EventRecords.Enqueue(new EventRecord(ev.FullPath, "deleted", null, Stopwatch.GetTimestamp())); + + watcher.Changed += (_, ev) => + EventRecords.Enqueue(new EventRecord(ev.FullPath, "changed", null, Stopwatch.GetTimestamp())); + watcher.Renamed += (_, ev) => + EventRecords.Enqueue(new EventRecord(ev.FullPath, "renamed", ev.OldFullPath, Stopwatch.GetTimestamp())); + watcher.Error += (_, ev) => + { + EventRecords.Enqueue(new EventRecord("", "error", null, Stopwatch.GetTimestamp())); + Console.WriteLine($"Error: {ev.GetException()}"); + }; + + // taken from existing code + watcher.InternalBufferSize = 32768; + watcher.EnableRaisingEvents = true; + + Console.WriteLine($"Recording. Now go ahead and perform the desired file system activities in {watchedDirectory}. " + + "Press CTRL + C to stop the recording."); + Console.CancelKeyPress += (_, _) => + { + Console.WriteLine("Exiting."); + ProcessQueueAndWriteToDisk(csvOutputFile); + Environment.Exit(0); + }; + + while (true) + { + Thread.Sleep(200); + } + } + + private static (string, string) ProcessArguments(string[] args) + { + if (args.Length < 2) + { + Console.WriteLine("Usage: dotnet run [directory to be watched] [output csv file]"); + Environment.Exit(1); + } + + return (args[0], args[1]); + } + + private static void ProcessQueueAndWriteToDisk(string csvOutputFile) + { + if (EventRecords.IsEmpty) + { + Console.WriteLine("Detected no file system events. Nothing is written."); + } + else + { + Console.WriteLine($"Recorded {EventRecords.Count} file system events."); + var records = MapToDiffTicks; + + Console.WriteLine($"Writing CSV to {csvOutputFile}."); + using (var writer = new StreamWriter(csvOutputFile)) + using (var csv = new CsvWriter(writer, CultureInfo.InvariantCulture)) + { + csv.WriteRecords(records); + } + + Console.WriteLine("Done."); + } + } + + // post-process queue. Calculate difference between previous and current event + private static IEnumerable MapToDiffTicks + { + get + { + List eventsWithDiffs = []; + long previousTicks = 0; + + foreach (var eventRecord in EventRecords) + { + var diff = previousTicks switch + { + 0 => 0, // first run + _ => eventRecord.NowInTicks - previousTicks + }; + + previousTicks = eventRecord.NowInTicks; + double diffInMilliseconds = Convert.ToInt64(new TimeSpan(diff).TotalMilliseconds); + + var directory = Path.GetDirectoryName(eventRecord.FullPath) ?? ""; + var fileName = Path.GetFileName(eventRecord.FullPath); + var oldFileName = Path.GetFileName(eventRecord.OldFullPath); + + var record = new EventRecordWithDiff( + directory, + fileName, + eventRecord.EventName, + oldFileName, + diff, + diffInMilliseconds); + eventsWithDiffs.Add(record); + } + + return eventsWithDiffs; + } + } +} \ No newline at end of file diff --git a/Source/FileSystemEventRecorder/FileSystemEventRecorder.csproj b/Source/FileSystemEventRecorder/FileSystemEventRecorder.csproj new file mode 100644 index 0000000..9e11035 --- /dev/null +++ b/Source/FileSystemEventRecorder/FileSystemEventRecorder.csproj @@ -0,0 +1,18 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + diff --git a/Source/FileSystemEventRecorder/README.md b/Source/FileSystemEventRecorder/README.md new file mode 100644 index 0000000..80b2f40 --- /dev/null +++ b/Source/FileSystemEventRecorder/README.md @@ -0,0 +1,17 @@ +# File System Event Recorder + +Command line tool to capture the raw events of [FileSystemWatcher](https://learn.microsoft.com/en-us/dotnet/api/system.io.filesystemwatcher) +in a CSV file. The CSV file can than be used to write integration tests against *FileWatcherEx*. + +Usage: +````sh +dotnet run C:\temp\fwtest\ C:\temp\fwevents.csv +```` + +Example output: +````csv +Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds +C:\temp\fwtest,a.txt,created,,0,0 +C:\temp\fwtest,b.txt,renamed,a.txt,1265338,127 +C:\temp\fwtest,b.txt,deleted,,6660690,666 +```` \ No newline at end of file diff --git a/Source/FileWatcherEx.sln b/Source/FileWatcherEx.sln deleted file mode 100644 index 42f195a..0000000 --- a/Source/FileWatcherEx.sln +++ /dev/null @@ -1,37 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.1.31911.260 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileWatcherEx", "FileWatcherEx\FileWatcherEx.csproj", "{CF81727D-B6EC-4202-8E78-087C5D5EABF3}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Demo.WinForms", "Demo\Demo.WinForms.csproj", "{F973F462-7769-433C-AEC1-17AF2902ED2D}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FileWatcherExTests", "FileWatcherExTests\FileWatcherExTests.csproj", "{1C0CA67C-369E-4258-B661-2C545B50A6FF}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {CF81727D-B6EC-4202-8E78-087C5D5EABF3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {CF81727D-B6EC-4202-8E78-087C5D5EABF3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {CF81727D-B6EC-4202-8E78-087C5D5EABF3}.Release|Any CPU.ActiveCfg = Release|Any CPU - {CF81727D-B6EC-4202-8E78-087C5D5EABF3}.Release|Any CPU.Build.0 = Release|Any CPU - {F973F462-7769-433C-AEC1-17AF2902ED2D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F973F462-7769-433C-AEC1-17AF2902ED2D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F973F462-7769-433C-AEC1-17AF2902ED2D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F973F462-7769-433C-AEC1-17AF2902ED2D}.Release|Any CPU.Build.0 = Release|Any CPU - {1C0CA67C-369E-4258-B661-2C545B50A6FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {1C0CA67C-369E-4258-B661-2C545B50A6FF}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1C0CA67C-369E-4258-B661-2C545B50A6FF}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1C0CA67C-369E-4258-B661-2C545B50A6FF}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {7A615E75-F056-422F-952E-9C0CD34E477B} - EndGlobalSection -EndGlobal diff --git a/Source/FileWatcherEx.slnx b/Source/FileWatcherEx.slnx new file mode 100644 index 0000000..868af94 --- /dev/null +++ b/Source/FileWatcherEx.slnx @@ -0,0 +1,6 @@ + + + + + + diff --git a/Source/FileWatcherEx/Helpers/EventProcessor.cs b/Source/FileWatcherEx/Helpers/EventProcessor.cs deleted file mode 100644 index 2836f7a..0000000 --- a/Source/FileWatcherEx/Helpers/EventProcessor.cs +++ /dev/null @@ -1,236 +0,0 @@ -/*--------------------------------------------------------- - * Copyright (C) Microsoft Corporation. All rights reserved. - *--------------------------------------------------------*/ - -namespace FileWatcherEx; - -internal class EventProcessor -{ - - /// - /// Aggregate and only emit events when changes have stopped for this duration (in ms) - /// - private static readonly int EVENT_DELAY = 50; - - /// - /// Warn after certain time span of event spam (in ticks) - /// - private static readonly int EVENT_SPAM_WARNING_THRESHOLD = 60 * 1000 * 10000; - - private readonly System.Object LOCK = new(); - private Task? delayTask = null; - - private readonly List events = new(); - private readonly Action handleEvent; - - private readonly Action logger; - - private long lastEventTime = 0; - private long delayStarted = 0; - - private long spamCheckStartTime = 0; - private bool spamWarningLogged = false; - - - internal static IEnumerable NormalizeEvents(FileChangedEvent[] events) - { - var mapPathToEvents = new Dictionary(); - var eventsWithoutDuplicates = new List(); - - // Normalize duplicates - foreach (var newEvent in events) - { - mapPathToEvents.TryGetValue(newEvent.FullPath, out var oldEvent); // Try get event from newEvent.FullPath - - if (oldEvent != null && oldEvent.ChangeType == ChangeType.CREATED && newEvent.ChangeType == ChangeType.DELETED) - { // CREATED + DELETED => remove - mapPathToEvents.Remove(oldEvent.FullPath); - eventsWithoutDuplicates.Remove(oldEvent); - } - else - if (oldEvent != null && oldEvent.ChangeType == ChangeType.DELETED && newEvent.ChangeType == ChangeType.CREATED) - { // DELETED + CREATED => CHANGED - oldEvent.ChangeType = ChangeType.CHANGED; - } - else - if (oldEvent != null && oldEvent.ChangeType == ChangeType.CREATED && newEvent.ChangeType == ChangeType.CHANGED) - { // CREATED + CHANGED => CREATED - // Do nothing - } - else - { // Otherwise - - if (newEvent.ChangeType == ChangeType.RENAMED) - { // If + RENAMED - do - { - mapPathToEvents.TryGetValue(newEvent.OldFullPath!, out var renameFromEvent); // Try get event from newEvent.OldFullPath - - if (renameFromEvent != null && renameFromEvent.ChangeType == ChangeType.CREATED) - { // If rename from CREATED file - // Remove data about the CREATED file - mapPathToEvents.Remove(renameFromEvent.FullPath); - eventsWithoutDuplicates.Remove(renameFromEvent); - // Handle new event as CREATED - newEvent.ChangeType = ChangeType.CREATED; - newEvent.OldFullPath = null; - - if (oldEvent != null && oldEvent.ChangeType == ChangeType.DELETED) - { // DELETED + CREATED => CHANGED - newEvent.ChangeType = ChangeType.CHANGED; - } - } - else - if (renameFromEvent != null && renameFromEvent.ChangeType == ChangeType.RENAMED) - { // If rename from RENAMED file - // Remove data about the RENAMED file - mapPathToEvents.Remove(renameFromEvent.FullPath); - eventsWithoutDuplicates.Remove(renameFromEvent); - // Change OldFullPath - newEvent.OldFullPath = renameFromEvent.OldFullPath; - // Check again - continue; - } - else - { // Otherwise - // Do nothing - //mapPathToEvents.TryGetValue(newEvent.OldFullPath, out oldEvent); // Try get event from newEvent.OldFullPath - } - } while (false); - } - - if (oldEvent != null) - { // If old event exists - // Replace old event data with data from the new event - oldEvent.ChangeType = newEvent.ChangeType; - oldEvent.OldFullPath = newEvent.OldFullPath; - } - else - { // If old event is not exist - // Add new event - mapPathToEvents.Add(newEvent.FullPath, newEvent); - eventsWithoutDuplicates.Add(newEvent); - } - } - } - - - return FilterDeleted(eventsWithoutDuplicates); - } - - // This algorithm will remove all DELETE events up to the root folder - // that got deleted if any. This ensures that we are not producing - // DELETE events for each file inside a folder that gets deleted. - // - // 1.) split ADD/CHANGE and DELETED events - // 2.) sort short deleted paths to the top - // 3.) for each DELETE, check if there is a deleted parent and ignore the event in that case - internal static IEnumerable FilterDeleted(IEnumerable eventsWithoutDuplicates) - { - // Handle deletes - var deletedPaths = new List(); - return eventsWithoutDuplicates - .Select((e, n) => new KeyValuePair(n, e)) // store original position value - .OrderBy(e => e.Value.FullPath.Length) // shortest path first - .Where(e => IsParent(e.Value, deletedPaths)) - .OrderBy(e => e.Key) // restore original position - .Select(e => e.Value); - } - - internal static bool IsParent(FileChangedEvent e, List deletedPaths) - { - if (e.ChangeType == ChangeType.DELETED) - { - if (deletedPaths.Any(d => IsParent(e.FullPath, d))) - { - return false; // DELETE is ignored if parent is deleted already - } - - // otherwise mark as deleted - deletedPaths.Add(e.FullPath); - } - - return true; - } - - - internal static bool IsParent(string p, string candidate) - { - return p.IndexOf(candidate + '\\') == 0; - } - - - - - public EventProcessor(Action onEvent, Action onLogging) - { - handleEvent = onEvent; - logger = onLogging; - } - - - public void ProcessEvent(FileChangedEvent fileEvent) - { - lock (LOCK) - { - var now = DateTime.Now.Ticks; - - // Check for spam - if (events.Count == 0) - { - spamWarningLogged = false; - spamCheckStartTime = now; - } - else if (!spamWarningLogged && spamCheckStartTime + EVENT_SPAM_WARNING_THRESHOLD < now) - { - spamWarningLogged = true; - logger(string.Format("Warning: Watcher is busy catching up with {0} file changes in 60 seconds. Latest path is '{1}'", events.Count, fileEvent.FullPath)); - } - - // Add into our queue - events.Add(fileEvent); - lastEventTime = now; - - // Process queue after delay - if (delayTask == null) - { - // Create function to buffer events - void func(Task value) - { - lock (LOCK) - { - // Check if another event has been received in the meantime - if (delayStarted == lastEventTime) - { - // Normalize and handle - var normalized = NormalizeEvents(events.ToArray()); - foreach (var e in normalized) - { - handleEvent(e); - } - - // Reset - events.Clear(); - delayTask = null; - } - - // Otherwise we have received a new event while this task was - // delayed and we reschedule it. - else - { - delayStarted = lastEventTime; - delayTask = Task.Delay(EVENT_DELAY).ContinueWith(func); - } - } - } - - // Start function after delay - delayStarted = lastEventTime; - delayTask = Task.Delay(EVENT_DELAY).ContinueWith(func); - } - } - } - - -} - diff --git a/Source/FileWatcherEx/Helpers/FileWatcher.cs b/Source/FileWatcherEx/Helpers/FileWatcher.cs deleted file mode 100644 index a632eec..0000000 --- a/Source/FileWatcherEx/Helpers/FileWatcher.cs +++ /dev/null @@ -1,224 +0,0 @@ -/*--------------------------------------------------------- - * Copyright (C) Microsoft Corporation. All rights reserved. - *--------------------------------------------------------*/ - -namespace FileWatcherEx; - - -internal class FileWatcher : IDisposable -{ - private string _watchPath = string.Empty; - private Action? _eventCallback = null; - private readonly Dictionary _fwDictionary = new(); - private Action? _onError = null; - - - /// - /// Create new instance of FileSystemWatcher - /// - /// Full folder path to watcher - /// onEvent callback - /// onError callback - /// - public FileSystemWatcher Create(string path, Action onEvent, Action onError) - { - _watchPath = path; - _eventCallback = onEvent; - _onError = onError; - - var watcher = new FileSystemWatcher - { - Path = _watchPath, - IncludeSubdirectories = true, - NotifyFilter = NotifyFilters.LastWrite - | NotifyFilters.FileName - | NotifyFilters.DirectoryName, - }; - - // Bind internal events to manipulate the possible symbolic links - watcher.Created += new(MakeWatcher_Created); - watcher.Deleted += new(MakeWatcher_Deleted); - - watcher.Changed += new((object _, FileSystemEventArgs e) => ProcessEvent(e, ChangeType.CHANGED)); - watcher.Created += new((object _, FileSystemEventArgs e) => ProcessEvent(e, ChangeType.CREATED)); - watcher.Deleted += new((object _, FileSystemEventArgs e) => ProcessEvent(e, ChangeType.DELETED)); - watcher.Renamed += new((object _, RenamedEventArgs e) => ProcessEvent(e)); - watcher.Error += new ErrorEventHandler((object source, ErrorEventArgs e) => onError(e)); - - //changing this to a higher value can lead into issues when watching UNC drives - watcher.InternalBufferSize = 32768; - _fwDictionary.Add(path, watcher); - - foreach (var dirInfo in new DirectoryInfo(path).GetDirectories()) - { - var attrs = File.GetAttributes(dirInfo.FullName); - - // TODO: consider skipping hidden/system folders? - // See IG Issue #405 comment below - // https://github.com/d2phap/ImageGlass/issues/405 - if (attrs.HasFlag(FileAttributes.Directory) - && attrs.HasFlag(FileAttributes.ReparsePoint)) - { - try - { - MakeWatcher(dirInfo.FullName); - } - catch - { - // IG Issue #405: throws exception on Windows 10 - // for "c:\users\user\application data" folder and sub-folders. - } - } - } - - return watcher; - } - - - /// - /// Process event for type = [CHANGED; DELETED; CREATED] - /// - /// - /// - private void ProcessEvent(FileSystemEventArgs e, ChangeType changeType) - { - _eventCallback?.Invoke(new() - { - ChangeType = changeType, - FullPath = e.FullPath, - }); - } - - - /// - /// Process event for type = RENAMED - /// - /// - private void ProcessEvent(RenamedEventArgs e) - { - _eventCallback?.Invoke(new() - { - ChangeType = ChangeType.RENAMED, - FullPath = e.FullPath, - OldFullPath = e.OldFullPath, - }); - } - - - private void MakeWatcher(string path) - { - if (!_fwDictionary.ContainsKey(path)) - { - var fileSystemWatcherRoot = new FileSystemWatcher - { - Path = path, - IncludeSubdirectories = true, - EnableRaisingEvents = true - }; - - // Bind internal events to manipulate the possible symbolic links - fileSystemWatcherRoot.Created += new(MakeWatcher_Created); - fileSystemWatcherRoot.Deleted += new(MakeWatcher_Deleted); - - fileSystemWatcherRoot.Changed += new((object _, FileSystemEventArgs e) => ProcessEvent(e, ChangeType.CHANGED)); - fileSystemWatcherRoot.Created += new((object _, FileSystemEventArgs e) => ProcessEvent(e, ChangeType.CREATED)); - fileSystemWatcherRoot.Deleted += new((object _, FileSystemEventArgs e) => ProcessEvent(e, ChangeType.DELETED)); - fileSystemWatcherRoot.Renamed += new((object _, RenamedEventArgs e) => ProcessEvent(e)); - fileSystemWatcherRoot.Error += new((object _, ErrorEventArgs e) => _onError?.Invoke(e)); - - _fwDictionary.Add(path, fileSystemWatcherRoot); - } - - foreach (var item in new DirectoryInfo(path).GetDirectories()) - { - var attrs = File.GetAttributes(item.FullName); - - // If is a directory and symbolic link - if (attrs.HasFlag(FileAttributes.Directory) - && attrs.HasFlag(FileAttributes.ReparsePoint)) - { - if (!_fwDictionary.ContainsKey(item.FullName)) - { - var fswItem = new FileSystemWatcher - { - Path = item.FullName, - IncludeSubdirectories = true, - EnableRaisingEvents = true, - }; - - // Bind internal events to manipulate the possible symbolic links - fswItem.Created += new(MakeWatcher_Created); - fswItem.Deleted += new(MakeWatcher_Deleted); - - fswItem.Changed += new((object _, FileSystemEventArgs e) => ProcessEvent(e, ChangeType.CHANGED)); - fswItem.Created += new((object _, FileSystemEventArgs e) => ProcessEvent(e, ChangeType.CREATED)); - fswItem.Deleted += new((object _, FileSystemEventArgs e) => ProcessEvent(e, ChangeType.DELETED)); - fswItem.Renamed += new((object _, RenamedEventArgs e) => ProcessEvent(e)); - fswItem.Error += new((object _, ErrorEventArgs e) => _onError?.Invoke(e)); - - _fwDictionary.Add(item.FullName, fswItem); - } - - MakeWatcher(item.FullName); - } - } - } - - - private void MakeWatcher_Created(object sender, FileSystemEventArgs e) - { - try - { - var attrs = File.GetAttributes(e.FullPath); - if (attrs.HasFlag(FileAttributes.Directory) - && attrs.HasFlag(FileAttributes.ReparsePoint)) - { - var watcherCreated = new FileSystemWatcher - { - Path = e.FullPath, - IncludeSubdirectories = true, - EnableRaisingEvents = true - }; - - // Bind internal events to manipulate the possible symbolic links - watcherCreated.Created += new(MakeWatcher_Created); - watcherCreated.Deleted += new(MakeWatcher_Deleted); - - watcherCreated.Changed += new((object _, FileSystemEventArgs e) => ProcessEvent(e, ChangeType.CHANGED)); - watcherCreated.Created += new((object _, FileSystemEventArgs e) => ProcessEvent(e, ChangeType.CREATED)); - watcherCreated.Deleted += new((object _, FileSystemEventArgs e) => ProcessEvent(e, ChangeType.DELETED)); - watcherCreated.Renamed += new((object _, RenamedEventArgs e) => ProcessEvent(e)); - watcherCreated.Error += new((object _, ErrorEventArgs e) => _onError?.Invoke(e)); - - _fwDictionary.Add(e.FullPath, watcherCreated); - } - } - catch (Exception ex) - { - Console.WriteLine("<>: " + ex.Message); - } - } - - - private void MakeWatcher_Deleted(object sender, FileSystemEventArgs e) - { - // If object removed, then I will dispose and remove them from dictionary - if (_fwDictionary.ContainsKey(e.FullPath)) - { - _fwDictionary[e.FullPath].Dispose(); - _fwDictionary.Remove(e.FullPath); - } - } - - - /// - /// Dispose the instance - /// - public void Dispose() - { - foreach (var item in _fwDictionary) - { - item.Value.Dispose(); - } - } -} diff --git a/Source/FileWatcherExTests/EventProcessorTest.cs b/Source/FileWatcherExTests/EventNormalizerTest.cs similarity index 81% rename from Source/FileWatcherExTests/EventProcessorTest.cs rename to Source/FileWatcherExTests/EventNormalizerTest.cs index 73999dc..3a0b97d 100644 --- a/Source/FileWatcherExTests/EventProcessorTest.cs +++ b/Source/FileWatcherExTests/EventNormalizerTest.cs @@ -1,9 +1,10 @@ +using D2Phap.FileWatcherEx; +using D2Phap.FileWatcherEx.Helpers; using Xunit; -using FileWatcherEx; namespace FileWatcherExTests; -public class EventProcessorTest +public class EventNormalizerTest { [Fact] public void No_Input_Gives_No_Output() @@ -142,7 +143,7 @@ public void Rename_After_Create_Gives_Changed_Event() Assert.Null(ev.OldFullPath); } -[Fact] + [Fact] public void Result_Suppressed_If_Delete_After_Create() { var events = NormalizeEvents( @@ -162,7 +163,31 @@ public void Result_Suppressed_If_Delete_After_Create() Assert.Empty(events); } - + + [Fact] + public void Created_Event_After_Deleted_Results_Into_Changed() + { + var events = NormalizeEvents( + new FileChangedEvent + { + ChangeType = ChangeType.DELETED, + FullPath = @"c:\foo", + OldFullPath = null + }, + new FileChangedEvent + { + ChangeType = ChangeType.CREATED, + FullPath = @"c:\foo", + OldFullPath = null + } + ); + + Assert.Single(events); + var ev = events.First(); + Assert.Equal(ChangeType.CHANGED, ev.ChangeType); + Assert.Equal(@"c:\foo", ev.FullPath); + Assert.Null(ev.OldFullPath); + } [Fact] public void Changed_Event_After_Created_Is_Ignored() @@ -214,7 +239,7 @@ public void Filter_Passes_Events_Through() } }; - var filtered = EventProcessor.FilterDeleted(events); + var filtered = EventNormalizer.FilterDeleted(events); Assert.Equal(events, filtered); } @@ -243,26 +268,26 @@ public void Filter_Out_Deleted_Event_With_Subdirectory() } }; - var filtered = EventProcessor.FilterDeleted(events).ToList(); + var filtered = EventNormalizer.FilterDeleted(events).ToList(); Assert.Equal(2, filtered.Count); Assert.Equal(ChangeType.DELETED, filtered[0].ChangeType); Assert.Equal(@"c:\bar", filtered[0].FullPath); Assert.Equal(ChangeType.CREATED, filtered[1].ChangeType); Assert.Equal(@"c:\foo", filtered[1].FullPath); } - - [Fact] - public void IsParent() - { - Assert.True(EventProcessor.IsParent(@"c:\a\b", @"c:")); - Assert.True(EventProcessor.IsParent(@"c:\a\b", @"c:\a")); - // candidate must not have backslash - Assert.False(EventProcessor.IsParent(@"c:\a\b", @"c:\")); - Assert.False(EventProcessor.IsParent(@"c:\a\b", @"c:\a\")); - - Assert.False(EventProcessor.IsParent(@"c:\", @"c:\foo")); - Assert.False(EventProcessor.IsParent(@"c:\", @"c:\")); + [Theory] + [InlineData(@"c:\a\b", @"c:", true)] + [InlineData(@"c:\a\b", @"c:\a", true)] + [InlineData(@"c:\a\b\", @"c:", true)] + [InlineData(@"c:\a\b\", @"c:\a", true)] + [InlineData(@"c:\a\b", @"c:\", true)] + [InlineData(@"c:\a\b", @"c:\a\", true)] + [InlineData(@"c:\", @"c:\foo", false)] + [InlineData(@"c:\", @"c:\", false)] + public void Is_Parent(string path, string candidatePath, bool expectedResult) + { + Assert.Equal(expectedResult, EventNormalizer.IsParent(path, candidatePath)); } [Fact] @@ -274,34 +299,34 @@ public void Parent_Dir_Is_Detected() FullPath = @"c:\foo" }; - Assert.True(EventProcessor.IsParent(ev, new List())); + Assert.True(EventNormalizer.IsParent(ev, new List())); } - + [Fact] public void Delete_Event_For_Subdirectory_Is_Detected() { var deletedFiles = new List(); - + var parentDirEvent = new FileChangedEvent { ChangeType = ChangeType.DELETED, FullPath = @"c:\foo" }; - - Assert.True(EventProcessor.IsParent(parentDirEvent, deletedFiles)); - + Assert.True(EventNormalizer.IsParent(parentDirEvent, deletedFiles)); + + var subDirEvent = new FileChangedEvent { ChangeType = ChangeType.DELETED, FullPath = @"c:\foo\bar" }; - - Assert.False(EventProcessor.IsParent(subDirEvent, deletedFiles)); + + Assert.False(EventNormalizer.IsParent(subDirEvent, deletedFiles)); } - + private static List NormalizeEvents(params FileChangedEvent[] events) { - return EventProcessor.NormalizeEvents(events).ToList(); + return new EventNormalizer().Normalize(events).ToList(); } } \ No newline at end of file diff --git a/Source/FileWatcherExTests/FileWatcherExIntegrationTest.cs b/Source/FileWatcherExTests/FileWatcherExIntegrationTest.cs new file mode 100644 index 0000000..faa52bf --- /dev/null +++ b/Source/FileWatcherExTests/FileWatcherExIntegrationTest.cs @@ -0,0 +1,375 @@ +using D2Phap.FileWatcherEx; +using FileWatcherExTests.Helper; +using System.Collections.Concurrent; +using Xunit; + +namespace FileWatcherExTests; + +/// +/// Integration/ Golden master test for FileWatcherEx +/// Note: the scenarios where recorded in C:\temp\fwtest +/// +public class FileWatcherExIntegrationTest : IDisposable +{ + private readonly ConcurrentQueue _events; + private readonly FileSystemWatcherEx _fileWatcher; + private readonly ReplayFileSystemWatcherFactory _replayFileSystemWatcherFactory; + private readonly TempDir _tempDir; + + public FileWatcherExIntegrationTest(ITestOutputHelper testOutputHelper) + { + // setup before each test run + _events = new ConcurrentQueue(); + _replayFileSystemWatcherFactory = new ReplayFileSystemWatcherFactory(); + + _tempDir = new TempDir(); + _fileWatcher = new FileSystemWatcherEx(_tempDir.FullPath, testOutputHelper.WriteLine) + { + FileSystemWatcherFactory = () => _replayFileSystemWatcherFactory.Create(), + IncludeSubdirectories = true + }; + + _fileWatcher.OnCreated += (_, ev) => _events.Enqueue(ev); + _fileWatcher.OnDeleted += (_, ev) => _events.Enqueue(ev); + _fileWatcher.OnChanged += (_, ev) => _events.Enqueue(ev); + _fileWatcher.OnRenamed += (_, ev) => _events.Enqueue(ev); + } + + + [Fact] + public void Create_Single_File() + { + StartFileWatcherAndReplay(@"scenario/create_file.csv"); + + Assert.Single(_events); + var ev = _events.First(); + Assert.Equal(ChangeType.CREATED, ev.ChangeType); + AssertEqualNormalized(@"C:\temp\fwtest\a.txt", ev.FullPath); + Assert.Equal("", ev.OldFullPath); + } + + + [Fact] + public void Create_And_Remove_Single_File() + { + StartFileWatcherAndReplay(@"scenario/create_and_remove_file.csv"); + + Assert.Equal(2, _events.Count); + var ev1 = _events.ToList()[0]; + var ev2 = _events.ToList()[1]; + + Assert.Equal(ChangeType.CREATED, ev1.ChangeType); + AssertEqualNormalized(@"C:\temp\fwtest\a.txt", ev1.FullPath); + Assert.Equal("", ev1.OldFullPath); + + Assert.Equal(ChangeType.DELETED, ev2.ChangeType); + AssertEqualNormalized(@"C:\temp\fwtest\a.txt", ev2.FullPath); + Assert.Equal("", ev2.OldFullPath); + } + + + [Fact] + public void Create_Rename_And_Remove_Single_File() + { + StartFileWatcherAndReplay(@"scenario/create_rename_and_remove_file.csv"); + + Assert.Equal(3, _events.Count); + var ev1 = _events.ToList()[0]; + var ev2 = _events.ToList()[1]; + var ev3 = _events.ToList()[2]; + + Assert.Equal(ChangeType.CREATED, ev1.ChangeType); + AssertEqualNormalized(@"C:\temp\fwtest\a.txt", ev1.FullPath); + + Assert.Equal(ChangeType.RENAMED, ev2.ChangeType); + AssertEqualNormalized(@"C:\temp\fwtest\b.txt", ev2.FullPath); + AssertEqualNormalized(@"C:\temp\fwtest\a.txt", ev2.OldFullPath); + + Assert.Equal(ChangeType.DELETED, ev3.ChangeType); + AssertEqualNormalized(@"C:\temp\fwtest\b.txt", ev3.FullPath); + Assert.Equal("", ev3.OldFullPath); + } + + + [Fact] + // filters out 2nd "changed" event + public void Create_Single_File_Via_WSL2() + { + StartFileWatcherAndReplay(@"scenario/create_file_wsl2.csv"); + + Assert.Single(_events); + var ev = _events.First(); + Assert.Equal(ChangeType.CREATED, ev.ChangeType); + AssertEqualNormalized(@"C:\temp\fwtest\a.txt", ev.FullPath); + Assert.Equal("", ev.OldFullPath); + } + + + [Fact] + // scenario creates "created" "changed" and "renamed" event. + // resulting event is just "created" with the filename taken from "renamed" + public void Create_And_Rename_Single_File_Via_WSL2() + { + StartFileWatcherAndReplay(@"scenario/create_and_rename_file_wsl2.csv"); + + Assert.Single(_events); + var ev = _events.First(); + Assert.Equal(ChangeType.CREATED, ev.ChangeType); + AssertEqualNormalized(@"C:\temp\fwtest\b.txt", ev.FullPath); + Assert.Null(ev.OldFullPath); + } + + + [Fact] + public void Create_Rename_And_Remove_Single_File_Via_WSL2() + { + StartFileWatcherAndReplay(@"scenario/create_rename_and_remove_file_wsl2.csv"); + Assert.Empty(_events); + } + + + [Fact] + public void Create_Rename_And_Remove_Single_File_With_Wait_Time_Via_WSL2() + { + StartFileWatcherAndReplay(@"scenario/create_rename_and_remove_file_with_wait_time_wsl2.csv"); + + Assert.Equal(3, _events.Count); + var ev1 = _events.ToList()[0]; + var ev2 = _events.ToList()[1]; + var ev3 = _events.ToList()[2]; + + Assert.Equal(ChangeType.CREATED, ev1.ChangeType); + AssertEqualNormalized(@"C:\temp\fwtest\a.txt", ev1.FullPath); + + Assert.Equal(ChangeType.RENAMED, ev2.ChangeType); + AssertEqualNormalized(@"C:\temp\fwtest\b.txt", ev2.FullPath); + AssertEqualNormalized(@"C:\temp\fwtest\a.txt", ev2.OldFullPath); + + Assert.Equal(ChangeType.DELETED, ev3.ChangeType); + AssertEqualNormalized(@"C:\temp\fwtest\b.txt", ev3.FullPath); + Assert.Equal("", ev3.OldFullPath); + } + + + [Fact] + public void Manually_Create_And_Rename_File_Via_Windows_Explorer() + { + StartFileWatcherAndReplay(@"scenario/create_and_rename_file_via_explorer.csv"); + + Assert.Equal(2, _events.Count); + + var ev1 = _events.ToList()[0]; + var ev2 = _events.ToList()[1]; + + Assert.Equal(ChangeType.CREATED, ev1.ChangeType); + AssertEqualNormalized(@"C:\temp\fwtest\New Text Document.txt", ev1.FullPath); + + Assert.Equal(ChangeType.RENAMED, ev2.ChangeType); + AssertEqualNormalized(@"C:\temp\fwtest\foo.txt", ev2.FullPath); + AssertEqualNormalized(@"C:\temp\fwtest\New Text Document.txt", ev2.OldFullPath); + } + + [Fact] + public void Manually_Create_Rename_And_Delete_File_Via_Windows_Explorer() + { + StartFileWatcherAndReplay(@"scenario/create_rename_and_delete_file_via_explorer.csv"); + + Assert.Equal(3, _events.Count); + var ev1 = _events.ToList()[0]; + var ev2 = _events.ToList()[1]; + var ev3 = _events.ToList()[2]; + + Assert.Equal(ChangeType.CREATED, ev1.ChangeType); + AssertEqualNormalized(@"C:\temp\fwtest\New Text Document.txt", ev1.FullPath); + + Assert.Equal(ChangeType.RENAMED, ev2.ChangeType); + AssertEqualNormalized(@"C:\temp\fwtest\foo.txt", ev2.FullPath); + AssertEqualNormalized(@"C:\temp\fwtest\New Text Document.txt", ev2.OldFullPath); + + Assert.Equal(ChangeType.DELETED, ev3.ChangeType); + AssertEqualNormalized(@"C:\temp\fwtest\foo.txt", ev3.FullPath); + Assert.Equal("", ev3.OldFullPath); + } + + [Fact] + public void Download_Image_Via_Edge_Browser() + { + StartFileWatcherAndReplay(@"scenario/download_image_via_Edge_browser.csv"); + + Assert.Equal(2, _events.Count); + var ev1 = _events.ToList()[0]; + var ev2 = _events.ToList()[1]; + + Assert.Equal(ChangeType.CREATED, ev1.ChangeType); + AssertEqualNormalized(@"C:\temp\fwtest\test.png.crdownload", ev1.FullPath); + + Assert.Equal(ChangeType.RENAMED, ev2.ChangeType); + AssertEqualNormalized(@"C:\temp\fwtest\test.png", ev2.FullPath); + AssertEqualNormalized(@"C:\temp\fwtest\test.png.crdownload", ev2.OldFullPath); + } + + // instantly removed file is not in the events list + [Fact] + public void Create_Sub_Directory_Add_And_Remove_File() + { + StartFileWatcherAndReplay(@"scenario/create_subdirectory_add_and_remove_file.csv"); + + Assert.Equal(2, _events.Count); + var ev1 = _events.ToList()[0]; + var ev2 = _events.ToList()[1]; + + Assert.Equal(ChangeType.CREATED, ev1.ChangeType); + AssertEqualNormalized(@"C:\temp\fwtest\subdir", ev1.FullPath); + + Assert.Equal(ChangeType.CHANGED, ev2.ChangeType); + AssertEqualNormalized(@"C:\temp\fwtest\subdir", ev2.FullPath); + Assert.Equal(@"", ev2.OldFullPath); + } + + [Fact] + public void Create_Sub_Directory_Add_And_Remove_File_With_Sleep() + { + StartFileWatcherAndReplay(@"scenario/create_subdirectory_add_and_remove_file_with_sleep.csv"); + + Assert.Equal(4, _events.Count); + var ev1 = _events.ToList()[0]; + var ev2 = _events.ToList()[1]; + var ev3 = _events.ToList()[2]; + var ev4 = _events.ToList()[3]; + + Assert.Equal(ChangeType.CREATED, ev1.ChangeType); + AssertEqualNormalized(@"C:\temp\fwtest\subdir", ev1.FullPath); + + Assert.Equal(ChangeType.CREATED, ev2.ChangeType); + AssertEqualNormalized(@"C:\temp\fwtest\subdir\a.txt", ev2.FullPath); + Assert.Equal(@"", ev2.OldFullPath); + + // TODO this could be filtered out + Assert.Equal(ChangeType.CHANGED, ev3.ChangeType); + AssertEqualNormalized(@"C:\temp\fwtest\subdir", ev3.FullPath); + Assert.Equal(@"", ev3.OldFullPath); + + Assert.Equal(ChangeType.DELETED, ev4.ChangeType); + AssertEqualNormalized(@"C:\temp\fwtest\subdir\a.txt", ev4.FullPath); + Assert.Equal(@"", ev4.OldFullPath); + } + + [Fact] + public void Filter_Settings_Are_Delegated() + { + using var dir = new TempDir(); + var watcher = new ReplayFileSystemWatcherWrapper(); + + var uut = new FileSystemWatcherEx(dir.FullPath) + { + FileSystemWatcherFactory = () => watcher + }; + uut.Filters.Add("*.foo"); + uut.Filters.Add("*.bar"); + + uut.Start(); + Assert.Equal(new List { "*.foo", "*.bar" }, watcher.Filters); + } + + [Fact] + public void Set_Filter() + { + using var dir = new TempDir(); + var watcher = new ReplayFileSystemWatcherWrapper(); + + var uut = new FileSystemWatcherEx(dir.FullPath) + { + FileSystemWatcherFactory = () => watcher + }; + + // "all files" by default + Assert.Equal("*", uut.Filter); + + uut.Filters.Add("*.foo"); + uut.Filters.Add("*.bar"); + + // two filter entries + Assert.Equal(2, uut.Filters.Count); + + // if multiple filters, only first is displayed. TODO Why ? + Assert.Equal("*.foo", uut.Filter); + + uut.Filter = "*.baz"; + Assert.Equal("*.baz", uut.Filter); + Assert.Single(uut.Filters); + } + + + + [Fact(Skip = "requires real (Windows) file system")] + public void Simple_Real_File_System_Test() + { + ConcurrentQueue events = new(); + var fw = new FileSystemWatcherEx(@"c:\temp\fwtest\"); + + fw.OnCreated += (_, ev) => events.Enqueue(ev); + fw.OnDeleted += (_, ev) => events.Enqueue(ev); + fw.OnChanged += (_, ev) => events.Enqueue(ev); + fw.OnRenamed += (_, ev) => events.Enqueue(ev); + fw.OnRenamed += (_, ev) => events.Enqueue(ev); + + const string testFile = @"c:\temp\fwtest\b.txt"; + if (File.Exists(testFile)) + { + File.Delete(testFile); + } + + _fileWatcher.StartForTesting( + p => FileAttributes.Normal, + p => []); + File.Create(testFile); + Thread.Sleep(250); + fw.Stop(); + + Assert.Single(events); + var ev = events.First(); + Assert.Equal(ChangeType.CREATED, ev.ChangeType); + Assert.Equal(@"c:\temp\fwtest\b.txt", ev.FullPath); + Assert.Equal("", ev.OldFullPath); + } + + // cleanup + public void Dispose() + { + _fileWatcher.Dispose(); + _tempDir.Dispose(); + } + + private void StartFileWatcherAndReplay(string csvFile) + { + _fileWatcher.StartForTesting( + p => FileAttributes.Normal, + // only used for FullName + p => [new DirectoryInfo(p)]); + _replayFileSystemWatcherFactory.RootWatcher.Replay(csvFile); + _fileWatcher.Stop(); + } + + private class ReplayFileSystemWatcherFactory + { + private readonly List _wrappers = []; + + public ReplayFileSystemWatcherWrapper Create() + { + var watcher = new ReplayFileSystemWatcherWrapper(); + _wrappers.Add(watcher); + return watcher; + } + + // At integration test, we're only interested in the root file watcher. + // This is the one which is registered first and watches the root directory. + public ReplayFileSystemWatcherWrapper RootWatcher => _wrappers[0]; + } + + // little hack to make the path comparision platform independent + private static void AssertEqualNormalized(string expected, string? actual) + { + actual = actual?.Replace("/", @"\"); + Assert.Equal(expected, actual); + } +} diff --git a/Source/FileWatcherExTests/FileWatcherExTests.csproj b/Source/FileWatcherExTests/FileWatcherExTests.csproj index 43d48e8..fa90e61 100644 --- a/Source/FileWatcherExTests/FileWatcherExTests.csproj +++ b/Source/FileWatcherExTests/FileWatcherExTests.csproj @@ -1,28 +1,36 @@ - net6.0 + net10.0 enable enable - false - - - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all + - + + + + + PreserveNewest + + diff --git a/Source/FileWatcherExTests/Helper/TempDir.cs b/Source/FileWatcherExTests/Helper/TempDir.cs new file mode 100644 index 0000000..ee01bea --- /dev/null +++ b/Source/FileWatcherExTests/Helper/TempDir.cs @@ -0,0 +1,32 @@ +namespace FileWatcherExTests.Helper; + +public class TempDir : IDisposable +{ + public string FullPath { get; } + + public TempDir() + { + FullPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(FullPath); + } + + public string CreateSubDir(string path) + { + var subDirPath = Path.Combine(FullPath, path); + Directory.CreateDirectory(subDirPath); + return subDirPath; + } + + public string CreateSymlink(string target, params string[] symLink) + { + var allElements = new[] { FullPath }.Concat(symLink).ToArray(); + var symlinkPath = Path.Combine(allElements); + Directory.CreateSymbolicLink(symlinkPath, target); + return symlinkPath; + } + + public void Dispose() + { + Directory.Delete(FullPath, true); + } +} diff --git a/Source/FileWatcherExTests/ReplayFileSystemWatcherWrapper.cs b/Source/FileWatcherExTests/ReplayFileSystemWatcherWrapper.cs new file mode 100644 index 0000000..39be8dc --- /dev/null +++ b/Source/FileWatcherExTests/ReplayFileSystemWatcherWrapper.cs @@ -0,0 +1,92 @@ +using CsvHelper; +using D2Phap.FileWatcherEx.Helpers; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Globalization; + +namespace FileWatcherExTests; + +internal record EventRecordWithDiff( + string Directory, + string FileName, + string EventName, + string? OldFileName, + long DiffInTicks, + double DiffInMilliseconds +); + +/// +/// Allows replaying of previously recorded file system events. +/// Used for integration testing. +/// +public class ReplayFileSystemWatcherWrapper : IFileSystemWatcherWrapper +{ + private readonly Collection _filters = []; + + public void Replay(string csvFile) + { + using var reader = new StreamReader(csvFile); + using var csv = new CsvReader(reader, CultureInfo.InvariantCulture); + + var records = csv.GetRecords(); + foreach (var record in records) + { + // introduce time gap like originally recorded + Thread.Sleep((int)record.DiffInMilliseconds); + + switch (record.EventName) + { + case "created": + { + var ev = new FileSystemEventArgs(WatcherChangeTypes.Created, record.Directory, record.FileName); + Created?.Invoke(this, ev); + break; + } + case "deleted": + { + var ev = new FileSystemEventArgs(WatcherChangeTypes.Deleted, record.Directory, record.FileName); + Deleted?.Invoke(this, ev); + break; + } + case "changed": + { + var ev = new FileSystemEventArgs(WatcherChangeTypes.Changed, record.Directory, record.FileName); + Changed?.Invoke(this, ev); + break; + } + case "renamed": + { + var ev = new RenamedEventArgs(WatcherChangeTypes.Renamed, record.Directory, record.FileName, + record.OldFileName); + Renamed?.Invoke(this, ev); + break; + } + } + } + // settle down + Thread.Sleep(250); + } + + public event FileSystemEventHandler? Created; + public event FileSystemEventHandler? Deleted; + public event FileSystemEventHandler? Changed; + public event RenamedEventHandler? Renamed; + +#pragma warning disable CS8618 // unused in replay implementation + public string Path { get; set; } + + public Collection Filters => _filters; + + public bool IncludeSubdirectories { get; set; } + public bool EnableRaisingEvents { get; set; } + public NotifyFilters NotifyFilter { get; set; } + +#pragma warning disable CS0067 // unused in replay implementation + public event ErrorEventHandler? Error; + public int InternalBufferSize { get; set; } + public ISynchronizeInvoke? SynchronizingObject { get; set; } + + public void Dispose() + { + } +} \ No newline at end of file diff --git a/Source/FileWatcherExTests/SymlinkAwareFileWatcherTest.cs b/Source/FileWatcherExTests/SymlinkAwareFileWatcherTest.cs new file mode 100644 index 0000000..b01c39a --- /dev/null +++ b/Source/FileWatcherExTests/SymlinkAwareFileWatcherTest.cs @@ -0,0 +1,258 @@ +using D2Phap.FileWatcherEx.Helpers; +using FileWatcherExTests.Helper; +using Moq; +using System.Collections.ObjectModel; +using System.ComponentModel; +using Xunit; + +namespace FileWatcherExTests; + +public class SymlinkAwareFileWatcherTest +{ + private readonly ITestOutputHelper _testOutputHelper; + private readonly List> _mocks; + + public SymlinkAwareFileWatcherTest(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + _mocks = []; + } + + [Fact] + public void Root_Watcher_Is_Created() + { + using var dir = new TempDir(); + CreateFileWatcher(dir.FullPath); + AssertContainsWatcherFor(dir.FullPath); + } + + + // creates 2 sub-directories and 2 nested symlinks + // file watchers are registered for the root dir and the symlinks + // for the sub-directories, no extra file watchers are created + // since the normal file watcher already emits events for subdirs + // + // this handles the initial setup after start. + // for registering sym-link watchers during runtime, MakeWatcher_Created is used. + [Fact] + public void FileWatchers_For_SymLink_Dirs_Are_Created_On_Startup() + { + using var dir = new TempDir(); + + // {tempdir}/subdir1 + var subdirPath1 = dir.CreateSubDir("subdir1"); + + // {tempdir}/subdir2 + var subdirPath2 = dir.CreateSubDir("subdir2"); + + // symlink {tempdir}/sym1 to {tempdir}/subdir1 + var symlinkPath1 = dir.CreateSymlink(symLink: "sym1", target: subdirPath1); + + // symlink {tempdir}/sym1/sym2 to {tempdir}/subdir2 + var symlinkPath2 = dir.CreateSymlink(symLink: ["sym1", "sym2"], target: subdirPath2); + + CreateFileWatcher(dir.FullPath); + + AssertContainsWatcherFor(dir.FullPath); + AssertContainsWatcherFor(symlinkPath1); + AssertContainsWatcherFor(symlinkPath2); + } + + [Fact] + public void FileWatchers_For_SymLink_Dirs_Are_Created_During_Runtime() + { + using var dir = new TempDir(); + var uut = CreateFileWatcher(dir.FullPath); + + var subdirPath = dir.CreateSubDir("subdir"); + + // simulate file watcher trigger + uut.TryRegisterFileWatcherForSymbolicLinkDir(subdirPath); + + // subdir is ignored + Assert.Single(uut.FileWatchers); + AssertContainsWatcherFor(dir.FullPath); + + var symlinkPath = dir.CreateSymlink(symLink: "sym", target: subdirPath); + + // simulate file watcher trigger + uut.TryRegisterFileWatcherForSymbolicLinkDir(symlinkPath); + + // symlink dir is registered + Assert.Equal(2, uut.FileWatchers.Count); + AssertContainsWatcherFor(dir.FullPath); + AssertContainsWatcherFor(symlinkPath); + + // remove the symlink again + Directory.Delete(symlinkPath); + + // simulate file watcher trigger + uut.UnregisterFileWatcherForSymbolicLinkDir(null, + new FileSystemEventArgs(WatcherChangeTypes.Deleted, dir.FullPath, "sym")); + + // sym-link file watcher is removed + Assert.Single(uut.FileWatchers); + AssertContainsWatcherFor(dir.FullPath); + + uut.Dispose(); + } + + [Fact] + public void MakeWatcher_Create_Exceptions_Are_Silently_Ignored() + { + var uut = CreateFileWatcher("/bar"); + uut.TryRegisterFileWatcherForSymbolicLinkDir("/not/existing/foo"); + } + + [Fact] + public void Properties_Are_Propagated() + { + using var dir = new TempDir(); + + var subDir = dir.CreateSubDir("subdir"); + + // symlink for detection at startup + dir.CreateSymlink( + symLink: "sym1", + target: subDir); + + var uut = new SymlinkAwareFileWatcher(dir.FullPath, + _ => { }, + _ => { }, + WatcherFactoryWithMemory, + _ => { }) + { + // perform settings. all, except SynchronizingObject are propagated + // to all registered watchers + NotifyFilter = NotifyFilters.LastAccess + }; + uut.Filters.Add("*.foo"); + uut.Filters.Add("*.bar"); + uut.EnableRaisingEvents = true; + uut.IncludeSubdirectories = true; + var syncObj = new Mock().Object; + uut.SynchronizingObject = syncObj; + + // finish object initialization + uut.Init(); + + // create symlink at runtime + var symlinkPath2 = dir.CreateSymlink( + symLink: "sym2", + target: subDir); + + // simulate that a new symlink dir was added + uut.TryRegisterFileWatcherForSymbolicLinkDir(symlinkPath2); + + // 1x root watcher, 1x sym link at startup, 1x sym link at runtime + Assert.Equal(3, _mocks.Count); + // all watchers have properties set + Assert.All( + _mocks, + mock => + mock.VerifySet(w => w.NotifyFilter = NotifyFilters.LastAccess)); + Assert.All( + _mocks, + mock => + mock.VerifySet(w => w.EnableRaisingEvents = true)); + Assert.All( + _mocks, + mock => + mock.VerifySet(w => w.IncludeSubdirectories = true)); + Assert.All( + _mocks, + mock => Assert.Equal(mock.Object.Filters, ["*.foo", "*.bar"])); + + // sync. object is only set for root watcher + Assert.Collection(_mocks, + rootWatcherMock => + { + rootWatcherMock.VerifySet(w => w.Path = dir.FullPath); + rootWatcherMock.VerifySet(w => w.SynchronizingObject = syncObj); + }, + otherWatcherMock => otherWatcherMock.VerifySet(w => w.SynchronizingObject = syncObj, Times.Never), + otherWatcherMock => otherWatcherMock.VerifySet(w => w.SynchronizingObject = syncObj, Times.Never)); + } + + + [Fact] + public void When_No_SubDirs_Are_Watched_Also_No_Additional_Symlink_Watchers_Are_Registered() + { + using var dir = new TempDir(); + + var subDir = dir.CreateSubDir("subdir"); + + // symlink for detection at startup + dir.CreateSymlink( + symLink: "sym1", + target: subDir); + + var uut = new SymlinkAwareFileWatcher(dir.FullPath, + _ => { }, + _ => { }, + WatcherFactoryWithMemory, + _ => { }) + { + IncludeSubdirectories = false + }; + uut.Init(); + + // create symlink at runtime + var symlinkPath2 = dir.CreateSymlink( + symLink: "sym2", + target: subDir); + + // simulate that a new symlink dir was added + uut.TryRegisterFileWatcherForSymbolicLinkDir(symlinkPath2); + + // only root watcher was registered + Assert.Single(_mocks); + } + + + private SymlinkAwareFileWatcher CreateFileWatcher(string path) + { + var fw = new SymlinkAwareFileWatcher(path, + _ => { }, + _ => { }, + WatcherFactoryWithMemory, + _ => { }) + { + IncludeSubdirectories = true + }; + fw.Init(); + return fw; + } + + private IFileSystemWatcherWrapper WatcherFactoryWithMemory() + { + var mock = new Mock(); + // this did the trick to have the 'Filters' property be recorded + mock.SetReturnsDefault(new Collection()); + _mocks.Add(mock); + return mock.Object; + } + + private void AssertContainsWatcherFor(string path) + { + var foundMocks = ( + from mock in _mocks + where HasPropertySetTo(mock, watcher => watcher.Path = path) + select mock) + .Count(); + Assert.Equal(1, foundMocks); + } + + private static bool HasPropertySetTo(Mock mock, Action setterExpression) + { + try + { + mock.VerifySet(setterExpression); + return true; + } + catch (MockException) + { + return false; + } + } +} diff --git a/Source/FileWatcherExTests/scenario/README.md b/Source/FileWatcherExTests/scenario/README.md new file mode 100644 index 0000000..ed0dfdf --- /dev/null +++ b/Source/FileWatcherExTests/scenario/README.md @@ -0,0 +1,101 @@ +# Scenarios For Integration Testing + +For each scenario: +- a fresh recording was started in an empty directory +- the listed commands were executed in a separate terminal +- the recording was stopped (CTRL + C) + +Then the recorded CSV files were used in integration tests using the `ReplayFileSystemWatcherWrapper.cs`. + +Example for starting a recording: +````powershell +PS C:\Projects\FileWatcherEx\Source\FileSystemEventRecorder> dotnet run C:\temp\fwtest\ C:\Projects\FileWatcherEx\Source\FileWatcherExTests\scenario\create_rename_and_remove_file_wsl2.csv +```` +# List of Scenarios + +## `create_file.csv` +````powershell +New-Item -Path 'c:\temp\fwtest\a.txt' -ItemType File +```` + +## `create_and_remove_file.csv` +````powershell +New-Item -Path 'c:\temp\fwtest\a.txt' -ItemType File +Remove-Item -Path 'c:\temp\fwtest\a.txt' -Recurse +```` + +## `create_rename_and_remove_file.csv` +````powershell +New-Item -Path 'c:\temp\fwtest\a.txt' -ItemType File +Rename-Item -Path 'c:\temp\fwtest\a.txt' -NewName 'c:\temp\fwtest\b.txt' +Remove-Item -Path 'c:\temp\fwtest\b.txt' -Recurse +```` + +## `create_file_wsl2.csv` +Create file in WSL 2. On file creation, a second "changed" event is written. +````sh +touch /mnt/c/temp/fwtest/a.txt +```` + + +## `create_and_rename_file_wsl2.csv` +Create and rename file in WSL 2. On file creation, a second "changed" event is written. +````sh +touch /mnt/c/temp/fwtest/a.txt +mv /mnt/c/temp/fwtest/a.txt /mnt/c/temp/fwtest/b.txt +```` + +## `create_rename_and_remove_file_wsl2.csv` +Create, rename and remove file in WSL 2. On file creation, a second "changed" event is written. +````sh +touch /mnt/c/temp/fwtest/a.txt +mv /mnt/c/temp/fwtest/a.txt /mnt/c/temp/fwtest/b.txt +rm /mnt/c/temp/fwtest/b.txt +```` + +## `create_rename_and_remove_file_with_wait_time_wsl2.csv` +Create, rename and remove file in WSL 2. Additionally, some wait time is added. +````sh +touch /mnt/c/temp/fwtest/a.txt +sleep 1 +mv /mnt/c/temp/fwtest/a.txt /mnt/c/temp/fwtest/b.txt +sleep 1 +rm /mnt/c/temp/fwtest/b.txt +```` + +## `create_and_rename_file_via_explorer.csv` +Manually create a file in the Windows explorer. Change the default name "New Text Document.txt" +to "foo.txt". + +## `create_rename_and_delete_file_via_explorer.csv` +Manually create, rename and delete a file in the Windows explorer. + +## `download_image_via_Edge_browser.csv` +Download (right click, "Save image as") single image via Edge 106.0.1370.42. + +## `create_subdirectory_add_and_remove_file.csv` +In WSL2, create a subdirectory, create and remove a file. +````sh +mkdir -p /mnt/c/temp/fwtest/subdir/ +touch /mnt/c/temp/fwtest/subdir/a.txt +rm /mnt/c/temp/fwtest/subdir/a.txt +```` + +## `create_subdirectory_add_and_remove_file_with_sleep.csv` +In WSL2, create a subdirectory, Create the file, wait for a while, then remove it. +````sh +mkdir -p /mnt/c/temp/fwtest/subdir/ +touch /mnt/c/temp/fwtest/subdir/a.txt +sleep 1 +rm /mnt/c/temp/fwtest/subdir/a.txt +```` + +## `create_file_inside_symbolic_link_directory` +Create subdir, create symbolic link to it and then place file inside this symbolic link dir. + +````powershell +New-Item –itemType Directory -Path 'c:\temp\fwtest\subdir' +New-Item -ItemType Junction -Path 'c:\temp\fwtest\symlink_subdir' -Target 'c:\temp\fwtest\subdir' +New-Item -ItemType File -Path 'c:\temp\fwtest\symlink_subdir\foo.txt' +Remove-Item -Path 'c:\temp\fwtest\symlink_subdir\foo.txt' -Recurse +```` diff --git a/Source/FileWatcherExTests/scenario/create_and_remove_file.csv b/Source/FileWatcherExTests/scenario/create_and_remove_file.csv new file mode 100644 index 0000000..e19d087 --- /dev/null +++ b/Source/FileWatcherExTests/scenario/create_and_remove_file.csv @@ -0,0 +1,3 @@ +Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds +C:\temp\fwtest,a.txt,created,,0,0 +C:\temp\fwtest,a.txt,deleted,,8263221,826 diff --git a/Source/FileWatcherExTests/scenario/create_and_rename_file_via_explorer.csv b/Source/FileWatcherExTests/scenario/create_and_rename_file_via_explorer.csv new file mode 100644 index 0000000..2143e79 --- /dev/null +++ b/Source/FileWatcherExTests/scenario/create_and_rename_file_via_explorer.csv @@ -0,0 +1,3 @@ +Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds +C:\temp\fwtest,New Text Document.txt,created,,0,0 +C:\temp\fwtest,foo.txt,renamed,New Text Document.txt,15017686,1502 diff --git a/Source/FileWatcherExTests/scenario/create_and_rename_file_wsl2.csv b/Source/FileWatcherExTests/scenario/create_and_rename_file_wsl2.csv new file mode 100644 index 0000000..0d92ec2 --- /dev/null +++ b/Source/FileWatcherExTests/scenario/create_and_rename_file_wsl2.csv @@ -0,0 +1,4 @@ +Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds +C:\temp\fwtest,a.txt,created,,0,0 +C:\temp\fwtest,a.txt,changed,,5808,1 +C:\temp\fwtest,b.txt,renamed,a.txt,85490,9 diff --git a/Source/FileWatcherExTests/scenario/create_file.csv b/Source/FileWatcherExTests/scenario/create_file.csv new file mode 100644 index 0000000..d5b452c --- /dev/null +++ b/Source/FileWatcherExTests/scenario/create_file.csv @@ -0,0 +1,2 @@ +Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds +C:\temp\fwtest,a.txt,created,,0,0 diff --git a/Source/FileWatcherExTests/scenario/create_file_inside_symbolic_link_directory.csv b/Source/FileWatcherExTests/scenario/create_file_inside_symbolic_link_directory.csv new file mode 100644 index 0000000..c2fe455 --- /dev/null +++ b/Source/FileWatcherExTests/scenario/create_file_inside_symbolic_link_directory.csv @@ -0,0 +1,7 @@ +Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds +C:\temp\fwtest,subdir,created,,0,0 +C:\temp\fwtest,symlink_subdir,created,,287271,29 +C:\temp\fwtest\subdir,foo.txt,created,,571620,57 +C:\temp\fwtest,subdir,changed,,7601128,760 +C:\temp\fwtest\subdir,foo.txt,deleted,,5158,1 +C:\temp\fwtest,subdir,changed,,1527420,153 diff --git a/Source/FileWatcherExTests/scenario/create_file_wsl2.csv b/Source/FileWatcherExTests/scenario/create_file_wsl2.csv new file mode 100644 index 0000000..5484ca5 --- /dev/null +++ b/Source/FileWatcherExTests/scenario/create_file_wsl2.csv @@ -0,0 +1,3 @@ +Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds +C:\temp\fwtest,a.txt,created,,0,0 +C:\temp\fwtest,a.txt,changed,,13323,1 diff --git a/Source/FileWatcherExTests/scenario/create_rename_and_delete_file_via_explorer.csv b/Source/FileWatcherExTests/scenario/create_rename_and_delete_file_via_explorer.csv new file mode 100644 index 0000000..c763e1c --- /dev/null +++ b/Source/FileWatcherExTests/scenario/create_rename_and_delete_file_via_explorer.csv @@ -0,0 +1,4 @@ +Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds +C:\temp\fwtest,New Text Document.txt,created,,0,0 +C:\temp\fwtest,foo.txt,renamed,New Text Document.txt,20053327,2005 +C:\temp\fwtest,foo.txt,deleted,,10305941,1031 diff --git a/Source/FileWatcherExTests/scenario/create_rename_and_remove_file.csv b/Source/FileWatcherExTests/scenario/create_rename_and_remove_file.csv new file mode 100644 index 0000000..e296370 --- /dev/null +++ b/Source/FileWatcherExTests/scenario/create_rename_and_remove_file.csv @@ -0,0 +1,4 @@ +Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds +C:\temp\fwtest,a.txt,created,,0,0 +C:\temp\fwtest,b.txt,renamed,a.txt,1046536,105 +C:\temp\fwtest,b.txt,deleted,,4102153,410 diff --git a/Source/FileWatcherExTests/scenario/create_rename_and_remove_file_with_wait_time_wsl2.csv b/Source/FileWatcherExTests/scenario/create_rename_and_remove_file_with_wait_time_wsl2.csv new file mode 100644 index 0000000..153306e --- /dev/null +++ b/Source/FileWatcherExTests/scenario/create_rename_and_remove_file_with_wait_time_wsl2.csv @@ -0,0 +1,5 @@ +Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds +C:\temp\fwtest,a.txt,created,,0,0 +C:\temp\fwtest,a.txt,changed,,6333,1 +C:\temp\fwtest,b.txt,renamed,a.txt,10362431,1036 +C:\temp\fwtest,b.txt,deleted,,10592668,1059 diff --git a/Source/FileWatcherExTests/scenario/create_rename_and_remove_file_wsl2.csv b/Source/FileWatcherExTests/scenario/create_rename_and_remove_file_wsl2.csv new file mode 100644 index 0000000..688ece8 --- /dev/null +++ b/Source/FileWatcherExTests/scenario/create_rename_and_remove_file_wsl2.csv @@ -0,0 +1,5 @@ +Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds +C:\temp\fwtest,a.txt,created,,0,0 +C:\temp\fwtest,a.txt,changed,,6136,1 +C:\temp\fwtest,b.txt,renamed,a.txt,94817,9 +C:\temp\fwtest,b.txt,deleted,,209770,21 diff --git a/Source/FileWatcherExTests/scenario/create_subdirectory_add_and_remove_file.csv b/Source/FileWatcherExTests/scenario/create_subdirectory_add_and_remove_file.csv new file mode 100644 index 0000000..aa953e1 --- /dev/null +++ b/Source/FileWatcherExTests/scenario/create_subdirectory_add_and_remove_file.csv @@ -0,0 +1,7 @@ +Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds +C:\temp\fwtest,subdir,created,,0,0 +C:\temp\fwtest\subdir,a.txt,created,,52133,5 +C:\temp\fwtest\subdir,a.txt,changed,,21171,2 +C:\temp\fwtest,subdir,changed,,132289,13 +C:\temp\fwtest\subdir,a.txt,deleted,,226731,23 +C:\temp\fwtest,subdir,changed,,2123902,212 diff --git a/Source/FileWatcherExTests/scenario/create_subdirectory_add_and_remove_file_with_sleep.csv b/Source/FileWatcherExTests/scenario/create_subdirectory_add_and_remove_file_with_sleep.csv new file mode 100644 index 0000000..7a9148c --- /dev/null +++ b/Source/FileWatcherExTests/scenario/create_subdirectory_add_and_remove_file_with_sleep.csv @@ -0,0 +1,6 @@ +Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds +C:\temp\fwtest,subdir,created,,0,0 +C:\temp\fwtest\subdir,a.txt,created,,371990,37 +C:\temp\fwtest\subdir,a.txt,changed,,78301,8 +C:\temp\fwtest,subdir,changed,,7198911,720 +C:\temp\fwtest\subdir,a.txt,deleted,,3310510,331 diff --git a/Source/FileWatcherExTests/scenario/download_image_via_Edge_browser.csv b/Source/FileWatcherExTests/scenario/download_image_via_Edge_browser.csv new file mode 100644 index 0000000..0ad936e --- /dev/null +++ b/Source/FileWatcherExTests/scenario/download_image_via_Edge_browser.csv @@ -0,0 +1,5 @@ +Directory,FileName,EventName,OldFileName,DiffInTicks,DiffInMilliseconds +C:\temp\fwtest,test.png,created,,0,0 +C:\temp\fwtest,test.png,deleted,,8470,1 +C:\temp\fwtest,test.png.crdownload,created,,3352286,335 +C:\temp\fwtest,test.png,renamed,test.png.crdownload,1451627,145