From 8e1cbf6b46f935223b4e80bdc1641211414927fe Mon Sep 17 00:00:00 2001 From: bUnit bot Date: Mon, 3 Aug 2026 19:41:19 +0000 Subject: [PATCH 01/13] Set version to '2.10-preview' --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index 0abf51310..fca6d42a3 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "2.9-preview", + "version": "2.10-preview", "assemblyVersion": { "precision": "revision" }, From 106939190eca2414a4e1c99239cdbb0c0c4309fe Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:17:32 +0000 Subject: [PATCH 02/13] Bump AngleSharp from 1.7.0 to 1.7.1 --- updated-dependencies: - dependency-name: AngleSharp dependency-version: 1.7.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 1dc6e6910..f1a07b765 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,7 +18,7 @@ - + From 85ce87799101a39ec66c7440801b328ef5f2e3ff Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:28:18 +0000 Subject: [PATCH 03/13] Bump NSubstitute from 6.0.0 to 6.1.0 --- updated-dependencies: - dependency-name: NSubstitute dependency-version: 6.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index f1a07b765..504c1ec23 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -93,7 +93,7 @@ - + From 81d2c485772a3c3a450801cc57174436d01b1a29 Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Mon, 31 Aug 2026 14:11:53 +0200 Subject: [PATCH 04/13] chore: Update packages --- .github/workflows/ci.yml | 15 ++++++++++----- .github/workflows/docs-deploy.yml | 3 ++- Directory.Packages.props | 12 ++++++------ docs/samples/global.json | 6 ++++++ global.json | 3 +++ .../bunit.generators.internal.csproj | 2 +- src/bunit.generators/bunit.generators.csproj | 2 +- .../JSRuntimeInvocationNotSetException.cs | 2 +- .../JSRuntimeUnhandledInvocationException.cs | 2 +- .../BlazorE2E/ComponentRenderingTest.cs | 8 ++++---- 10 files changed, 35 insertions(+), 20 deletions(-) create mode 100644 docs/samples/global.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 415d1cd40..5a52b66ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -182,32 +182,36 @@ jobs: dotnet new bunit --no-restore -o ${{ github.workspace }}/TemplateTestXunit echo '' >> ${{ github.workspace }}/TemplateTestXunit/Directory.Build.props echo 'false' >> ${{ github.workspace }}/TemplateTestXunit/Directory.Packages.props + echo '{"sdk":{"rollForward":"latestMajor","allowPrerelease":true}}' >> ${{ github.workspace }}/TemplateTestXunit/global.json dotnet restore ${{ github.workspace }}/TemplateTestXunit --source https://api.nuget.org/v3/index.json --source ${{ env.NUGET_DIRECTORY }} - dotnet test ${{ github.workspace }}/TemplateTestXunit + cd ${{ github.workspace }}/TemplateTestXunit && dotnet test - name: โœ” Verify xUnit.v3 template run: | dotnet new bunit --framework xunitv3 --no-restore -o ${{ github.workspace }}/TemplateTestXunitv3 echo '' >> ${{ github.workspace }}/TemplateTestXunitv3/Directory.Build.props echo 'false' >> ${{ github.workspace }}/TemplateTestXunitv3/Directory.Packages.props + echo '{"sdk":{"rollForward":"latestMajor","allowPrerelease":true}}' >> ${{ github.workspace }}/TemplateTestXunitv3/global.json dotnet restore ${{ github.workspace }}/TemplateTestXunitv3 --source https://api.nuget.org/v3/index.json --source ${{ env.NUGET_DIRECTORY }} - dotnet test ${{ github.workspace }}/TemplateTestXunitv3 + cd ${{ github.workspace }}/TemplateTestXunitv3 && dotnet test - name: โœ” Verify NUnit template run: | dotnet new bunit --framework nunit --no-restore -o ${{ github.workspace }}/TemplateTestNunit echo '' >> ${{ github.workspace }}/TemplateTestNunit/Directory.Build.props echo 'false' >> ${{ github.workspace }}/TemplateTestNunit/Directory.Packages.props + echo '{"sdk":{"rollForward":"latestMajor","allowPrerelease":true}}' >> ${{ github.workspace }}/TemplateTestNunit/global.json dotnet restore ${{ github.workspace }}/TemplateTestNunit --source https://api.nuget.org/v3/index.json --source ${{ env.NUGET_DIRECTORY }} - dotnet test ${{ github.workspace }}/TemplateTestNunit + cd ${{ github.workspace }}/TemplateTestNunit && dotnet test - name: โœ” Verify MSTest template run: | dotnet new bunit --framework mstest --no-restore -o ${{ github.workspace }}/TemplateTestMstest echo '' >> ${{ github.workspace }}/TemplateTestMstest/Directory.Build.props echo 'false' >> ${{ github.workspace }}/TemplateTestMstest/Directory.Packages.props + echo '{"sdk":{"rollForward":"latestMajor","allowPrerelease":true}}' >> ${{ github.workspace }}/TemplateTestMstest/global.json dotnet restore ${{ github.workspace }}/TemplateTestMstest --source https://api.nuget.org/v3/index.json --source ${{ env.NUGET_DIRECTORY }} - dotnet test ${{ github.workspace }}/TemplateTestMstest + cd ${{ github.workspace }}/TemplateTestMstest && dotnet test validate-docs: runs-on: ubuntu-latest @@ -251,7 +255,8 @@ jobs: run: dotnet build -c release - name: ๐Ÿงช Run sample unit tests - run: dotnet test docs/samples/samples.sln -p:VSTestUseMSBuildOutput=false -f net8.0 + working-directory: docs/samples + run: dotnet test samples.sln -p:VSTestUseMSBuildOutput=false -f net8.0 - name: ๐Ÿ“„ Build docs working-directory: ./docs/site diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 81075fccc..35f3bbdfe 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -87,7 +87,8 @@ jobs: run: dotnet build /p:PublicRelease=true -c release - name: ๐Ÿ› ๏ธ Verify docs samples - run: dotnet test docs/samples/samples.sln + working-directory: docs/samples + run: dotnet test samples.sln - name: ๐Ÿ“„ Building docs working-directory: ./docs/site diff --git a/Directory.Packages.props b/Directory.Packages.props index 504c1ec23..66ffaf82b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,7 +8,7 @@ - + @@ -91,7 +91,7 @@ - + @@ -105,10 +105,10 @@ - - - - + + + + diff --git a/docs/samples/global.json b/docs/samples/global.json new file mode 100644 index 000000000..83105ca8e --- /dev/null +++ b/docs/samples/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "rollForward": "latestMajor", + "allowPrerelease": true + } +} diff --git a/global.json b/global.json index 83105ca8e..e5c95761f 100644 --- a/global.json +++ b/global.json @@ -2,5 +2,8 @@ "sdk": { "rollForward": "latestMajor", "allowPrerelease": true + }, + "test": { + "runner": "Microsoft.Testing.Platform" } } diff --git a/src/bunit.generators.internal/bunit.generators.internal.csproj b/src/bunit.generators.internal/bunit.generators.internal.csproj index cae79f712..023f92c9c 100644 --- a/src/bunit.generators.internal/bunit.generators.internal.csproj +++ b/src/bunit.generators.internal/bunit.generators.internal.csproj @@ -2,7 +2,7 @@ netstandard2.0 - 12.0 + 14.0 true Bunit false diff --git a/src/bunit.generators/bunit.generators.csproj b/src/bunit.generators/bunit.generators.csproj index cde790b2c..d053641dd 100644 --- a/src/bunit.generators/bunit.generators.csproj +++ b/src/bunit.generators/bunit.generators.csproj @@ -2,7 +2,7 @@ netstandard2.0 - 12.0 + 14.0 true Bunit diff --git a/src/bunit/JSInterop/JSRuntimeInvocationNotSetException.cs b/src/bunit/JSInterop/JSRuntimeInvocationNotSetException.cs index 4eff577f1..d3dce9302 100644 --- a/src/bunit/JSInterop/JSRuntimeInvocationNotSetException.cs +++ b/src/bunit/JSInterop/JSRuntimeInvocationNotSetException.cs @@ -64,7 +64,7 @@ private static string CreateErrorMessage(JSRuntimeInvocation invocation) private static string GetArguments(JSRuntimeInvocation invocation) { - if (!invocation.Arguments.Any()) + if (invocation.Arguments.Count == 0) return $"\"{invocation.Identifier}\""; var argStrings = invocation.Arguments.Select(FormatArgument).Prepend($"\"{invocation.Identifier}\""); diff --git a/src/bunit/JSInterop/JSRuntimeUnhandledInvocationException.cs b/src/bunit/JSInterop/JSRuntimeUnhandledInvocationException.cs index 500fe5a48..8f68bddb9 100644 --- a/src/bunit/JSInterop/JSRuntimeUnhandledInvocationException.cs +++ b/src/bunit/JSInterop/JSRuntimeUnhandledInvocationException.cs @@ -60,7 +60,7 @@ private static string CreateErrorMessage(JSRuntimeInvocation invocation) sb.AppendLine(FormattableString.Invariant($" Setup<{GetReturnTypeName(invocation.ResultType)}>({GetArguments(invocation)})")); } - if (invocation.Arguments.Any()) + if (invocation.Arguments.Count > 0) { sb.AppendLine("or the following, to match any arguments:"); if (invocation.IsVoidResultInvocation) diff --git a/tests/bunit.tests/BlazorE2E/ComponentRenderingTest.cs b/tests/bunit.tests/BlazorE2E/ComponentRenderingTest.cs index 494c6df50..9c3fb672b 100644 --- a/tests/bunit.tests/BlazorE2E/ComponentRenderingTest.cs +++ b/tests/bunit.tests/BlazorE2E/ComponentRenderingTest.cs @@ -669,7 +669,7 @@ public async Task CanHandleRemovedParentObjects() cut.Find("button").Click(); - await cut.WaitForStateAsync(() => !cut.FindAll("div").Any()); + await cut.WaitForStateAsync(() => cut.FindAll("div").Count == 0); cut.FindAll("div").Count.ShouldBe(0); } @@ -681,7 +681,7 @@ public void CanHandleRemovedParentObjects_Sync() cut.Find("button").Click(); - cut.WaitForState(() => !cut.FindAll("div").Any()); + cut.WaitForState(() => cut.FindAll("div").Count == 0); cut.FindAll("div").Count.ShouldBe(0); } @@ -693,7 +693,7 @@ public async Task CanHandleRemovedParentObjectsAsync() await cut.Find("button").ClickAsync(new MouseEventArgs()); - await cut.WaitForStateAsync(() => !cut.FindAll("div").Any()); + await cut.WaitForStateAsync(() => cut.FindAll("div").Count == 0); cut.FindAll("div").Count.ShouldBe(0); } @@ -717,7 +717,7 @@ public async Task CanHandleRemovedParentObjectsAsync_Sync() await cut.Find("button").ClickAsync(new MouseEventArgs()); - cut.WaitForState(() => !cut.FindAll("div").Any()); + cut.WaitForState(() => cut.FindAll("div").Count == 0); cut.FindAll("div").Count.ShouldBe(0); } From 3adcd4a19ad87802ac1a767329d6516b92d36535 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:47:58 +0000 Subject: [PATCH 05/13] Bump NSubstitute from 6.1.0 to 6.2.0 --- updated-dependencies: - dependency-name: NSubstitute dependency-version: 6.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 66ffaf82b..9841961e7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -93,7 +93,7 @@ - + From afd0498f24e168e72c1516d57e1dbff9ab2efce6 Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Mon, 31 Aug 2026 19:52:44 +0200 Subject: [PATCH 06/13] chore: Update packages --- Directory.Packages.props | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 9841961e7..f0e588d97 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,7 +8,7 @@ - + @@ -18,12 +18,12 @@ - - + + - + From b74b1ae75a663831528cb51870f358b742fdaa55 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:26:59 +0000 Subject: [PATCH 07/13] Bump Verify.XunitV3 from 31.28.0 to 32.0.0 --- updated-dependencies: - dependency-name: Verify.XunitV3 dependency-version: 32.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index f0e588d97..fe6d8fa87 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -99,7 +99,7 @@ - + From 17ca92246ecab60ff6e600aba52331cee58f25fd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:15:05 +0000 Subject: [PATCH 08/13] Bump AngleSharp from 1.7.2 to 1.7.3 --- updated-dependencies: - dependency-name: AngleSharp dependency-version: 1.7.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index fe6d8fa87..e5046039d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,7 +18,7 @@ - + From fa46d8a84ee65a8459c7624bf21b7e1481bb4a63 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:15:07 +0000 Subject: [PATCH 09/13] Bump dotnet-dump from 9.0.661903 to 10.0.731102 --- updated-dependencies: - dependency-name: dotnet-dump dependency-version: 10.0.731102 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .config/dotnet-tools.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index a2ee9a494..372393c31 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -17,7 +17,7 @@ "rollForward": false }, "dotnet-dump": { - "version": "9.0.661903", + "version": "10.0.731102", "commands": [ "dotnet-dump" ], From 69f4038e42731fef7c9c87243c3ae1e228463777 Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Fri, 4 Sep 2026 10:32:55 +0200 Subject: [PATCH 10/13] fix: Race condition between HTML parser disposal and concurrent parsing --- CHANGELOG.md | 4 ++ src/bunit/Rendering/BunitHtmlParser.cs | 29 +++++++--- .../Rendering/BunitHtmlParserTest.cs | 57 +++++++++++++++++++ 3 files changed, 82 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 676334358..c568bdff2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes to **bUnit** will be documented in this file. The project ad ## [Unreleased] +### Fixed + +- `BunitHtmlParser.Dispose()` no longer throws `InvalidOperationException: Collection was modified` when a parse is in flight on another thread during test teardown. Reported by [@thimobuchheister](https://github.com/thimobuchheister) in #1892. Fixed by [@linkdotnet](https://github.com/linkdotnet). + ## [2.9.0] - 2026-08-03 ### Changed diff --git a/src/bunit/Rendering/BunitHtmlParser.cs b/src/bunit/Rendering/BunitHtmlParser.cs index fa1e08736..47fcf0095 100644 --- a/src/bunit/Rendering/BunitHtmlParser.cs +++ b/src/bunit/Rendering/BunitHtmlParser.cs @@ -23,6 +23,8 @@ internal sealed class BunitHtmlParser : IDisposable private readonly IBrowsingContext context; private readonly HtmlParser htmlParser; private readonly List documents = new(); + private readonly object parserLock = new(); + private bool disposed; /// /// Initializes a new instance of the class @@ -69,13 +71,16 @@ public INodeList Parse([StringSyntax("Html")] string markup) { ArgumentNullException.ThrowIfNull(markup); - var document = GetNewDocumentAsync().GetAwaiter().GetResult(); + lock (parserLock) + { + var document = GetNewDocumentAsync().GetAwaiter().GetResult(); - var (ctx, matchedElement) = GetParseContext(markup, document); + var (ctx, matchedElement) = GetParseContext(markup, document); - return ctx is null && matchedElement is not null - ? ParseSpecial(markup, matchedElement) - : htmlParser.ParseFragment(markup, ctx!); + return ctx is null && matchedElement is not null + ? ParseSpecial(markup, matchedElement) + : htmlParser.ParseFragment(markup, ctx!); + } } private INodeList ParseSpecial(string markup, string matchedElement) @@ -158,10 +163,18 @@ private async Task GetNewDocumentAsync() /// public void Dispose() { - context.Dispose(); - foreach (var doc in documents) + lock (parserLock) { - doc.Dispose(); + if (disposed) + return; + + disposed = true; + + context.Dispose(); + foreach (var doc in documents) + { + doc.Dispose(); + } } } diff --git a/tests/bunit.tests/Rendering/BunitHtmlParserTest.cs b/tests/bunit.tests/Rendering/BunitHtmlParserTest.cs index 3f386b6ea..1b85f8ced 100644 --- a/tests/bunit.tests/Rendering/BunitHtmlParserTest.cs +++ b/tests/bunit.tests/Rendering/BunitHtmlParserTest.cs @@ -170,6 +170,63 @@ public void Test021() actual[1].ShouldBeAssignableTo(); } + [Fact(DisplayName = "Dispose() does not throw while another thread is parsing")] + public async Task DisposeDoesNotThrowWhileAnotherThreadIsParsing() + { + using var cts = new CancellationTokenSource(); + using var parser = new BunitHtmlParser(); + var parseCount = 0; + Exception? parseException = null; + + var parsing = Task.Run( + () => + { + while (!cts.IsCancellationRequested) + { + try + { + parser.Parse("

Hello world

"); + Interlocked.Increment(ref parseCount); + } + catch (InvalidOperationException ex) + { + // "Collection was modified" - the race this test guards against. + parseException = ex; + return; + } + catch (Exception) + { + // Expected once Dispose() has completed: parsing against a + // disposed AngleSharp browsing context. + } + } + }, + CancellationToken.None); + + // Let the parser build up a sizeable document list, so the enumeration in + // Dispose() is long enough to overlap with a concurrent call to Parse(). + while (Volatile.Read(ref parseCount) < 100) + { + await Task.Yield(); + } + + Should.NotThrow(parser.Dispose); + + await cts.CancelAsync(); + await parsing; + + parseException.ShouldBeNull(); + } + + [Fact(DisplayName = "Dispose() is idempotent")] + public void DisposeIsIdempotent() + { + using var parser = new BunitHtmlParser(); + parser.Dispose(); + + Should.NotThrow(parser.Dispose); + } + private static void VerifyElementParsedWithId(string expectedElementName, List actual) { var elm = actual.OfType() From c61eec70bbc0390e2598e43cc83bf960e584ab33 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:14:02 +0000 Subject: [PATCH 11/13] Bump AngleSharp from 1.7.3 to 1.8.0 --- updated-dependencies: - dependency-name: AngleSharp dependency-version: 1.8.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index e5046039d..07acc6812 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,7 +18,7 @@ - + From 7efd6dfafa5780ef8be3ac4135840a78942e5e9c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:19:12 +0000 Subject: [PATCH 12/13] Bump AngleSharp.Css from 1.0.2 to 1.1.0 --- updated-dependencies: - dependency-name: AngleSharp.Css dependency-version: 1.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 07acc6812..56664c00a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -19,7 +19,7 @@ - + From 2fb4faa2431940a51391ffce6ce7eea460fb78dc Mon Sep 17 00:00:00 2001 From: bUnit bot Date: Tue, 8 Sep 2026 08:25:05 +0000 Subject: [PATCH 13/13] Set version to '2.10' --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index fca6d42a3..cf16a5ace 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "2.10-preview", + "version": "2.10", "assemblyVersion": { "precision": "revision" },