diff --git a/Source/Testably.Abstractions.Testing/FileSystem/FileSystemWatcherMock.cs b/Source/Testably.Abstractions.Testing/FileSystem/FileSystemWatcherMock.cs index 4b6d2be3..573161f9 100644 --- a/Source/Testably.Abstractions.Testing/FileSystem/FileSystemWatcherMock.cs +++ b/Source/Testably.Abstractions.Testing/FileSystem/FileSystemWatcherMock.cs @@ -347,24 +347,14 @@ protected override void Dispose(bool disposing) private bool MatchesFilter(ChangeDescription changeDescription) { - string fullPath = _fileSystem.Execute.Path.GetFullPath(Path); - if (IncludeSubdirectories) + if (!MatchesWatcherPath(changeDescription.Path)) { - if (!changeDescription.Path.StartsWith(fullPath, - _fileSystem.Execute.StringComparisonMode)) + if (changeDescription.ChangeType != WatcherChangeTypes.Renamed || + !MatchesWatcherPath(changeDescription.OldPath)) { - return changeDescription.ChangeType == WatcherChangeTypes.Renamed && - changeDescription.OldPath?.StartsWith(fullPath, - _fileSystem.Execute.StringComparisonMode) == true; + return false; } } - else if (!string.Equals( - _fileSystem.Execute.Path.GetDirectoryName(changeDescription.Path), - fullPath, - _fileSystem.Execute.StringComparisonMode)) - { - return false; - } if ((NotifyFilter & changeDescription.NotifyFilters) == 0) { @@ -384,6 +374,23 @@ private bool MatchesFilter(ChangeDescription changeDescription) filter)); } + private bool MatchesWatcherPath(string? path) + { + if (path == null) + { + return false; + } + + string fullPath = _fileSystem.Execute.Path.GetFullPath(Path); + if (IncludeSubdirectories) + { + return path.StartsWith(fullPath, _fileSystem.Execute.StringComparisonMode); + } + + return string.Equals(_fileSystem.Execute.Path.GetDirectoryName(path), fullPath, + _fileSystem.Execute.StringComparisonMode); + } + private void NotifyChange(ChangeDescription item) { InternalEvent?.Invoke(this, new ChangeDescriptionEventArgs(item)); @@ -505,16 +512,18 @@ private FileSystemEventArgs ToFileSystemEventArgs( changeName, out string name); - FileSystemEventArgs eventArgs = new(changeType, path, name); - if (_fileSystem.SimulationMode != SimulationMode.Native) - { - // FileSystemEventArgs implicitly combines the path in https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemEventArgs.cs - // HACK: Have to resort to Reflection to override this behavior! - string fullPath = _fileSystem.Execute.Path.Combine(path, name); - typeof(FileSystemEventArgs) - .GetField("_fullPath", BindingFlags.Instance | BindingFlags.NonPublic)? - .SetValue(eventArgs, fullPath); - } + FileSystemEventArgs eventArgs = new(changeType, changePath, name); + // FileSystemEventArgs implicitly combines the path in https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/FileSystemEventArgs.cs + // HACK: Have to resort to Reflection to override this behavior! +#if NETFRAMEWORK + typeof(FileSystemEventArgs) + .GetField("fullPath", BindingFlags.Instance | BindingFlags.NonPublic)? + .SetValue(eventArgs, path); +#else + typeof(FileSystemEventArgs) + .GetField("_fullPath", BindingFlags.Instance | BindingFlags.NonPublic)? + .SetValue(eventArgs, path); +#endif return eventArgs; } @@ -526,40 +535,35 @@ private string TransformPathAndName( { string? transformedName = changeDescriptionName; string? path = changeDescriptionPath; - if (transformedName == null || - _fileSystem.Execute.Path.IsPathRooted(changeDescriptionName)) + if (!_fileSystem.Path.IsPathRooted(Path)) { + string rootedWatchedPath = _fileSystem.Directory.GetCurrentDirectory(); + if (!rootedWatchedPath.EndsWith(_fileSystem.Path.DirectorySeparatorChar)) + { + rootedWatchedPath += _fileSystem.Path.DirectorySeparatorChar; + } + + if (path.StartsWith(rootedWatchedPath, _fileSystem.Execute.StringComparisonMode)) + { + path = path.Substring(rootedWatchedPath.Length); + } + transformedName = _fileSystem.Execute.Path.GetFileName(changeDescriptionPath); - path = _fileSystem.Execute.Path.GetDirectoryName(path); } - else if (path.EndsWith(transformedName, _fileSystem.Execute.StringComparisonMode)) + else if (transformedName == null || + _fileSystem.Execute.Path.IsPathRooted(changeDescriptionName)) { - path = path.Substring(0, path.Length - transformedName.Length); + transformedName = _fileSystem.Execute.Path.GetFileName(changeDescriptionPath); } name = transformedName; - if (!_fileSystem.Path.IsPathRooted(Path)) - { - string rootedWatchedPath = _fileSystem.Path.GetFullPath(Path); - if (path?.StartsWith(rootedWatchedPath, _fileSystem.Execute.StringComparisonMode) == - true) - { - path = _fileSystem.Path.Combine(Path, path.Substring(rootedWatchedPath.Length)); - } - } return path ?? ""; } private void TriggerRenameNotification(ChangeDescription item) { - if (!item.Path.StartsWith(Path, _fileSystem.Execute.StringComparisonMode) && - item.OldPath != null) - { - Deleted?.Invoke(this, ToFileSystemEventArgs( - WatcherChangeTypes.Deleted, item.OldPath, item.OldName)); - } - else if (_fileSystem.Execute.IsWindows) + if (_fileSystem.Execute.IsWindows) { if (TryMakeRenamedEventArgs(item, out RenamedEventArgs? eventArgs)) @@ -568,10 +572,17 @@ private void TriggerRenameNotification(ChangeDescription item) } else if (item.OldPath != null) { - Deleted?.Invoke(this, ToFileSystemEventArgs( - WatcherChangeTypes.Deleted, item.OldPath, item.OldName)); - Created?.Invoke(this, ToFileSystemEventArgs( - WatcherChangeTypes.Created, item.Path, item.Name)); + if (MatchesWatcherPath(item.OldPath)) + { + Deleted?.Invoke(this, ToFileSystemEventArgs( + WatcherChangeTypes.Deleted, item.OldPath, item.OldName)); + } + + if (MatchesWatcherPath(item.Path)) + { + Created?.Invoke(this, ToFileSystemEventArgs( + WatcherChangeTypes.Created, item.Path, item.Name)); + } } } else @@ -600,7 +611,7 @@ private bool TryMakeRenamedEventArgs( changeDescription.Name, out string name); - TransformPathAndName( + string oldPath = TransformPathAndName( changeDescription.OldPath, changeDescription.OldName, out string oldName); @@ -610,19 +621,23 @@ private bool TryMakeRenamedEventArgs( path, name, oldName); - if (_fileSystem.SimulationMode != SimulationMode.Native) - { - // RenamedEventArgs implicitly combines the path in https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/RenamedEventArgs.cs - // HACK: Have to resort to Reflection to override this behavior! - string fullPath = _fileSystem.Execute.Path.Combine(path, name); - typeof(FileSystemEventArgs) - .GetField("_fullPath", BindingFlags.Instance | BindingFlags.NonPublic)? - .SetValue(eventArgs, fullPath); - string oldFullPath = _fileSystem.Execute.Path.Combine(path, oldName); - typeof(RenamedEventArgs) - .GetField("_oldFullPath", BindingFlags.Instance | BindingFlags.NonPublic)? - .SetValue(eventArgs, oldFullPath); - } + // RenamedEventArgs implicitly combines the path in https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/System.IO.FileSystem.Watcher/src/System/IO/RenamedEventArgs.cs + // HACK: Have to resort to Reflection to override this behavior! +#if NETFRAMEWORK + typeof(FileSystemEventArgs) + .GetField("fullPath", BindingFlags.Instance | BindingFlags.NonPublic)? + .SetValue(eventArgs, path); + typeof(RenamedEventArgs) + .GetField("oldFullPath", BindingFlags.Instance | BindingFlags.NonPublic)? + .SetValue(eventArgs, oldPath); +#else + typeof(FileSystemEventArgs) + .GetField("_fullPath", BindingFlags.Instance | BindingFlags.NonPublic)? + .SetValue(eventArgs, path); + typeof(RenamedEventArgs) + .GetField("_oldFullPath", BindingFlags.Instance | BindingFlags.NonPublic)? + .SetValue(eventArgs, oldPath); +#endif return _fileSystem.Execute.Path.GetDirectoryName(changeDescription.Path)? .Equals(_fileSystem.Execute.Path.GetDirectoryName(changeDescription.OldPath), diff --git a/Tests/Testably.Abstractions.Testing.Tests/FileSystem/FileSystemWatcherMockTests.cs b/Tests/Testably.Abstractions.Testing.Tests/FileSystem/FileSystemWatcherMockTests.cs index 64207165..fd61f3a9 100644 --- a/Tests/Testably.Abstractions.Testing.Tests/FileSystem/FileSystemWatcherMockTests.cs +++ b/Tests/Testably.Abstractions.Testing.Tests/FileSystem/FileSystemWatcherMockTests.cs @@ -252,7 +252,7 @@ public async Task FileSystemEventArgs_ShouldUseDirectorySeparatorFromSimulatedFi string expectedName = fileSystem.Path.Combine(parentDirectory, directoryName); using IFileSystemWatcher fileSystemWatcher = - fileSystem.FileSystemWatcher.New(parentDirectory); + fileSystem.FileSystemWatcher.New(fileSystem.Path.GetFullPath(parentDirectory)); using ManualResetEventSlim ms = new(); fileSystemWatcher.Created += (_, eventArgs) => { diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/NotifyFiltersTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/NotifyFiltersTests.cs index d63efddd..dd58e9cd 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/NotifyFiltersTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/NotifyFiltersTests.cs @@ -524,6 +524,98 @@ public async Task NotifyFilter_MoveFile_DifferentDirectories_ShouldNotNotify_OnW await That(result).IsNull(); } + [Theory] + [InlineAutoData(true)] + [InlineAutoData(false)] + public async Task NotifyFilter_MoveFileOutOfTheWatchedDirectory_ShouldTriggerDeleted_OnWindows( + bool includeSubdirectories, string sourcePath, string sourceName, + string destinationPath, string destinationName) + { + SkipIfLongRunningTestsShouldBeSkipped(); + Skip.IfNot(Test.RunsOnWindows); + + FileSystem.Initialize() + .WithSubdirectory(sourcePath).Initialized(s => s + .WithFile(sourceName)) + .WithSubdirectory(destinationPath); + FileSystemEventArgs? result = null; + using ManualResetEventSlim ms = new(); + using IFileSystemWatcher fileSystemWatcher = + FileSystem.FileSystemWatcher.New(sourcePath); + fileSystemWatcher.Deleted += (_, eventArgs) => + { + // ReSharper disable once AccessToDisposedClosure + try + { + result = eventArgs; + ms.Set(); + } + catch (ObjectDisposedException) + { + // Ignore any ObjectDisposedException + } + }; + + fileSystemWatcher.IncludeSubdirectories = includeSubdirectories; + fileSystemWatcher.EnableRaisingEvents = true; + + FileSystem.File.Move( + FileSystem.Path.Combine(sourcePath, sourceName), + FileSystem.Path.Combine(destinationPath, destinationName)); + + await That(ms.Wait(ExpectSuccess, TestContext.Current.CancellationToken)).IsTrue(); + await That(result).IsNotNull(); + await That(result!.ChangeType).IsEqualTo(WatcherChangeTypes.Deleted); + await That(result.FullPath).IsEqualTo(FileSystem.Path.Combine(sourcePath, sourceName)); + await That(result.Name).IsEqualTo(sourceName); + } + + [Theory] + [InlineAutoData(true)] + [InlineAutoData(false)] + public async Task NotifyFilter_MoveFileInToTheWatchedDirectory_ShouldTriggerCreated_OnWindows( + bool includeSubdirectories, string sourcePath, string sourceName, + string destinationPath, string destinationName) + { + SkipIfLongRunningTestsShouldBeSkipped(); + Skip.IfNot(Test.RunsOnWindows); + + FileSystem.Initialize() + .WithSubdirectory(sourcePath).Initialized(s => s + .WithFile(sourceName)) + .WithSubdirectory(destinationPath); + FileSystemEventArgs? result = null; + using ManualResetEventSlim ms = new(); + using IFileSystemWatcher fileSystemWatcher = + FileSystem.FileSystemWatcher.New(destinationPath); + fileSystemWatcher.Created += (_, eventArgs) => + { + // ReSharper disable once AccessToDisposedClosure + try + { + result = eventArgs; + ms.Set(); + } + catch (ObjectDisposedException) + { + // Ignore any ObjectDisposedException + } + }; + + fileSystemWatcher.IncludeSubdirectories = includeSubdirectories; + fileSystemWatcher.EnableRaisingEvents = true; + + FileSystem.File.Move( + FileSystem.Path.Combine(sourcePath, sourceName), + FileSystem.Path.Combine(destinationPath, destinationName)); + + await That(ms.Wait(ExpectSuccess, TestContext.Current.CancellationToken)).IsTrue(); + await That(result).IsNotNull(); + await That(result!.ChangeType).IsEqualTo(WatcherChangeTypes.Created); + await That(result.FullPath).IsEqualTo(FileSystem.Path.Combine(destinationPath, destinationName)); + await That(result.Name).IsEqualTo(destinationName); + } + [Theory] [AutoData] public async Task NotifyFilter_MoveFile_ShouldNotNotifyOnOtherFilters( @@ -571,7 +663,7 @@ public async Task NotifyFilter_MoveFile_ShouldNotNotifyOnOtherFilters( [Theory] [InlineAutoData(NotifyFilters.FileName)] - public async Task NotifyFilter_MoveFile_ShouldTriggerChangedEventOnNotifyFilters( + public async Task NotifyFilter_MoveFile_ShouldTriggerRenamedEventOnNotifyFilters( NotifyFilters notifyFilter, string sourceName, string destinationName) { SkipIfLongRunningTestsShouldBeSkipped(); @@ -613,7 +705,7 @@ public async Task NotifyFilter_MoveFile_ShouldTriggerChangedEventOnNotifyFilters [Theory] [InlineAutoData(NotifyFilters.DirectoryName)] - public async Task NotifyFilter_MoveDirectory_ShouldTriggerChangedEventOnNotifyFilters( + public async Task NotifyFilter_MoveDirectory_ShouldTriggerRenamedEventOnNotifyFilters( NotifyFilters notifyFilter, string sourceName, string destinationName) { SkipIfLongRunningTestsShouldBeSkipped(); @@ -654,11 +746,13 @@ public async Task NotifyFilter_MoveDirectory_ShouldTriggerChangedEventOnNotifyFi } [Theory] - [InlineAutoData(NotifyFilters.DirectoryName)] - public async Task NotifyFilter_MoveDirectoryOutOfTheWatchedDirectory_ShouldTriggerChangedEventOnNotifyFilters( - NotifyFilters notifyFilter, string sourceName, string destinationName) + [InlineAutoData(NotifyFilters.DirectoryName, true)] + [InlineAutoData(NotifyFilters.DirectoryName, false)] + public async Task NotifyFilter_MoveDirectoryOutOfTheWatchedDirectory_ShouldTriggerDeletedEventOnNotifyFilters_OnWindows( + NotifyFilters notifyFilter, bool includeSubdirectories, string sourceName, string destinationName) { SkipIfLongRunningTestsShouldBeSkipped(); + Skip.IfNot(Test.RunsOnWindows); FileSystem.Initialize().WithSubdirectory("watched"); var sourcePath = FileSystem.Path.Combine("watched", sourceName); @@ -682,7 +776,7 @@ public async Task NotifyFilter_MoveDirectoryOutOfTheWatchedDirectory_ShouldTrigg }; fileSystemWatcher.NotifyFilter = notifyFilter; - fileSystemWatcher.IncludeSubdirectories = true; + fileSystemWatcher.IncludeSubdirectories = includeSubdirectories; fileSystemWatcher.EnableRaisingEvents = true; FileSystem.Directory.Move(sourcePath, destinationName); @@ -694,6 +788,49 @@ public async Task NotifyFilter_MoveDirectoryOutOfTheWatchedDirectory_ShouldTrigg await That(result.Name).IsEqualTo(sourceName); } + [Theory] + [InlineAutoData(NotifyFilters.DirectoryName, true)] + [InlineAutoData(NotifyFilters.DirectoryName, false)] + public async Task NotifyFilter_MoveDirectoryInToTheWatchedDirectory_ShouldTriggerCreatedEventOnNotifyFilters_OnWindows( + NotifyFilters notifyFilter, bool includeSubdirectories, string sourceName, string destinationName) + { + SkipIfLongRunningTestsShouldBeSkipped(); + Skip.IfNot(Test.RunsOnWindows); + + FileSystem.Initialize().WithSubdirectory("watched"); + var destinationPath = FileSystem.Path.Combine("watched", destinationName); + FileSystem.Directory.CreateDirectory(sourceName); + FileSystemEventArgs? result = null; + using ManualResetEventSlim ms = new(); + using IFileSystemWatcher fileSystemWatcher = + FileSystem.FileSystemWatcher.New("watched"); + fileSystemWatcher.Created += (_, eventArgs) => + { + // ReSharper disable once AccessToDisposedClosure + try + { + result = eventArgs; + ms.Set(); + } + catch (ObjectDisposedException) + { + // Ignore any ObjectDisposedException + } + }; + + fileSystemWatcher.NotifyFilter = notifyFilter; + fileSystemWatcher.IncludeSubdirectories = includeSubdirectories; + fileSystemWatcher.EnableRaisingEvents = true; + + FileSystem.Directory.Move(sourceName, destinationPath); + + await That(ms.Wait(ExpectSuccess, TestContext.Current.CancellationToken)).IsTrue(); + await That(result).IsNotNull(); + await That(result!.ChangeType).IsEqualTo(WatcherChangeTypes.Created); + await That(result.FullPath).IsEqualTo(destinationPath); + await That(result.Name).IsEqualTo(destinationName); + } + [Theory] [AutoData] public async Task NotifyFilter_WriteFile_ShouldNotNotifyOnOtherFilters(string fileName)