From 5709c92b71bd6388cd713faa8736fd217d7c22e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sun, 17 May 2026 12:27:16 +0200 Subject: [PATCH 1/2] feat: replay prior change notifications via OnEventOrReplay Adds INotificationHandler.OnEventOrReplay, which registers a subscriber and atomically replays any matching ChangeDescription events that fired before the subscription. Enables late-subscriber assertion patterns (e.g. asserting against a MockFileSystem that has already mutated) without bracketing the trigger inside the assertion call. Notification history is recorded by default and can be disabled via MockFileSystemOptions.WithoutNotificationHistory(); when disabled, OnEventOrReplay throws InvalidOperationException so misconfiguration cannot manifest as flaky tests. --- .../FileSystem/ChangeHandler.cs | 52 ++++++++- .../FileSystem/INotificationHandler.cs | 23 ++++ .../MockFileSystem.cs | 30 ++++-- .../Notification.cs | 22 +++- .../FileSystem/ChangeHandlerTests.cs | 100 +++++++++++++----- 5 files changed, 189 insertions(+), 38 deletions(-) diff --git a/Source/Testably.Abstractions.Testing/FileSystem/ChangeHandler.cs b/Source/Testably.Abstractions.Testing/FileSystem/ChangeHandler.cs index 10f856edd..a865109c1 100644 --- a/Source/Testably.Abstractions.Testing/FileSystem/ChangeHandler.cs +++ b/Source/Testably.Abstractions.Testing/FileSystem/ChangeHandler.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using Testably.Abstractions.Testing.Storage; @@ -13,14 +14,22 @@ private readonly Notification.INotificationFactory private readonly Notification.INotificationFactory _changeOccurringCallbacks = Notification.CreateFactory(); + private readonly List? _history; +#if NET9_0_OR_GREATER + private readonly System.Threading.Lock _historyLock = new(); +#else + private readonly object _historyLock = new(); +#endif + private readonly MockFileSystem _mockFileSystem; private readonly Notification.INotificationFactory _watcherNotificationTriggeredCallbacks = Notification.CreateFactory(); - public ChangeHandler(MockFileSystem mockFileSystem) + public ChangeHandler(MockFileSystem mockFileSystem, bool recordNotificationHistory) { _mockFileSystem = mockFileSystem; + _history = recordNotificationHistory ? new List() : null; } #region IInterceptionHandler Members @@ -44,6 +53,33 @@ public IAwaitableCallback OnEvent( Func? predicate = null) => _changeOccurredCallbacks.RegisterCallback(notificationCallback, predicate); + /// + public IAwaitableCallback OnEventOrReplay( + Action? notificationCallback = null, + Func? predicate = null) + { + if (_history is null) + { + throw new InvalidOperationException( + $"{nameof(OnEventOrReplay)} requires notification history, but it was disabled via " + + $"{nameof(MockFileSystem.MockFileSystemOptions)}." + + $"{nameof(MockFileSystem.MockFileSystemOptions.WithoutNotificationHistory)}. " + + $"Use {nameof(OnEvent)} instead, or remove the opt-out."); + } + + lock (_historyLock) + { + IAwaitableCallback waiter = + _changeOccurredCallbacks.RegisterCallback(notificationCallback, predicate); + foreach (ChangeDescription past in _history) + { + _changeOccurredCallbacks.Replay(waiter, past); + } + + return waiter; + } + } + #endregion #region IWatcherTriggeredHandler Members @@ -58,8 +94,20 @@ public IAwaitableCallback OnTriggered( internal void NotifyCompletedChange(ChangeDescription? fileSystemChange) { - if (fileSystemChange != null) + if (fileSystemChange is null) + { + return; + } + + if (_history is null) + { + _changeOccurredCallbacks.InvokeCallbacks(fileSystemChange); + return; + } + + lock (_historyLock) { + _history.Add(fileSystemChange); _changeOccurredCallbacks.InvokeCallbacks(fileSystemChange); } } diff --git a/Source/Testably.Abstractions.Testing/FileSystem/INotificationHandler.cs b/Source/Testably.Abstractions.Testing/FileSystem/INotificationHandler.cs index ae30b28b2..e516a2f54 100644 --- a/Source/Testably.Abstractions.Testing/FileSystem/INotificationHandler.cs +++ b/Source/Testably.Abstractions.Testing/FileSystem/INotificationHandler.cs @@ -20,4 +20,27 @@ public interface INotificationHandler : IFileSystemEntity IAwaitableCallback OnEvent( Action? notificationCallback = null, Func? predicate = null); + + /// + /// Like , but the returned callback also replays any matching changes + /// that occurred before this call, in their original order. + /// + /// Notification history is enabled by default. If it has been disabled via + /// , calling + /// this method throws ; use + /// instead in that case. + /// + /// (optional) The callback to execute for each replayed and future change. + /// + /// (optional) A predicate used to filter which changes are replayed and which future changes notify the callback.
+ /// If set to (default value) all changes are considered. + /// + /// An to un-register the callback on dispose. + /// + /// Notification history was disabled via + /// . + /// + IAwaitableCallback OnEventOrReplay( + Action? notificationCallback = null, + Func? predicate = null); } diff --git a/Source/Testably.Abstractions.Testing/MockFileSystem.cs b/Source/Testably.Abstractions.Testing/MockFileSystem.cs index 57c5697e2..0e4a91e66 100644 --- a/Source/Testably.Abstractions.Testing/MockFileSystem.cs +++ b/Source/Testably.Abstractions.Testing/MockFileSystem.cs @@ -26,11 +26,6 @@ public sealed class MockFileSystem : IFileSystem /// public INotificationHandler Notify => ChangeHandler; - /// - /// Get notified of events after they were triggered by a . - /// - public IWatcherTriggeredHandler Watcher => ChangeHandler; - /// /// The used random system. /// @@ -64,6 +59,11 @@ public sealed class MockFileSystem : IFileSystem /// public ITimeSystem TimeSystem { get; } + /// + /// Get notified of events after they were triggered by a . + /// + public IWatcherTriggeredHandler Watcher => ChangeHandler; + internal IAccessControlStrategy AccessControlStrategy { get; @@ -144,7 +144,7 @@ public MockFileSystem(Func options TimeSystem = initialization.TimeSystem ?? new MockTimeSystem(TimeProvider.Now()); _pathMock = new PathMock(this); _storage = new InMemoryStorage(this); - ChangeHandler = new ChangeHandler(this); + ChangeHandler = new ChangeHandler(this, initialization.RecordNotificationHistory); _directoryMock = new DirectoryMock(this); _fileMock = new FileMock(this); DirectoryInfo = new DirectoryInfoFactoryMock(this); @@ -314,6 +314,12 @@ public class MockFileSystemOptions /// internal IRandomProvider? RandomProvider { get; private set; } + /// + /// Whether to record a history of completed change notifications so that + /// can replay past events. + /// + internal bool RecordNotificationHistory { get; private set; } = true; + /// /// The simulated operating system. /// @@ -370,5 +376,17 @@ public MockFileSystemOptions UseTimeSystem(ITimeSystem timeSystem) TimeSystem = timeSystem; return this; } + + /// + /// Disables recording the change notification history. With history disabled, + /// throws + /// ; use + /// instead. + /// + public MockFileSystemOptions WithoutNotificationHistory() + { + RecordNotificationHistory = false; + return this; + } } } diff --git a/Source/Testably.Abstractions.Testing/Notification.cs b/Source/Testably.Abstractions.Testing/Notification.cs index 0b567dd59..65a4bd257 100644 --- a/Source/Testably.Abstractions.Testing/Notification.cs +++ b/Source/Testably.Abstractions.Testing/Notification.cs @@ -69,6 +69,14 @@ public IAwaitableCallback RegisterCallback( return callbackWaiter; } + public void Replay(IAwaitableCallback callback, TValue value) + { + if (callback is CallbackWaiter waiter) + { + waiter.Invoke(value); + } + } + #endregion private void UnRegisterCallback(Guid key) @@ -120,7 +128,8 @@ public void Wait(Func? filter, { if (_isDisposed) { - throw new ObjectDisposedException(null, "The awaitable callback is already disposed."); + throw new ObjectDisposedException(null, + "The awaitable callback is already disposed."); } _filter = filter; @@ -159,7 +168,8 @@ public TValue[] Wait(int count = 1, TimeSpan? timeout = null) { if (_isDisposed) { - throw new ObjectDisposedException(null, "The awaitable callback is already disposed."); + throw new ObjectDisposedException(null, + "The awaitable callback is already disposed."); } _reset.Reset(); @@ -196,7 +206,8 @@ public async Task WaitAsync( { if (_isDisposed) { - throw new ObjectDisposedException(null, "The awaitable callback is already disposed."); + throw new ObjectDisposedException(null, + "The awaitable callback is already disposed."); } List values = []; @@ -265,6 +276,8 @@ internal interface INotificationFactory IAwaitableCallback RegisterCallback( Action? callback, Func? predicate = null); + + void Replay(IAwaitableCallback callback, TValue value); } /// @@ -326,7 +339,8 @@ public void Dispose() => _awaitableCallback.Dispose(); /// - [Obsolete("Use another `Wait` or `WaitAsync` overload and move the filter to the creation of the awaitable callback.")] + [Obsolete( + "Use another `Wait` or `WaitAsync` overload and move the filter to the creation of the awaitable callback.")] public TFunc Wait(Func? filter, int timeout = 30000, int count = 1, diff --git a/Tests/Testably.Abstractions.Testing.Tests/FileSystem/ChangeHandlerTests.cs b/Tests/Testably.Abstractions.Testing.Tests/FileSystem/ChangeHandlerTests.cs index 1fdaf2a61..4fe4bf443 100644 --- a/Tests/Testably.Abstractions.Testing.Tests/FileSystem/ChangeHandlerTests.cs +++ b/Tests/Testably.Abstractions.Testing.Tests/FileSystem/ChangeHandlerTests.cs @@ -7,12 +7,8 @@ namespace Testably.Abstractions.Testing.Tests.FileSystem; public class ChangeHandlerTests { - #region Test Setup - public MockFileSystem FileSystem { get; } = new(); - #endregion - [Test] [AutoArguments] public async Task CreateDirectory_CustomException_ShouldNotCreateDirectory( @@ -97,6 +93,80 @@ public async Task ExecuteCallback_ShouldTriggerNotification( await That(receivedPath).IsEqualTo(FileSystem.Path.GetFullPath(path)); } + public static + IEnumerable<(Action?, Action, WatcherChangeTypes, FileSystemTypes, string)> NotificationTriggeringMethods() + { + yield return (null, (f, p) => f.Directory.CreateDirectory(p), WatcherChangeTypes.Created, + FileSystemTypes.Directory, $"path_{Guid.NewGuid()}"); + yield return ((f, p) => f.Directory.CreateDirectory(p), (f, p) => f.Directory.Delete(p), + WatcherChangeTypes.Deleted, FileSystemTypes.Directory, $"path_{Guid.NewGuid()}"); + yield return (null, (f, p) => f.File.WriteAllText(p, null), WatcherChangeTypes.Created, + FileSystemTypes.File, $"path_{Guid.NewGuid()}"); + yield return ((f, p) => f.File.WriteAllText(p, null), (f, p) => f.File.Delete(p), + WatcherChangeTypes.Deleted, FileSystemTypes.File, $"path_{Guid.NewGuid()}"); + } + + [Test] + [AutoArguments] + public async Task OnEventOrReplay_ShouldAlsoReceiveFutureEvents(string path1, string path2) + { + FileSystem.File.WriteAllText(path1, null); + + using IAwaitableCallback onEvent = FileSystem.Notify + .OnEventOrReplay(predicate: c => c.ChangeType == WatcherChangeTypes.Created); + + FileSystem.File.WriteAllText(path2, null); + + ChangeDescription[] received = await onEvent.WaitAsync( + count: 2, + timeout: TimeSpan.FromSeconds(5)); + + await That(received.Length).IsEqualTo(2); + } + + [Test] + [AutoArguments] + public async Task OnEventOrReplay_ShouldNotReplayEventsFilteredOutByPredicate(string path) + { + FileSystem.File.WriteAllText(path, null); + + using IAwaitableCallback onEvent = FileSystem.Notify + .OnEventOrReplay(predicate: c => c.ChangeType == WatcherChangeTypes.Deleted); + + void Act() => + // ReSharper disable once AccessToDisposedClosure + onEvent.Wait(timeout: 50); + + await That(Act).Throws(); + } + + [Test] + [AutoArguments] + public async Task OnEventOrReplay_ShouldReplayPriorMatchingEvents(string path) + { + FileSystem.File.WriteAllText(path, null); + + using IAwaitableCallback onEvent = FileSystem.Notify + .OnEventOrReplay(predicate: c => c.ChangeType == WatcherChangeTypes.Created); + + ChangeDescription[] replayed = + await onEvent.WaitAsync(timeout: TimeSpan.FromMilliseconds(100)); + + await That(replayed.Length).IsEqualTo(1); + await That(replayed[0].Path).IsEqualTo(FileSystem.Path.GetFullPath(path)); + } + + [Test] + public async Task OnEventOrReplay_WithoutNotificationHistory_ShouldThrow() + { + MockFileSystem fileSystem = new(o => o.WithoutNotificationHistory()); + + void Act() => fileSystem.Notify.OnEventOrReplay(); + + await That(Act).Throws() + .WithMessage("*WithoutNotificationHistory*").AsWildcard(); + } + [Test] public async Task Watcher_ShouldNotTriggerWhenFileSystemWatcherDoesNotMatch() { @@ -131,26 +201,4 @@ public async Task Watcher_ShouldTriggerWhenFileSystemWatcherSendsNotification() await That(isTriggered).IsTrue(); } - - #region Helpers - - public static - IEnumerable<( - Action?, - Action, - WatcherChangeTypes, - FileSystemTypes, - string)> NotificationTriggeringMethods() - { - yield return (null, (f, p) => f.Directory.CreateDirectory(p), WatcherChangeTypes.Created, - FileSystemTypes.Directory, $"path_{Guid.NewGuid()}"); - yield return ((f, p) => f.Directory.CreateDirectory(p), (f, p) => f.Directory.Delete(p), - WatcherChangeTypes.Deleted, FileSystemTypes.Directory, $"path_{Guid.NewGuid()}"); - yield return (null, (f, p) => f.File.WriteAllText(p, null), WatcherChangeTypes.Created, - FileSystemTypes.File, $"path_{Guid.NewGuid()}"); - yield return ((f, p) => f.File.WriteAllText(p, null), (f, p) => f.File.Delete(p), - WatcherChangeTypes.Deleted, FileSystemTypes.File, $"path_{Guid.NewGuid()}"); - } - - #endregion } From 13fa64a7305e47f03e094c207ad06302d4044d61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sun, 17 May 2026 13:10:50 +0200 Subject: [PATCH 2/2] fix: invoke notification callbacks outside the history lock and dispose waiter on replay failure Addresses two review issues on OnEventOrReplay: - If a callback throws during replay of buffered changes, the newly registered waiter is now disposed before the exception propagates so it cannot keep receiving future events. - NotifyCompletedChange no longer holds the history lock while invoking user callbacks. The callback set is snapshotted under the lock via SnapshotInvocations and invoked afterward, preserving exactly-once delivery across the live/replay boundary while avoiding callback-induced blocking and deadlock risk. Regenerates the Testably.Abstractions.Testing public API baselines so the ApiChecks task includes the OnEventOrReplay and WithoutNotificationHistory additions. --- .../FileSystem/ChangeHandler.cs | 25 +++++++++++++++---- .../Notification.cs | 21 ++++++++++++++++ .../Testably.Abstractions.Testing_net10.0.txt | 2 ++ .../Testably.Abstractions.Testing_net6.0.txt | 2 ++ .../Testably.Abstractions.Testing_net8.0.txt | 2 ++ .../Testably.Abstractions.Testing_net9.0.txt | 2 ++ ...ly.Abstractions.Testing_netstandard2.0.txt | 2 ++ ...ly.Abstractions.Testing_netstandard2.1.txt | 2 ++ .../FileSystem/ChangeHandlerTests.cs | 15 +++++++++++ 9 files changed, 68 insertions(+), 5 deletions(-) diff --git a/Source/Testably.Abstractions.Testing/FileSystem/ChangeHandler.cs b/Source/Testably.Abstractions.Testing/FileSystem/ChangeHandler.cs index a865109c1..cc737908a 100644 --- a/Source/Testably.Abstractions.Testing/FileSystem/ChangeHandler.cs +++ b/Source/Testably.Abstractions.Testing/FileSystem/ChangeHandler.cs @@ -67,17 +67,29 @@ public IAwaitableCallback OnEventOrReplay( $"Use {nameof(OnEvent)} instead, or remove the opt-out."); } + IAwaitableCallback waiter; + ChangeDescription[] snapshot; lock (_historyLock) { - IAwaitableCallback waiter = + waiter = _changeOccurredCallbacks.RegisterCallback(notificationCallback, predicate); - foreach (ChangeDescription past in _history) + snapshot = _history.ToArray(); + } + + try + { + foreach (ChangeDescription past in snapshot) { _changeOccurredCallbacks.Replay(waiter, past); } - - return waiter; } + catch + { + waiter.Dispose(); + throw; + } + + return waiter; } #endregion @@ -105,11 +117,14 @@ internal void NotifyCompletedChange(ChangeDescription? fileSystemChange) return; } + Action invoke; lock (_historyLock) { _history.Add(fileSystemChange); - _changeOccurredCallbacks.InvokeCallbacks(fileSystemChange); + invoke = _changeOccurredCallbacks.SnapshotInvocations(); } + + invoke(fileSystemChange); } internal ChangeDescription NotifyPendingChange(WatcherChangeTypes changeType, diff --git a/Source/Testably.Abstractions.Testing/Notification.cs b/Source/Testably.Abstractions.Testing/Notification.cs index 65a4bd257..a3e84844e 100644 --- a/Source/Testably.Abstractions.Testing/Notification.cs +++ b/Source/Testably.Abstractions.Testing/Notification.cs @@ -77,6 +77,19 @@ public void Replay(IAwaitableCallback callback, TValue value) } } + public Action SnapshotInvocations() + { + System.Collections.Generic.ICollection snapshot = + _callbackWaiters.Values; + return value => + { + foreach (CallbackWaiter callback in snapshot) + { + callback.Invoke(value); + } + }; + } + #endregion private void UnRegisterCallback(Guid key) @@ -278,6 +291,14 @@ IAwaitableCallback RegisterCallback( Func? predicate = null); void Replay(IAwaitableCallback callback, TValue value); + + /// + /// Captures the set of currently-registered callbacks and returns a delegate that, when + /// invoked with a value, delivers it to that snapshot. Call this under whatever lock guards + /// the surrounding state, then invoke the returned delegate outside the lock so user + /// callbacks do not run while the lock is held. + /// + Action SnapshotInvocations(); } /// diff --git a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net10.0.txt b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net10.0.txt index d62864906..eddfa0632 100644 --- a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net10.0.txt +++ b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net10.0.txt @@ -83,6 +83,7 @@ namespace Testably.Abstractions.Testing public Testably.Abstractions.Testing.MockFileSystem.MockFileSystemOptions UseCurrentDirectory(string path) { } public Testably.Abstractions.Testing.MockFileSystem.MockFileSystemOptions UseRandomProvider(Testably.Abstractions.Testing.RandomSystem.IRandomProvider randomProvider) { } public Testably.Abstractions.Testing.MockFileSystem.MockFileSystemOptions UseTimeSystem(Testably.Abstractions.ITimeSystem timeSystem) { } + public Testably.Abstractions.Testing.MockFileSystem.MockFileSystemOptions WithoutNotificationHistory() { } } } public static class MockFileSystemExtensions @@ -217,6 +218,7 @@ namespace Testably.Abstractions.Testing.FileSystem public interface INotificationHandler : System.IO.Abstractions.IFileSystemEntity { Testably.Abstractions.Testing.IAwaitableCallback OnEvent(System.Action? notificationCallback = null, System.Func? predicate = null); + Testably.Abstractions.Testing.IAwaitableCallback OnEventOrReplay(System.Action? notificationCallback = null, System.Func? predicate = null); } public interface ISafeFileHandleStrategy { diff --git a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net6.0.txt b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net6.0.txt index cafc8389d..07ce3eb9c 100644 --- a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net6.0.txt +++ b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net6.0.txt @@ -82,6 +82,7 @@ namespace Testably.Abstractions.Testing public Testably.Abstractions.Testing.MockFileSystem.MockFileSystemOptions UseCurrentDirectory(string path) { } public Testably.Abstractions.Testing.MockFileSystem.MockFileSystemOptions UseRandomProvider(Testably.Abstractions.Testing.RandomSystem.IRandomProvider randomProvider) { } public Testably.Abstractions.Testing.MockFileSystem.MockFileSystemOptions UseTimeSystem(Testably.Abstractions.ITimeSystem timeSystem) { } + public Testably.Abstractions.Testing.MockFileSystem.MockFileSystemOptions WithoutNotificationHistory() { } } } public static class MockFileSystemExtensions @@ -215,6 +216,7 @@ namespace Testably.Abstractions.Testing.FileSystem public interface INotificationHandler : System.IO.Abstractions.IFileSystemEntity { Testably.Abstractions.Testing.IAwaitableCallback OnEvent(System.Action? notificationCallback = null, System.Func? predicate = null); + Testably.Abstractions.Testing.IAwaitableCallback OnEventOrReplay(System.Action? notificationCallback = null, System.Func? predicate = null); } public interface ISafeFileHandleStrategy { diff --git a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net8.0.txt b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net8.0.txt index 287c938d3..d68c6252f 100644 --- a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net8.0.txt +++ b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net8.0.txt @@ -83,6 +83,7 @@ namespace Testably.Abstractions.Testing public Testably.Abstractions.Testing.MockFileSystem.MockFileSystemOptions UseCurrentDirectory(string path) { } public Testably.Abstractions.Testing.MockFileSystem.MockFileSystemOptions UseRandomProvider(Testably.Abstractions.Testing.RandomSystem.IRandomProvider randomProvider) { } public Testably.Abstractions.Testing.MockFileSystem.MockFileSystemOptions UseTimeSystem(Testably.Abstractions.ITimeSystem timeSystem) { } + public Testably.Abstractions.Testing.MockFileSystem.MockFileSystemOptions WithoutNotificationHistory() { } } } public static class MockFileSystemExtensions @@ -217,6 +218,7 @@ namespace Testably.Abstractions.Testing.FileSystem public interface INotificationHandler : System.IO.Abstractions.IFileSystemEntity { Testably.Abstractions.Testing.IAwaitableCallback OnEvent(System.Action? notificationCallback = null, System.Func? predicate = null); + Testably.Abstractions.Testing.IAwaitableCallback OnEventOrReplay(System.Action? notificationCallback = null, System.Func? predicate = null); } public interface ISafeFileHandleStrategy { diff --git a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net9.0.txt b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net9.0.txt index 5aaf355e2..147f89e51 100644 --- a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net9.0.txt +++ b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_net9.0.txt @@ -83,6 +83,7 @@ namespace Testably.Abstractions.Testing public Testably.Abstractions.Testing.MockFileSystem.MockFileSystemOptions UseCurrentDirectory(string path) { } public Testably.Abstractions.Testing.MockFileSystem.MockFileSystemOptions UseRandomProvider(Testably.Abstractions.Testing.RandomSystem.IRandomProvider randomProvider) { } public Testably.Abstractions.Testing.MockFileSystem.MockFileSystemOptions UseTimeSystem(Testably.Abstractions.ITimeSystem timeSystem) { } + public Testably.Abstractions.Testing.MockFileSystem.MockFileSystemOptions WithoutNotificationHistory() { } } } public static class MockFileSystemExtensions @@ -217,6 +218,7 @@ namespace Testably.Abstractions.Testing.FileSystem public interface INotificationHandler : System.IO.Abstractions.IFileSystemEntity { Testably.Abstractions.Testing.IAwaitableCallback OnEvent(System.Action? notificationCallback = null, System.Func? predicate = null); + Testably.Abstractions.Testing.IAwaitableCallback OnEventOrReplay(System.Action? notificationCallback = null, System.Func? predicate = null); } public interface ISafeFileHandleStrategy { diff --git a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_netstandard2.0.txt b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_netstandard2.0.txt index 4b20f5667..16b86b69a 100644 --- a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_netstandard2.0.txt +++ b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_netstandard2.0.txt @@ -77,6 +77,7 @@ namespace Testably.Abstractions.Testing public Testably.Abstractions.Testing.MockFileSystem.MockFileSystemOptions UseCurrentDirectory(string path) { } public Testably.Abstractions.Testing.MockFileSystem.MockFileSystemOptions UseRandomProvider(Testably.Abstractions.Testing.RandomSystem.IRandomProvider randomProvider) { } public Testably.Abstractions.Testing.MockFileSystem.MockFileSystemOptions UseTimeSystem(Testably.Abstractions.ITimeSystem timeSystem) { } + public Testably.Abstractions.Testing.MockFileSystem.MockFileSystemOptions WithoutNotificationHistory() { } } } public static class MockFileSystemExtensions @@ -209,6 +210,7 @@ namespace Testably.Abstractions.Testing.FileSystem public interface INotificationHandler : System.IO.Abstractions.IFileSystemEntity { Testably.Abstractions.Testing.IAwaitableCallback OnEvent(System.Action? notificationCallback = null, System.Func? predicate = null); + Testably.Abstractions.Testing.IAwaitableCallback OnEventOrReplay(System.Action? notificationCallback = null, System.Func? predicate = null); } public interface ISafeFileHandleStrategy { diff --git a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_netstandard2.1.txt b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_netstandard2.1.txt index 2a745332f..46c01a2f4 100644 --- a/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_netstandard2.1.txt +++ b/Tests/Api/Testably.Abstractions.Api.Tests/Expected/Testably.Abstractions.Testing_netstandard2.1.txt @@ -77,6 +77,7 @@ namespace Testably.Abstractions.Testing public Testably.Abstractions.Testing.MockFileSystem.MockFileSystemOptions UseCurrentDirectory(string path) { } public Testably.Abstractions.Testing.MockFileSystem.MockFileSystemOptions UseRandomProvider(Testably.Abstractions.Testing.RandomSystem.IRandomProvider randomProvider) { } public Testably.Abstractions.Testing.MockFileSystem.MockFileSystemOptions UseTimeSystem(Testably.Abstractions.ITimeSystem timeSystem) { } + public Testably.Abstractions.Testing.MockFileSystem.MockFileSystemOptions WithoutNotificationHistory() { } } } public static class MockFileSystemExtensions @@ -209,6 +210,7 @@ namespace Testably.Abstractions.Testing.FileSystem public interface INotificationHandler : System.IO.Abstractions.IFileSystemEntity { Testably.Abstractions.Testing.IAwaitableCallback OnEvent(System.Action? notificationCallback = null, System.Func? predicate = null); + Testably.Abstractions.Testing.IAwaitableCallback OnEventOrReplay(System.Action? notificationCallback = null, System.Func? predicate = null); } public interface ISafeFileHandleStrategy { diff --git a/Tests/Testably.Abstractions.Testing.Tests/FileSystem/ChangeHandlerTests.cs b/Tests/Testably.Abstractions.Testing.Tests/FileSystem/ChangeHandlerTests.cs index 4fe4bf443..5d33757a5 100644 --- a/Tests/Testably.Abstractions.Testing.Tests/FileSystem/ChangeHandlerTests.cs +++ b/Tests/Testably.Abstractions.Testing.Tests/FileSystem/ChangeHandlerTests.cs @@ -167,6 +167,21 @@ await That(Act).Throws() .WithMessage("*WithoutNotificationHistory*").AsWildcard(); } + [Test] + [AutoArguments] + public async Task OnEventOrReplay_CallbackThrowsDuringReplay_ShouldNotLeakWaiter(string path) + { + FileSystem.File.WriteAllText(path, null); + + void Subscribe() => FileSystem.Notify.OnEventOrReplay( + _ => throw new InvalidOperationException("boom")); + + await That(Subscribe).Throws().WithMessage("boom"); + + void TriggerAnotherChange() => FileSystem.File.WriteAllText(path, "more"); + await That(TriggerAnotherChange).DoesNotThrow(); + } + [Test] public async Task Watcher_ShouldNotTriggerWhenFileSystemWatcherDoesNotMatch() {