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" ], 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/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/Directory.Packages.props b/Directory.Packages.props index 1dc6e6910..56664c00a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,7 +8,7 @@ - + @@ -18,12 +18,12 @@ - - + + - + @@ -91,24 +91,24 @@ - + - + - + - - - - + + + + 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/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/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); } 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() diff --git a/version.json b/version.json index 105776cf0..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.9", + "version": "2.10", "assemblyVersion": { "precision": "revision" },