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
63 changes: 59 additions & 4 deletions TUnit.Aspire/AspireFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,16 @@ private void RemoveResources(IDistributedApplicationTestingBuilder builder)
/// </summary>
public virtual async ValueTask DisposeAsync()
{
if (_otlpReceiver is not null && _app is not null)
{
// Give the SUT's BatchSpanProcessor a chance to flush trailing spans while the
// AppHost is still up. Without this, fast tests stop the app before the worker's
// exporter ticks (default 1s after our OTEL_BSP_SCHEDULE_DELAY override) and the
// last set of spans never reaches us.
LogProgress("Draining OTLP receiver...");
await _otlpReceiver.DrainAsync();
}

if (_app is not null)
{
LogProgress("Stopping application...");
Expand All @@ -386,20 +396,65 @@ public virtual async ValueTask DisposeAsync()
// --- OTLP Telemetry ---

private const string DashboardOtlpEndpointEnvVar = "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL";
private const string DashboardOtlpHttpEndpointEnvVar = "DOTNET_DASHBOARD_OTLP_HTTP_ENDPOINT_URL";
private const string OtelExporterHeadersEnvVar = "OTEL_EXPORTER_OTLP_HEADERS";
private const string OtelExporterProtocolEnvVar = "OTEL_EXPORTER_OTLP_PROTOCOL";
private const string OtelServiceNameEnvVar = "OTEL_SERVICE_NAME";
private const string OtelBlrpScheduleDelayEnvVar = "OTEL_BLRP_SCHEDULE_DELAY";
private const string OtelBspScheduleDelayEnvVar = "OTEL_BSP_SCHEDULE_DELAY";

private void StartOtlpReceiver()
{
// Check if there's an existing upstream OTLP endpoint (e.g., Aspire dashboard)
// that we should forward to after processing.
var upstreamEndpoint = Environment.GetEnvironmentVariable(DashboardOtlpEndpointEnvVar);
_otlpReceiver = new OtlpReceiver(upstreamEndpoint);
// Prefer the dashboard's HTTP/protobuf endpoint — the receiver only forwards
// http/protobuf, not gRPC, so DOTNET_DASHBOARD_OTLP_ENDPOINT_URL (which is gRPC by
// default) would fail every forward. Fall back to the gRPC endpoint only as a
// last resort so users with a custom dashboard config still see *something*.
var upstreamEndpoint = Environment.GetEnvironmentVariable(DashboardOtlpHttpEndpointEnvVar)
?? Environment.GetEnvironmentVariable(DashboardOtlpEndpointEnvVar);

_otlpReceiver = new OtlpReceiver(upstreamEndpoint)
{
UpstreamHeaders = ParseOtlpHeaders(Environment.GetEnvironmentVariable(OtelExporterHeadersEnvVar)),
};
_otlpReceiver.Start();
}

/// <summary>
/// Parses the <c>OTEL_EXPORTER_OTLP_HEADERS</c> format (<c>key1=value1,key2=value2</c>)
/// into a header list. The Aspire dashboard requires <c>x-otlp-api-key</c> on its OTLP
/// endpoints when auth is enabled (the default since Aspire 9), so without forwarding
/// these the dashboard rejects every proxied span.
/// </summary>
private static IReadOnlyDictionary<string, string>? ParseOtlpHeaders(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
{
return null;
}

var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
// Split on the first '=' only — base64 tokens (api keys) commonly carry trailing
// '=' padding that must stay in the value, and the OTEL spec permits empty values
// (key=) so we don't reject them.
var eq = pair.IndexOf('=');
if (eq <= 0)
{
continue;
}

var key = pair[..eq].Trim();
var value = pair[(eq + 1)..].Trim();
if (key.Length > 0)
{
result[key] = value;
}
}

return result.Count == 0 ? null : result;
}

private void ConfigureOtlpEndpoints(IDistributedApplicationTestingBuilder builder)
{
var otlpEndpoint = $"http://127.0.0.1:{_otlpReceiver!.Port}";
Expand Down
19 changes: 19 additions & 0 deletions TUnit.Core/Settings/ReportSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace TUnit.Core.Settings;

/// <summary>
/// Controls HTML report rendering. Independent from <see cref="DisplaySettings"/>, which
/// governs console output.
/// </summary>
public sealed class ReportSettings
{
internal ReportSettings() { }

/// <summary>
/// When <c>true</c>, the HTML report's class timeline includes each test-case span and
/// its non-<c>test body</c> children, making BDD-style <c>[DependsOn]</c> chains visible
/// at the class level. When <c>false</c> (default), the class timeline shows only
/// class-level infrastructure spans (suite, init/dispose) — quieter for classes of
/// independent tests.
/// </summary>
public bool ExpandClassTimeline { get; set; }
}
5 changes: 5 additions & 0 deletions TUnit.Core/Settings/TUnitSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,9 @@ internal TUnitSettings() { }
/// Controls test run behavior.
/// </summary>
public ExecutionSettings Execution { get; } = new();

/// <summary>
/// Controls HTML report rendering.
/// </summary>
public ReportSettings Report { get; } = new();
}
1 change: 1 addition & 0 deletions TUnit.Core/TUnit.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<InternalsVisibleTo Include="TUnit.Aspire.Tests" />
<InternalsVisibleTo Include="TUnit.OpenTelemetry" />
<InternalsVisibleTo Include="TUnit.OpenTelemetry.Tests" />
<InternalsVisibleTo Include="TUnit.Engine.Tests" />
<InternalsVisibleTo Include="TUnit.Playwright" />
</ItemGroup>

Expand Down
54 changes: 54 additions & 0 deletions TUnit.Core/TraceRegistry.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#if NET
using System.Collections.Concurrent;
using System.Diagnostics;

namespace TUnit.Core;

Expand Down Expand Up @@ -63,6 +64,59 @@ internal static bool IsRegistered(string traceId)
return TraceToContextId.GetValueOrDefault(traceId);
}

/// <summary>
/// Associates <paramref name="derivedTraceId"/> with the same test(s) as
/// <paramref name="sourceTraceId"/>. Useful for messaging/queue consumers that start
/// a new trace but keep a causal link to the original test trace via OTEL span links.
/// </summary>
/// <returns>
/// <c>true</c> when the derived trace was associated with at least one test from the
/// source trace (span correlation), or when both trace IDs are the same and the source
/// trace is already registered; otherwise, <c>false</c>.
/// <para>
/// A <c>true</c> result does NOT guarantee log routing — if the source trace has no
/// context-id mapping (only added by the 3-arg <see cref="Register(string,string,string)"/>),
/// span correlation succeeds but log records for the derived trace fall through
/// <see cref="GetContextId"/> and are dropped. The case is logged via
/// <see cref="Trace.WriteLine"/>.
/// </para>
/// </returns>
internal static bool TryRegisterDerivedTrace(string derivedTraceId, string sourceTraceId)
{
// Fast path: if both IDs are the same we only need to report whether the source
// trace is already registered — no dictionary updates required.
if (string.Equals(derivedTraceId, sourceTraceId, StringComparison.OrdinalIgnoreCase))
{
return IsRegistered(sourceTraceId);
}

if (!TraceToTests.TryGetValue(sourceTraceId, out var testNodeUids))
{
return false;
}

foreach (var testNodeUid in testNodeUids)
{
Register(derivedTraceId, testNodeUid.Key);
}

if (TraceToContextId.TryGetValue(sourceTraceId, out var contextId))
{
TraceToContextId.TryAdd(derivedTraceId, contextId);
}
else
{
// Source trace had test associations but no context-id mapping. The derived
// trace's TraceToTests entry will work for span correlation, but log routing
// through GetContextId will return null and ProcessLogs will silently drop
// records. Surface that here so a missing log line in the report points at a
// concrete cause instead of "nothing happened".
Trace.WriteLine($"[TUnit.Core] TraceRegistry.TryRegisterDerivedTrace: source trace {sourceTraceId} has no context-id mapping; logs for derived trace {derivedTraceId} will not be routed to a test.");
}

return true;
}

/// <summary>
/// Gets all trace IDs registered for the given test node UID.
/// Used by HtmlReporter to populate additional trace IDs on test results.
Expand Down
86 changes: 86 additions & 0 deletions TUnit.Engine.Tests/HtmlReporterTests.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
#pragma warning disable TPEXP

using System.IO.Compression;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Testing.Platform.Extensions.Messages;
using Microsoft.Testing.Platform.TestHost;
using Shouldly;
using TUnit.Core;
using TUnit.Engine.Reporters.Html;

namespace TUnit.Engine.Tests;
Expand Down Expand Up @@ -112,6 +116,75 @@ public void FilterAdditionalTraceIds_Returns_Empty_When_Only_Primary()
result.ShouldBeEmpty();
}

[Test]
public void OrderTestsForDisplay_SortsByStartTime_ThenName()
{
var later = CreateTestResultWithStartTime("Later", "2026-05-07T09:26:25.0000000Z");
var earlier = CreateTestResultWithStartTime("Earlier", "2026-05-07T09:26:24.0000000Z");
var sameTimeButLaterName = CreateTestResultWithStartTime("Zeta", "2026-05-07T09:26:24.0000000Z");

var ordered = HtmlReporter.OrderTestsForDisplay([later, sameTimeButLaterName, earlier]);

ordered.Select(static test => test.DisplayName).ShouldBe(["Earlier", "Zeta", "Later"]);
}

[Test]
public void GenerateHtml_RoundTrips_TestBodySpans_AndChildren_Through_EmbeddedData()
{
// Server-side data path only — the client-side collapseTestBodySpans JS runs in the
// browser and is not exercised here. This test pins down the contract the JS relies
// on: a 'test body' span with children survives serialisation into the embedded
// JSON so the JS can re-parent children to the test-case span at render time.
const string traceId = "0123456789abcdef0123456789abcdef";
var spans = new[]
{
new SpanData
{
TraceId = traceId, SpanId = "aaaaaaaaaaaaaaaa", Name = "test body",
Source = "TUnit", Kind = "Internal", Status = "Ok",
},
new SpanData
{
TraceId = traceId, SpanId = "bbbbbbbbbbbbbbbb", ParentSpanId = "aaaaaaaaaaaaaaaa",
Name = "wiremock-call", Source = "TUnit", Kind = "Client", Status = "Ok",
},
};

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 = [],
Spans = spans,
});

var embedded = ExtractEmbeddedReportJson(html);
embedded.ShouldContain("\"name\":\"test body\"");
embedded.ShouldContain("\"name\":\"wiremock-call\"");
embedded.ShouldContain("\"parentSpanId\":\"aaaaaaaaaaaaaaaa\"");
}

private static string ExtractEmbeddedReportJson(string html)
{
// The renderer embeds ReportData as gzip+base64 inside <script id="test-data" ...>.
var match = Regex.Match(
html,
"<script id=\"test-data\"[^>]*>(?<payload>[A-Za-z0-9+/=]+)</script>",
RegexOptions.Singleline);
match.Success.ShouldBeTrue("Expected embedded test-data script in rendered HTML.");
var compressed = Convert.FromBase64String(match.Groups["payload"].Value);
using var ms = new MemoryStream(compressed);
using var gz = new GZipStream(ms, CompressionMode.Decompress);
using var reader = new StreamReader(gz, Encoding.UTF8);
return reader.ReadToEnd();
}

[Test]
public async Task PublishArtifactAsync_Publishes_With_Correct_SessionUid()
{
Expand All @@ -133,4 +206,17 @@ public async Task PublishArtifactAsync_Publishes_With_Correct_SessionUid()
File.Delete(tempFile);
}
}

private static ReportTestResult CreateTestResultWithStartTime(string displayName, string? startTime) => new()
{
Id = displayName,
DisplayName = displayName,
MethodName = displayName,
ClassName = "SampleTests",
Status = "passed",
DurationMs = 1,
StartTime = startTime,
EndTime = startTime,
RetryAttempt = 0,
};
}
7 changes: 6 additions & 1 deletion TUnit.Engine/Reporters/Html/ActivityCollector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,12 @@ internal void IngestExternalSpan(SpanData span)
{
if (!_knownTraceIds.ContainsKey(span.TraceId))
{
return;
if (!TraceRegistry.IsRegistered(span.TraceId))
{
return;
}

_knownTraceIds.TryAdd(span.TraceId, 0);
}

// Prefer per-test cap when the span's direct parent is a known test case span.
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 @@ -49,6 +49,9 @@ internal sealed class ReportData

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

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

internal sealed class ReportSummary
Expand Down
55 changes: 43 additions & 12 deletions TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1610,6 +1610,34 @@ function walk(sid) {
return traceSpans.filter(s => included.has(s.spanId));
}

// '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.
function collapseTestBodySpans(spans) {
if (!spans || !spans.length) return [];
const byId = {};
spans.forEach(function(s) { byId[s.spanId] = s; });
const testBodyIds = new Set(
spans
.filter(function(s) { return s.name === 'test body'; })
.map(function(s) { return s.spanId; })
);
if (!testBodyIds.size) return spans;
return spans
.filter(function(s) { return !testBodyIds.has(s.spanId); })
.map(function(s) {
if (!s.parentSpanId || !testBodyIds.has(s.parentSpanId)) return s;
const testBody = byId[s.parentSpanId];
// testBody.parentSpanId is the test-case span in TUnit's model — null fallback
// is defensive only; a parentless test-body span shouldn't occur in practice.
return {
...s,
parentSpanId: testBody && testBody.parentSpanId ? testBody.parentSpanId : null
};
});
}

// Render a span waterfall from a filtered list of spans
function renderSpanRows(sp, uid) {
if (!sp || !sp.length) return '';
Expand Down Expand Up @@ -1653,14 +1681,8 @@ function gd(s) {
function renderTrace(tid, rootSpanId) {
const allSpans = spansByTrace[tid];
if (!allSpans || !allSpans.length) return '';
let sp = getDescendants(allSpans, rootSpanId);
let sp = collapseTestBodySpans(getDescendants(allSpans, rootSpanId));
if (!sp.length) return '';
// 'test body' must match TUnitActivitySource.SpanTestBody in C#
const directChildren = sp.filter(s => s.parentSpanId === rootSpanId);
if (directChildren.length === 1 && directChildren[0].name === 'test body') {
const tbId = directChildren[0].spanId;
sp = sp.filter(s => s.spanId !== tbId).map(s => s.parentSpanId === tbId ? {...s, parentSpanId: rootSpanId} : s);
}
if (sp.length <= 1) return '';
return '<div class="d-sec"><div class="d-lbl">Trace Timeline</div>' + renderSpanRows(sp, 't-' + rootSpanId) + '</div>';
}
Expand Down Expand Up @@ -1720,11 +1742,20 @@ function renderSuiteTrace(className) {
const allSpans = spansByTrace[suite.traceId];
if (!allSpans) return '';
const all = getDescendants(allSpans, suite.spanId);
const testCaseIds = new Set();
all.forEach(s => { if (s.spanType === 'test case') testCaseIds.add(s.spanId); });
const tcDescendants = new Set();
testCaseIds.forEach(id => { getDescendants(all, id).forEach(s => { if (s.spanId !== id) tcDescendants.add(s.spanId); }); });
const filtered = all.filter(s => !tcDescendants.has(s.spanId) && !testCaseIds.has(s.spanId));
let filtered;
if (data.expandClassTimeline) {
// 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);
} else {
// Default: drop test-case spans and their full subtrees so the class timeline
// shows only class-level infrastructure (suite, init/dispose, parallel coordination).
const testCaseIds = new Set();
all.forEach(s => { if (s.spanType === 'test case') testCaseIds.add(s.spanId); });
const tcDescendants = new Set();
testCaseIds.forEach(id => { getDescendants(all, id).forEach(s => { if (s.spanId !== id) tcDescendants.add(s.spanId); }); });
filtered = all.filter(s => !tcDescendants.has(s.spanId) && !testCaseIds.has(s.spanId));
}
// Include parent spans (assembly, session) for context
let ancestor = suite.parentSpanId ? bySpanId[suite.parentSpanId] : null;
while (ancestor) {
Expand Down
Loading
Loading