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
15 changes: 8 additions & 7 deletions .github/workflows/platform-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,19 @@ jobs:
- platform: Android
os: ubuntu-latest
workload: android
# The .NET 11 Apple workloads (27.x) require Xcode 27, which only the xcode-27 image has.
- platform: iOS
os: macos-26
os: xcode-27
workload: ios
device: iPhone 17
simulator: iOS-26-5
simulator: iOS-27-0
- platform: tvOS
os: macos-26
os: xcode-27
workload: tvos
device: Apple TV
simulator: tvOS-26-5
device: Apple TV 4K (3rd generation)
simulator: tvOS-27-0
- platform: MacCatalyst
os: macos-26
os: xcode-27
workload: maccatalyst
- platform: WASI
os: ubuntu-latest
Expand All @@ -59,7 +60,7 @@ jobs:
restore-keys: platform-smoke-${{ runner.os }}-${{ runner.arch }}-${{ matrix.platform }}-
- name: Select the installed Xcode
if: runner.os == 'macOS'
run: sudo xcode-select --switch /Applications/Xcode_26.6.app
run: sudo xcode-select --switch /Applications/Xcode_27.0.app
- name: Start the installed Apple simulator
if: matrix.platform == 'iOS' || matrix.platform == 'tvOS'
run: |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,18 @@ await Assert.That(StartupCalledAt).IsNotNull()
[DisplayName("Server startup time is recorded accurately")]
public async Task Server_Startup_Time_Is_Recorded()
{
// Add small buffer to account for DateTime precision differences
var beforeCall = DateTime.UtcNow.AddMilliseconds(-500);

// The Before(Test) hook that builds Factory starts the server before this body runs,
// so a timestamp taken here can be later than startup. SetupAsync runs before that build.
_ = Factory.CreateClient();

var afterCall = DateTime.UtcNow.AddMilliseconds(500);
var afterCall = DateTime.UtcNow;

await Assert.That(SetupCalledAt).IsNotNull();
await Assert.That(StartupCalledAt).IsNotNull();
await Assert.That(StartupCalledAt!.Value).IsGreaterThanOrEqualTo(beforeCall)
.Because("Server startup should occur around when we call CreateClient");
await Assert.That(StartupCalledAt!.Value).IsGreaterThanOrEqualTo(SetupCalledAt!.Value)
.Because("Server startup happens after SetupAsync, when the factory is built");
await Assert.That(StartupCalledAt!.Value).IsLessThanOrEqualTo(afterCall)
.Because("Server startup should complete around when CreateClient returns");
.Because("Server startup should complete before CreateClient returns");
}

[Test]
Expand Down
33 changes: 26 additions & 7 deletions tests/TUnit.AspNetCore.Tests/CreateFactoryAsyncTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,8 @@ public async Task Concurrent_Calls_Track_Every_Factory()
/// </summary>
/// <remarks>
/// Uses its own factory type, so it gets its own session-shared global factory. That factory disposes
/// its derived factories again at session end; the failing hosted service only fails its first stop, and
/// its derived factories again at session end, so the failing hosted service stops failing once the test
/// has seen its failure.
/// <see cref="TestWebApplicationFactory{TEntryPoint}.SuppressHostedServiceExecutionContextFlow"/> is off
/// because its wrapper replays the first stop result, which would fail that second disposal too.
/// </remarks>
Expand All @@ -142,14 +143,23 @@ public class PerTestWebApplicationTestDisposalFailureTests : PerTestWebApplicati
[Test]
public async Task DisposeFactoryAsync_Disposes_Remaining_Factories_When_One_Throws()
{
var failing = await CreateFactoryAsync(builder =>
builder.ConfigureTestServices(services => services.AddHostedService<ThrowingOnStopService>()));
var stopFailure = new ThrowingOnStopService();
await CreateFactoryAsync(builder =>
builder.ConfigureTestServices(services => services.AddHostedService(_ => stopFailure)));
var healthy = await CreateFactoryAsync(builder =>
builder.ConfigureTestServices(services => services.AddSingleton<DisposalProbe>()));

var probe = healthy.Services.GetRequiredService<DisposalProbe>();

await Assert.That(DisposeFactoryAsync).Throws<Exception>().WithMessageContaining(ThrowingOnStopService.Message);
try
{
await Assert.That(DisposeFactoryAsync).Throws<Exception>().WithMessageContaining(ThrowingOnStopService.Message);
}
finally
{
stopFailure.StopFailing();
}

await Assert.That(probe.IsDisposed).IsTrue();
}
}
Expand Down Expand Up @@ -217,18 +227,27 @@ public sealed class DisposalProbe : IDisposable
}

/// <summary>
/// Fails the first stop only, so the global factory's second disposal at session end succeeds.
/// Fails every stop until <see cref="StopFailing"/> is called, so the global factory's second
/// disposal at session end succeeds.
/// </summary>
/// <remarks>
/// It must not fail only the first stop. A minimal-hosting app stops its host twice, concurrently:
/// once from <c>WebApplicationFactory.DisposeAsync</c> and once from its own <c>app.Run()</c>,
/// which wakes on <c>ApplicationStopping</c>. When <c>app.Run()</c> made the first call, the
/// disposal saw a clean stop and the test failed.
/// </remarks>
public sealed class ThrowingOnStopService : Microsoft.Extensions.Hosting.IHostedService
{
public const string Message = "Stop failed on purpose.";

private int _stopCalls;
private volatile bool _failStops = true;

public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;

public Task StopAsync(CancellationToken cancellationToken) =>
Interlocked.Increment(ref _stopCalls) == 1
_failStops
? throw new InvalidOperationException(Message)
: Task.CompletedTask;

public void StopFailing() => _failStops = false;
}
9 changes: 8 additions & 1 deletion tests/TUnit.Engine.Tests/HtmlReporterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ namespace TUnit.Engine.Tests;

public class HtmlReporterTests
{
// A reporter's activity collector listens process-wide, so it also records "test session"
// spans that other tests emit, and BuildReportData then reports that span's duration as the
// run duration. Tests that emit a session span or assert the run duration share this key.
private const string SessionSpanKey = "HtmlReporterSessionSpan";

[Test]
public void CommandProvider_DefaultMode_Exposes_Legacy_Options()
{
Expand Down Expand Up @@ -95,6 +100,7 @@ public void HtmlReporter_DataTypesProduced_Contains_SessionFileArtifact()
}

[Test]
[NotInParallel(SessionSpanKey)]
public async Task Stopping_Activity_Collection_Preserves_Spans_For_Report_Data(CancellationToken cancellationToken)
{
using var reporter = new HtmlReporter(new MockExtension());
Expand Down Expand Up @@ -575,12 +581,13 @@ public async Task BuildReportData_Reconstructs_Attempts_From_RetryAttemptsProper
}

[Test]
[NotInParallel(SessionSpanKey)]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
public async Task BuildReportData_Ignores_Default_Start_Timestamps()
{
// #6942: tests that never started (e.g. skipped before execution) carry a default
// TimingInfo whose StartTime is 0001-01-01. Neither the run duration nor the per-test
// StartTime may be derived from it, or the report shows ~17,757,303 hours.
var reporter = new HtmlReporter(new MockExtension());
using var reporter = new HtmlReporter(new MockExtension());

var start = DateTimeOffset.UtcNow;
var ranNode = CreateTimedNode("ran-1", "RanTest", PassedTestNodeStateProperty.CachedInstance,
Expand Down
14 changes: 6 additions & 8 deletions tests/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,9 @@ public async Task Receiver_Forwarding_PropagatesHeadersAndCountsSuccess()
// Mock upstream stands in for the Aspire dashboard — gates on the api-key header
// so we can prove the receiver actually propagated it instead of just dropping body
// bytes onto a permissive endpoint.
using var upstreamListener = new HttpListener();
var upstreamPort = LoopbackHttpListenerFactory.FindFreePort();
upstreamListener.Prefixes.Add($"http://127.0.0.1:{upstreamPort}/");
upstreamListener.Start();
// Create retries when another listener takes the probed port before this one binds it.
var (upstreamListener, upstreamPort) = LoopbackHttpListenerFactory.Create();
using var upstreamListenerScope = upstreamListener;

var receivedAuth = new TaskCompletionSource<string?>(TaskCreationOptions.RunContinuationsAsynchronously);
var listenerTask = Task.Run(async () =>
Expand Down Expand Up @@ -196,10 +195,9 @@ public async Task Receiver_Forwarding_PropagatesHeadersAndCountsSuccess()
[Test]
public async Task Receiver_DrainAsync_WaitsForInFlightForwardBeforeReturning()
{
using var upstreamListener = new HttpListener();
var upstreamPort = LoopbackHttpListenerFactory.FindFreePort();
upstreamListener.Prefixes.Add($"http://127.0.0.1:{upstreamPort}/");
upstreamListener.Start();
// Create retries when another listener takes the probed port before this one binds it.
var (upstreamListener, upstreamPort) = LoopbackHttpListenerFactory.Create();
using var upstreamListenerScope = upstreamListener;

var upstreamReceived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseUpstream = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
Expand Down
78 changes: 62 additions & 16 deletions tests/TUnit.UnitTests/ParallelMapTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Threading.Tasks.Sources;
using TUnit.Engine.Utilities;

namespace TUnit.UnitTests;
Expand Down Expand Up @@ -107,29 +108,48 @@ public async Task SelectorException_PropagatesToCaller()
[Test]
public async Task SelectorException_StopsSiblingsPullingNewItems()
{
// Every worker takes one item and suspends on it, so the test decides the order:
// first item 0 faults, then the siblings finish. Letting the items race instead
// made this flaky: a worker preempted between the fault and parking the cursor
// let its siblings process all items.
const int count = 10_000;
const int workers = 4;
var invoked = 0;
var allWorkersBusy = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var siblingsGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var faultingItem = new ObservableValueTaskSource<int>();

await Assert.ThrowsAsync<InvalidOperationException>(() =>
ParallelMap.ForParallelAsync(
count,
async i =>
var run = ParallelMap.ForParallelValueAsync(
count,
i =>
{
if (Interlocked.Increment(ref invoked) == workers)
{
Interlocked.Increment(ref invoked);
allWorkersBusy.SetResult();
}

if (i == 0)
{
throw new InvalidOperationException("fail fast");
}
return i == 0 ? faultingItem.ValueTask : new ValueTask<int>(WaitForGateAsync(i));
},
workers);

await Task.Yield();
return i;
},
Environment.ProcessorCount));
await allWorkersBusy.Task;
await faultingItem.Awaited;

// The parked cursor must prevent the full list from being processed.
// In-flight items may still complete, so allow generous slack.
await Assert.That(invoked).IsLessThan(count);
// The source runs continuations synchronously, so the worker's catch block has
// parked the cursor by the time SetException returns.
faultingItem.SetException(new InvalidOperationException("fail fast"));
siblingsGate.SetResult();

await Assert.ThrowsAsync<InvalidOperationException>(() => run);

// Only the items taken before the fault ran; the parked cursor stopped the rest.
await Assert.That(invoked).IsEqualTo(workers);

async Task<int> WaitForGateAsync(int i)
{
await siblingsGate.Task;
return i;
}
}

[Test]
Expand Down Expand Up @@ -192,4 +212,30 @@ private static void InterlockedMax(ref int location, int value)
}
} while (Interlocked.CompareExchange(ref location, value, snapshot) != snapshot);
}

/// <summary>
/// A value-task source that reports when its awaiter suspends on it and, once completed,
/// runs that awaiter's continuation synchronously on the completing thread.
/// </summary>
private sealed class ObservableValueTaskSource<T> : IValueTaskSource<T>
{
private readonly TaskCompletionSource _awaited = new(TaskCreationOptions.RunContinuationsAsynchronously);
private ManualResetValueTaskSourceCore<T> _core;

public ValueTask<T> ValueTask => new(this, _core.Version);

public Task Awaited => _awaited.Task;

public void SetException(Exception exception) => _core.SetException(exception);

public T GetResult(short token) => _core.GetResult(token);

public ValueTaskSourceStatus GetStatus(short token) => _core.GetStatus(token);

public void OnCompleted(Action<object?> continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags)
{
_core.OnCompleted(continuation, state, token, flags);
_awaited.TrySetResult();
}
}
}
Loading