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
55 changes: 55 additions & 0 deletions TUnit.Core/Attributes/TestMetadata/ClassTimelineAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using TUnit.Core.Enums;
using TUnit.Core.Interfaces;

namespace TUnit.Core;

/// <summary>
/// Opts a test class (or every class in an assembly) into a specific HTML-report
/// class-timeline rendering mode. Class-level usage wins over assembly-level usage.
/// </summary>
/// <remarks>
/// <para>
/// Useful for BDD-style chains of <see cref="DependsOnAttribute"/> tests where seeing the
/// whole flow on one timeline matters, without making every class noisier in the report.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// [ClassTimeline(TimelineMode.FullExecution)]
/// public class OrderProcessingFlow
/// {
/// [Test] public Task SetupWiremock() => Task.CompletedTask;
///
/// [Test, DependsOn(nameof(SetupWiremock))]
/// public Task SendMessage() => Task.CompletedTask;
///
/// [Test, DependsOn(nameof(SendMessage))]
/// public Task VerifyWiremockWasCalled() => Task.CompletedTask;
/// }
/// </code>
/// </example>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Assembly)]
public sealed class ClassTimelineAttribute(TimelineMode mode) : TUnitAttribute, ITestDiscoveryEventReceiver, IScopedAttribute
{
/// <summary>The timeline rendering mode to apply.</summary>
public TimelineMode Mode { get; } = mode;

/// <inheritdoc />
public int Order => 0;

/// <inheritdoc />
public Type ScopeType => typeof(ClassTimelineAttribute);

/// <inheritdoc />
public ValueTask OnTestDiscovered(DiscoveredTestContext context)
{
context.AddProperty(ClassTimelinePropertyKey, Mode.ToString());
return default;
}

/// <summary>
/// Custom-property key used to round-trip the chosen <see cref="TimelineMode"/> into
/// <c>TestDetails.CustomProperties</c> so the HTML reporter can read it back per class.
/// </summary>
internal const string ClassTimelinePropertyKey = "tunit.report.timeline";
}
22 changes: 22 additions & 0 deletions TUnit.Core/Enums/TimelineMode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace TUnit.Core.Enums;

/// <summary>
/// Controls how the HTML report renders the class-level execution timeline. Used by
/// <see cref="ClassTimelineAttribute"/>.
/// </summary>
public enum TimelineMode
{
/// <summary>
/// Drop test-case spans and their full subtrees from the class timeline, leaving only
/// class-level infrastructure (suite, init/dispose). Same as the implicit default for
/// classes without a <see cref="ClassTimelineAttribute"/>.
/// </summary>
Collapsed = 0,

/// <summary>
/// Render the union of every test-case span and its non-<c>test body</c> children on
/// the class timeline. Surfaces multi-step <c>[DependsOn]</c> / BDD-style flows
/// end-to-end at the class level.
/// </summary>
FullExecution = 1,
}
19 changes: 0 additions & 19 deletions TUnit.Core/Settings/ReportSettings.cs

This file was deleted.

5 changes: 0 additions & 5 deletions TUnit.Core/Settings/TUnitSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,4 @@ internal TUnitSettings() { }
/// Controls test run behavior.
/// </summary>
public ExecutionSettings Execution { get; } = new();

/// <summary>
/// Controls HTML report rendering.
/// </summary>
public ReportSettings Report { get; } = new();
}
55 changes: 55 additions & 0 deletions TUnit.Engine.Tests/HtmlReporterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Microsoft.Testing.Platform.TestHost;
using Shouldly;
using TUnit.Core;
using TUnit.Core.Enums;
using TUnit.Engine.Reporters.Html;

namespace TUnit.Engine.Tests;
Expand Down Expand Up @@ -207,6 +208,60 @@ public async Task PublishArtifactAsync_Publishes_With_Correct_SessionUid()
}
}

[Test]
public void ClassTimelineAttribute_Exposes_Mode_And_ScopeType()
{
var full = new ClassTimelineAttribute(TimelineMode.FullExecution);
full.Mode.ShouldBe(TimelineMode.FullExecution);
full.ScopeType.ShouldBe(typeof(ClassTimelineAttribute));

var collapsed = new ClassTimelineAttribute(TimelineMode.Collapsed);
collapsed.Mode.ShouldBe(TimelineMode.Collapsed);
}

[Test]
public void GenerateHtml_RoundTrips_ClassTimeline_CustomProperty_OnTest()
{
// The JS reads group.tests[0].customProperties for tunit.report.timeline; this
// pins the contract that the property survives serialisation into the embedded JSON.
var html = HtmlReportGenerator.GenerateHtml(new ReportData
{
AssemblyName = "Tests",
MachineName = "machine",
Timestamp = "2026-05-07T09:26:24.0000000Z",
TUnitVersion = "1.0.0",
OperatingSystem = "Linux",
RuntimeVersion = ".NET 10.0",
TotalDurationMs = 0,
Summary = new ReportSummary(),
Groups =
[
new ReportTestGroup
{
ClassName = "BddFlow",
Namespace = "Sample",
Summary = new ReportSummary(),
Tests =
[
new ReportTestResult
{
Id = "t1", DisplayName = "t1", MethodName = "t1",
ClassName = "BddFlow", Status = "passed",
CustomProperties =
[
new ReportKeyValue { Key = ClassTimelineAttribute.ClassTimelinePropertyKey, Value = nameof(TimelineMode.FullExecution) }
],
},
],
},
],
});

var embedded = ExtractEmbeddedReportJson(html);
embedded.ShouldContain("\"key\":\"tunit.report.timeline\"");
embedded.ShouldContain("\"value\":\"FullExecution\"");
}

private static ReportTestResult CreateTestResultWithStartTime(string displayName, string? startTime) => new()
{
Id = displayName,
Expand Down
3 changes: 0 additions & 3 deletions TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,6 @@ internal sealed class ReportData

[JsonPropertyName("repositorySlug")]
public string? RepositorySlug { get; init; }

[JsonPropertyName("expandClassTimeline")]
public bool ExpandClassTimeline { get; init; }
}

internal sealed class ReportSummary
Expand Down
24 changes: 18 additions & 6 deletions TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1611,9 +1611,10 @@ function walk(sid) {
}

// 'test body' must match TUnitActivitySource.SpanTestBody in C#.
// Used by renderTrace (per-test view, always) and by renderSuiteTrace only when
// data.expandClassTimeline is true. The default class-timeline branch excludes
// test-case spans + their entire subtrees, so no test-body span survives to collapse.
// Used by renderTrace (per-test view, always) and by renderSuiteTrace only when the
// class opts in via [ClassTimeline(TimelineMode.FullExecution)]. The default
// class-timeline branch excludes test-case spans + their entire subtrees, so no
// test-body span survives to collapse.
function collapseTestBodySpans(spans) {
if (!spans || !spans.length) return [];
const byId = {};
Expand Down Expand Up @@ -1736,14 +1737,25 @@ function renderClassSummary(g, ft) {
return h;
}

function renderSuiteTrace(className) {
// Per-class opt-in: [ClassTimeline(TimelineMode.FullExecution)] writes 'tunit.report.timeline'
// onto every test in the class via DiscoveredTestContext.AddProperty.
// Key/value strings must match ClassTimelineAttribute.ClassTimelinePropertyKey
// and nameof(TimelineMode.FullExecution) in C#.
function isClassTimelineFullExecution(group) {
const test = group.tests && group.tests[0];
if (!test || !test.customProperties) return false;
return test.customProperties.some(p => p.key === 'tunit.report.timeline' && p.value === 'FullExecution');
}

function renderSuiteTrace(group) {
const className = group.className;
const suite = suiteSpanByClass[className];
if (!suite) return '';
const allSpans = spansByTrace[suite.traceId];
if (!allSpans) return '';
const all = getDescendants(allSpans, suite.spanId);
let filtered;
if (data.expandClassTimeline) {
if (isClassTimelineFullExecution(group)) {
// BDD/DependsOn mode: include test-case spans and their non-'test body' children
// so multi-step flows are visible at the class level.
filtered = collapseTestBodySpans(all);
Expand Down Expand Up @@ -1901,7 +1913,7 @@ function render() {
html += '<div class="grp-body"><div class="grp-body-inner"><div class="grp-body-pad">';
if (groupMode === 'class') {
html += renderClassSummary(g, ft);
html += renderSuiteTrace(g.className);
html += renderSuiteTrace(g);
}
ft.forEach((t,ti)=>{
html += '<div class="t-row" id="test-'+t.id+'" data-gi="'+gi+'" data-ti="'+ti+'" data-tid="'+t.id+'" style="--row-idx:'+Math.min(ti,7)+'">';
Expand Down
2 changes: 0 additions & 2 deletions TUnit.Engine/Reporters/Html/HtmlReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
using Microsoft.Testing.Platform.Services;
using Microsoft.Testing.Platform.TestHost;
using TUnit.Core;
using TUnit.Core.Settings;
using TUnit.Engine.Configuration;
using TUnit.Engine.Constants;
using TUnit.Engine.Exceptions;
Expand Down Expand Up @@ -379,7 +378,6 @@ private ReportData BuildReportData()
Branch = branch,
PullRequestNumber = prNumber,
RepositorySlug = repoSlug,
ExpandClassTimeline = TUnitSettings.Default.Report.ExpandClassTimeline,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,15 @@ namespace
public static .ClassMetadata GetOrAdd(string name, <.ClassMetadata> factory) { }
public static .ClassMetadata GetOrAdd(string name, .ClassMetadata value) { }
}
[(.Assembly | .Class)]
public sealed class ClassTimelineAttribute : .TUnitAttribute, .IScopedAttribute, ., .
{
public ClassTimelineAttribute(. mode) { }
public . Mode { get; }
public int Order { get; }
public ScopeType { get; }
public . OnTestDiscovered(.DiscoveredTestContext context) { }
}
[(.Class | .Method)]
public sealed class CombinedDataSourcesAttribute : .AsyncUntypedDataSourceGeneratorAttribute
{
Expand Down Expand Up @@ -1934,6 +1943,11 @@ namespace .Enums
Generated = 2,
Derived = 3,
}
public enum TimelineMode
{
Collapsed = 0,
FullExecution = 1,
}
}
namespace .Events
{
Expand Down Expand Up @@ -2975,16 +2989,11 @@ namespace .Settings
{
public int? MaximumParallelTests { get; set; }
}
public sealed class ReportSettings
{
public bool ExpandClassTimeline { get; set; }
}
public sealed class TUnitSettings
{
public . Display { get; }
public . Execution { get; }
public . Parallelism { get; }
public . Report { get; }
public . Timeouts { get; }
}
public sealed class TimeoutSettings
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,15 @@ namespace
public static .ClassMetadata GetOrAdd(string name, <.ClassMetadata> factory) { }
public static .ClassMetadata GetOrAdd(string name, .ClassMetadata value) { }
}
[(.Assembly | .Class)]
public sealed class ClassTimelineAttribute : .TUnitAttribute, .IScopedAttribute, ., .
{
public ClassTimelineAttribute(. mode) { }
public . Mode { get; }
public int Order { get; }
public ScopeType { get; }
public . OnTestDiscovered(.DiscoveredTestContext context) { }
}
[(.Class | .Method)]
public sealed class CombinedDataSourcesAttribute : .AsyncUntypedDataSourceGeneratorAttribute
{
Expand Down Expand Up @@ -1934,6 +1943,11 @@ namespace .Enums
Generated = 2,
Derived = 3,
}
public enum TimelineMode
{
Collapsed = 0,
FullExecution = 1,
}
}
namespace .Events
{
Expand Down Expand Up @@ -2975,16 +2989,11 @@ namespace .Settings
{
public int? MaximumParallelTests { get; set; }
}
public sealed class ReportSettings
{
public bool ExpandClassTimeline { get; set; }
}
public sealed class TUnitSettings
{
public . Display { get; }
public . Execution { get; }
public . Parallelism { get; }
public . Report { get; }
public . Timeouts { get; }
}
public sealed class TimeoutSettings
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,15 @@ namespace
public static .ClassMetadata GetOrAdd(string name, <.ClassMetadata> factory) { }
public static .ClassMetadata GetOrAdd(string name, .ClassMetadata value) { }
}
[(.Assembly | .Class)]
public sealed class ClassTimelineAttribute : .TUnitAttribute, .IScopedAttribute, ., .
{
public ClassTimelineAttribute(. mode) { }
public . Mode { get; }
public int Order { get; }
public ScopeType { get; }
public . OnTestDiscovered(.DiscoveredTestContext context) { }
}
[(.Class | .Method)]
public sealed class CombinedDataSourcesAttribute : .AsyncUntypedDataSourceGeneratorAttribute
{
Expand Down Expand Up @@ -1934,6 +1943,11 @@ namespace .Enums
Generated = 2,
Derived = 3,
}
public enum TimelineMode
{
Collapsed = 0,
FullExecution = 1,
}
}
namespace .Events
{
Expand Down Expand Up @@ -2975,16 +2989,11 @@ namespace .Settings
{
public int? MaximumParallelTests { get; set; }
}
public sealed class ReportSettings
{
public bool ExpandClassTimeline { get; set; }
}
public sealed class TUnitSettings
{
public . Display { get; }
public . Execution { get; }
public . Parallelism { get; }
public . Report { get; }
public . Timeouts { get; }
}
public sealed class TimeoutSettings
Expand Down
Loading
Loading