From ee5e78ecc136c70c0e77c73f0863c8f881087956 Mon Sep 17 00:00:00 2001 From: "p.wintrich" Date: Tue, 25 Nov 2025 12:55:11 +0100 Subject: [PATCH 1/4] fix: fixed FileSystemWatcherMock dropping sub directories --- .../FileSystem/FileSystemWatcherMock.cs | 198 ++++++++--------- .../IncludeSubdirectoriesTests.cs | 199 ++++++++++++++++-- 2 files changed, 280 insertions(+), 117 deletions(-) diff --git a/Source/Testably.Abstractions.Testing/FileSystem/FileSystemWatcherMock.cs b/Source/Testably.Abstractions.Testing/FileSystem/FileSystemWatcherMock.cs index fd43e17bf..e1bff42ae 100644 --- a/Source/Testably.Abstractions.Testing/FileSystem/FileSystemWatcherMock.cs +++ b/Source/Testably.Abstractions.Testing/FileSystem/FileSystemWatcherMock.cs @@ -38,6 +38,7 @@ internal sealed class FileSystemWatcherMock : Component, IFileSystemWatcher NotifyFilters.LastWrite; private string _path = string.Empty; + private string _fullPath = string.Empty; private ISynchronizeInvoke? _synchronizingObject; @@ -213,6 +214,7 @@ public string Path } _path = value; + FullPath = _path; } } @@ -258,6 +260,32 @@ 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() { @@ -399,19 +427,19 @@ private void NotifyChange(ChangeDescription item) if (item.ChangeType.HasFlag(WatcherChangeTypes.Created)) { Created?.Invoke(this, ToFileSystemEventArgs( - item.ChangeType, item.Path, item.Name)); + item.ChangeType, item.Path)); } if (item.ChangeType.HasFlag(WatcherChangeTypes.Deleted)) { Deleted?.Invoke(this, ToFileSystemEventArgs( - item.ChangeType, item.Path, item.Name)); + item.ChangeType, item.Path)); } if (item.ChangeType.HasFlag(WatcherChangeTypes.Changed)) { Changed?.Invoke(this, ToFileSystemEventArgs( - item.ChangeType, item.Path, item.Name)); + item.ChangeType, item.Path)); } if (item.ChangeType.HasFlag(WatcherChangeTypes.Renamed)) @@ -502,68 +530,6 @@ private void Stop() _changeHandler?.Dispose(); } - private FileSystemEventArgs ToFileSystemEventArgs( - WatcherChangeTypes changeType, - string changePath, - string? changeName) - { - string path = TransformPathAndName( - changePath, - 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! -#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; - } - - private string TransformPathAndName( - string changeDescriptionPath, - string? changeDescriptionName, - out string name) - { - string? transformedName = changeDescriptionName; - string? path = changeDescriptionPath; - 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); - } - else if (transformedName == null || - _fileSystem.Execute.Path.IsPathRooted(changeDescriptionName)) - { - transformedName = _fileSystem.Execute.Path.GetFileName(changeDescriptionPath); - } - - name = transformedName; - - return path ?? ""; - } - private void TriggerRenameNotification(ChangeDescription item) { if (_fileSystem.Execute.IsWindows) @@ -578,13 +544,13 @@ private void TriggerRenameNotification(ChangeDescription item) if (MatchesWatcherPath(item.OldPath)) { Deleted?.Invoke(this, ToFileSystemEventArgs( - WatcherChangeTypes.Deleted, item.OldPath, item.OldName)); + WatcherChangeTypes.Deleted, item.OldPath)); } if (MatchesWatcherPath(item.Path)) { Created?.Invoke(this, ToFileSystemEventArgs( - WatcherChangeTypes.Created, item.Path, item.Name)); + WatcherChangeTypes.Created, item.Path)); } } } @@ -601,54 +567,92 @@ private void TriggerRenameNotification(ChangeDescription item) private bool TryMakeRenamedEventArgs( ChangeDescription changeDescription, - [NotNullWhen(true)] out RenamedEventArgs? eventArgs) + [NotNullWhen(true)] out RenamedEventArgs? eventArgs + ) { if (changeDescription.OldPath == null) { eventArgs = null; + return false; } - string path = TransformPathAndName( - changeDescription.Path, - changeDescription.Name, - out string name); + string name = TransformPathAndName(changeDescription.Path); + + string oldName = TransformPathAndName(changeDescription.OldPath); + + eventArgs = new RenamedEventArgs(changeDescription.ChangeType, Path, name, oldName); + + SetFileSystemEventArgsFullPath(eventArgs, name); + SetRenamedEventArgsFullPath(eventArgs, oldName); - string oldPath = TransformPathAndName( - changeDescription.OldPath, - changeDescription.OldName, - out string oldName); + return _fileSystem.Execute.Path.GetDirectoryName(changeDescription.Path)?.Equals( + _fileSystem.Execute.Path.GetDirectoryName(changeDescription.OldPath), + _fileSystem.Execute.StringComparisonMode + ) + ?? true; + } + + private FileSystemEventArgs ToFileSystemEventArgs( + WatcherChangeTypes changeType, + string changePath) + { + string name = TransformPathAndName(changePath); + + FileSystemEventArgs eventArgs = new(changeType, Path, name); + + SetFileSystemEventArgsFullPath(eventArgs, name); + + return eventArgs; + } - eventArgs = new RenamedEventArgs( - changeDescription.ChangeType, - Path, - name, - oldName); + private string TransformPathAndName(string changeDescriptionPath) + { + return changeDescriptionPath.Substring(FullPath.Length).TrimStart(_fileSystem.Path.DirectorySeparatorChar); + } - if (_fileSystem.SimulationMode != SimulationMode.Native) + private void SetFileSystemEventArgsFullPath(FileSystemEventArgs args, string name) + { + 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! + 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(eventArgs, path); + .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(eventArgs, oldPath); + .SetValue(args, fullPath); #else - typeof(FileSystemEventArgs) - .GetField("_fullPath", BindingFlags.Instance | BindingFlags.NonPublic)? - .SetValue(eventArgs, path); - typeof(RenamedEventArgs) - .GetField("_oldFullPath", BindingFlags.Instance | BindingFlags.NonPublic)? - .SetValue(eventArgs, oldPath); + typeof(RenamedEventArgs) + .GetField("_oldFullPath", BindingFlags.Instance | BindingFlags.NonPublic)? + .SetValue(args, fullPath); #endif - } - - return _fileSystem.Execute.Path.GetDirectoryName(changeDescription.Path)? - .Equals(_fileSystem.Execute.Path.GetDirectoryName(changeDescription.OldPath), - _fileSystem.Execute.StringComparisonMode) ?? true; } private IWaitForChangedResult WaitForChangedInternal( diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/IncludeSubdirectoriesTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/IncludeSubdirectoriesTests.cs index 64e14b8ea..80033d91d 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/IncludeSubdirectoriesTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/IncludeSubdirectoriesTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.IO; using System.Threading; @@ -9,15 +10,17 @@ public partial class IncludeSubdirectoriesTests [Theory] [AutoData] public async Task IncludeSubdirectories_SetToFalse_ShouldNotTriggerNotification( - string baseDirectory, string path) + string baseDirectory, + string path + ) { - FileSystem.Initialize() - .WithSubdirectory(baseDirectory).Initialized(s => s - .WithSubdirectory(path)); + FileSystem.Initialize().WithSubdirectory(baseDirectory) + .Initialized(s => s.WithSubdirectory(path)); + using ManualResetEventSlim ms = new(); FileSystemEventArgs? result = null; - using IFileSystemWatcher fileSystemWatcher = - FileSystem.FileSystemWatcher.New(BasePath); + using IFileSystemWatcher fileSystemWatcher = FileSystem.FileSystemWatcher.New(BasePath); + fileSystemWatcher.Deleted += (_, eventArgs) => { // ReSharper disable once AccessToDisposedClosure @@ -31,6 +34,7 @@ public async Task IncludeSubdirectories_SetToFalse_ShouldNotTriggerNotification( // Ignore any ObjectDisposedException } }; + fileSystemWatcher.IncludeSubdirectories = false; fileSystemWatcher.EnableRaisingEvents = true; FileSystem.Directory.Delete(FileSystem.Path.Combine(baseDirectory, path)); @@ -42,16 +46,21 @@ public async Task IncludeSubdirectories_SetToFalse_ShouldNotTriggerNotification( [Theory] [AutoData] public async Task IncludeSubdirectories_SetToTrue_ShouldOnlyTriggerNotificationOnSubdirectories( - string baseDirectory, string subdirectoryName, string otherDirectory) + string baseDirectory, + string subdirectoryName, + string otherDirectory + ) { - FileSystem.Initialize() - .WithSubdirectory(baseDirectory).Initialized(s => s - .WithSubdirectory(subdirectoryName)) + FileSystem.Initialize().WithSubdirectory(baseDirectory) + .Initialized(s => s.WithSubdirectory(subdirectoryName)) .WithSubdirectory(otherDirectory); + using ManualResetEventSlim ms = new(); FileSystemEventArgs? result = null; - using IFileSystemWatcher fileSystemWatcher = - FileSystem.FileSystemWatcher.New(baseDirectory); + + using IFileSystemWatcher fileSystemWatcher + = FileSystem.FileSystemWatcher.New(baseDirectory); + fileSystemWatcher.Deleted += (_, eventArgs) => { // ReSharper disable once AccessToDisposedClosure @@ -65,6 +74,7 @@ public async Task IncludeSubdirectories_SetToTrue_ShouldOnlyTriggerNotificationO // Ignore any ObjectDisposedException } }; + fileSystemWatcher.IncludeSubdirectories = true; fileSystemWatcher.EnableRaisingEvents = true; FileSystem.Directory.Delete(otherDirectory); @@ -76,17 +86,18 @@ public async Task IncludeSubdirectories_SetToTrue_ShouldOnlyTriggerNotificationO [Theory] [AutoData] public async Task IncludeSubdirectories_SetToTrue_ShouldTriggerNotificationOnSubdirectories( - string baseDirectory, string subdirectoryName) + string baseDirectory, + string subdirectoryName + ) { - FileSystem.Initialize() - .WithSubdirectory(baseDirectory).Initialized(s => s - .WithSubdirectory(subdirectoryName)); - string subdirectoryPath = - FileSystem.Path.Combine(baseDirectory, subdirectoryName); + FileSystem.Initialize().WithSubdirectory(baseDirectory) + .Initialized(s => s.WithSubdirectory(subdirectoryName)); + + string subdirectoryPath = FileSystem.Path.Combine(baseDirectory, subdirectoryName); using ManualResetEventSlim ms = new(); FileSystemEventArgs? result = null; - using IFileSystemWatcher fileSystemWatcher = - FileSystem.FileSystemWatcher.New(BasePath); + using IFileSystemWatcher fileSystemWatcher = FileSystem.FileSystemWatcher.New(BasePath); + fileSystemWatcher.Deleted += (_, eventArgs) => { // ReSharper disable once AccessToDisposedClosure @@ -100,6 +111,7 @@ public async Task IncludeSubdirectories_SetToTrue_ShouldTriggerNotificationOnSub // Ignore any ObjectDisposedException } }; + fileSystemWatcher.IncludeSubdirectories = true; fileSystemWatcher.EnableRaisingEvents = true; FileSystem.Directory.Delete(subdirectoryPath); @@ -110,4 +122,151 @@ public async Task IncludeSubdirectories_SetToTrue_ShouldTriggerNotificationOnSub await That(result.Name).IsEqualTo(subdirectoryPath); await That(result!.ChangeType).IsEqualTo(WatcherChangeTypes.Deleted); } + + [Theory] + [InlineAutoData(true)] + [InlineAutoData(false)] + public async Task IncludeSubdirectories_SetToTrue_ArgsNameShouldContainRelativePath( + bool watchRootedPath, + string baseDirectory, + string subdirectoryName, + string subSubdirectoryName, + string fileName + ) + { + // 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 newFilePath = filePath + ".new"; + + string expectedFileName = FileSystem.Path.Combine( + subdirectoryName, subSubdirectoryName, fileName + ); + + string expectedNewFileName = expectedFileName + ".new"; + + string watchPath = watchRootedPath + ? FileSystem.Path.Combine(FileSystem.Directory.GetCurrentDirectory(), baseDirectory) + : baseDirectory; + + using ManualResetEventSlim createdMre = new(); + using ManualResetEventSlim changedMre = new(); + using ManualResetEventSlim renamedMre = new(); + using ManualResetEventSlim deletedMre = new(); + FileSystemEventArgs? createdArgs = null; + FileSystemEventArgs? changedArgs = null; + RenamedEventArgs? renamedArgs = null; + FileSystemEventArgs? deletedArgs = null; + + using IFileSystemWatcher fileSystemWatcher = FileSystem.FileSystemWatcher.New(watchPath); + + fileSystemWatcher.Created += (_, eventArgs) => + { + // ReSharper disable once AccessToDisposedClosure + try + { + createdArgs ??= eventArgs; + createdMre.Set(); + } + catch (ObjectDisposedException) + { + // Ignore any ObjectDisposedException + } + }; + + fileSystemWatcher.Changed += (_, eventArgs) => + { + // ReSharper disable once AccessToDisposedClosure + try + { + // OS fires for subSubDir due to item changing + if (!string.Equals(eventArgs.Name, expectedFileName, StringComparison.Ordinal)) + { + return; + } + + changedArgs ??= eventArgs; + changedMre.Set(); + } + catch (ObjectDisposedException) + { + // Ignore any ObjectDisposedException + } + }; + + fileSystemWatcher.Renamed += (_, eventArgs) => + { + // ReSharper disable once AccessToDisposedClosure + try + { + renamedArgs ??= eventArgs; + renamedMre.Set(); + } + catch (ObjectDisposedException) + { + // Ignore any ObjectDisposedException + } + }; + + fileSystemWatcher.Deleted += (_, eventArgs) => + { + // ReSharper disable once AccessToDisposedClosure + try + { + deletedArgs ??= eventArgs; + deletedMre.Set(); + } + catch (ObjectDisposedException) + { + // Ignore any ObjectDisposedException + } + }; + + fileSystemWatcher.IncludeSubdirectories = true; + fileSystemWatcher.EnableRaisingEvents = true; + + // Act + + FileSystem.File.Create(filePath).Dispose(); + FileSystem.File.WriteAllText(filePath, "Hello World!"); + FileSystem.File.Move(filePath, newFilePath); + FileSystem.File.Delete(newFilePath); + + // Assert + + await That(createdMre.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsTrue(); + await That(changedMre.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsTrue(); + await That(renamedMre.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsTrue(); + await That(deletedMre.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsTrue(); + + await That(createdArgs).IsNotNull().And + .Satisfies(args => string.Equals(args?.Name, expectedFileName, StringComparison.Ordinal) + ); + + await That(changedArgs).IsNotNull().And + .Satisfies(args => string.Equals(args?.Name, expectedFileName, StringComparison.Ordinal) + ); + + await That(renamedArgs).IsNotNull().And + .Satisfies(args => string.Equals( + args?.Name, expectedNewFileName, StringComparison.Ordinal + ) + ).And.Satisfies(args => string.Equals( + args?.OldName, expectedFileName, StringComparison.Ordinal + ) + ); + + await That(deletedArgs).IsNotNull().And.Satisfies(args => string.Equals( + args?.Name, expectedNewFileName, + StringComparison.Ordinal + ) + ); + } } From a8554f60bfef32d6ef0c3cf8b666d0a994ed8d40 Mon Sep 17 00:00:00 2001 From: "p.wintrich" Date: Tue, 25 Nov 2025 13:45:03 +0100 Subject: [PATCH 2/4] tests: fixed tests in FileSystemWatcherMockTests.EventArgsTests --- .../FileSystem/FileSystemWatcherMockTests.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Tests/Testably.Abstractions.Testing.Tests/FileSystem/FileSystemWatcherMockTests.cs b/Tests/Testably.Abstractions.Testing.Tests/FileSystem/FileSystemWatcherMockTests.cs index fd61f3a96..d49e81997 100644 --- a/Tests/Testably.Abstractions.Testing.Tests/FileSystem/FileSystemWatcherMockTests.cs +++ b/Tests/Testably.Abstractions.Testing.Tests/FileSystem/FileSystemWatcherMockTests.cs @@ -249,7 +249,6 @@ public async Task FileSystemEventArgs_ShouldUseDirectorySeparatorFromSimulatedFi FileSystemEventArgs? result = null; string expectedFullPath = fileSystem.Path.GetFullPath( fileSystem.Path.Combine(parentDirectory, directoryName)); - string expectedName = fileSystem.Path.Combine(parentDirectory, directoryName); using IFileSystemWatcher fileSystemWatcher = fileSystem.FileSystemWatcher.New(fileSystem.Path.GetFullPath(parentDirectory)); @@ -269,12 +268,12 @@ public async Task FileSystemEventArgs_ShouldUseDirectorySeparatorFromSimulatedFi }; fileSystemWatcher.NotifyFilter = NotifyFilters.DirectoryName; fileSystemWatcher.EnableRaisingEvents = true; - fileSystem.Directory.CreateDirectory(expectedName); + fileSystem.Directory.CreateDirectory(expectedFullPath); ms.Wait(5000, TestContext.Current.CancellationToken); await That(result).IsNotNull(); await That(result!.FullPath).IsEqualTo(expectedFullPath); - await That(result.Name).IsEqualTo(expectedName); + await That(result.Name).IsEqualTo(directoryName); await That(result.ChangeType).IsEqualTo(WatcherChangeTypes.Created); } #endif From 38065c062f78631fa0f3dc9572e65c7e8810fbd0 Mon Sep 17 00:00:00 2001 From: "p.wintrich" Date: Wed, 26 Nov 2025 15:50:45 +0100 Subject: [PATCH 3/4] test: split IncludeSubdirectories_SetToTrue_ArgsNameShouldContainRelativePath for better control --- .../IncludeSubdirectoriesTests.cs | 204 ++++++++++++++---- 1 file changed, 166 insertions(+), 38 deletions(-) diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/IncludeSubdirectoriesTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/IncludeSubdirectoriesTests.cs index 80033d91d..d6be811ac 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/IncludeSubdirectoriesTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/IncludeSubdirectoriesTests.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.IO; using System.Threading; @@ -126,7 +125,7 @@ string subdirectoryName [Theory] [InlineAutoData(true)] [InlineAutoData(false)] - public async Task IncludeSubdirectories_SetToTrue_ArgsNameShouldContainRelativePath( + public async Task IncludeSubdirectories_SetToTrue_Created_ArgsNameShouldContainRelativePath( bool watchRootedPath, string baseDirectory, string subdirectoryName, @@ -144,26 +143,16 @@ string fileName baseDirectory, subdirectoryName, subSubdirectoryName, fileName ); - string newFilePath = filePath + ".new"; - string expectedFileName = FileSystem.Path.Combine( subdirectoryName, subSubdirectoryName, fileName ); - string expectedNewFileName = expectedFileName + ".new"; - string watchPath = watchRootedPath ? FileSystem.Path.Combine(FileSystem.Directory.GetCurrentDirectory(), baseDirectory) : baseDirectory; using ManualResetEventSlim createdMre = new(); - using ManualResetEventSlim changedMre = new(); - using ManualResetEventSlim renamedMre = new(); - using ManualResetEventSlim deletedMre = new(); FileSystemEventArgs? createdArgs = null; - FileSystemEventArgs? changedArgs = null; - RenamedEventArgs? renamedArgs = null; - FileSystemEventArgs? deletedArgs = null; using IFileSystemWatcher fileSystemWatcher = FileSystem.FileSystemWatcher.New(watchPath); @@ -172,7 +161,7 @@ string fileName // ReSharper disable once AccessToDisposedClosure try { - createdArgs ??= eventArgs; + createdArgs = eventArgs; createdMre.Set(); } catch (ObjectDisposedException) @@ -181,6 +170,56 @@ 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) + ); + } + + [Theory] + [InlineAutoData(true)] + [InlineAutoData(false)] + public async Task IncludeSubdirectories_SetToTrue_Changed_ArgsNameShouldContainRelativePath( + bool watchRootedPath, + string baseDirectory, + string subdirectoryName, + string subSubdirectoryName, + string fileName + ) + { + // 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 @@ -200,13 +239,68 @@ 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) + ); + } + + [Theory] + [InlineAutoData(true)] + [InlineAutoData(false)] + public async Task IncludeSubdirectories_SetToTrue_Renamed_ArgsNameShouldContainRelativePath( + bool watchRootedPath, + string baseDirectory, + string subdirectoryName, + string subSubdirectoryName, + string fileName + ) + { + // 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 newFilePath = filePath + ".new"; + + string expectedFileName = FileSystem.Path.Combine( + subdirectoryName, subSubdirectoryName, fileName + ); + + string expectedNewFileName = expectedFileName + ".new"; + + string watchPath = watchRootedPath + ? FileSystem.Path.Combine(FileSystem.Directory.GetCurrentDirectory(), baseDirectory) + : baseDirectory; + + using ManualResetEventSlim renamedMre = new(); + RenamedEventArgs? renamedArgs = null; + + using IFileSystemWatcher fileSystemWatcher = FileSystem.FileSystemWatcher.New(watchPath); fileSystemWatcher.Renamed += (_, eventArgs) => { // ReSharper disable once AccessToDisposedClosure try { - renamedArgs ??= eventArgs; + renamedArgs = eventArgs; renamedMre.Set(); } catch (ObjectDisposedException) @@ -215,6 +309,62 @@ 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 + ) + ); + } + + [Theory] + [InlineAutoData(true)] + [InlineAutoData(false)] + public async Task IncludeSubdirectories_SetToTrue_Deleted_ArgsNameShouldContainRelativePath( + bool watchRootedPath, + string baseDirectory, + string subdirectoryName, + string subSubdirectoryName, + string fileName + ) + { + // 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 deletedMre = new(); + FileSystemEventArgs? deletedArgs = null; + + using IFileSystemWatcher fileSystemWatcher = FileSystem.FileSystemWatcher.New(watchPath); + fileSystemWatcher.Deleted += (_, eventArgs) => { // ReSharper disable once AccessToDisposedClosure @@ -235,36 +385,14 @@ string fileName // Act FileSystem.File.Create(filePath).Dispose(); - FileSystem.File.WriteAllText(filePath, "Hello World!"); - FileSystem.File.Move(filePath, newFilePath); - FileSystem.File.Delete(newFilePath); + FileSystem.File.Delete(filePath); // Assert - await That(createdMre.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsTrue(); - await That(changedMre.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsTrue(); - await That(renamedMre.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsTrue(); await That(deletedMre.Wait(ExpectTimeout, TestContext.Current.CancellationToken)).IsTrue(); - await That(createdArgs).IsNotNull().And - .Satisfies(args => string.Equals(args?.Name, expectedFileName, StringComparison.Ordinal) - ); - - await That(changedArgs).IsNotNull().And - .Satisfies(args => string.Equals(args?.Name, expectedFileName, StringComparison.Ordinal) - ); - - await That(renamedArgs).IsNotNull().And - .Satisfies(args => string.Equals( - args?.Name, expectedNewFileName, StringComparison.Ordinal - ) - ).And.Satisfies(args => string.Equals( - args?.OldName, expectedFileName, StringComparison.Ordinal - ) - ); - await That(deletedArgs).IsNotNull().And.Satisfies(args => string.Equals( - args?.Name, expectedNewFileName, + args?.Name, expectedFileName, StringComparison.Ordinal ) ); From 06d3fb238cd8a38fefc358f0dabc66a68e5199e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sun, 30 Nov 2025 12:27:33 +0100 Subject: [PATCH 4/4] Fix failing test on .NET Framework --- .../IncludeSubdirectoriesTests.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/IncludeSubdirectoriesTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/IncludeSubdirectoriesTests.cs index d6be811ac..32999384f 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/IncludeSubdirectoriesTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileSystemWatcher/IncludeSubdirectoriesTests.cs @@ -128,11 +128,11 @@ string subdirectoryName public async Task IncludeSubdirectories_SetToTrue_Created_ArgsNameShouldContainRelativePath( bool watchRootedPath, string baseDirectory, - string subdirectoryName, - string subSubdirectoryName, string fileName ) { + string subdirectoryName = "a"; + string subSubdirectoryName = "b"; // Arrange FileSystem.Initialize().WithSubdirectory(baseDirectory) .Initialized(s => s.WithSubdirectory(subdirectoryName) @@ -192,11 +192,11 @@ await That(createdArgs).IsNotNull().And public async Task IncludeSubdirectories_SetToTrue_Changed_ArgsNameShouldContainRelativePath( bool watchRootedPath, string baseDirectory, - string subdirectoryName, - string subSubdirectoryName, string fileName ) { + string subdirectoryName = "a"; + string subSubdirectoryName = "b"; // Arrange FileSystem.Initialize().WithSubdirectory(baseDirectory) .Initialized(s => s.WithSubdirectory(subdirectoryName) @@ -263,11 +263,11 @@ await That(changedArgs).IsNotNull().And public async Task IncludeSubdirectories_SetToTrue_Renamed_ArgsNameShouldContainRelativePath( bool watchRootedPath, string baseDirectory, - string subdirectoryName, - string subSubdirectoryName, string fileName ) { + string subdirectoryName = "a"; + string subSubdirectoryName = "b"; // Arrange FileSystem.Initialize().WithSubdirectory(baseDirectory) .Initialized(s => s.WithSubdirectory(subdirectoryName) @@ -337,11 +337,11 @@ await That(renamedArgs).IsNotNull().And public async Task IncludeSubdirectories_SetToTrue_Deleted_ArgsNameShouldContainRelativePath( bool watchRootedPath, string baseDirectory, - string subdirectoryName, - string subSubdirectoryName, string fileName ) { + string subdirectoryName = "a"; + string subSubdirectoryName = "b"; // Arrange FileSystem.Initialize().WithSubdirectory(baseDirectory) .Initialized(s => s.WithSubdirectory(subdirectoryName)