Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .config/dotnet-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"rollForward": false
},
"dotnet-dump": {
"version": "9.0.661903",
"version": "10.0.731102",
"commands": [
"dotnet-dump"
],
Expand Down
15 changes: 10 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -182,32 +182,36 @@ jobs:
dotnet new bunit --no-restore -o ${{ github.workspace }}/TemplateTestXunit
echo '<?xml version="1.0" encoding="utf-8"?><Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"></Project>' >> ${{ github.workspace }}/TemplateTestXunit/Directory.Build.props
echo '<Project><PropertyGroup><ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally></PropertyGroup></Project>' >> ${{ 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 '<?xml version="1.0" encoding="utf-8"?><Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"></Project>' >> ${{ github.workspace }}/TemplateTestXunitv3/Directory.Build.props
echo '<Project><PropertyGroup><ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally></PropertyGroup></Project>' >> ${{ 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 '<?xml version="1.0" encoding="utf-8"?><Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"></Project>' >> ${{ github.workspace }}/TemplateTestNunit/Directory.Build.props
echo '<Project><PropertyGroup><ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally></PropertyGroup></Project>' >> ${{ 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 '<?xml version="1.0" encoding="utf-8"?><Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"></Project>' >> ${{ github.workspace }}/TemplateTestMstest/Directory.Build.props
echo '<Project><PropertyGroup><ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally></PropertyGroup></Project>' >> ${{ 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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/docs-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 11 additions & 11 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<GlobalPackageReference Include="AsyncFixer" Version="2.1.0" PrivateAssets="All" IncludeAssets="Runtime;Build;Native;contentFiles;Analyzers"/>
<GlobalPackageReference Include="SonarAnalyzer.CSharp" Version="10.29.0.143774" PrivateAssets="All" IncludeAssets="Runtime;Build;Native;contentFiles;Analyzers"/>
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
<PackageVersion Include="Meziantou.Polyfill" Version="1.0.158" />
<PackageVersion Include="Meziantou.Polyfill" Version="1.0.164" />
</ItemGroup>

<ItemGroup Label="Shared">
Expand All @@ -18,12 +18,12 @@
<PackageVersion Include="Serilog" Version="4.4.0"/>
<PackageVersion Include="Serilog.Expressions" Version="5.0.0"/>
<PackageVersion Include="AngleSharp.Diffing" Version="1.1.1"/>
<PackageVersion Include="AngleSharp" Version="1.7.0"/>
<PackageVersion Include="AngleSharp.Css" Version="1.0.1"/>
<PackageVersion Include="AngleSharp" Version="1.8.0"/>
<PackageVersion Include="AngleSharp.Css" Version="1.1.0"/>
</ItemGroup>

<ItemGroup Label="Package Versioning">
<PackageVersion Include="Nerdbank.GitVersioning" Version="3.10.91" />
<PackageVersion Include="Nerdbank.GitVersioning" Version="3.10.94" />
</ItemGroup>

<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
Expand Down Expand Up @@ -91,24 +91,24 @@
<PackageVersion Include="AutoFixture.Xunit3" Version="4.19.0"/>
<PackageVersion Include="MSTest.TestAdapter" Version="3.6.0" />
<PackageVersion Include="MSTest.TestFramework" Version="3.6.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1"/>
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.9.0"/>
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="NSubstitute" Version="6.0.0" />
<PackageVersion Include="NSubstitute" Version="6.2.0" />
<PackageVersion Include="NUnit3TestAdapter" Version="5.0.0" />
<PackageVersion Include="RichardSzalay.MockHttp" Version="7.0.0" />
<PackageVersion Include="Serilog.Extensions.Logging" Version="10.0.0" />
<PackageVersion Include="Shouldly" Version="4.3.0"/>
<PackageVersion Include="Verify.SourceGenerators" Version="2.5.0"/>
<PackageVersion Include="Verify.XunitV3" Version="31.28.0"/>
<PackageVersion Include="Verify.XunitV3" Version="32.0.0"/>
<PackageVersion Include="Xunit.Combinatorial" Version="2.0.24"/>
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="coverlet.msbuild" Version="10.0.1" />
<PackageVersion Include="nunit" Version="4.4.0" />
<PackageVersion Include="xunit" Version="2.9.3"/>
<PackageVersion Include="xunit.v3" Version="3.2.2"/>
<PackageVersion Include="xunit.v3.extensibility.core" Version="3.2.2" />
<PackageVersion Include="xunit.v3.assert" Version="3.2.2"/>
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5"/>
<PackageVersion Include="xunit.v3" Version="4.0.0"/>
<PackageVersion Include="xunit.v3.extensibility.core" Version="4.0.0" />
<PackageVersion Include="xunit.v3.assert" Version="4.0.0"/>
<PackageVersion Include="xunit.runner.visualstudio" Version="4.0.0"/>
</ItemGroup>

<ItemGroup Label="Source Code Generators">
Expand Down
6 changes: 6 additions & 0 deletions docs/samples/global.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"sdk": {
"rollForward": "latestMajor",
"allowPrerelease": true
}
}
3 changes: 3 additions & 0 deletions global.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,8 @@
"sdk": {
"rollForward": "latestMajor",
"allowPrerelease": true
},
"test": {
"runner": "Microsoft.Testing.Platform"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>12.0</LangVersion>
<LangVersion>14.0</LangVersion>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<RootNamespace>Bunit</RootNamespace>
<IsPackable>false</IsPackable>
Expand Down
2 changes: 1 addition & 1 deletion src/bunit.generators/bunit.generators.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>12.0</LangVersion>
<LangVersion>14.0</LangVersion>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<RootNamespace>Bunit</RootNamespace>
</PropertyGroup>
Expand Down
2 changes: 1 addition & 1 deletion src/bunit/JSInterop/JSRuntimeInvocationNotSetException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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}\"");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
29 changes: 21 additions & 8 deletions src/bunit/Rendering/BunitHtmlParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ internal sealed class BunitHtmlParser : IDisposable
private readonly IBrowsingContext context;
private readonly HtmlParser htmlParser;
private readonly List<IDocument> documents = new();
private readonly object parserLock = new();
private bool disposed;

/// <summary>
/// Initializes a new instance of the <see cref="BunitHtmlParser"/> class
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -158,10 +163,18 @@ private async Task<IDocument> GetNewDocumentAsync()
/// <inheritdoc/>
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();
}
}
}

Expand Down
8 changes: 4 additions & 4 deletions tests/bunit.tests/BlazorE2E/ComponentRenderingTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -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);
}

Expand All @@ -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);
}

Expand All @@ -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);
}

Expand Down
57 changes: 57 additions & 0 deletions tests/bunit.tests/Rendering/BunitHtmlParserTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,63 @@ public void Test021()
actual[1].ShouldBeAssignableTo<IHtmlHtmlElement>();
}

[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("<p>Hello world</p>");
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<INode> actual)
{
var elm = actual.OfType<IElement>()
Expand Down
2 changes: 1 addition & 1 deletion version.json
Original file line number Diff line number Diff line change
@@ -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"
},
Expand Down
Loading