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
16 changes: 16 additions & 0 deletions TUnit.Core/Interfaces/ITestExecution.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,22 @@ public interface ITestExecution
/// </summary>
int CurrentRetryAttempt { get; internal set; }

/// <summary>
/// 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
/// <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).
/// Each entry is the <see cref="TestResult"/> that attempt produced before it was retried.
/// </summary>
/// <remarks>
/// This member has no default implementation because <c>ITestExecution</c> targets
/// netstandard2.0, which predates default interface members. External types that implement
/// <c>ITestExecution</c> directly must add this property when upgrading — return an empty
/// list (e.g. <c>Array.Empty&lt;TestResult&gt;()</c>) if retry history is not tracked.
/// </remarks>
IReadOnlyList<TestResult> RetryAttempts { get; }

/// <summary>
/// Gets the reason why this test was skipped, or null if not skipped.
/// </summary>
Expand Down
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<TestResult>? 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<TestResult> ITestExecution.RetryAttempts
=> (IReadOnlyList<TestResult>?)RetryAttempts ?? Array.Empty<TestResult>();

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 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" },
]))
};

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 TestResult { State = TestState.Failed, Start = null, End = null, Duration = TimeSpan.FromMilliseconds(50), Exception = new Exception("boom"), ComputerName = "test" },
];

// 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
14 changes: 12 additions & 2 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 @@ -118,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);

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<TestResult> 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 Expand Up @@ -240,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));

Expand Down
17 changes: 17 additions & 0 deletions TUnit.Engine/Extensions/TestNodeStatePropertyExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Microsoft.Testing.Platform.Extensions.Messages;

#pragma warning disable TPEXP

namespace TUnit.Engine.Extensions;

internal static class TestNodeStatePropertyExtensions
{
/// <summary>
/// Determines whether a state represents a final (terminal) test outcome — i.e. anything
/// other than the discovery or in-progress placeholder states. Returns <c>false</c> for
/// <c>null</c>. Shared by the node builder (<see cref="TestExtensions"/>) and the reporters
/// so the "is this the reportable result?" rule lives in exactly one place.
/// </summary>
public static bool IsFinalState(this TestNodeStateProperty? stateProperty)
=> stateProperty is not null and not InProgressTestNodeStateProperty and not DiscoveredTestNodeStateProperty;
}
3 changes: 2 additions & 1 deletion TUnit.Engine/Reporters/GitHubReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -84,7 +85,7 @@ public Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationTo
var uid = testNodeUpdateMessage.TestNode.Uid.Value;

var state = testNodeUpdateMessage.TestNode.Properties.OfType<TestNodeStateProperty>().FirstOrDefault();
if (state is not null and not InProgressTestNodeStateProperty and not DiscoveredTestNodeStateProperty)
if (state.IsFinalState())
{
_terminalStateCounts.AddOrUpdate(uid, 1, static (_, count) => count + 1);
}
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
1 change: 1 addition & 0 deletions TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading