diff --git a/Source/Testably.Abstractions.Testing/FileSystem/FileSystemWatcherMock.cs b/Source/Testably.Abstractions.Testing/FileSystem/FileSystemWatcherMock.cs index e1bff42a..c9ee9fed 100644 --- a/Source/Testably.Abstractions.Testing/FileSystem/FileSystemWatcherMock.cs +++ b/Source/Testably.Abstractions.Testing/FileSystem/FileSystemWatcherMock.cs @@ -24,11 +24,38 @@ internal sealed class FileSystemWatcherMock : Component, IFileSystemWatcher /// private const int BytesPerMessage = 128; + /// + /// Caches the full path of + /// + private string FullPath + { + get => _fullPath; + set + { + if (string.IsNullOrEmpty(value)) + { + _fullPath = value; + + return; + } + + string fullPath = _fileSystem.Execute.Path.GetFullPath(value); + + if (!fullPath.EndsWith(_fileSystem.Execute.Path.DirectorySeparatorChar)) + { + fullPath += _fileSystem.Execute.Path.DirectorySeparatorChar; + } + + _fullPath = fullPath; + } + } + private CancellationTokenSource? _cancellationTokenSource; private IDisposable? _changeHandler; private bool _enableRaisingEvents; private readonly MockFileSystem _fileSystem; private readonly Collection _filters = []; + private string _fullPath = string.Empty; private bool _includeSubdirectories; private int _internalBufferSize = 8192; private bool _isInitializing; @@ -38,7 +65,6 @@ internal sealed class FileSystemWatcherMock : Component, IFileSystemWatcher NotifyFilters.LastWrite; private string _path = string.Empty; - private string _fullPath = string.Empty; private ISynchronizeInvoke? _synchronizingObject; @@ -260,32 +286,6 @@ public ISynchronizeInvoke? SynchronizingObject } } - /// - /// Caches the full path of - /// - private string FullPath - { - get => _fullPath; - set - { - if (string.IsNullOrEmpty(value)) - { - _fullPath = value; - - return; - } - - string fullPath = _fileSystem.Path.GetFullPath(value); - - if (!fullPath.EndsWith(_fileSystem.Path.DirectorySeparatorChar)) - { - fullPath += _fileSystem.Path.DirectorySeparatorChar; - } - - _fullPath = fullPath; - } - } - /// public void BeginInit() { @@ -463,6 +463,46 @@ private void Restart() } } + private void SetFileSystemEventArgsFullPath(FileSystemEventArgs args, string fullPath) + { + if (_fileSystem.SimulationMode == SimulationMode.Native) + { + return; + } + + // 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: The combination uses the system separator, so to simulate the behavior, we must override it using reflection! +#if NETFRAMEWORK + typeof(FileSystemEventArgs) + .GetField("fullPath", BindingFlags.Instance | BindingFlags.NonPublic)? + .SetValue(args, fullPath); +#else + typeof(FileSystemEventArgs) + .GetField("_fullPath", BindingFlags.Instance | BindingFlags.NonPublic)? + .SetValue(args, fullPath); +#endif + } + + private void SetRenamedEventArgsOldFullPath(RenamedEventArgs args, string oldFullPath) + { + if (_fileSystem.SimulationMode == SimulationMode.Native) + { + return; + } + + // 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: The combination uses the system separator, so to simulate the behavior, we must override it using reflection! +#if NETFRAMEWORK + typeof(RenamedEventArgs) + .GetField("oldFullPath", BindingFlags.Instance | BindingFlags.NonPublic)? + .SetValue(args, oldFullPath); +#else + typeof(RenamedEventArgs) + .GetField("_oldFullPath", BindingFlags.Instance | BindingFlags.NonPublic)? + .SetValue(args, oldFullPath); +#endif + } + private void Start() { if (_isInitializing) @@ -530,6 +570,30 @@ private void Stop() _changeHandler?.Dispose(); } + private FileSystemEventArgs ToFileSystemEventArgs( + WatcherChangeTypes changeType, + string changePath) + { + string name = TransformPathAndName(changePath); + + FileSystemEventArgs eventArgs = new(changeType, Path, name); + + SetFileSystemEventArgsFullPath(eventArgs, _fileSystem.Execute.Path.Combine(Path, name)); + + return eventArgs; + } + + private string TransformPathAndName(string changeDescriptionPath) + { + if (changeDescriptionPath.StartsWith(FullPath, _fileSystem.Execute.StringComparisonMode)) + { + return changeDescriptionPath.Substring(FullPath.Length) + .TrimStart(_fileSystem.Execute.Path.DirectorySeparatorChar); + } + + return _fileSystem.Execute.Path.GetFileName(changeDescriptionPath); + } + private void TriggerRenameNotification(ChangeDescription item) { if (_fileSystem.Execute.IsWindows) @@ -581,10 +645,18 @@ private bool TryMakeRenamedEventArgs( string oldName = TransformPathAndName(changeDescription.OldPath); - eventArgs = new RenamedEventArgs(changeDescription.ChangeType, Path, name, oldName); + eventArgs = new RenamedEventArgs(changeDescription.ChangeType, Path, + _fileSystem.Execute.Path.GetFileName(name), + _fileSystem.Execute.Path.GetFileName(oldName)); - SetFileSystemEventArgsFullPath(eventArgs, name); - SetRenamedEventArgsFullPath(eventArgs, oldName); + SetFileSystemEventArgsFullPath(eventArgs, + changeDescription.Path.StartsWith(FullPath, _fileSystem.Execute.StringComparisonMode) + ? _fileSystem.Execute.Path.Combine(Path, name) + : name); + SetRenamedEventArgsOldFullPath(eventArgs, + changeDescription.OldPath.StartsWith(FullPath, _fileSystem.Execute.StringComparisonMode) + ? _fileSystem.Execute.Path.Combine(Path, oldName) + : oldName); return _fileSystem.Execute.Path.GetDirectoryName(changeDescription.Path)?.Equals( _fileSystem.Execute.Path.GetDirectoryName(changeDescription.OldPath), @@ -593,68 +665,6 @@ private bool TryMakeRenamedEventArgs( ?? true; } - private FileSystemEventArgs ToFileSystemEventArgs( - WatcherChangeTypes changeType, - string changePath) - { - string name = TransformPathAndName(changePath); - - FileSystemEventArgs eventArgs = new(changeType, Path, name); - - SetFileSystemEventArgsFullPath(eventArgs, name); - - return eventArgs; - } - - private string TransformPathAndName(string changeDescriptionPath) - { - return changeDescriptionPath.Substring(FullPath.Length).TrimStart(_fileSystem.Path.DirectorySeparatorChar); - } - - private void SetFileSystemEventArgsFullPath(FileSystemEventArgs args, string name) - { - if (_fileSystem.SimulationMode == SimulationMode.Native) - { - return; - } - - string fullPath = _fileSystem.Path.Combine(Path, 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: The combination uses the system separator, so to simulate the behavior, we must override it using reflection! -#if NETFRAMEWORK - typeof(FileSystemEventArgs) - .GetField("fullPath", BindingFlags.Instance | BindingFlags.NonPublic)? - .SetValue(args, fullPath); -#else - typeof(FileSystemEventArgs) - .GetField("_fullPath", BindingFlags.Instance | BindingFlags.NonPublic)? - .SetValue(args, fullPath); -#endif - } - - private void SetRenamedEventArgsFullPath(RenamedEventArgs args, string oldName) - { - if (_fileSystem.SimulationMode == SimulationMode.Native) - { - return; - } - - string fullPath = _fileSystem.Path.Combine(Path, oldName); - - // 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: The combination uses the system separator, so to simulate the behavior, we must override it using reflection! -#if NETFRAMEWORK - typeof(RenamedEventArgs) - .GetField("oldFullPath", BindingFlags.Instance | BindingFlags.NonPublic)? - .SetValue(args, fullPath); -#else - typeof(RenamedEventArgs) - .GetField("_oldFullPath", BindingFlags.Instance | BindingFlags.NonPublic)? - .SetValue(args, fullPath); -#endif - } - private IWaitForChangedResult WaitForChangedInternal( WatcherChangeTypes changeType, TimeSpan timeout) { diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/IncludeSubdirectoriesTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/IncludeSubdirectoriesTests.cs index 32999384..2b27c4ad 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/IncludeSubdirectoriesTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/IncludeSubdirectoriesTests.cs @@ -133,7 +133,6 @@ string fileName { string subdirectoryName = "a"; string subSubdirectoryName = "b"; - // Arrange FileSystem.Initialize().WithSubdirectory(baseDirectory) .Initialized(s => s.WithSubdirectory(subdirectoryName) .Initialized(ss => ss.WithSubdirectory(subSubdirectoryName)) @@ -172,18 +171,14 @@ string fileName fileSystemWatcher.IncludeSubdirectories = true; fileSystemWatcher.EnableRaisingEvents = true; - - // Act FileSystem.File.Create(filePath).Dispose(); - - // Assert await That(createdMre.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsTrue(); await That(createdArgs).IsNotNull().And - .Satisfies(args => string.Equals(args?.Name, expectedFileName, StringComparison.Ordinal) - ); + .Satisfies(args => string.Equals(args?.Name, expectedFileName, + StringComparison.Ordinal)); } [Theory] @@ -197,29 +192,22 @@ string fileName { string subdirectoryName = "a"; string subSubdirectoryName = "b"; - // Arrange FileSystem.Initialize().WithSubdirectory(baseDirectory) .Initialized(s => s.WithSubdirectory(subdirectoryName) .Initialized(ss => ss.WithSubdirectory(subSubdirectoryName)) ); - string filePath = FileSystem.Path.Combine( baseDirectory, subdirectoryName, subSubdirectoryName, fileName ); - string expectedFileName = FileSystem.Path.Combine( subdirectoryName, subSubdirectoryName, fileName ); - string watchPath = watchRootedPath ? FileSystem.Path.Combine(FileSystem.Directory.GetCurrentDirectory(), baseDirectory) : baseDirectory; - using ManualResetEventSlim changedMre = new(); FileSystemEventArgs? changedArgs = null; - using IFileSystemWatcher fileSystemWatcher = FileSystem.FileSystemWatcher.New(watchPath); - fileSystemWatcher.Changed += (_, eventArgs) => { // ReSharper disable once AccessToDisposedClosure @@ -239,22 +227,16 @@ string fileName // Ignore any ObjectDisposedException } }; - fileSystemWatcher.IncludeSubdirectories = true; fileSystemWatcher.EnableRaisingEvents = true; - - // Act FileSystem.File.Create(filePath).Dispose(); FileSystem.File.WriteAllText(filePath, "Hello World!"); - // Assert - await That(changedMre.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsTrue(); - await That(changedArgs).IsNotNull().And - .Satisfies(args => string.Equals(args?.Name, expectedFileName, StringComparison.Ordinal) - ); + .Satisfies(args => string.Equals(args?.Name, expectedFileName, + StringComparison.Ordinal)); } [Theory] @@ -268,7 +250,6 @@ string fileName { string subdirectoryName = "a"; string subSubdirectoryName = "b"; - // Arrange FileSystem.Initialize().WithSubdirectory(baseDirectory) .Initialized(s => s.WithSubdirectory(subdirectoryName) .Initialized(ss => ss.WithSubdirectory(subSubdirectoryName)) @@ -312,23 +293,15 @@ string fileName fileSystemWatcher.IncludeSubdirectories = true; fileSystemWatcher.EnableRaisingEvents = true; - // Act - FileSystem.File.Create(filePath).Dispose(); FileSystem.File.Move(filePath, newFilePath); - // Assert - await That(renamedMre.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsTrue(); - await That(renamedArgs).IsNotNull().And - .Satisfies(args => string.Equals( - args?.Name, expectedNewFileName, StringComparison.Ordinal - ) - ).And.Satisfies(args => string.Equals( - args?.OldName, expectedFileName, StringComparison.Ordinal - ) - ); + .Satisfies(args => string.Equals(args?.Name, expectedNewFileName, + StringComparison.Ordinal)).And + .Satisfies(args => string.Equals(args?.OldName, expectedFileName, + StringComparison.Ordinal)); } [Theory] @@ -342,7 +315,6 @@ string fileName { string subdirectoryName = "a"; string subSubdirectoryName = "b"; - // Arrange FileSystem.Initialize().WithSubdirectory(baseDirectory) .Initialized(s => s.WithSubdirectory(subdirectoryName) .Initialized(ss => ss.WithSubdirectory(subSubdirectoryName)) @@ -381,20 +353,13 @@ string fileName fileSystemWatcher.IncludeSubdirectories = true; fileSystemWatcher.EnableRaisingEvents = true; - - // Act FileSystem.File.Create(filePath).Dispose(); FileSystem.File.Delete(filePath); - - // Assert await That(deletedMre.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsTrue(); - - await That(deletedArgs).IsNotNull().And.Satisfies(args => string.Equals( - args?.Name, expectedFileName, - StringComparison.Ordinal - ) - ); + await That(deletedArgs).IsNotNull().And + .Satisfies(args => string.Equals(args?.Name, expectedFileName, + StringComparison.Ordinal)); } } diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/NotifyFiltersTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/NotifyFiltersTests.cs index dd58e9cd..9e7c2881 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/NotifyFiltersTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/NotifyFiltersTests.cs @@ -524,6 +524,54 @@ public async Task NotifyFilter_MoveFile_DifferentDirectories_ShouldNotNotify_OnW await That(result).IsNull(); } + [Theory] + [InlineAutoData(true)] + [InlineAutoData(false)] + public async Task NotifyFilter_MoveFileOutOfTheWatchedDirectory_ShouldTriggerRenamed_OnLinuxOrMac( + bool includeSubdirectories, string sourcePath, string sourceName, + string destinationPath, string destinationName) + { + SkipIfLongRunningTestsShouldBeSkipped(); + Skip.If(Test.RunsOnWindows); + + FileSystem.Initialize() + .WithSubdirectory(sourcePath).Initialized(s => s + .WithFile(sourceName)) + .WithSubdirectory(destinationPath); + RenamedEventArgs? result = null; + using ManualResetEventSlim ms = new(); + using IFileSystemWatcher fileSystemWatcher = + FileSystem.FileSystemWatcher.New(sourcePath); + fileSystemWatcher.Renamed += (_, 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.Renamed); + await That(result.FullPath).IsEqualTo(FileSystem.Path.Combine(destinationPath, destinationName)); + await That(result.Name).IsEqualTo(destinationName); + await That(result.OldFullPath).IsEqualTo(FileSystem.Path.Combine(sourcePath, sourceName)); + await That(result.OldName).IsEqualTo(sourceName); + } + [Theory] [InlineAutoData(true)] [InlineAutoData(false)] @@ -570,6 +618,54 @@ public async Task NotifyFilter_MoveFileOutOfTheWatchedDirectory_ShouldTriggerDel await That(result.Name).IsEqualTo(sourceName); } + [Theory] + [InlineAutoData(true)] + [InlineAutoData(false)] + public async Task NotifyFilter_MoveFileInToTheWatchedDirectory_ShouldTriggerRenamed_OnLinuxOrMac( + bool includeSubdirectories, string sourcePath, string sourceName, + string destinationPath, string destinationName) + { + SkipIfLongRunningTestsShouldBeSkipped(); + Skip.If(Test.RunsOnWindows); + + FileSystem.Initialize() + .WithSubdirectory(sourcePath).Initialized(s => s + .WithFile(sourceName)) + .WithSubdirectory(destinationPath); + RenamedEventArgs? result = null; + using ManualResetEventSlim ms = new(); + using IFileSystemWatcher fileSystemWatcher = + FileSystem.FileSystemWatcher.New(destinationPath); + fileSystemWatcher.Renamed += (_, 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.Renamed); + await That(result.FullPath).IsEqualTo(FileSystem.Path.Combine(destinationPath, destinationName)); + await That(result.Name).IsEqualTo(destinationName); + await That(result.OldFullPath).IsEqualTo(FileSystem.Path.Combine(sourcePath, sourceName)); + await That(result.OldName).IsEqualTo(sourceName); + } + [Theory] [InlineAutoData(true)] [InlineAutoData(false)] @@ -745,6 +841,51 @@ public async Task NotifyFilter_MoveDirectory_ShouldTriggerRenamedEventOnNotifyFi await That(result.OldName).IsEqualTo(FileSystem.Path.GetFileName(sourceName)); } + [Theory] + [InlineAutoData(NotifyFilters.DirectoryName, true)] + [InlineAutoData(NotifyFilters.DirectoryName, false)] + public async Task NotifyFilter_MoveDirectoryOutOfTheWatchedDirectory_ShouldTriggerRenamedEventOnNotifyFilters_OnLinuxOrMac( + NotifyFilters notifyFilter, bool includeSubdirectories, string sourceName, string destinationName) + { + SkipIfLongRunningTestsShouldBeSkipped(); + Skip.If(Test.RunsOnWindows); + + FileSystem.Initialize().WithSubdirectory("watched"); + var sourcePath = FileSystem.Path.Combine("watched", sourceName); + FileSystem.Directory.CreateDirectory(sourcePath); + RenamedEventArgs? result = null; + using ManualResetEventSlim ms = new(); + using IFileSystemWatcher fileSystemWatcher = + FileSystem.FileSystemWatcher.New("watched"); + fileSystemWatcher.Renamed += (_, 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(sourcePath, destinationName); + + await That(ms.Wait(ExpectSuccess, TestContext.Current.CancellationToken)).IsTrue(); + await That(result).IsNotNull(); + await That(result!.ChangeType).IsEqualTo(WatcherChangeTypes.Renamed); + await That(result.FullPath).IsEqualTo(destinationName); + await That(result.Name).IsEqualTo(destinationName); + await That(result.OldFullPath).IsEqualTo(sourcePath); + await That(result.OldName).IsEqualTo(sourceName); + } + [Theory] [InlineAutoData(NotifyFilters.DirectoryName, true)] [InlineAutoData(NotifyFilters.DirectoryName, false)] @@ -788,6 +929,51 @@ 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_ShouldTriggerRenamedEventOnNotifyFilters_OnLinuxOrMac( + NotifyFilters notifyFilter, bool includeSubdirectories, string sourceName, string destinationName) + { + SkipIfLongRunningTestsShouldBeSkipped(); + Skip.If(Test.RunsOnWindows); + + FileSystem.Initialize().WithSubdirectory("watched"); + var destinationPath = FileSystem.Path.Combine("watched", destinationName); + FileSystem.Directory.CreateDirectory(sourceName); + RenamedEventArgs? result = null; + using ManualResetEventSlim ms = new(); + using IFileSystemWatcher fileSystemWatcher = + FileSystem.FileSystemWatcher.New("watched"); + fileSystemWatcher.Renamed += (_, 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.Renamed); + await That(result.FullPath).IsEqualTo(destinationPath); + await That(result.Name).IsEqualTo(destinationName); + await That(result.OldFullPath).IsEqualTo(sourceName); + await That(result.OldName).IsEqualTo(sourceName); + } + [Theory] [InlineAutoData(NotifyFilters.DirectoryName, true)] [InlineAutoData(NotifyFilters.DirectoryName, false)]