From f7bad984294aa4327fcb57db3079c49652356a6a Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Sat, 29 Aug 2026 17:18:23 +1000 Subject: [PATCH] Allow a recording to be started again once consumed TryStop runs inside the engine's child async context, so its asyncLocal null never flows back to the test. The shared State is deliberately left cleared and paused instead, which IsRecording reads correctly as not recording. Start only checked for a non null state though, so the supported pattern Recording.Start(); ... await Verify(x); Recording.Start(); ... await Verify(y); threw 'Recording already started', which was not true: the first recording had already been consumed. The state now carries a Stopped flag, and Start replaces a stopped state while still rejecting a live one. --- docs/recording.md | 22 +++++++++++----------- src/Verify.Tests/RecordingTests.cs | 27 +++++++++++++++++++++++++++ src/Verify/Recording/Recording.cs | 8 +++++--- src/Verify/Recording/State.cs | 14 ++++++++++++++ 4 files changed, 57 insertions(+), 14 deletions(-) diff --git a/docs/recording.md b/docs/recording.md index 998e98a0c3..6494b47024 100644 --- a/docs/recording.md +++ b/docs/recording.md @@ -25,7 +25,7 @@ public Task Usage() return Verify("TheValue"); } ``` -snippet source | anchor +snippet source | anchor Results in: @@ -61,7 +61,7 @@ public Task TryAdd() return Verify("TheValue"); } ``` -snippet source | anchor +snippet source | anchor @@ -85,7 +85,7 @@ public Task RecordingScoped() return Verify(); } ``` -snippet source | anchor +snippet source | anchor Results in: @@ -117,7 +117,7 @@ public Task SameKey() return Verify("TheValue"); } ``` -snippet source | anchor +snippet source | anchor Results in: @@ -156,7 +156,7 @@ public Task Identifier() return Verify(Recording.Stop("identifier")); } ``` -snippet source | anchor +snippet source | anchor Results in: @@ -188,7 +188,7 @@ public Task Case() return Verify("TheValue"); } ``` -snippet source | anchor +snippet source | anchor Results in: @@ -223,7 +223,7 @@ public Task Stop() return Verify(appends.Where(_ => _.Name != "name1")); } ``` -snippet source | anchor +snippet source | anchor Results in: @@ -255,7 +255,7 @@ public Task StopNotInResult() return Verify("other data"); } ``` -snippet source | anchor +snippet source | anchor Results in: @@ -284,7 +284,7 @@ public void IsRecording() Assert.True(Recording.IsRecording()); } ``` -snippet source | anchor +snippet source | anchor This can be helpful if the cost of capturing data, to add to recording, is high. @@ -307,7 +307,7 @@ public Task Clear() return Verify(); } ``` -snippet source | anchor +snippet source | anchor Results in: @@ -343,7 +343,7 @@ public Task PauseResume() return Verify(); } ``` -snippet source | anchor +snippet source | anchor Results in: diff --git a/src/Verify.Tests/RecordingTests.cs b/src/Verify.Tests/RecordingTests.cs index edff2ad480..686f7ebd04 100644 --- a/src/Verify.Tests/RecordingTests.cs +++ b/src/Verify.Tests/RecordingTests.cs @@ -21,6 +21,33 @@ public async Task StoppedInChildContextIsNotRecording() Assert.False(Recording.IsRecording()); } + // The engine consumes the recording in a child context, so the null does not flow back + // and the test's own context still holds the stopped state. Starting again after that + // is the supported pattern, not a double start. + [Fact] + public async Task StartAfterConsumedInChildContext() + { + Recording.Start(); + Recording.Add("name", "value1"); + await Task.Run(() => Recording.TryStop(out _)); + + Recording.Start(); + Recording.Add("name", "value2"); + + Assert.True(Recording.TryStop(out var recorded)); + Assert.Equal(["value2"], recorded.Select(_ => _.Data)); + } + + [Fact] + public void StartWhileRecordingStillThrows() + { + using (Recording.Start()) + { + var exception = Assert.Throws(Recording.Start); + Assert.Equal("Recording already started", exception.Message); + } + } + [Fact] public void DisposeAfterStopDoesNotThrow() { diff --git a/src/Verify/Recording/Recording.cs b/src/Verify/Recording/Recording.cs index f24828ca48..2012a67369 100644 --- a/src/Verify/Recording/Recording.cs +++ b/src/Verify/Recording/Recording.cs @@ -79,8 +79,7 @@ public static bool TryStop([NotNullWhen(true)] out IReadOnlyCollection // Snapshot the items, then stop the shared State so the stop is observable // through the caller's reference. recorded = value.Items.ToList(); - value.Clear(); - value.Pause(); + value.Stop(); asyncLocal.Value = null; return true; } @@ -125,7 +124,10 @@ public static IDisposable Start() { var value = asyncLocal.Value; - if (value != null) + // A stopped state is one the caller's context is still holding after the recording + // was consumed, for example by a Verify. That is finished, so starting again is + // valid: only a live recording is a double start. + if (value is {Stopped: false}) { throw new("Recording already started"); } diff --git a/src/Verify/Recording/State.cs b/src/Verify/Recording/State.cs index 73b4371c59..aa0471958f 100644 --- a/src/Verify/Recording/State.cs +++ b/src/Verify/Recording/State.cs @@ -23,6 +23,20 @@ public void Add(string name, object item) items.Enqueue(append); } + /// + /// Set once the recording has been consumed. Nulling the AsyncLocal does not flow back + /// to the caller when the engine stops a recording from inside the verification, so the + /// stop has to be observable through the caller's own reference to this state. + /// + public bool Stopped { get; private set; } + + public void Stop() + { + Clear(); + Pause(); + Stopped = true; + } + public void Pause() => Paused = true;