Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
9 changes: 9 additions & 0 deletions TUnit.Core/Interfaces/ITestExecution.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ public interface ITestExecution
/// </summary>
int CurrentRetryAttempt { get; internal set; }

/// <summary>
/// Gets the history of prior execution attempts that triggered a retry, in attempt order.
/// Empty when the test was not retried. The final (surviving) attempt is reflected by
/// <see cref="Result"/> and is not included here — so for a test that ran 3 times,
/// <c>RetryAttempts.Count</c> is 2 (the two failed prior attempts) and
/// <see cref="CurrentRetryAttempt"/> is 2 (the zero-based index of the surviving attempt).
/// </summary>
IReadOnlyList<RetryAttemptRecord> RetryAttempts { get; }

/// <summary>
/// Gets the reason why this test was skipped, or null if not skipped.
/// </summary>
Expand Down
35 changes: 35 additions & 0 deletions TUnit.Core/RetryAttemptRecord.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
namespace TUnit.Core;

/// <summary>
/// Records the outcome of a single test execution attempt when a test is retried.
/// Exposed via <see cref="Interfaces.ITestExecution.RetryAttempts"/> so reporters and user
/// code can show retry/flaky history (e.g. the HTML report's attempt timeline).
/// </summary>
public readonly record struct RetryAttemptRecord
{
/// <summary>
/// The final state this attempt reached (typically <see cref="TestState.Failed"/> or
/// <see cref="TestState.Timeout"/>, since only attempts that triggered a retry are recorded).
/// </summary>
public required TestState State { get; init; }

/// <summary>
/// How long this attempt took to execute.
/// </summary>
public required TimeSpan Duration { get; init; }

/// <summary>
/// The full type name of the exception that ended this attempt, or null if none.
/// </summary>
public string? ExceptionType { get; init; }

/// <summary>
/// The message of the exception that ended this attempt, or null if none.
/// </summary>
public string? ExceptionMessage { get; init; }

/// <summary>
/// The stack trace of the exception that ended this attempt, or null if none.
/// </summary>
public string? ExceptionStackTrace { get; init; }
}
6 changes: 6 additions & 0 deletions TUnit.Core/TestContext.Execution.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ public partial class TestContext
internal DateTimeOffset? TestStart { get; set; }
internal DateTimeOffset? TestEnd { get; set; }
internal int CurrentRetryAttempt { get; set; }
// Lazily allocated; stays null for the common no-retry case so passing tests pay nothing.
internal List<RetryAttemptRecord>? RetryAttempts { get; set; }
internal Func<TestContext, Exception, int, Task<bool>>? RetryFunc { get; set; }
internal IHookExecutor? CustomHookExecutor { get; set; }
internal bool ReportResult { get; set; } = true;
Expand Down Expand Up @@ -51,6 +53,10 @@ int ITestExecution.CurrentRetryAttempt
set => CurrentRetryAttempt = value;
}

// Array.Empty for the common no-retry path so passing tests allocate nothing.
IReadOnlyList<RetryAttemptRecord> ITestExecution.RetryAttempts
=> (IReadOnlyList<RetryAttemptRecord>?)RetryAttempts ?? Array.Empty<RetryAttemptRecord>();

string? ITestExecution.SkipReason => SkipReason;
Func<TestContext, Exception, int, Task<bool>>? ITestExecution.RetryFunc => RetryFunc;
IHookExecutor? ITestExecution.CustomHookExecutor
Expand Down
47 changes: 47 additions & 0 deletions TUnit.Engine.Tests/HtmlReporterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,53 @@ public void GenerateHtml_EmitsAttemptsArray_WhenTestWasRetried()
embedded.ShouldContain("System.TimeoutException");
}

[Test]
public async Task BuildReportData_Reconstructs_Attempts_From_RetryAttemptsProperty()
{
// #6119: the engine emits only one update per test (the final result), so the reporter
// must rebuild the per-attempt history from TUnitRetryAttemptsProperty carried on the
// final node — failed attempts stitched in front of the surviving final attempt — rather
// than from the (single) update stream. Without this the flaky/retry UI stays empty.
var reporter = new HtmlReporter(new MockExtension());

var start = DateTimeOffset.UtcNow;
var finalNode = new TestNode
{
Uid = new TestNodeUid("flaky-1"),
DisplayName = "FlakyTest",
Properties = new PropertyBag(
PassedTestNodeStateProperty.CachedInstance,
new TestMethodIdentifierProperty(
@namespace: "Sample",
assemblyFullName: "TestAssembly",
typeName: "FlakyTests",
methodName: "FlakyTest",
parameterTypeFullNames: [],
returnTypeFullName: "System.Void",
methodArity: 0),
new TimingProperty(new TimingInfo(start, start.AddMilliseconds(200), TimeSpan.FromMilliseconds(200))),
new TUnitRetryAttemptsProperty(
[
new RetryAttemptRecord { State = TestState.Failed, Duration = TimeSpan.FromMilliseconds(100), ExceptionType = "System.TimeoutException", ExceptionMessage = "transient 1" },
new RetryAttemptRecord { State = TestState.Failed, Duration = TimeSpan.FromMilliseconds(150), ExceptionType = "System.TimeoutException", ExceptionMessage = "transient 2" },
]))
};

await reporter.ConsumeAsync(reporter, new TestNodeUpdateMessage(new SessionUid("s"), finalNode), CancellationToken.None);

var data = reporter.BuildReportData();

var test = data.Groups.SelectMany(g => g.Tests).Single(t => t.Id == "flaky-1");
test.Attempts.ShouldNotBeNull();
test.Attempts!.Length.ShouldBe(3); // 2 failed attempts + the surviving final pass
test.Attempts[0].Status.ShouldBe("failed");
test.Attempts[0].ExceptionType.ShouldBe("System.TimeoutException");
test.Attempts[2].Status.ShouldBe("passed");
test.RetryAttempt.ShouldBe(2);
test.Status.ShouldBe("passed");
data.Summary.Flaky.ShouldBe(1); // passed-after-retry is flaky
}

[Test]
public void FilterEngineNotices_StripsTUnitPrefixedLines()
{
Expand Down
34 changes: 34 additions & 0 deletions TUnit.Engine.Tests/TestNodeLocationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Shouldly;
using TUnit.Core;
using TUnit.Engine.Extensions;
using TUnit.Engine.Reporters;

namespace TUnit.Engine.Tests;

Expand Down Expand Up @@ -60,6 +61,39 @@ public void ToTestNode_Falls_Back_To_Start_Line_When_End_Line_Is_Unavailable()
location.LineSpan.End.Column.ShouldBe(0);
}

[Test]
public void ToTestNode_Attaches_RetryAttempts_On_Final_State_Only()
{
// #6119: failed retry attempts are captured on the TestContext during execution. They
// must ride along on the final node so the HTML report can rebuild the attempt history;
// intermediate (Discovered/InProgress) updates carry no final result, so nothing attaches.
TestExtensions.ClearCaches();

var context = CreateTestContext(
testId: Guid.NewGuid().ToString("N"),
filePath: @"C:\tests\SampleTests.cs",
lineNumber: 12,
startColumnNumber: 5,
endLineNumber: 16,
endColumnNumber: 6);

context.RetryAttempts =
[
new RetryAttemptRecord { State = TestState.Failed, Duration = TimeSpan.FromMilliseconds(50), ExceptionType = "System.Exception", ExceptionMessage = "boom" },
];

// Final state -> attached.
var finalNode = context.ToTestNode(PassedTestNodeStateProperty.CachedInstance);
var attached = finalNode.Properties.AsEnumerable().OfType<TUnitRetryAttemptsProperty>().SingleOrDefault();
attached.ShouldNotBeNull();
attached!.Attempts.Count.ShouldBe(1);
attached.Attempts[0].State.ShouldBe(TestState.Failed);

// Discovered/in-progress state -> not attached.
var discoveredNode = context.ToTestNode(DiscoveredTestNodeStateProperty.CachedInstance);
discoveredNode.Properties.AsEnumerable().OfType<TUnitRetryAttemptsProperty>().ShouldBeEmpty();
}

private static TestContext CreateTestContext(
string testId,
string filePath,
Expand Down
10 changes: 10 additions & 0 deletions TUnit.Engine/Extensions/TestExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using TUnit.Core;
using TUnit.Core.Extensions;
using TUnit.Engine.Capabilities;
using TUnit.Engine.Reporters;
#pragma warning disable TPEXP

namespace TUnit.Engine.Extensions;
Expand Down Expand Up @@ -208,6 +209,15 @@ internal static TestNode ToTestNode(this TestContext testContext, TestNodeStateP
propertyBag.Add(GetTimingProperty(testContext, testContext.Execution.TestStart.GetValueOrDefault()));
}

// Carry failed-retry history to reporters. Only the final update is emitted per test, so
// this is the one chance to surface the per-attempt list captured during execution.
if (isFinalState && testContext.RetryAttempts is { Count: > 0 } retryAttempts)
{
// Defensive copy: the live List<RetryAttemptRecord> on the TestContext could otherwise
// be mutated after the property is published.
propertyBag.Add(new TUnitRetryAttemptsProperty([.. retryAttempts]));
}

var testNode = new TestNode
{
Uid = new TestNodeUid(testDetails.TestId),
Expand Down
3 changes: 3 additions & 0 deletions TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,9 @@ internal sealed class ReportAttempt

[JsonPropertyName("exceptionMessage")]
public string? ExceptionMessage { get; init; }

[JsonPropertyName("stackTrace")]
public string? StackTrace { get; init; }
}

internal sealed class ReportExceptionData
Expand Down
73 changes: 47 additions & 26 deletions TUnit.Engine/Reporters/Html/HtmlReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ internal void SetResultsDirectory(string path)
_resultsDirectory = path;
}

private ReportData BuildReportData()
internal ReportData BuildReportData()
{
var assemblyName = Assembly.GetEntryAssembly()?.GetName().Name ?? "TestResults";
var tunitVersion = typeof(HtmlReporter).Assembly.GetName().Version?.ToString() ?? "unknown";
Expand Down Expand Up @@ -253,41 +253,48 @@ private ReportData BuildReportData()
spanId = spanInfo.SpanId;
}

// Walk all updates in order so we capture each retry attempt's status and
// timing — not just the count. The renderer's flaky/retry UI needs the
// per-attempt list, and we have all of it sitting on the update messages.
// Build the per-attempt history for the flaky/retry UI. The engine emits only one
// update per test (the final result), so we cannot reconstruct attempts from the
// update stream. Instead, failed attempts that triggered a retry are captured during
// execution and carried here on the final node via TUnitRetryAttemptsProperty; the
// final attempt is the node's own state. We stitch the two together in order.
ReportAttempt[]? attempts = null;
var retryAttempt = 0;
if (_updates.TryGetValue(kvp.Key, out var allUpdates))
var priorAttempts = testNode.Properties.AsEnumerable()
.OfType<TUnitRetryAttemptsProperty>()
.FirstOrDefault();
if (priorAttempts is { Attempts.Count: > 0 })
{
List<ReportAttempt>? attemptList = null;
foreach (var update in allUpdates)
var finalState = testNode.Properties.SingleOrDefault<TestNodeStateProperty>();
var (finalStatus, finalException, _) = ExtractStatus(finalState);
var finalDuration = testNode.Properties.AsEnumerable()
.OfType<TimingProperty>()
.FirstOrDefault()?.GlobalTiming.Duration.TotalMilliseconds ?? 0;

var attemptList = new List<ReportAttempt>(priorAttempts.Attempts.Count + 1);
foreach (var prior in priorAttempts.Attempts)
{
var state = update.TestNode.Properties.SingleOrDefault<TestNodeStateProperty>();
if (state is null or InProgressTestNodeStateProperty or DiscoveredTestNodeStateProperty)
{
continue;
}

var (attemptStatus, attemptException, _) = ExtractStatus(state);
var attemptDuration = update.TestNode.Properties.AsEnumerable()
.OfType<TimingProperty>()
.FirstOrDefault()?.GlobalTiming.Duration.TotalMilliseconds ?? 0;
attemptList ??= new List<ReportAttempt>();
attemptList.Add(new ReportAttempt
{
Status = attemptStatus,
DurationMs = attemptDuration,
ExceptionType = attemptException?.Type,
ExceptionMessage = attemptException?.Message,
Status = StatusFromState(prior.State),
DurationMs = prior.Duration.TotalMilliseconds,
ExceptionType = prior.ExceptionType,
ExceptionMessage = prior.ExceptionMessage,
StackTrace = prior.ExceptionStackTrace,
});
}

if (attemptList is { Count: > 1 })
attemptList.Add(new ReportAttempt
{
retryAttempt = attemptList.Count - 1;
attempts = attemptList.ToArray();
}
Status = finalStatus,
DurationMs = finalDuration,
ExceptionType = finalException?.Type,
ExceptionMessage = finalException?.Message,
StackTrace = finalException?.StackTrace,
});

retryAttempt = attemptList.Count - 1;
attempts = attemptList.ToArray();
}

#if NET
Expand Down Expand Up @@ -600,6 +607,20 @@ private static (string Status, ReportExceptionData? Exception, string? SkipReaso
};
}

// Maps a captured retry attempt's TestState to the same status vocabulary ExtractStatus
// produces for the final node, so prior and final attempts render consistently. A retried
// attempt is always a failure of some kind; Failed maps to "failed" (HtmlReportGenerator's
// MapStatus collapses "failed"/"error"/"timedOut" to "fail" for the UI).
private static string StatusFromState(TestState state) => state switch
{
TestState.Passed => "passed",
TestState.Failed => "failed",
TestState.Timeout => "timedOut",
TestState.Skipped => "skipped",
TestState.Cancelled => "cancelled",
_ => "error",
};

private static ReportExceptionData? MapException(Exception? ex)
{
if (ex is null)
Expand Down
15 changes: 15 additions & 0 deletions TUnit.Engine/Reporters/TUnitRetryAttemptsProperty.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Microsoft.Testing.Platform.Extensions.Messages;
using TUnit.Core;

#pragma warning disable TPEXP

namespace TUnit.Engine.Reporters;

/// <summary>
/// Carries the history of failed retry attempts on the final <see cref="TestNode"/> update so
/// reporters (the HTML report) can render retry/flaky information. The engine emits only one
/// update per test (the final result), so without this property the per-attempt history — which
/// lives transiently on the <see cref="TestContext"/> during execution — would be unavailable
/// to consumers that work purely off <c>TestNodeUpdateMessage</c>.
/// </summary>
internal sealed record TUnitRetryAttemptsProperty(IReadOnlyList<RetryAttemptRecord> Attempts) : IProperty;
18 changes: 18 additions & 0 deletions TUnit.Engine/Services/TestExecution/RetryHelper.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using TUnit.Core;
using TUnit.Engine.Exceptions;

namespace TUnit.Engine.Services.TestExecution;

Expand Down Expand Up @@ -45,6 +46,23 @@ public static async Task ExecuteWithRetry(TestContext testContext, Func<ValueTas
// Apply backoff delay before retrying
await ApplyBackoffDelay(testContext, attempt).ConfigureAwait(false);

// Record this failed attempt before it's cleared, so reporters (e.g. the HTML
// report's retry/flaky UI) can show the full attempt history. The engine only
// emits one update per test (the final result), so without this the per-attempt
// data would be lost.
var failedResult = testContext.Execution.Result;
// Unwrap TUnit wrapper exceptions so the captured type/message/stack trace match
// what the final attempt records via HtmlReporter.MapException (which also unwraps).
var capturedException = TUnitFailedException.Unwrap(ex);
(testContext.RetryAttempts ??= []).Add(new RetryAttemptRecord
{
State = failedResult?.State ?? TestState.Failed,
Duration = failedResult?.Duration ?? TimeSpan.Zero,
ExceptionType = capturedException.GetType().FullName,
ExceptionMessage = capturedException.Message,
ExceptionStackTrace = capturedException.StackTrace,
});

// Clear the previous result before retrying
testContext.Execution.Result = null;
testContext.TestStart = null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1227,6 +1227,13 @@ namespace
public bool ProceedOnFailure { get; }
public required .AbstractExecutableTest Test { get; init; }
}
public readonly struct RetryAttemptRecord : <.RetryAttemptRecord>
{
public required Duration { get; init; }
public string? ExceptionMessage { get; init; }
public string? ExceptionType { get; init; }
public required .TestState State { get; init; }
}
[(.Assembly | .Class | .Method)]
public class RetryAttribute : .TUnitAttribute, .IScopedAttribute, ., .
{
Expand Down Expand Up @@ -2635,6 +2642,7 @@ namespace .Interfaces
.TestPhase Phase { get; }
bool ReportResult { get; set; }
.TestResult? Result { get; }
.<.RetryAttemptRecord> RetryAttempts { get; }
<.TestContext, , int, .<bool>>? RetryFunc { get; }
string? SkipReason { get; }
? TestEnd { get; }
Expand Down
Loading
Loading