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": "10.0.731102",
"version": "10.0.745401",
"commands": [
"dotnet-dump"
],
Expand Down
1 change: 1 addition & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,7 @@ dotnet_diagnostic.BL0005.severity = none
dotnet_diagnostic.BL0006.severity = none
dotnet_diagnostic.BL0007.severity = none
dotnet_diagnostic.BL0010.severity = none
dotnet_diagnostic.BL0012.severity = none

##########################################
# Custom Test Code Analyzers Rules
Expand Down
6 changes: 6 additions & 0 deletions .globalconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Razor compiler diagnostics reported on .razor files are not matched by .editorconfig
# path sections, so their severity can only be configured globally.
is_global = true

dotnet_diagnostic.BL0013.severity = none
dotnet_diagnostic.BL0016.severity = none
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ All notable changes to **bUnit** will be documented in this file. The project ad

## [Unreleased]

### Fixed

- `InvokeOnSpacerBeforeVisible` now uses 4 parameters on .NET 11.0. Reported by [@vnbaaij](https://github.com/vnbaaij) in #1915. Fixed by [@vnbaaij](https://github.com/vnbaaij) in #1919.
- A JSInterop timeout elapsing while a result was set could crash the test host with `InvalidOperationException: Nullable object must have a value`. Reported by [@calebcwells](https://github.com/calebcwells) in [#1920](https://github.com/bUnit-dev/bUnit/issues/1920). Fixed by [@linkdotnet](https://github.com/linkdotnet).

## [2.10.3] - 2026-09-08

### Fixed
Expand Down
32 changes: 16 additions & 16 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.164" />
<PackageVersion Include="Meziantou.Polyfill" Version="1.0.165" />
</ItemGroup>

<ItemGroup Label="Shared">
Expand All @@ -18,8 +18,8 @@
<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.8.0"/>
<PackageVersion Include="AngleSharp.Css" Version="1.1.0"/>
<PackageVersion Include="AngleSharp" Version="1.8.1"/>
<PackageVersion Include="AngleSharp.Css" Version="1.1.2"/>
</ItemGroup>

<ItemGroup Label="Package Versioning">
Expand Down Expand Up @@ -72,26 +72,26 @@
</ItemGroup>

<ItemGroup Condition="'$(TargetFramework)' == 'net11.0'">
<PackageVersion Include="Microsoft.Extensions.Logging" Version="11.0.0-preview.6.26359.118"/>
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="11.0.0-preview.6.26359.118"/>
<PackageVersion Include="Microsoft.AspNetCore.Components" Version="11.0.0-preview.6.26359.118"/>

<PackageVersion Include="Microsoft.AspNetCore.Components.Authorization" Version="11.0.0-preview.6.26359.118"/>
<PackageVersion Include="Microsoft.Extensions.Localization.Abstractions" Version="11.0.0-preview.6.26359.118"/>
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="11.0.0-preview.6.26359.118"/>
<PackageVersion Include="Microsoft.AspNetCore.Components.Web" Version="11.0.0-preview.6.26359.118"/>
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly" Version="11.0.0-preview.6.26359.118"/>
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="11.0.0-preview.6.26359.118"/>

<PackageVersion Include="System.Text.Json" Version="11.0.0-preview.6.26359.118"/>
<PackageVersion Include="Microsoft.Extensions.Logging" Version="11.0.0-rc.1.26425.128"/>
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="11.0.0-rc.1.26425.128"/>
<PackageVersion Include="Microsoft.AspNetCore.Components" Version="11.0.0-rc.1.26425.128"/>

<PackageVersion Include="Microsoft.AspNetCore.Components.Authorization" Version="11.0.0-rc.1.26425.128"/>
<PackageVersion Include="Microsoft.Extensions.Localization.Abstractions" Version="11.0.0-rc.1.26425.128"/>
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="11.0.0-rc.1.26425.128"/>
<PackageVersion Include="Microsoft.AspNetCore.Components.Web" Version="11.0.0-rc.1.26425.128"/>
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly" Version="11.0.0-rc.1.26425.128"/>
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="11.0.0-rc.1.26425.128"/>

<PackageVersion Include="System.Text.Json" Version="11.0.0-rc.1.26425.128"/>
</ItemGroup>

<ItemGroup Label="Test Dependencies">
<PackageVersion Include="AutoFixture" Version="4.18.1"/>
<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.9.0"/>
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.10.0"/>
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="NSubstitute" Version="6.2.0" />
<PackageVersion Include="NUnit3TestAdapter" Version="5.0.0" />
Expand Down
1 change: 1 addition & 0 deletions bunit.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<File Path=".config/dotnet-tools.json" />
<File Path=".editorconfig" />
<File Path=".gitattributes" />
<File Path=".globalconfig" />
<File Path=".gitignore" />
<File Path="Directory.Build.props" />
<File Path="Directory.Packages.props" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
using Microsoft.AspNetCore.Components.Web.Virtualization;
using System.Diagnostics;
using System.Reflection;
using Microsoft.AspNetCore.Components.Web.Virtualization;

namespace Bunit.JSInterop.InvocationHandlers.Implementation;

Expand Down Expand Up @@ -35,10 +35,15 @@ internal VirtualizeJSRuntimeInvocationHandler()
/// <inheritdoc/>
protected internal override Task<Microsoft.JSInterop.Infrastructure.IJSVoidResult> HandleAsync(JSRuntimeInvocation invocation)
{
if (!invocation.Identifier.Equals(JsFunctionsPrefix + "dispose", StringComparison.Ordinal))
if (!invocation.Identifier.Equals(JsFunctionsPrefix + "dispose", StringComparison.Ordinal) &&
!invocation.Identifier.Equals(JsFunctionsPrefix + "refreshObservers", StringComparison.Ordinal))
{
Debug.Assert(invocation.Identifier.Equals(JsFunctionsPrefix + "init", StringComparison.Ordinal));
#if NET11_0_OR_GREATER
Debug.Assert(invocation.Arguments.Count == 4);
#else
Debug.Assert(invocation.Arguments.Count == 3);
#endif
Debug.Assert(invocation.Arguments[0] is not null);

InvokeOnSpacerBeforeVisible(invocation.Arguments[0]!);
Expand All @@ -58,7 +63,12 @@ private static void InvokeOnSpacerBeforeVisible(object dotNetObjectReference)
0f, /* spacerSize */
0f, /* spacerSeparation */
1_000_000_000f, /* containerSize - very large number to ensure all items are loaded at once */
#if NET11_0_OR_GREATER
0, /* UserScroll <see cref="SpacerVisibilityReason" /> */
#endif

};

onSpacerBeforeVisibleMethodInfo.Invoke(virtualizeJsInterop, parameters);
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
using System.Collections.Concurrent;

namespace Bunit;

// Invocation tracking mirrors ASP.NET Core's JSRuntime: no per-invocation state lives in instance
// fields. Each call gets its own TaskCompletionSource in a ConcurrentDictionary keyed by an
// Interlocked id, and the timeout closes over that entry alone, so an elapsing timeout can never
// race a concurrently set result. See https://github.com/dotnet/aspnetcore/blob/main/src/JSInterop/Microsoft.JSInterop/src/JSRuntime.cs
/// <summary>
/// Represents an invocation handler for <see cref="JSRuntimeInvocation"/> instances.
/// </summary>
public abstract class JSRuntimeInvocationHandlerBase<TResult> : IDisposable
{
private readonly InvocationMatcher invocationMatcher;
private TaskCompletionSource<TResult> completionSource;
private Timer? timeoutTimer;
private JSRuntimeInvocation? currentInvocation;
private readonly ConcurrentDictionary<long, PendingInvocation> pendingInvocations = new();
private long nextInvocationId;
private Task<TResult>? outcome;
private bool disposed;

/// <summary>
Expand All @@ -34,48 +40,29 @@ public abstract class JSRuntimeInvocationHandlerBase<TResult> : IDisposable
protected JSRuntimeInvocationHandlerBase(InvocationMatcher matcher, bool isCatchAllHandler)
{
invocationMatcher = matcher ?? throw new ArgumentNullException(nameof(matcher));
completionSource = new TaskCompletionSource<TResult>(TaskCreationOptions.RunContinuationsAsynchronously);
IsCatchAllHandler = isCatchAllHandler;
}

/// <summary>
/// Marks the <see cref="Task{TResult}"/> that invocations will receive as canceled.
/// </summary>
protected void SetCanceledBase()
{
ClearTimeoutTimer();
if (completionSource.Task.IsCompleted)
completionSource = new TaskCompletionSource<TResult>(TaskCreationOptions.RunContinuationsAsynchronously);

completionSource.SetCanceled();
}
=> CompleteAll(Task.FromCanceled<TResult>(new CancellationToken(canceled: true)));

/// <summary>
/// Sets the <typeparamref name="TException"/> exception that invocations will receive.
/// </summary>
/// <param name="exception">The type of exception to pass to the callers.</param>
protected void SetExceptionBase<TException>(TException exception)
where TException : Exception
{
ClearTimeoutTimer();
if (completionSource.Task.IsCompleted)
completionSource = new TaskCompletionSource<TResult>(TaskCreationOptions.RunContinuationsAsynchronously);

completionSource.SetException(exception);
}
=> CompleteAll(Task.FromException<TResult>(exception));

/// <summary>
/// Sets the <typeparamref name="TResult"/> result that invocations will receive.
/// </summary>
/// <param name="result">The type of result to pass to the callers.</param>
protected void SetResultBase(TResult result)
{
ClearTimeoutTimer();
if (completionSource.Task.IsCompleted)
completionSource = new TaskCompletionSource<TResult>(TaskCreationOptions.RunContinuationsAsynchronously);

completionSource.SetResult(result);
}
=> CompleteAll(Task.FromResult(result));

/// <summary>
/// Call this to have the this handler handle the <paramref name="invocation"/>.
Expand All @@ -89,18 +76,29 @@ protected internal virtual Task<TResult> HandleAsync(JSRuntimeInvocation invocat
{
Invocations.RegisterInvocation(invocation);

var task = completionSource.Task;
if (task is { IsCanceled: false, IsFaulted: false, IsCompletedSuccessfully: false })
if (Volatile.Read(ref outcome) is { } configured)
return configured;

var timeout = BunitContext.DefaultWaitTimeout;
if (timeout <= TimeSpan.Zero)
{
if (BunitContext.DefaultWaitTimeout <= TimeSpan.Zero)
{
throw new JSRuntimeInvocationNotSetException(invocation);
}
throw new JSRuntimeInvocationNotSetException(invocation);
}

var id = Interlocked.Increment(ref nextInvocationId);
var pending = new PendingInvocation(id, invocation);
pendingInvocations[id] = pending;

StartTimeoutTimer(invocation);
if (Volatile.Read(ref outcome) is { } raced && pendingInvocations.TryRemove(id, out _))
{
Transfer(raced, pending.CompletionSource);
}
else
{
pending.StartTimeout(OnTimeoutElapsed, timeout);
}

return task;
return pending.CompletionSource.Task;
}

/// <summary>
Expand All @@ -122,34 +120,71 @@ protected virtual void Dispose(bool disposing)
{
if (!disposed && disposing)
{
ClearTimeoutTimer();
foreach (var id in pendingInvocations.Keys)
{
if (pendingInvocations.TryRemove(id, out var pending))
pending.Dispose();
}

disposed = true;
}
}

private void StartTimeoutTimer(JSRuntimeInvocation invocation)
private void CompleteAll(Task<TResult> next)
{
ClearTimeoutTimer();
Volatile.Write(ref outcome, next);

currentInvocation = invocation;
timeoutTimer = new Timer(OnTimeoutElapsed, null, BunitContext.DefaultWaitTimeout, Timeout.InfiniteTimeSpan);
foreach (var id in pendingInvocations.Keys)
{
if (pendingInvocations.TryRemove(id, out var pending))
{
pending.Dispose();
Transfer(next, pending.CompletionSource);
}
}
}

private void ClearTimeoutTimer()
private void OnTimeoutElapsed(object? state)
{
timeoutTimer?.Dispose();
timeoutTimer = null;
currentInvocation = null;
if (state is not PendingInvocation pending || !pendingInvocations.TryRemove(pending.Id, out _))
return;

pending.Dispose();
pending.CompletionSource.TrySetException(new JSRuntimeInvocationNotSetException(pending.Invocation));
}

private void OnTimeoutElapsed(object? state)
private static void Transfer(Task<TResult> from, TaskCompletionSource<TResult> to)
{
if (!completionSource.Task.IsCompleted && currentInvocation.HasValue)
if (from.IsCanceled)
to.TrySetCanceled();
else if (from.Exception is { } exception)
to.TrySetException(exception.InnerExceptions);
else
to.TrySetResult(from.Result);
}

private sealed class PendingInvocation : IDisposable
{
private Timer? timeoutTimer;

public long Id { get; }

public JSRuntimeInvocation Invocation { get; }

public TaskCompletionSource<TResult> CompletionSource { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);

public PendingInvocation(long id, JSRuntimeInvocation invocation)
{
Id = id;
Invocation = invocation;
}

public void StartTimeout(TimerCallback callback, TimeSpan timeout)
{
var exception = new JSRuntimeInvocationNotSetException(currentInvocation.Value);
completionSource.TrySetException(exception);
timeoutTimer = new Timer(callback, this, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
timeoutTimer.Change(timeout, Timeout.InfiniteTimeSpan);
}

ClearTimeoutTimer();
public void Dispose() => timeoutTimer?.Dispose();
}
}
4 changes: 2 additions & 2 deletions tests/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@
<SuppressTfmSupportBuildWarnings>true</SuppressTfmSupportBuildWarnings>
<OutputType>Exe</OutputType>
</PropertyGroup>

<PropertyGroup>
<!--
<!--
Because of a CVE in System.Text.Json we get NU1903.
As this is a test project, we can ignore this warning for the time being.
-->
Expand Down
Loading
Loading