From 01989694899217f0212c718fa4efb6ea074aa793 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 30 May 2026 00:04:41 +0100 Subject: [PATCH 01/13] fix: populate retry/flaky attempt history in HTML report (#6119) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retried tests showed as ordinary passes in the HTML report — no retry badge, empty flaky panel, no attempt timeline. Root cause: RetryHelper discarded each failed attempt (Result/timings cleared) before reporting, and the engine emits only one TestNodeUpdateMessage per test (the final result). The HtmlReporter tried to rebuild attempts by walking the update stream, but that stream never contained more than one final state, so the attempts array was always null. Capture each failed attempt on TestContext before it is cleared, expose it publicly via ITestExecution.RetryAttempts, carry it to the reporter on the final node via TUnitRetryAttemptsProperty, and have HtmlReporter stitch the prior attempts in front of the surviving final attempt. - TestContext.Execution: RetryAttempts collection + public RetryAttemptRecord - RetryHelper: record attempt before clearing result - TestExtensions.ToTestNode: attach property on final state - HtmlReporter: rebuild attempts from the property instead of the update walk - Tests: reporter reconstruction + node-attachment coverage - PublicAPI snapshots updated for the new public surface --- TUnit.Core/Interfaces/ITestExecution.cs | 7 ++ TUnit.Core/RetryAttemptRecord.cs | 30 ++++++++ TUnit.Core/TestContext.Execution.cs | 4 ++ TUnit.Engine.Tests/HtmlReporterTests.cs | 47 ++++++++++++ TUnit.Engine.Tests/TestNodeLocationTests.cs | 34 +++++++++ TUnit.Engine/Extensions/TestExtensions.cs | 8 +++ TUnit.Engine/Reporters/Html/HtmlReporter.cs | 71 ++++++++++++------- .../Html/TUnitRetryAttemptsProperty.cs | 15 ++++ .../Services/TestExecution/RetryHelper.cs | 13 ++++ ...Has_No_API_Changes.DotNet10_0.verified.txt | 8 +++ ..._Has_No_API_Changes.DotNet8_0.verified.txt | 8 +++ ..._Has_No_API_Changes.DotNet9_0.verified.txt | 8 +++ ...ary_Has_No_API_Changes.Net4_7.verified.txt | 8 +++ 13 files changed, 235 insertions(+), 26 deletions(-) create mode 100644 TUnit.Core/RetryAttemptRecord.cs create mode 100644 TUnit.Engine/Reporters/Html/TUnitRetryAttemptsProperty.cs diff --git a/TUnit.Core/Interfaces/ITestExecution.cs b/TUnit.Core/Interfaces/ITestExecution.cs index efab81b9c0..dab0b720ae 100644 --- a/TUnit.Core/Interfaces/ITestExecution.cs +++ b/TUnit.Core/Interfaces/ITestExecution.cs @@ -37,6 +37,13 @@ public interface ITestExecution /// int CurrentRetryAttempt { get; internal set; } + /// + /// 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 + /// and is not included here. + /// + IReadOnlyList RetryAttempts { get; } + /// /// Gets the reason why this test was skipped, or null if not skipped. /// diff --git a/TUnit.Core/RetryAttemptRecord.cs b/TUnit.Core/RetryAttemptRecord.cs new file mode 100644 index 0000000000..74aa0b881e --- /dev/null +++ b/TUnit.Core/RetryAttemptRecord.cs @@ -0,0 +1,30 @@ +namespace TUnit.Core; + +/// +/// Records the outcome of a single test execution attempt when a test is retried. +/// Exposed via so reporters and user +/// code can show retry/flaky history (e.g. the HTML report's attempt timeline). +/// +public readonly record struct RetryAttemptRecord +{ + /// + /// The final state this attempt reached (typically or + /// , since only attempts that triggered a retry are recorded). + /// + public required TestState State { get; init; } + + /// + /// How long this attempt took to execute. + /// + public required TimeSpan Duration { get; init; } + + /// + /// The full type name of the exception that ended this attempt, or null if none. + /// + public string? ExceptionType { get; init; } + + /// + /// The message of the exception that ended this attempt, or null if none. + /// + public string? ExceptionMessage { get; init; } +} diff --git a/TUnit.Core/TestContext.Execution.cs b/TUnit.Core/TestContext.Execution.cs index 2a45e0848d..401cebc105 100644 --- a/TUnit.Core/TestContext.Execution.cs +++ b/TUnit.Core/TestContext.Execution.cs @@ -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? RetryAttempts { get; set; } internal Func>? RetryFunc { get; set; } internal IHookExecutor? CustomHookExecutor { get; set; } internal bool ReportResult { get; set; } = true; @@ -51,6 +53,8 @@ int ITestExecution.CurrentRetryAttempt set => CurrentRetryAttempt = value; } + IReadOnlyList ITestExecution.RetryAttempts => RetryAttempts ?? (IReadOnlyList)[]; + string? ITestExecution.SkipReason => SkipReason; Func>? ITestExecution.RetryFunc => RetryFunc; IHookExecutor? ITestExecution.CustomHookExecutor diff --git a/TUnit.Engine.Tests/HtmlReporterTests.cs b/TUnit.Engine.Tests/HtmlReporterTests.cs index ca556860a7..9d5c61b4b1 100644 --- a/TUnit.Engine.Tests/HtmlReporterTests.cs +++ b/TUnit.Engine.Tests/HtmlReporterTests.cs @@ -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() { diff --git a/TUnit.Engine.Tests/TestNodeLocationTests.cs b/TUnit.Engine.Tests/TestNodeLocationTests.cs index c1d218e323..9cd4eb9612 100644 --- a/TUnit.Engine.Tests/TestNodeLocationTests.cs +++ b/TUnit.Engine.Tests/TestNodeLocationTests.cs @@ -4,6 +4,7 @@ using Shouldly; using TUnit.Core; using TUnit.Engine.Extensions; +using TUnit.Engine.Reporters.Html; namespace TUnit.Engine.Tests; @@ -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().SingleOrDefault(); + attached.ShouldNotBeNull(); + attached!.Attempts.Length.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().ShouldBeEmpty(); + } + private static TestContext CreateTestContext( string testId, string filePath, diff --git a/TUnit.Engine/Extensions/TestExtensions.cs b/TUnit.Engine/Extensions/TestExtensions.cs index 875fe97e5f..0339a4bffc 100644 --- a/TUnit.Engine/Extensions/TestExtensions.cs +++ b/TUnit.Engine/Extensions/TestExtensions.cs @@ -6,6 +6,7 @@ using TUnit.Core; using TUnit.Core.Extensions; using TUnit.Engine.Capabilities; +using TUnit.Engine.Reporters.Html; #pragma warning disable TPEXP namespace TUnit.Engine.Extensions; @@ -208,6 +209,13 @@ 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) + { + propertyBag.Add(new TUnitRetryAttemptsProperty(retryAttempts.ToArray())); + } + var testNode = new TestNode { Uid = new TestNodeUid(testDetails.TestId), diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs index 453dde957b..b3ac9d9564 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs @@ -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"; @@ -253,41 +253,46 @@ 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() + .FirstOrDefault(); + if (priorAttempts is { Attempts.Length: > 0 }) { - List? attemptList = null; - foreach (var update in allUpdates) + var finalState = testNode.Properties.SingleOrDefault(); + var (finalStatus, finalException, _) = ExtractStatus(finalState); + var finalDuration = testNode.Properties.AsEnumerable() + .OfType() + .FirstOrDefault()?.GlobalTiming.Duration.TotalMilliseconds ?? 0; + + var attemptList = new List(priorAttempts.Attempts.Length + 1); + foreach (var prior in priorAttempts.Attempts) { - var state = update.TestNode.Properties.SingleOrDefault(); - if (state is null or InProgressTestNodeStateProperty or DiscoveredTestNodeStateProperty) - { - continue; - } - - var (attemptStatus, attemptException, _) = ExtractStatus(state); - var attemptDuration = update.TestNode.Properties.AsEnumerable() - .OfType() - .FirstOrDefault()?.GlobalTiming.Duration.TotalMilliseconds ?? 0; - attemptList ??= new List(); 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, }); } - 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, + }); + + retryAttempt = attemptList.Count - 1; + attempts = attemptList.ToArray(); } #if NET @@ -600,6 +605,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) diff --git a/TUnit.Engine/Reporters/Html/TUnitRetryAttemptsProperty.cs b/TUnit.Engine/Reporters/Html/TUnitRetryAttemptsProperty.cs new file mode 100644 index 0000000000..03b3f5a323 --- /dev/null +++ b/TUnit.Engine/Reporters/Html/TUnitRetryAttemptsProperty.cs @@ -0,0 +1,15 @@ +using Microsoft.Testing.Platform.Extensions.Messages; +using TUnit.Core; + +#pragma warning disable TPEXP + +namespace TUnit.Engine.Reporters.Html; + +/// +/// Carries the history of failed retry attempts on the final 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 during execution — would be unavailable +/// to consumers that work purely off TestNodeUpdateMessage. +/// +internal sealed record TUnitRetryAttemptsProperty(RetryAttemptRecord[] Attempts) : IProperty; diff --git a/TUnit.Engine/Services/TestExecution/RetryHelper.cs b/TUnit.Engine/Services/TestExecution/RetryHelper.cs index 7fc2aed66f..35451cde0b 100644 --- a/TUnit.Engine/Services/TestExecution/RetryHelper.cs +++ b/TUnit.Engine/Services/TestExecution/RetryHelper.cs @@ -45,6 +45,19 @@ public static async Task ExecuteWithRetry(TestContext testContext, Func + { + 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, ., . { @@ -2635,6 +2642,7 @@ namespace .Interfaces .TestPhase Phase { get; } bool ReportResult { get; set; } .TestResult? Result { get; } + .<.RetryAttemptRecord> RetryAttempts { get; } <.TestContext, , int, .>? RetryFunc { get; } string? SkipReason { get; } ? TestEnd { get; } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt index 67bf6e0e85..35c8faf229 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -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, ., . { @@ -2635,6 +2642,7 @@ namespace .Interfaces .TestPhase Phase { get; } bool ReportResult { get; set; } .TestResult? Result { get; } + .<.RetryAttemptRecord> RetryAttempts { get; } <.TestContext, , int, .>? RetryFunc { get; } string? SkipReason { get; } ? TestEnd { get; } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index a53b2b7905..4a27ff30d8 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -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, ., . { @@ -2635,6 +2642,7 @@ namespace .Interfaces .TestPhase Phase { get; } bool ReportResult { get; set; } .TestResult? Result { get; } + .<.RetryAttemptRecord> RetryAttempts { get; } <.TestContext, , int, .>? RetryFunc { get; } string? SkipReason { get; } ? TestEnd { get; } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt index 7b10e10041..6556b9b154 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -1184,6 +1184,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, ., . { @@ -2565,6 +2572,7 @@ namespace .Interfaces .TestPhase Phase { get; } bool ReportResult { get; set; } .TestResult? Result { get; } + .<.RetryAttemptRecord> RetryAttempts { get; } <.TestContext, , int, .>? RetryFunc { get; } string? SkipReason { get; } ? TestEnd { get; } From 56f9848be8f6ccf5123809d26cd57cd6b9aea30c Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 30 May 2026 00:14:13 +0100 Subject: [PATCH 02/13] refactor: simplify retry-attempt plumbing per review - Move TUnitRetryAttemptsProperty to neutral TUnit.Engine.Reporters namespace (shared ToTestNode should not depend on the Html reporter namespace) - Accept IReadOnlyList in the property and pass the list directly, dropping a per-retried-test array copy in ToTestNode - Drop the redundant cast in ITestExecution.RetryAttempts (?? []) --- TUnit.Core/TestContext.Execution.cs | 2 +- TUnit.Engine.Tests/TestNodeLocationTests.cs | 4 ++-- TUnit.Engine/Extensions/TestExtensions.cs | 4 ++-- TUnit.Engine/Reporters/Html/HtmlReporter.cs | 4 ++-- .../Reporters/{Html => }/TUnitRetryAttemptsProperty.cs | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) rename TUnit.Engine/Reporters/{Html => }/TUnitRetryAttemptsProperty.cs (80%) diff --git a/TUnit.Core/TestContext.Execution.cs b/TUnit.Core/TestContext.Execution.cs index 401cebc105..807f37e0a2 100644 --- a/TUnit.Core/TestContext.Execution.cs +++ b/TUnit.Core/TestContext.Execution.cs @@ -53,7 +53,7 @@ int ITestExecution.CurrentRetryAttempt set => CurrentRetryAttempt = value; } - IReadOnlyList ITestExecution.RetryAttempts => RetryAttempts ?? (IReadOnlyList)[]; + IReadOnlyList ITestExecution.RetryAttempts => RetryAttempts ?? []; string? ITestExecution.SkipReason => SkipReason; Func>? ITestExecution.RetryFunc => RetryFunc; diff --git a/TUnit.Engine.Tests/TestNodeLocationTests.cs b/TUnit.Engine.Tests/TestNodeLocationTests.cs index 9cd4eb9612..324d996834 100644 --- a/TUnit.Engine.Tests/TestNodeLocationTests.cs +++ b/TUnit.Engine.Tests/TestNodeLocationTests.cs @@ -4,7 +4,7 @@ using Shouldly; using TUnit.Core; using TUnit.Engine.Extensions; -using TUnit.Engine.Reporters.Html; +using TUnit.Engine.Reporters; namespace TUnit.Engine.Tests; @@ -86,7 +86,7 @@ public void ToTestNode_Attaches_RetryAttempts_On_Final_State_Only() var finalNode = context.ToTestNode(PassedTestNodeStateProperty.CachedInstance); var attached = finalNode.Properties.AsEnumerable().OfType().SingleOrDefault(); attached.ShouldNotBeNull(); - attached!.Attempts.Length.ShouldBe(1); + attached!.Attempts.Count.ShouldBe(1); attached.Attempts[0].State.ShouldBe(TestState.Failed); // Discovered/in-progress state -> not attached. diff --git a/TUnit.Engine/Extensions/TestExtensions.cs b/TUnit.Engine/Extensions/TestExtensions.cs index 0339a4bffc..2d5a7f22f4 100644 --- a/TUnit.Engine/Extensions/TestExtensions.cs +++ b/TUnit.Engine/Extensions/TestExtensions.cs @@ -6,7 +6,7 @@ using TUnit.Core; using TUnit.Core.Extensions; using TUnit.Engine.Capabilities; -using TUnit.Engine.Reporters.Html; +using TUnit.Engine.Reporters; #pragma warning disable TPEXP namespace TUnit.Engine.Extensions; @@ -213,7 +213,7 @@ internal static TestNode ToTestNode(this TestContext testContext, TestNodeStateP // this is the one chance to surface the per-attempt list captured during execution. if (isFinalState && testContext.RetryAttempts is { Count: > 0 } retryAttempts) { - propertyBag.Add(new TUnitRetryAttemptsProperty(retryAttempts.ToArray())); + propertyBag.Add(new TUnitRetryAttemptsProperty(retryAttempts)); } var testNode = new TestNode diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs index b3ac9d9564..15ef498caa 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs @@ -263,7 +263,7 @@ internal ReportData BuildReportData() var priorAttempts = testNode.Properties.AsEnumerable() .OfType() .FirstOrDefault(); - if (priorAttempts is { Attempts.Length: > 0 }) + if (priorAttempts is { Attempts.Count: > 0 }) { var finalState = testNode.Properties.SingleOrDefault(); var (finalStatus, finalException, _) = ExtractStatus(finalState); @@ -271,7 +271,7 @@ internal ReportData BuildReportData() .OfType() .FirstOrDefault()?.GlobalTiming.Duration.TotalMilliseconds ?? 0; - var attemptList = new List(priorAttempts.Attempts.Length + 1); + var attemptList = new List(priorAttempts.Attempts.Count + 1); foreach (var prior in priorAttempts.Attempts) { attemptList.Add(new ReportAttempt diff --git a/TUnit.Engine/Reporters/Html/TUnitRetryAttemptsProperty.cs b/TUnit.Engine/Reporters/TUnitRetryAttemptsProperty.cs similarity index 80% rename from TUnit.Engine/Reporters/Html/TUnitRetryAttemptsProperty.cs rename to TUnit.Engine/Reporters/TUnitRetryAttemptsProperty.cs index 03b3f5a323..c9102cd053 100644 --- a/TUnit.Engine/Reporters/Html/TUnitRetryAttemptsProperty.cs +++ b/TUnit.Engine/Reporters/TUnitRetryAttemptsProperty.cs @@ -3,7 +3,7 @@ #pragma warning disable TPEXP -namespace TUnit.Engine.Reporters.Html; +namespace TUnit.Engine.Reporters; /// /// Carries the history of failed retry attempts on the final update so @@ -12,4 +12,4 @@ namespace TUnit.Engine.Reporters.Html; /// lives transiently on the during execution — would be unavailable /// to consumers that work purely off TestNodeUpdateMessage. /// -internal sealed record TUnitRetryAttemptsProperty(RetryAttemptRecord[] Attempts) : IProperty; +internal sealed record TUnitRetryAttemptsProperty(IReadOnlyList Attempts) : IProperty; From e4bac7ec485b7ae4b064816758e8aed25af01f7a Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 30 May 2026 00:32:30 +0100 Subject: [PATCH 03/13] fix: capture stack trace and harden retry-attempt history (#6119) - Add ExceptionStackTrace to RetryAttemptRecord and ReportAttempt so prior retry attempts carry stack traces (parity with the final attempt). - Defensive-copy the live RetryAttempts list when publishing the property. - Clarify ITestExecution.RetryAttempts doc on the n-1 count semantics. --- TUnit.Core/Interfaces/ITestExecution.cs | 4 +++- TUnit.Core/RetryAttemptRecord.cs | 5 +++++ TUnit.Engine/Extensions/TestExtensions.cs | 4 +++- TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs | 3 +++ TUnit.Engine/Reporters/Html/HtmlReporter.cs | 2 ++ TUnit.Engine/Services/TestExecution/RetryHelper.cs | 1 + 6 files changed, 17 insertions(+), 2 deletions(-) diff --git a/TUnit.Core/Interfaces/ITestExecution.cs b/TUnit.Core/Interfaces/ITestExecution.cs index dab0b720ae..6b7cb452ca 100644 --- a/TUnit.Core/Interfaces/ITestExecution.cs +++ b/TUnit.Core/Interfaces/ITestExecution.cs @@ -40,7 +40,9 @@ public interface ITestExecution /// /// 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 - /// and is not included here. + /// and is not included here — so for a test that ran 3 times, + /// RetryAttempts.Count is 2 (the two failed prior attempts) and + /// is 2 (the zero-based index of the surviving attempt). /// IReadOnlyList RetryAttempts { get; } diff --git a/TUnit.Core/RetryAttemptRecord.cs b/TUnit.Core/RetryAttemptRecord.cs index 74aa0b881e..e7cd1c6179 100644 --- a/TUnit.Core/RetryAttemptRecord.cs +++ b/TUnit.Core/RetryAttemptRecord.cs @@ -27,4 +27,9 @@ public readonly record struct RetryAttemptRecord /// The message of the exception that ended this attempt, or null if none. /// public string? ExceptionMessage { get; init; } + + /// + /// The stack trace of the exception that ended this attempt, or null if none. + /// + public string? ExceptionStackTrace { get; init; } } diff --git a/TUnit.Engine/Extensions/TestExtensions.cs b/TUnit.Engine/Extensions/TestExtensions.cs index 2d5a7f22f4..6d2fae88ce 100644 --- a/TUnit.Engine/Extensions/TestExtensions.cs +++ b/TUnit.Engine/Extensions/TestExtensions.cs @@ -213,7 +213,9 @@ internal static TestNode ToTestNode(this TestContext testContext, TestNodeStateP // this is the one chance to surface the per-attempt list captured during execution. if (isFinalState && testContext.RetryAttempts is { Count: > 0 } retryAttempts) { - propertyBag.Add(new TUnitRetryAttemptsProperty(retryAttempts)); + // Defensive copy: the live List on the TestContext could otherwise + // be mutated after the property is published. + propertyBag.Add(new TUnitRetryAttemptsProperty([.. retryAttempts])); } var testNode = new TestNode diff --git a/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs b/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs index 10e775693b..251dd4d9b2 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs @@ -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 diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs index 15ef498caa..c69bad0fb2 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs @@ -280,6 +280,7 @@ internal ReportData BuildReportData() DurationMs = prior.Duration.TotalMilliseconds, ExceptionType = prior.ExceptionType, ExceptionMessage = prior.ExceptionMessage, + StackTrace = prior.ExceptionStackTrace, }); } @@ -289,6 +290,7 @@ internal ReportData BuildReportData() DurationMs = finalDuration, ExceptionType = finalException?.Type, ExceptionMessage = finalException?.Message, + StackTrace = finalException?.StackTrace, }); retryAttempt = attemptList.Count - 1; diff --git a/TUnit.Engine/Services/TestExecution/RetryHelper.cs b/TUnit.Engine/Services/TestExecution/RetryHelper.cs index 35451cde0b..8837d692f2 100644 --- a/TUnit.Engine/Services/TestExecution/RetryHelper.cs +++ b/TUnit.Engine/Services/TestExecution/RetryHelper.cs @@ -56,6 +56,7 @@ public static async Task ExecuteWithRetry(TestContext testContext, Func Date: Sat, 30 May 2026 00:54:32 +0100 Subject: [PATCH 04/13] fix: unwrap retry exceptions and avoid empty-list alloc (#6119) - Unwrap TUnit wrapper exceptions when recording a failed retry attempt so the captured type/message/stack trace match the final attempt (which unwraps via HtmlReporter.MapException). Fixes inconsistent stack traces for assertion failures across prior vs final attempts. - Use Array.Empty for the no-retry RetryAttempts fast path. --- TUnit.Core/TestContext.Execution.cs | 4 +++- TUnit.Engine/Services/TestExecution/RetryHelper.cs | 10 +++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/TUnit.Core/TestContext.Execution.cs b/TUnit.Core/TestContext.Execution.cs index 807f37e0a2..f67298b5c4 100644 --- a/TUnit.Core/TestContext.Execution.cs +++ b/TUnit.Core/TestContext.Execution.cs @@ -53,7 +53,9 @@ int ITestExecution.CurrentRetryAttempt set => CurrentRetryAttempt = value; } - IReadOnlyList ITestExecution.RetryAttempts => RetryAttempts ?? []; + // Array.Empty for the common no-retry path so passing tests allocate nothing. + IReadOnlyList ITestExecution.RetryAttempts + => (IReadOnlyList?)RetryAttempts ?? Array.Empty(); string? ITestExecution.SkipReason => SkipReason; Func>? ITestExecution.RetryFunc => RetryFunc; diff --git a/TUnit.Engine/Services/TestExecution/RetryHelper.cs b/TUnit.Engine/Services/TestExecution/RetryHelper.cs index 8837d692f2..056a2174ce 100644 --- a/TUnit.Engine/Services/TestExecution/RetryHelper.cs +++ b/TUnit.Engine/Services/TestExecution/RetryHelper.cs @@ -1,4 +1,5 @@ using TUnit.Core; +using TUnit.Engine.Exceptions; namespace TUnit.Engine.Services.TestExecution; @@ -50,13 +51,16 @@ public static async Task ExecuteWithRetry(TestContext testContext, Func Date: Sat, 30 May 2026 12:40:03 +0100 Subject: [PATCH 05/13] test: regenerate PublicAPI baseline for retry attempt history (#6124) --- ...Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt | 1 + .../Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt | 1 + .../Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt | 1 + .../Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt | 1 + 4 files changed, 4 insertions(+) diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt index b0ac65ccd1..d6c6b62a26 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -1231,6 +1231,7 @@ namespace { public required Duration { get; init; } public string? ExceptionMessage { get; init; } + public string? ExceptionStackTrace { get; init; } public string? ExceptionType { get; init; } public required .TestState State { get; init; } } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt index 35c8faf229..9e3438615d 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -1231,6 +1231,7 @@ namespace { public required Duration { get; init; } public string? ExceptionMessage { get; init; } + public string? ExceptionStackTrace { get; init; } public string? ExceptionType { get; init; } public required .TestState State { get; init; } } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index 4a27ff30d8..a2d82a3ece 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -1231,6 +1231,7 @@ namespace { public required Duration { get; init; } public string? ExceptionMessage { get; init; } + public string? ExceptionStackTrace { get; init; } public string? ExceptionType { get; init; } public required .TestState State { get; init; } } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt index 6556b9b154..143e3ac011 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -1188,6 +1188,7 @@ namespace { public required Duration { get; init; } public string? ExceptionMessage { get; init; } + public string? ExceptionStackTrace { get; init; } public string? ExceptionType { get; init; } public required .TestState State { get; init; } } From f53fa2addfb42644456f21b66bd0123cc0a1f2ee Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Sun, 31 May 2026 14:29:02 +0100 Subject: [PATCH 06/13] fix(html-report): normalise per-test trace timeline to earliest span (#6131) The Trace tab plotted spans against an axis starting at 0, but span start values are absolute offsets from the run start. Tests starting partway through a run had all their bars pushed to the right with empty lead-in space, making the trace look erroneously sparse. Normalise span positions to the earliest span start (minStart), mirroring the logic already used by the other timeline renderer in the template. Co-authored-by: Claude Opus 4.8 --- TUnit.Engine/Reporters/Html/TestReport.template.html | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/TUnit.Engine/Reporters/Html/TestReport.template.html b/TUnit.Engine/Reporters/Html/TestReport.template.html index 84a79967d5..5971ba850c 100644 --- a/TUnit.Engine/Reporters/Html/TestReport.template.html +++ b/TUnit.Engine/Reporters/Html/TestReport.template.html @@ -2655,14 +2655,18 @@

Select a test

const byId = new Map(spans.map(s => [s.id, s])); spans.forEach(s => { s._children = []; }); spans.forEach(s => { if (s.parent && byId.has(s.parent)) byId.get(s.parent)._children.push(s); }); - const totalDur = Math.max(...spans.map(s => s.start + s.dur)); + // Span starts are absolute offsets from the run start, so normalise to the + // earliest span — otherwise the axis begins at 0 and all bars are pushed to + // the right, leaving the lead-in time before the test empty. + const minStart = Math.min(...spans.map(s => s.start)); + const totalDur = (Math.max(...spans.map(s => s.start + s.dur)) - minStart) || 1; const ticks = 6, axisHtml = []; for (let i = 0; i <= ticks; i++) axisHtml.push(`${fmtDur((i/ticks)*totalDur)}`); const rows = []; function walk(span, depth) { const color = SERVICE_COLORS[span.service] || 'var(--text-muted)'; - const left = (span.start / totalDur) * 100; + const left = ((span.start - minStart) / totalDur) * 100; const width = Math.max(0.2, (span.dur / totalDur) * 100); const err = (span.attrs && span.attrs['http.status_code'] >= 500) ? ' err' : ''; rows.push(` From 2326542d59acbce6e166a3600393dd93f1c26066 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 31 May 2026 17:03:36 +0100 Subject: [PATCH 07/13] perf(html-report): store only the reported update per test The retry-history rework means BuildReportData no longer replays the per-test update stream - it reads each test's final node once. Keeping the whole ConcurrentQueue per test was therefore dead weight: every non-retry test held a queue for a single node. Collapse _updates to ConcurrentDictionary, selecting the node to report (final-state preferred, else latest) at consume time. --- TUnit.Engine/Reporters/Html/HtmlReporter.cs | 43 +++++++++++---------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs index c69bad0fb2..dd44dc14c0 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs @@ -32,7 +32,7 @@ internal sealed class HtmlReporter(IExtension extension) : IDataConsumer, IDataP private string? _outputPath; private IMessageBus? _messageBus; private string _resultsDirectory = "TestResults"; - private readonly ConcurrentDictionary> _updates = []; + private readonly ConcurrentDictionary _updates = []; private GitHubReporter? _githubReporter; #if NET @@ -64,10 +64,28 @@ public async Task IsEnabledAsync() public Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationToken cancellationToken) { var testNodeUpdateMessage = (TestNodeUpdateMessage)value; - _updates.GetOrAdd(testNodeUpdateMessage.TestNode.Uid.Value, _ => []).Enqueue(testNodeUpdateMessage); + // Keep only the update we'll report per test: a final-state update always wins over a + // non-final one, otherwise the latest wins. The engine emits a single final update per + // test, so storing the whole stream (the old ConcurrentQueue) just wasted memory. + _updates.AddOrUpdate( + testNodeUpdateMessage.TestNode.Uid.Value, + testNodeUpdateMessage, + (_, existing) => PreferForReport(existing, testNodeUpdateMessage)); return Task.CompletedTask; } + // Selects which update to keep for the report when more than one arrives for a test: a + // final-state update always wins over a non-final one; otherwise the later (incoming) one + // wins. Mirrors the previous "last final, else last overall" walk over the per-test queue. + private static TestNodeUpdateMessage PreferForReport(TestNodeUpdateMessage existing, TestNodeUpdateMessage incoming) + => IsFinalState(incoming) || !IsFinalState(existing) ? incoming : existing; + + private static bool IsFinalState(TestNodeUpdateMessage update) + { + var state = update.TestNode.Properties.SingleOrDefault(); + return state is not null and not InProgressTestNodeStateProperty and not DiscoveredTestNodeStateProperty; + } + public Type[] DataTypesConsumed { get; } = [typeof(TestNodeUpdateMessage)]; public Type[] DataTypesProduced { get; } = [typeof(SessionFileArtifact)]; @@ -206,24 +224,9 @@ internal ReportData BuildReportData() var tunitVersion = typeof(HtmlReporter).Assembly.GetName().Version?.ToString() ?? "unknown"; // Get the last update with a final state for each test - var lastUpdates = new Dictionary(_updates.Count); - foreach (var kvp in _updates) - { - TestNodeUpdateMessage? lastFinal = null; - foreach (var update in kvp.Value) - { - var state = update.TestNode.Properties.SingleOrDefault(); - if (state is not null and not InProgressTestNodeStateProperty and not DiscoveredTestNodeStateProperty) - { - lastFinal = update; - } - } - - if (lastFinal != null) - { - lastUpdates[kvp.Key] = lastFinal; - } - } + // Each test's update was already reduced to the one we report (final-state preferred) in + // ConsumeAsync, so _updates maps directly to the nodes to render. + var lastUpdates = _updates; var summary = new ReportSummary(); var groupsByClass = new Dictionary>(); From 82afc00bab35ac7b6dad18e5d95379128e543573 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 31 May 2026 18:57:27 +0100 Subject: [PATCH 08/13] refactor(retry): reuse TestResult for retry attempt history (#6119) Replace the parallel RetryAttemptRecord model with TestResult, the existing "outcome of a test execution" type. Prior attempts are past results, so a dedicated narrower struct created two models for one concept and an asymmetry between the surviving attempt (TestResult) and prior attempts. - ITestExecution.RetryAttempts now IReadOnlyList - RetryHelper stores the attempt's own result with TestContext nulled to avoid retaining the live execution graph - HtmlReporter unwraps via MapException at render time (matches the final attempt; preserves inner-exception chain instead of flattening) - Regenerate PublicAPI baselines (net472/8/9/10) --- TUnit.Core/Interfaces/ITestExecution.cs | 5 +-- TUnit.Core/RetryAttemptRecord.cs | 35 ------------------- TUnit.Core/TestContext.Execution.cs | 6 ++-- TUnit.Engine.Tests/HtmlReporterTests.cs | 4 +-- TUnit.Engine.Tests/TestNodeLocationTests.cs | 2 +- TUnit.Engine/Reporters/Html/HtmlReporter.cs | 9 ++--- .../Reporters/TUnitRetryAttemptsProperty.cs | 2 +- .../Services/TestExecution/RetryHelper.cs | 30 +++++++++------- ...Has_No_API_Changes.DotNet10_0.verified.txt | 10 +----- ..._Has_No_API_Changes.DotNet8_0.verified.txt | 10 +----- ..._Has_No_API_Changes.DotNet9_0.verified.txt | 10 +----- ...ary_Has_No_API_Changes.Net4_7.verified.txt | 10 +----- 12 files changed, 37 insertions(+), 96 deletions(-) delete mode 100644 TUnit.Core/RetryAttemptRecord.cs diff --git a/TUnit.Core/Interfaces/ITestExecution.cs b/TUnit.Core/Interfaces/ITestExecution.cs index 6b7cb452ca..dc23b324c3 100644 --- a/TUnit.Core/Interfaces/ITestExecution.cs +++ b/TUnit.Core/Interfaces/ITestExecution.cs @@ -38,13 +38,14 @@ public interface ITestExecution int CurrentRetryAttempt { get; internal set; } /// - /// Gets the history of prior execution attempts that triggered a retry, in attempt order. + /// Gets the results 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 /// and is not included here — so for a test that ran 3 times, /// RetryAttempts.Count is 2 (the two failed prior attempts) and /// is 2 (the zero-based index of the surviving attempt). + /// Each entry is the that attempt produced before it was retried. /// - IReadOnlyList RetryAttempts { get; } + IReadOnlyList RetryAttempts { get; } /// /// Gets the reason why this test was skipped, or null if not skipped. diff --git a/TUnit.Core/RetryAttemptRecord.cs b/TUnit.Core/RetryAttemptRecord.cs deleted file mode 100644 index e7cd1c6179..0000000000 --- a/TUnit.Core/RetryAttemptRecord.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace TUnit.Core; - -/// -/// Records the outcome of a single test execution attempt when a test is retried. -/// Exposed via so reporters and user -/// code can show retry/flaky history (e.g. the HTML report's attempt timeline). -/// -public readonly record struct RetryAttemptRecord -{ - /// - /// The final state this attempt reached (typically or - /// , since only attempts that triggered a retry are recorded). - /// - public required TestState State { get; init; } - - /// - /// How long this attempt took to execute. - /// - public required TimeSpan Duration { get; init; } - - /// - /// The full type name of the exception that ended this attempt, or null if none. - /// - public string? ExceptionType { get; init; } - - /// - /// The message of the exception that ended this attempt, or null if none. - /// - public string? ExceptionMessage { get; init; } - - /// - /// The stack trace of the exception that ended this attempt, or null if none. - /// - public string? ExceptionStackTrace { get; init; } -} diff --git a/TUnit.Core/TestContext.Execution.cs b/TUnit.Core/TestContext.Execution.cs index f67298b5c4..54b9881cdc 100644 --- a/TUnit.Core/TestContext.Execution.cs +++ b/TUnit.Core/TestContext.Execution.cs @@ -20,7 +20,7 @@ public partial class TestContext 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? RetryAttempts { get; set; } + internal List? RetryAttempts { get; set; } internal Func>? RetryFunc { get; set; } internal IHookExecutor? CustomHookExecutor { get; set; } internal bool ReportResult { get; set; } = true; @@ -54,8 +54,8 @@ int ITestExecution.CurrentRetryAttempt } // Array.Empty for the common no-retry path so passing tests allocate nothing. - IReadOnlyList ITestExecution.RetryAttempts - => (IReadOnlyList?)RetryAttempts ?? Array.Empty(); + IReadOnlyList ITestExecution.RetryAttempts + => (IReadOnlyList?)RetryAttempts ?? Array.Empty(); string? ITestExecution.SkipReason => SkipReason; Func>? ITestExecution.RetryFunc => RetryFunc; diff --git a/TUnit.Engine.Tests/HtmlReporterTests.cs b/TUnit.Engine.Tests/HtmlReporterTests.cs index 9d5c61b4b1..b4ea888eae 100644 --- a/TUnit.Engine.Tests/HtmlReporterTests.cs +++ b/TUnit.Engine.Tests/HtmlReporterTests.cs @@ -449,8 +449,8 @@ public async Task BuildReportData_Reconstructs_Attempts_From_RetryAttemptsProper 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" }, + new TestResult { State = TestState.Failed, Start = start, End = start.AddMilliseconds(100), Duration = TimeSpan.FromMilliseconds(100), Exception = new TimeoutException("transient 1"), ComputerName = "test" }, + new TestResult { State = TestState.Failed, Start = start, End = start.AddMilliseconds(150), Duration = TimeSpan.FromMilliseconds(150), Exception = new TimeoutException("transient 2"), ComputerName = "test" }, ])) }; diff --git a/TUnit.Engine.Tests/TestNodeLocationTests.cs b/TUnit.Engine.Tests/TestNodeLocationTests.cs index 324d996834..40c6405627 100644 --- a/TUnit.Engine.Tests/TestNodeLocationTests.cs +++ b/TUnit.Engine.Tests/TestNodeLocationTests.cs @@ -79,7 +79,7 @@ public void ToTestNode_Attaches_RetryAttempts_On_Final_State_Only() context.RetryAttempts = [ - new RetryAttemptRecord { State = TestState.Failed, Duration = TimeSpan.FromMilliseconds(50), ExceptionType = "System.Exception", ExceptionMessage = "boom" }, + new TestResult { State = TestState.Failed, Start = null, End = null, Duration = TimeSpan.FromMilliseconds(50), Exception = new Exception("boom"), ComputerName = "test" }, ]; // Final state -> attached. diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs index dd44dc14c0..d5bee56e3e 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs @@ -277,13 +277,14 @@ internal ReportData BuildReportData() var attemptList = new List(priorAttempts.Attempts.Count + 1); foreach (var prior in priorAttempts.Attempts) { + var priorException = MapException(prior.Exception); attemptList.Add(new ReportAttempt { Status = StatusFromState(prior.State), - DurationMs = prior.Duration.TotalMilliseconds, - ExceptionType = prior.ExceptionType, - ExceptionMessage = prior.ExceptionMessage, - StackTrace = prior.ExceptionStackTrace, + DurationMs = prior.Duration?.TotalMilliseconds ?? 0, + ExceptionType = priorException?.Type, + ExceptionMessage = priorException?.Message, + StackTrace = priorException?.StackTrace, }); } diff --git a/TUnit.Engine/Reporters/TUnitRetryAttemptsProperty.cs b/TUnit.Engine/Reporters/TUnitRetryAttemptsProperty.cs index c9102cd053..1921939daa 100644 --- a/TUnit.Engine/Reporters/TUnitRetryAttemptsProperty.cs +++ b/TUnit.Engine/Reporters/TUnitRetryAttemptsProperty.cs @@ -12,4 +12,4 @@ namespace TUnit.Engine.Reporters; /// lives transiently on the during execution — would be unavailable /// to consumers that work purely off TestNodeUpdateMessage. /// -internal sealed record TUnitRetryAttemptsProperty(IReadOnlyList Attempts) : IProperty; +internal sealed record TUnitRetryAttemptsProperty(IReadOnlyList Attempts) : IProperty; diff --git a/TUnit.Engine/Services/TestExecution/RetryHelper.cs b/TUnit.Engine/Services/TestExecution/RetryHelper.cs index 056a2174ce..e49d923622 100644 --- a/TUnit.Engine/Services/TestExecution/RetryHelper.cs +++ b/TUnit.Engine/Services/TestExecution/RetryHelper.cs @@ -49,19 +49,25 @@ public static async Task ExecuteWithRetry(TestContext testContext, Func - { - public required Duration { get; init; } - public string? ExceptionMessage { get; init; } - public string? ExceptionStackTrace { get; init; } - public string? ExceptionType { get; init; } - public required .TestState State { get; init; } - } [(.Assembly | .Class | .Method)] public class RetryAttribute : .TUnitAttribute, .IScopedAttribute, ., . { @@ -2643,7 +2635,7 @@ namespace .Interfaces .TestPhase Phase { get; } bool ReportResult { get; set; } .TestResult? Result { get; } - .<.RetryAttemptRecord> RetryAttempts { get; } + .<.TestResult> RetryAttempts { get; } <.TestContext, , int, .>? RetryFunc { get; } string? SkipReason { get; } ? TestEnd { get; } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt index 9e3438615d..1257216841 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -1227,14 +1227,6 @@ 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? ExceptionStackTrace { get; init; } - public string? ExceptionType { get; init; } - public required .TestState State { get; init; } - } [(.Assembly | .Class | .Method)] public class RetryAttribute : .TUnitAttribute, .IScopedAttribute, ., . { @@ -2643,7 +2635,7 @@ namespace .Interfaces .TestPhase Phase { get; } bool ReportResult { get; set; } .TestResult? Result { get; } - .<.RetryAttemptRecord> RetryAttempts { get; } + .<.TestResult> RetryAttempts { get; } <.TestContext, , int, .>? RetryFunc { get; } string? SkipReason { get; } ? TestEnd { get; } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index a2d82a3ece..3ab33b0d2f 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -1227,14 +1227,6 @@ 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? ExceptionStackTrace { get; init; } - public string? ExceptionType { get; init; } - public required .TestState State { get; init; } - } [(.Assembly | .Class | .Method)] public class RetryAttribute : .TUnitAttribute, .IScopedAttribute, ., . { @@ -2643,7 +2635,7 @@ namespace .Interfaces .TestPhase Phase { get; } bool ReportResult { get; set; } .TestResult? Result { get; } - .<.RetryAttemptRecord> RetryAttempts { get; } + .<.TestResult> RetryAttempts { get; } <.TestContext, , int, .>? RetryFunc { get; } string? SkipReason { get; } ? TestEnd { get; } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt index 143e3ac011..33b624363a 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -1184,14 +1184,6 @@ 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? ExceptionStackTrace { get; init; } - public string? ExceptionType { get; init; } - public required .TestState State { get; init; } - } [(.Assembly | .Class | .Method)] public class RetryAttribute : .TUnitAttribute, .IScopedAttribute, ., . { @@ -2573,7 +2565,7 @@ namespace .Interfaces .TestPhase Phase { get; } bool ReportResult { get; set; } .TestResult? Result { get; } - .<.RetryAttemptRecord> RetryAttempts { get; } + .<.TestResult> RetryAttempts { get; } <.TestContext, , int, .>? RetryFunc { get; } string? SkipReason { get; } ? TestEnd { get; } From c5a6c0cd5a547545790b36717c44067ed7b88fb5 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 31 May 2026 19:19:37 +0100 Subject: [PATCH 09/13] docs: fix stale comment and document ITestExecution.RetryAttempts break - TestExtensions comment referenced removed RetryAttemptRecord type; now List - ITestExecution.RetryAttempts has no default impl (netstandard2.0); add remark guiding downstream implementors to return an empty list when upgrading --- TUnit.Core/Interfaces/ITestExecution.cs | 6 ++++++ TUnit.Engine/Extensions/TestExtensions.cs | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/TUnit.Core/Interfaces/ITestExecution.cs b/TUnit.Core/Interfaces/ITestExecution.cs index dc23b324c3..b5f2efc57f 100644 --- a/TUnit.Core/Interfaces/ITestExecution.cs +++ b/TUnit.Core/Interfaces/ITestExecution.cs @@ -45,6 +45,12 @@ public interface ITestExecution /// is 2 (the zero-based index of the surviving attempt). /// Each entry is the that attempt produced before it was retried. /// + /// + /// This member has no default implementation because ITestExecution targets + /// netstandard2.0, which predates default interface members. External types that implement + /// ITestExecution directly must add this property when upgrading — return an empty + /// list (e.g. Array.Empty<TestResult>()) if retry history is not tracked. + /// IReadOnlyList RetryAttempts { get; } /// diff --git a/TUnit.Engine/Extensions/TestExtensions.cs b/TUnit.Engine/Extensions/TestExtensions.cs index 6d2fae88ce..feaab24177 100644 --- a/TUnit.Engine/Extensions/TestExtensions.cs +++ b/TUnit.Engine/Extensions/TestExtensions.cs @@ -213,7 +213,7 @@ internal static TestNode ToTestNode(this TestContext testContext, TestNodeStateP // 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 on the TestContext could otherwise + // Defensive copy: the live List on the TestContext could otherwise // be mutated after the property is published. propertyBag.Add(new TUnitRetryAttemptsProperty([.. retryAttempts])); } From 6fc0430c159bec7d764020fa97aeaec67e3b2b6d Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 31 May 2026 21:29:39 +0100 Subject: [PATCH 10/13] refactor(reporters): extract shared IsFinalState helper; clarify retry comments Address review feedback on the retry/flaky HTML report PR: - Extract the triplicated terminal-state predicate (HtmlReporter, GitHubReporter, TestExtensions x2) into a single TestNodeStatePropertyExtensions.IsFinalState() helper so the rule lives in one place and cannot drift. - Document that retryAttempt mirrors testContext.CurrentRetryAttempt, derived from the attempt list because it is not serialised on the node. - Document when the RetryHelper TestResult fallback is reachable. - Note that TUnitRetryAttemptsProperty is an engine-internal transport, not a public extensibility contract. --- TUnit.Engine/Extensions/TestExtensions.cs | 4 ++-- .../TestNodeStatePropertyExtensions.cs | 17 +++++++++++++++++ TUnit.Engine/Reporters/GitHubReporter.cs | 3 ++- TUnit.Engine/Reporters/Html/HtmlReporter.cs | 10 ++++++---- .../Reporters/TUnitRetryAttemptsProperty.cs | 6 ++++++ .../Services/TestExecution/RetryHelper.cs | 4 ++++ 6 files changed, 37 insertions(+), 7 deletions(-) create mode 100644 TUnit.Engine/Extensions/TestNodeStatePropertyExtensions.cs diff --git a/TUnit.Engine/Extensions/TestExtensions.cs b/TUnit.Engine/Extensions/TestExtensions.cs index feaab24177..50f0ef87cf 100644 --- a/TUnit.Engine/Extensions/TestExtensions.cs +++ b/TUnit.Engine/Extensions/TestExtensions.cs @@ -119,7 +119,7 @@ internal static TestNode ToTestNode(this TestContext testContext, TestNodeStateP { var testDetails = testContext.Metadata.TestDetails ?? throw new ArgumentNullException(nameof(testContext.Metadata.TestDetails)); - var isFinalState = stateProperty is not DiscoveredTestNodeStateProperty and not InProgressTestNodeStateProperty; + var isFinalState = stateProperty.IsFinalState(); var isTrxEnabled = isFinalState && IsTrxEnabled(testContext); @@ -250,7 +250,7 @@ private static (Exception? Exception, string? Reason) GetException(TestNodeState private static int EstimateCount(TestContext testContext, TestNodeStateProperty stateProperty, bool isTrxEnabled) { - var isFinalState = stateProperty is not DiscoveredTestNodeStateProperty and not InProgressTestNodeStateProperty; + var isFinalState = stateProperty.IsFinalState(); var testDetails = testContext.Metadata.TestDetails ?? throw new ArgumentNullException(nameof(testContext.Metadata.TestDetails)); diff --git a/TUnit.Engine/Extensions/TestNodeStatePropertyExtensions.cs b/TUnit.Engine/Extensions/TestNodeStatePropertyExtensions.cs new file mode 100644 index 0000000000..c32e032cac --- /dev/null +++ b/TUnit.Engine/Extensions/TestNodeStatePropertyExtensions.cs @@ -0,0 +1,17 @@ +using Microsoft.Testing.Platform.Extensions.Messages; + +#pragma warning disable TPEXP + +namespace TUnit.Engine.Extensions; + +internal static class TestNodeStatePropertyExtensions +{ + /// + /// Determines whether a state represents a final (terminal) test outcome — i.e. anything + /// other than the discovery or in-progress placeholder states. Returns false for + /// null. Shared by the node builder () and the reporters + /// so the "is this the reportable result?" rule lives in exactly one place. + /// + public static bool IsFinalState(this TestNodeStateProperty? stateProperty) + => stateProperty is not null and not InProgressTestNodeStateProperty and not DiscoveredTestNodeStateProperty; +} diff --git a/TUnit.Engine/Reporters/GitHubReporter.cs b/TUnit.Engine/Reporters/GitHubReporter.cs index 96a4c0333f..f1f816c60e 100644 --- a/TUnit.Engine/Reporters/GitHubReporter.cs +++ b/TUnit.Engine/Reporters/GitHubReporter.cs @@ -10,6 +10,7 @@ using TUnit.Engine.Configuration; using TUnit.Engine.Constants; using TUnit.Engine.Exceptions; +using TUnit.Engine.Extensions; using TUnit.Engine.Framework; using TUnit.Engine.Helpers; @@ -84,7 +85,7 @@ public Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationTo var uid = testNodeUpdateMessage.TestNode.Uid.Value; var state = testNodeUpdateMessage.TestNode.Properties.OfType().FirstOrDefault(); - if (state is not null and not InProgressTestNodeStateProperty and not DiscoveredTestNodeStateProperty) + if (state.IsFinalState()) { _terminalStateCounts.AddOrUpdate(uid, 1, static (_, count) => count + 1); } diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs index d5bee56e3e..dbdbae23e6 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs @@ -15,6 +15,7 @@ using TUnit.Engine.Configuration; using TUnit.Engine.Constants; using TUnit.Engine.Exceptions; +using TUnit.Engine.Extensions; using TUnit.Engine.Framework; using TUnit.Engine.Helpers; using TUnit.Engine.Reporters; @@ -81,10 +82,7 @@ private static TestNodeUpdateMessage PreferForReport(TestNodeUpdateMessage exist => IsFinalState(incoming) || !IsFinalState(existing) ? incoming : existing; private static bool IsFinalState(TestNodeUpdateMessage update) - { - var state = update.TestNode.Properties.SingleOrDefault(); - return state is not null and not InProgressTestNodeStateProperty and not DiscoveredTestNodeStateProperty; - } + => update.TestNode.Properties.SingleOrDefault().IsFinalState(); public Type[] DataTypesConsumed { get; } = [typeof(TestNodeUpdateMessage)]; @@ -297,6 +295,10 @@ internal ReportData BuildReportData() StackTrace = finalException?.StackTrace, }); + // Index of the surviving (final) attempt; equals testContext.CurrentRetryAttempt. + // Derived from the attempt list rather than read back from the node because + // CurrentRetryAttempt is not serialised onto the TestNode. A non-zero value here is + // what flags the test as flaky in AccumulateStatus. retryAttempt = attemptList.Count - 1; attempts = attemptList.ToArray(); } diff --git a/TUnit.Engine/Reporters/TUnitRetryAttemptsProperty.cs b/TUnit.Engine/Reporters/TUnitRetryAttemptsProperty.cs index 1921939daa..8dcd900e9e 100644 --- a/TUnit.Engine/Reporters/TUnitRetryAttemptsProperty.cs +++ b/TUnit.Engine/Reporters/TUnitRetryAttemptsProperty.cs @@ -12,4 +12,10 @@ namespace TUnit.Engine.Reporters; /// lives transiently on the during execution — would be unavailable /// to consumers that work purely off TestNodeUpdateMessage. /// +/// +/// This is an engine-internal transport, not a public extensibility contract. The same data is +/// exposed publicly to in-process consumers via ITestExecution.RetryAttempts (reachable +/// through TestContext); out-of-process reporters that only see TestNodeUpdateMessage +/// should not depend on this property type, as it may change without notice. +/// internal sealed record TUnitRetryAttemptsProperty(IReadOnlyList Attempts) : IProperty; diff --git a/TUnit.Engine/Services/TestExecution/RetryHelper.cs b/TUnit.Engine/Services/TestExecution/RetryHelper.cs index e49d923622..4ed8c6a37a 100644 --- a/TUnit.Engine/Services/TestExecution/RetryHelper.cs +++ b/TUnit.Engine/Services/TestExecution/RetryHelper.cs @@ -53,6 +53,10 @@ public static async Task ExecuteWithRetry(TestContext testContext, Func Date: Sun, 31 May 2026 22:24:08 +0100 Subject: [PATCH 11/13] fix(html-report): surface per-attempt stack traces in retry history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReportAttempt.StackTrace was populated in BuildReportData but never serialized — HtmlReportGenerator wrote only type/message for each attempt's error object, so the data dead-ended and the retry-history UI could never show a prior attempt's stack trace. - Serialize the attempt 'stack' alongside type/message, mirroring the top-level WriteException. - Surface type, message, and stack in the attempt pill's hover tooltip (the pill previously had no title at all), so every collected field is now visible. esc() keeps the title attribute safe. Addresses code-review feedback on PR #6124. --- TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs | 1 + TUnit.Engine/Reporters/Html/TestReport.template.html | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs index f6c40de49e..a594f0fc46 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs @@ -564,6 +564,7 @@ private static void WriteTest( w.WriteStartObject(); if (!string.IsNullOrEmpty(a.ExceptionType)) w.WriteString("type", a.ExceptionType!); if (!string.IsNullOrEmpty(a.ExceptionMessage)) w.WriteString("message", a.ExceptionMessage!); + if (!string.IsNullOrEmpty(a.StackTrace)) w.WriteString("stack", a.StackTrace!); w.WriteEndObject(); } w.WriteEndObject(); diff --git a/TUnit.Engine/Reporters/Html/TestReport.template.html b/TUnit.Engine/Reporters/Html/TestReport.template.html index 5971ba850c..ba3cccbfea 100644 --- a/TUnit.Engine/Reporters/Html/TestReport.template.html +++ b/TUnit.Engine/Reporters/Html/TestReport.template.html @@ -2492,7 +2492,13 @@

${esc(namePart)}${argsPart ? `${esc( const ico = a.status === 'pass' ? `` : ``; - pills.push(`${i+1}${ico}${fmtDur(a.duration)}${isFinal && attempts.length > 1 ? `FINAL` : ''}`); + const tipParts = [`Attempt ${i+1}: ${a.status}`]; + if (a.error) { + const head = [a.error.type, a.error.message].filter(Boolean).join(': '); + if (head) tipParts.push(head); + if (a.error.stack) tipParts.push(a.error.stack); + } + pills.push(`${i+1}${ico}${fmtDur(a.duration)}${isFinal && attempts.length > 1 ? `FINAL` : ''}`); }); return `
From ef1e88ab9c06f6d360936f618bc4d99bbc1ece27 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 31 May 2026 22:36:47 +0100 Subject: [PATCH 12/13] fix(html-report): skip non-final test nodes in BuildReportData A test abandoned mid-flight (e.g. process-crash recovery) may only ever produce a non-final TestNodeUpdateMessage. ConsumeAsync/PreferForReport keeps that node in _updates, so BuildReportData would render it as an "inProgress" result. Re-add the final-state guard the old queue-walk had, reusing the existing IsFinalState helper. --- TUnit.Engine/Reporters/Html/HtmlReporter.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs index dbdbae23e6..87476cc8c1 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs @@ -244,6 +244,14 @@ internal ReportData BuildReportData() foreach (var kvp in lastUpdates) { + // ConsumeAsync prefers a final-state update per test, but a test abandoned mid-flight + // (e.g. process-crash recovery) may only ever have a non-final update stored. Skip those + // so the report never renders a discovered/in-progress node as if it were a result. + if (!IsFinalState(kvp.Value)) + { + continue; + } + var testNode = kvp.Value.TestNode; // Correlate trace/span IDs from collected activities From 2d3d4c8b44c577aff6f0840ce22f455b30dfbe30 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 31 May 2026 22:41:23 +0100 Subject: [PATCH 13/13] refactor(html-report): address review nits on retry attempt history - RetryHelper: store the raw exception in the no-Result fallback path instead of pre-unwrapping, so both the common and fallback paths defer wrapper unwrapping to HtmlReporter.MapException. Makes the public ITestExecution.RetryAttempts exception consistent across paths and the existing "unwrapping deferred to the reporter" comment accurate. Drops the now-unused TUnit.Engine.Exceptions import. - HtmlReporter: rename the private IsFinalState(TestNodeUpdateMessage) wrapper to HasFinalState to avoid two same-named symbols in scope alongside the TestNodeStateProperty.IsFinalState() extension. - HtmlReporter: note that MapException's inner-exception chain is intentionally discarded for prior attempts, matching final-attempt rendering (ReportAttempt has no inner-exception field). --- TUnit.Engine/Reporters/Html/HtmlReporter.cs | 11 ++++++++--- TUnit.Engine/Services/TestExecution/RetryHelper.cs | 3 +-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs index 87476cc8c1..9edf953ec1 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs @@ -79,9 +79,11 @@ public Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationTo // final-state update always wins over a non-final one; otherwise the later (incoming) one // wins. Mirrors the previous "last final, else last overall" walk over the per-test queue. private static TestNodeUpdateMessage PreferForReport(TestNodeUpdateMessage existing, TestNodeUpdateMessage incoming) - => IsFinalState(incoming) || !IsFinalState(existing) ? incoming : existing; + => HasFinalState(incoming) || !HasFinalState(existing) ? incoming : existing; - private static bool IsFinalState(TestNodeUpdateMessage update) + // Named distinctly from the TestNodeStateProperty.IsFinalState() extension to avoid two + // same-named symbols in scope; this overload reaches into the update's node state for callers. + private static bool HasFinalState(TestNodeUpdateMessage update) => update.TestNode.Properties.SingleOrDefault().IsFinalState(); public Type[] DataTypesConsumed { get; } = [typeof(TestNodeUpdateMessage)]; @@ -247,7 +249,7 @@ internal ReportData BuildReportData() // ConsumeAsync prefers a final-state update per test, but a test abandoned mid-flight // (e.g. process-crash recovery) may only ever have a non-final update stored. Skip those // so the report never renders a discovered/in-progress node as if it were a result. - if (!IsFinalState(kvp.Value)) + if (!HasFinalState(kvp.Value)) { continue; } @@ -283,6 +285,9 @@ internal ReportData BuildReportData() var attemptList = new List(priorAttempts.Attempts.Count + 1); foreach (var prior in priorAttempts.Attempts) { + // We keep only the top-level type/message/stack here; MapException's recursive + // InnerException chain is intentionally discarded, matching how the final + // attempt is rendered (ReportAttempt has no inner-exception field). var priorException = MapException(prior.Exception); attemptList.Add(new ReportAttempt { diff --git a/TUnit.Engine/Services/TestExecution/RetryHelper.cs b/TUnit.Engine/Services/TestExecution/RetryHelper.cs index 4ed8c6a37a..d53939e248 100644 --- a/TUnit.Engine/Services/TestExecution/RetryHelper.cs +++ b/TUnit.Engine/Services/TestExecution/RetryHelper.cs @@ -1,5 +1,4 @@ using TUnit.Core; -using TUnit.Engine.Exceptions; namespace TUnit.Engine.Services.TestExecution; @@ -68,7 +67,7 @@ public static async Task ExecuteWithRetry(TestContext testContext, Func