diff --git a/TUnit.Core/Attributes/TestMetadata/ClassTimelineAttribute.cs b/TUnit.Core/Attributes/TestMetadata/ClassTimelineAttribute.cs new file mode 100644 index 0000000000..8d521d7709 --- /dev/null +++ b/TUnit.Core/Attributes/TestMetadata/ClassTimelineAttribute.cs @@ -0,0 +1,55 @@ +using TUnit.Core.Enums; +using TUnit.Core.Interfaces; + +namespace TUnit.Core; + +/// +/// 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. +/// +/// +/// +/// Useful for BDD-style chains of tests where seeing the +/// whole flow on one timeline matters, without making every class noisier in the report. +/// +/// +/// +/// +/// [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; +/// } +/// +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Assembly)] +public sealed class ClassTimelineAttribute(TimelineMode mode) : TUnitAttribute, ITestDiscoveryEventReceiver, IScopedAttribute +{ + /// The timeline rendering mode to apply. + public TimelineMode Mode { get; } = mode; + + /// + public int Order => 0; + + /// + public Type ScopeType => typeof(ClassTimelineAttribute); + + /// + public ValueTask OnTestDiscovered(DiscoveredTestContext context) + { + context.AddProperty(ClassTimelinePropertyKey, Mode.ToString()); + return default; + } + + /// + /// Custom-property key used to round-trip the chosen into + /// TestDetails.CustomProperties so the HTML reporter can read it back per class. + /// + internal const string ClassTimelinePropertyKey = "tunit.report.timeline"; +} diff --git a/TUnit.Core/Enums/TimelineMode.cs b/TUnit.Core/Enums/TimelineMode.cs new file mode 100644 index 0000000000..82aafa84df --- /dev/null +++ b/TUnit.Core/Enums/TimelineMode.cs @@ -0,0 +1,22 @@ +namespace TUnit.Core.Enums; + +/// +/// Controls how the HTML report renders the class-level execution timeline. Used by +/// . +/// +public enum TimelineMode +{ + /// + /// 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 . + /// + Collapsed = 0, + + /// + /// Render the union of every test-case span and its non-test body children on + /// the class timeline. Surfaces multi-step [DependsOn] / BDD-style flows + /// end-to-end at the class level. + /// + FullExecution = 1, +} diff --git a/TUnit.Core/Settings/ReportSettings.cs b/TUnit.Core/Settings/ReportSettings.cs deleted file mode 100644 index f8b31aa42f..0000000000 --- a/TUnit.Core/Settings/ReportSettings.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace TUnit.Core.Settings; - -/// -/// Controls HTML report rendering. Independent from , which -/// governs console output. -/// -public sealed class ReportSettings -{ - internal ReportSettings() { } - - /// - /// When true, the HTML report's class timeline includes each test-case span and - /// its non-test body children, making BDD-style [DependsOn] chains visible - /// at the class level. When false (default), the class timeline shows only - /// class-level infrastructure spans (suite, init/dispose) — quieter for classes of - /// independent tests. - /// - public bool ExpandClassTimeline { get; set; } -} diff --git a/TUnit.Core/Settings/TUnitSettings.cs b/TUnit.Core/Settings/TUnitSettings.cs index bb864d535a..cc15574a89 100644 --- a/TUnit.Core/Settings/TUnitSettings.cs +++ b/TUnit.Core/Settings/TUnitSettings.cs @@ -38,9 +38,4 @@ internal TUnitSettings() { } /// Controls test run behavior. /// public ExecutionSettings Execution { get; } = new(); - - /// - /// Controls HTML report rendering. - /// - public ReportSettings Report { get; } = new(); } diff --git a/TUnit.Engine.Tests/HtmlReporterTests.cs b/TUnit.Engine.Tests/HtmlReporterTests.cs index d068562aee..e5faada495 100644 --- a/TUnit.Engine.Tests/HtmlReporterTests.cs +++ b/TUnit.Engine.Tests/HtmlReporterTests.cs @@ -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; @@ -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, diff --git a/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs b/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs index 0a284c4e5c..5589f26169 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs @@ -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 diff --git a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs index 0ca56b4950..f7b6a4097c 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs @@ -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 = {}; @@ -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); @@ -1901,7 +1913,7 @@ function render() { html += '
'; if (groupMode === 'class') { html += renderClassSummary(g, ft); - html += renderSuiteTrace(g.className); + html += renderSuiteTrace(g); } ft.forEach((t,ti)=>{ html += '
'; diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs index a8cf5898f0..943a348e28 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs @@ -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; @@ -379,7 +378,6 @@ private ReportData BuildReportData() Branch = branch, PullRequestNumber = prNumber, RepositorySlug = repoSlug, - ExpandClassTimeline = TUnitSettings.Default.Report.ExpandClassTimeline, }; } 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 6d96c41c58..5ab83ff645 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 @@ -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 { @@ -1934,6 +1943,11 @@ namespace .Enums Generated = 2, Derived = 3, } + public enum TimelineMode + { + Collapsed = 0, + FullExecution = 1, + } } namespace .Events { @@ -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 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 87db8453d8..2c27794cfb 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 @@ -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 { @@ -1934,6 +1943,11 @@ namespace .Enums Generated = 2, Derived = 3, } + public enum TimelineMode + { + Collapsed = 0, + FullExecution = 1, + } } namespace .Events { @@ -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 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 966ca22248..24562acdb3 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 @@ -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 { @@ -1934,6 +1943,11 @@ namespace .Enums Generated = 2, Derived = 3, } + public enum TimelineMode + { + Collapsed = 0, + FullExecution = 1, + } } namespace .Events { @@ -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 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 1bee3ea610..f02e58b1e3 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 @@ -228,6 +228,15 @@ namespace public . OnTestDiscovered(.DiscoveredTestContext context) { } } [(.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) { } + } + [(.Assembly | .Class)] public class ClassConstructorAttribute : .TUnitAttribute { public ClassConstructorAttribute( classConstructorType) { } @@ -1872,6 +1881,11 @@ namespace .Enums Generated = 2, Derived = 3, } + public enum TimelineMode + { + Collapsed = 0, + FullExecution = 1, + } } namespace .Events { @@ -2896,16 +2910,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 diff --git a/TUnit.UnitTests/TUnitSettingsTests.cs b/TUnit.UnitTests/TUnitSettingsTests.cs index df4ced08be..287fdbd63b 100644 --- a/TUnit.UnitTests/TUnitSettingsTests.cs +++ b/TUnit.UnitTests/TUnitSettingsTests.cs @@ -18,7 +18,6 @@ public class TUnitSettingsTests private int? _savedMaximumParallelTests; private bool _savedDetailedStackTrace; private bool _savedFailFast; - private bool _savedExpandClassTimeline; [Before(HookType.Test)] public void SnapshotSettings() @@ -30,7 +29,6 @@ public void SnapshotSettings() _savedMaximumParallelTests = TUnitSettings.Default.Parallelism.MaximumParallelTests; _savedDetailedStackTrace = TUnitSettings.Default.Display.DetailedStackTrace; _savedFailFast = TUnitSettings.Default.Execution.FailFast; - _savedExpandClassTimeline = TUnitSettings.Default.Report.ExpandClassTimeline; } [After(HookType.Test)] @@ -43,7 +41,6 @@ public void RestoreSettings() TUnitSettings.Default.Parallelism.MaximumParallelTests = _savedMaximumParallelTests; TUnitSettings.Default.Display.DetailedStackTrace = _savedDetailedStackTrace; TUnitSettings.Default.Execution.FailFast = _savedFailFast; - TUnitSettings.Default.Report.ExpandClassTimeline = _savedExpandClassTimeline; } [Test] @@ -56,7 +53,6 @@ public async Task Defaults_Are_Correct() await Assert.That(TUnitSettings.Default.Parallelism.MaximumParallelTests).IsNull(); await Assert.That(TUnitSettings.Default.Display.DetailedStackTrace).IsFalse(); await Assert.That(TUnitSettings.Default.Execution.FailFast).IsFalse(); - await Assert.That(TUnitSettings.Default.Report.ExpandClassTimeline).IsFalse(); } [Test]