diff --git a/TUnit.Aspire/AspireFixture.cs b/TUnit.Aspire/AspireFixture.cs index 8acabf65e4..f4744ec315 100644 --- a/TUnit.Aspire/AspireFixture.cs +++ b/TUnit.Aspire/AspireFixture.cs @@ -361,6 +361,16 @@ private void RemoveResources(IDistributedApplicationTestingBuilder builder) /// 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..."); @@ -386,6 +396,8 @@ 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"; @@ -393,13 +405,56 @@ public virtual async ValueTask DisposeAsync() 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(); } + /// + /// Parses the OTEL_EXPORTER_OTLP_HEADERS format (key1=value1,key2=value2) + /// into a header list. The Aspire dashboard requires x-otlp-api-key on its OTLP + /// endpoints when auth is enabled (the default since Aspire 9), so without forwarding + /// these the dashboard rejects every proxied span. + /// + private static IReadOnlyDictionary? ParseOtlpHeaders(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return null; + } + + var result = new Dictionary(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}"; diff --git a/TUnit.Core/Settings/ReportSettings.cs b/TUnit.Core/Settings/ReportSettings.cs new file mode 100644 index 0000000000..f8b31aa42f --- /dev/null +++ b/TUnit.Core/Settings/ReportSettings.cs @@ -0,0 +1,19 @@ +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 cc15574a89..bb864d535a 100644 --- a/TUnit.Core/Settings/TUnitSettings.cs +++ b/TUnit.Core/Settings/TUnitSettings.cs @@ -38,4 +38,9 @@ internal TUnitSettings() { } /// Controls test run behavior. /// public ExecutionSettings Execution { get; } = new(); + + /// + /// Controls HTML report rendering. + /// + public ReportSettings Report { get; } = new(); } diff --git a/TUnit.Core/TUnit.Core.csproj b/TUnit.Core/TUnit.Core.csproj index 81a5b5be0e..a6eec1aeec 100644 --- a/TUnit.Core/TUnit.Core.csproj +++ b/TUnit.Core/TUnit.Core.csproj @@ -11,6 +11,7 @@ + diff --git a/TUnit.Core/TraceRegistry.cs b/TUnit.Core/TraceRegistry.cs index 7259429f7b..a294bf6d7c 100644 --- a/TUnit.Core/TraceRegistry.cs +++ b/TUnit.Core/TraceRegistry.cs @@ -1,5 +1,6 @@ #if NET using System.Collections.Concurrent; +using System.Diagnostics; namespace TUnit.Core; @@ -63,6 +64,59 @@ internal static bool IsRegistered(string traceId) return TraceToContextId.GetValueOrDefault(traceId); } + /// + /// Associates with the same test(s) as + /// . Useful for messaging/queue consumers that start + /// a new trace but keep a causal link to the original test trace via OTEL span links. + /// + /// + /// true 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, false. + /// + /// A true result does NOT guarantee log routing — if the source trace has no + /// context-id mapping (only added by the 3-arg ), + /// span correlation succeeds but log records for the derived trace fall through + /// and are dropped. The case is logged via + /// . + /// + /// + 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; + } + /// /// Gets all trace IDs registered for the given test node UID. /// Used by HtmlReporter to populate additional trace IDs on test results. diff --git a/TUnit.Engine.Tests/HtmlReporterTests.cs b/TUnit.Engine.Tests/HtmlReporterTests.cs index c94041b47a..d068562aee 100644 --- a/TUnit.Engine.Tests/HtmlReporterTests.cs +++ b/TUnit.Engine.Tests/HtmlReporterTests.cs @@ -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; @@ -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 ", + 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() { @@ -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, + }; } diff --git a/TUnit.Engine/Reporters/Html/ActivityCollector.cs b/TUnit.Engine/Reporters/Html/ActivityCollector.cs index f57ee760a2..e233d07aa4 100644 --- a/TUnit.Engine/Reporters/Html/ActivityCollector.cs +++ b/TUnit.Engine/Reporters/Html/ActivityCollector.cs @@ -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. diff --git a/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs b/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs index 5589f26169..0a284c4e5c 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs @@ -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 diff --git a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs index 7eea57d877..0ca56b4950 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs @@ -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 ''; @@ -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 '
Trace Timeline
' + renderSpanRows(sp, 't-' + rootSpanId) + '
'; } @@ -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) { diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs index 5c3213503f..a8cf5898f0 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Globalization; using System.Net; using System.Reflection; using System.Runtime.InteropServices; @@ -11,6 +12,7 @@ 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; @@ -336,7 +338,7 @@ private ReportData BuildReportData() ClassName = kvp.Key, Namespace = groupNamespaces.GetValueOrDefault(kvp.Key, ""), Summary = groupSummary, - Tests = kvp.Value.ToArray() + Tests = OrderTestsForDisplay(kvp.Value) }; } @@ -376,7 +378,8 @@ private ReportData BuildReportData() CommitSha = commitSha, Branch = branch, PullRequestNumber = prNumber, - RepositorySlug = repoSlug + RepositorySlug = repoSlug, + ExpandClassTimeline = TUnitSettings.Default.Report.ExpandClassTimeline, }; } @@ -465,6 +468,24 @@ internal static string[] FilterAdditionalTraceIds(string[] allTraceIds, string? } #endif + internal static ReportTestResult[] OrderTestsForDisplay(IEnumerable tests) + { + // Parse to DateTimeOffset so the sort works regardless of how the caller formatted + // StartTime — production writes UTC ISO-8601, but tests construct ReportTestResult + // directly via InternalsVisibleTo and could pass non-UTC offsets. + return tests + .OrderBy(static test => ParseStartTimeForSort(test.StartTime)) + .ThenBy(static test => test.DisplayName, StringComparer.Ordinal) + .ToArray(); + } + + private static DateTimeOffset ParseStartTimeForSort(string? raw) + { + return DateTimeOffset.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed) + ? parsed + : DateTimeOffset.MaxValue; + } + private static ReportTestResult ExtractTestResult(string testId, TestNode testNode, string? traceId, string? spanId, int retryAttempt, string[]? additionalTraceIds) { IProperty? stateProperty = null; @@ -533,8 +554,8 @@ private static ReportTestResult ExtractTestResult(string testId, TestNode testNo ClassName = className, Status = status, DurationMs = durationMs, - StartTime = startTime?.ToString("o"), - EndTime = endTime?.ToString("o"), + StartTime = startTime?.ToUniversalTime().ToString("o"), + EndTime = endTime?.ToUniversalTime().ToString("o"), Exception = exception, Output = stdOut, ErrorOutput = stdErr, diff --git a/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs b/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs index 22ea4c078c..7973e7634d 100644 --- a/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs +++ b/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Net; using OpenTelemetry; using OpenTelemetry.Exporter; using OpenTelemetry.Trace; @@ -11,6 +12,11 @@ namespace TUnit.OpenTelemetry.Tests; public class OtlpReceiverIngestionTests { + private const ulong SpanKindConsumer = 5; // OTLP SpanKind.CONSUMER + private const string TestSpanId = "0123456789abcdef"; + private const ulong TestStartTimeUnixNano = 1; + private const ulong TestEndTimeUnixNano = 2; + [Test] public async Task Receiver_ParsedTrace_ReachesActivityCollector() { @@ -47,4 +53,340 @@ public async Task Receiver_ParsedTrace_ReachesActivityCollector() await Assert.That(span).IsNotNull(); } + + [Test] + public async Task Receiver_Diagnostics_ClassifiesEachRequestPath() + { + await using var receiver = new OtlpReceiver(); + receiver.Start(); + + using var client = new HttpClient(); + using var emptyTraces = new ByteArrayContent(Array.Empty()); + using var emptyLogs = new ByteArrayContent(Array.Empty()); + using var emptyMetrics = new ByteArrayContent(Array.Empty()); + using var emptyOther = new ByteArrayContent(Array.Empty()); + + await client.PostAsync($"http://127.0.0.1:{receiver.Port}/v1/traces", emptyTraces); + await client.PostAsync($"http://127.0.0.1:{receiver.Port}/v1/logs", emptyLogs); + await client.PostAsync($"http://127.0.0.1:{receiver.Port}/v1/metrics", emptyMetrics); + await client.PostAsync($"http://127.0.0.1:{receiver.Port}/some/other/path", emptyOther); + + await receiver.WhenIdle(); + + var diag = receiver.Diagnostics; + await Assert.That(diag.TracesRequests).IsEqualTo(1); + await Assert.That(diag.LogsRequests).IsEqualTo(1); + await Assert.That(diag.MetricsRequests).IsEqualTo(1); + await Assert.That(diag.OtherRequests).IsEqualTo(1); + await Assert.That(diag.TotalRequests).IsEqualTo(4); + + var summary = diag.FormatSummary(receiver.Port); + await Assert.That(summary).Contains("requests.v1_traces = 1"); + await Assert.That(summary).Contains("requests.v1_metrics = 1"); + await Assert.That(summary).Contains("other_path[/some/other/path] = 1"); + } + + [Test] + public async Task Receiver_GrpcRequest_RejectedWith415AndCounted() + { + await using var receiver = new OtlpReceiver(); + receiver.Start(); + + using var client = new HttpClient(); + + using var grpcByContentType = new ByteArrayContent(Array.Empty()); + grpcByContentType.Headers.TryAddWithoutValidation("Content-Type", "application/grpc"); + var contentTypeResponse = await client.PostAsync($"http://127.0.0.1:{receiver.Port}/v1/traces", grpcByContentType); + + using var grpcByPath = new ByteArrayContent(Array.Empty()); + var pathResponse = await client.PostAsync( + $"http://127.0.0.1:{receiver.Port}/opentelemetry.proto.collector.trace.v1.TraceService/Export", + grpcByPath); + + await receiver.WhenIdle(); + + await Assert.That((int)contentTypeResponse.StatusCode).IsEqualTo(415); + await Assert.That((int)pathResponse.StatusCode).IsEqualTo(415); + await Assert.That(receiver.Diagnostics.GrpcRejected).IsEqualTo(2); + await Assert.That(receiver.Diagnostics.TracesRequests).IsEqualTo(0); + + // Content-type-triggered rejection must NOT pollute the unknown-paths map with + // /v1/traces — only the path-triggered rejection should record its path. + var summary = receiver.Diagnostics.FormatSummary(receiver.Port); + await Assert.That(summary).DoesNotContain("other_path[/v1/traces]"); + await Assert.That(summary).Contains("other_path[/opentelemetry.proto.collector.trace.v1.TraceService/Export]"); + } + + [Test] + public async Task Receiver_BatchOfSameTrace_CountsRegistrationOncePerTrace() + { + await using var receiver = new OtlpReceiver(); + receiver.Start(); + + // Register a fake trace so all three spans hit the "already registered" path. + var traceId = Guid.NewGuid().ToString("N"); + TraceRegistry.Register(traceId, "fake-test-context-id"); + + var body = BuildMultiSpanBatch(traceId, spanCount: 3); + + using var client = new HttpClient(); + using var content = new ByteArrayContent(body); + content.Headers.ContentType = new("application/x-protobuf"); + await client.PostAsync($"http://127.0.0.1:{receiver.Port}/v1/traces", content); + + await receiver.WhenIdle(); + + await Assert.That(receiver.Diagnostics.TracesSpansParsed).IsEqualTo(3); + await Assert.That(receiver.Diagnostics.TracesAlreadyRegistered).IsEqualTo(1); + await Assert.That(receiver.Diagnostics.TracesNoMatch).IsEqualTo(0); + } + + [Test] + public async Task Receiver_Forwarding_PropagatesHeadersAndCountsSuccess() + { + // Mock upstream stands in for the Aspire dashboard — gates on the api-key header + // so we can prove the receiver actually propagated it instead of just dropping body + // bytes onto a permissive endpoint. + using var upstreamListener = new HttpListener(); + var upstreamPort = LoopbackHttpListenerFactory.FindFreePort(); + upstreamListener.Prefixes.Add($"http://127.0.0.1:{upstreamPort}/"); + upstreamListener.Start(); + + var receivedAuth = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var listenerTask = Task.Run(async () => + { + var ctx = await upstreamListener.GetContextAsync(); + receivedAuth.TrySetResult(ctx.Request.Headers["x-otlp-api-key"]); + ctx.Response.StatusCode = 200; + ctx.Response.Close(); + }); + + try + { + await using var receiver = new OtlpReceiver($"http://127.0.0.1:{upstreamPort}") + { + UpstreamHeaders = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["x-otlp-api-key"] = "test-token-abc", + }, + }; + receiver.Start(); + + using var client = new HttpClient(); + using var content = new ByteArrayContent(Array.Empty()); + content.Headers.ContentType = new("application/x-protobuf"); + await client.PostAsync($"http://127.0.0.1:{receiver.Port}/v1/traces", content); + + await receiver.DrainAsync(TimeSpan.FromSeconds(3)); + + var auth = await receivedAuth.Task.WaitAsync(TimeSpan.FromSeconds(2)); + + await Assert.That(auth).IsEqualTo("test-token-abc"); + await Assert.That(receiver.Diagnostics.UpstreamForwardSuccess).IsEqualTo(1); + await Assert.That(receiver.Diagnostics.UpstreamForwardFailures).IsEqualTo(0); + } + finally + { + upstreamListener.Stop(); + // Ignore — listener context may already be torn down on assertion failure. + try { await listenerTask; } catch { } + } + } + + [Test] + public async Task Receiver_DrainAsync_WaitsForLatePostBeforeReturning() + { + await using var receiver = new OtlpReceiver(); + receiver.Start(); + + // Simulate a SUT exporter that flushes a couple hundred ms after the test logic + // would finish — without DrainAsync, AspireFixture would tear down the AppHost + // and the late POST would fail / be dropped. + var latePost = Task.Run(async () => + { + await Task.Delay(TimeSpan.FromMilliseconds(200)); + using var client = new HttpClient(); + using var content = new ByteArrayContent(Array.Empty()); + await client.PostAsync($"http://127.0.0.1:{receiver.Port}/v1/traces", content); + }); + + var drainStart = DateTime.UtcNow; + await receiver.DrainAsync(TimeSpan.FromSeconds(3)); + var drainElapsed = DateTime.UtcNow - drainStart; + + await latePost; + + await Assert.That(receiver.Diagnostics.TracesRequests).IsEqualTo(1); + // The drain must have waited past the first 250ms stable window — otherwise the + // 200ms-delayed POST would have landed after the drain returned. Lower bound is + // 350ms to leave headroom for CI scheduling jitter; the real invariant is "drain + // didn't return at ~250ms". + await Assert.That(drainElapsed).IsGreaterThanOrEqualTo(TimeSpan.FromMilliseconds(350)); + // And it must respect the cap — no point waiting indefinitely once quiet. + await Assert.That(drainElapsed).IsLessThan(TimeSpan.FromSeconds(3)); + } + + [Test] + public async Task Receiver_ParsedLinkedTrace_RegistersAgainstOwningTest() + { + var collector = ActivityCollector.Current; + await Assert.That(collector).IsNotNull(); + await Assert.That(Activity.Current).IsNotNull(); + + await using var receiver = new OtlpReceiver(); + receiver.Start(); + + var linkedContext = Activity.Current!.Context; + var derivedTraceId = Guid.NewGuid().ToString("N"); + var body = BuildLinkedTraceExportRequest( + derivedTraceId, + TestSpanId, + "sut-linked-op", + linkedContext.TraceId.ToString(), + linkedContext.SpanId.ToString()); + + using var client = new HttpClient(); + using var content = new ByteArrayContent(body); + content.Headers.ContentType = new("application/x-protobuf"); + var response = await client.PostAsync($"http://127.0.0.1:{receiver.Port}/v1/traces", content); + + await Assert.That(response.IsSuccessStatusCode).IsTrue(); + await receiver.WhenIdle(); + + var span = collector!.GetAllSpans().FirstOrDefault(s => + s.TraceId == derivedTraceId && s.Name == "sut-linked-op"); + + await Assert.That(span).IsNotNull(); + await Assert.That(TraceRegistry.IsRegistered(derivedTraceId)).IsTrue(); + await Assert.That(TraceRegistry.GetContextId(derivedTraceId)).IsEqualTo(TestContext.Current!.Id); + } + + private static byte[] BuildMultiSpanBatch(string traceId, int spanCount) + { + // Build N sibling spans that all share traceId but have distinct spanIds. This + // exercises the per-batch dedupe in ProcessTraces — each span should NOT bump + // the registration counter. + using var scopeStream = new MemoryStream(); + for (var i = 0; i < spanCount; i++) + { + var spanId = i.ToString("X16"); + using var spanStream = new MemoryStream(); + WriteField(spanStream, 1, Convert.FromHexString(traceId)); + WriteField(spanStream, 2, Convert.FromHexString(spanId)); + WriteStringField(spanStream, 5, $"sut-batch-op-{i}"); + WriteVarintField(spanStream, 6, SpanKindConsumer); + WriteFixed64Field(spanStream, 7, TestStartTimeUnixNano); + WriteFixed64Field(spanStream, 8, TestEndTimeUnixNano); + WriteField(scopeStream, 2, spanStream.ToArray()); + } + + using var resourceStream = new MemoryStream(); + WriteField(resourceStream, 2, scopeStream.ToArray()); + using var exportStream = new MemoryStream(); + WriteField(exportStream, 1, resourceStream.ToArray()); + return exportStream.ToArray(); + } + + private static byte[] BuildLinkedTraceExportRequest( + string derivedTraceId, + string derivedSpanId, + string spanName, + string sourceTraceId, + string sourceSpanId) + { + using var exportStream = new MemoryStream(); + var resourceSpans = BuildResourceSpans( + BuildScopeSpans( + BuildSpan(derivedTraceId, derivedSpanId, spanName, sourceTraceId, sourceSpanId))); + WriteField(exportStream, 1, resourceSpans); + return exportStream.ToArray(); + } + + private static byte[] BuildResourceSpans(byte[] scopeSpans) + { + using var stream = new MemoryStream(); + WriteField(stream, 2, scopeSpans); + return stream.ToArray(); + } + + private static byte[] BuildScopeSpans(byte[] span) + { + using var stream = new MemoryStream(); + WriteField(stream, 2, span); + return stream.ToArray(); + } + + private static byte[] BuildSpan( + string derivedTraceId, + string derivedSpanId, + string spanName, + string sourceTraceId, + string sourceSpanId) + { + using var stream = new MemoryStream(); + WriteField(stream, 1, Convert.FromHexString(derivedTraceId)); + WriteField(stream, 2, Convert.FromHexString(derivedSpanId)); + WriteStringField(stream, 5, spanName); + WriteVarintField(stream, 6, SpanKindConsumer); + WriteFixed64Field(stream, 7, TestStartTimeUnixNano); // start_time_unix_nano + WriteFixed64Field(stream, 8, TestEndTimeUnixNano); // end_time_unix_nano + WriteField(stream, 13, BuildSpanLink(sourceTraceId, sourceSpanId)); + return stream.ToArray(); + } + + private static byte[] BuildSpanLink(string traceId, string spanId) + { + using var stream = new MemoryStream(); + WriteField(stream, 1, Convert.FromHexString(traceId)); + WriteField(stream, 2, Convert.FromHexString(spanId)); + return stream.ToArray(); + } + + private static void WriteStringField(MemoryStream stream, int fieldNumber, string value) + { + WriteField(stream, fieldNumber, System.Text.Encoding.UTF8.GetBytes(value)); + } + + private static void WriteVarintField(MemoryStream stream, int fieldNumber, ulong value) + { + WriteTag(stream, fieldNumber, 0); + WriteVarint(stream, value); + } + + private static void WriteFixed64Field(MemoryStream stream, int fieldNumber, ulong value) + { + WriteTag(stream, fieldNumber, 1); + stream.Write(BitConverter.GetBytes(value)); + } + + private static void WriteField(MemoryStream stream, int fieldNumber, byte[] value) + { + WriteTag(stream, fieldNumber, 2); + WriteVarint(stream, (ulong)value.Length); + stream.Write(value); + } + + private static void WriteTag(MemoryStream stream, int fieldNumber, int wireType) + { + WriteVarint(stream, (ulong)((fieldNumber << 3) | wireType)); + } + + private static void WriteVarint(MemoryStream stream, ulong value) + { + // Manual protobuf encoding keeps this regression test self-contained and avoids + // introducing a protobuf dependency just to build one OTLP payload. + // Standard protobuf wire-format varint encoding: integers are emitted in + // 7-bit chunks and the high bit marks that another byte follows. + do + { + var current = (byte)(value & 0x7F); + value >>= 7; + if (value != 0) + { + current |= 0x80; + } + + stream.WriteByte(current); + } while (value != 0); + } } diff --git a/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs b/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs index 2f26ce7f4b..5403c904a8 100644 --- a/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs +++ b/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs @@ -26,16 +26,19 @@ namespace TUnit.OpenTelemetry.Receiver; internal sealed class OtlpReceiver : IAsyncDisposable { private const long MaxBodyBytes = 16 * 1024 * 1024; // 16 MB + private const string DrainWindowEnvVar = "TUNIT_OTLP_DRAIN_MS"; + private const string DiagnosticsDumpEnvVar = "TUNIT_OTLP_DEBUG"; private static readonly HttpClient s_forwardingClient = new(); private readonly HttpListener _listener; private readonly CancellationTokenSource _cts = new(); private readonly ConcurrentDictionary _inflightTasks = new(); + private readonly OtlpReceiverDiagnostics _diagnostics = new(); private string? _upstreamEndpoint; + private IReadOnlyDictionary? _upstreamHeaders; private Task? _listenTask; private int _taskIdCounter; - private int _requestCount; /// /// The port the receiver is listening on. @@ -43,9 +46,18 @@ internal sealed class OtlpReceiver : IAsyncDisposable public int Port { get; } /// - /// The number of POST requests successfully processed. + /// Total POST requests received, including those rejected (e.g. gRPC content-type) + /// before signal-specific processing. Use the per-signal counters + /// (LogsRequests, TracesRequests, MetricsRequests, GrpcRejected) + /// on for finer-grained breakdown. /// - internal int RequestCount => _requestCount; + internal int RequestCount => _diagnostics.TotalRequests; + + /// + /// Per-counter snapshot of receiver activity. Useful in tests and when diagnosing + /// silent drops in user environments via . + /// + internal OtlpReceiverDiagnostics Diagnostics => _diagnostics; /// /// Creates a new OTLP receiver. @@ -71,6 +83,17 @@ public string? UpstreamEndpoint set => Volatile.Write(ref _upstreamEndpoint, value?.TrimEnd('/')); } + /// + /// Optional headers attached to upstream forwarded requests. The Aspire dashboard + /// gates its OTLP endpoints on an x-otlp-api-key header; without it, forwarding + /// returns 401 and the dashboard never sees the SUT's spans. + /// + public IReadOnlyDictionary? UpstreamHeaders + { + get => Volatile.Read(ref _upstreamHeaders); + set => Volatile.Write(ref _upstreamHeaders, value); + } + /// /// Starts accepting OTLP requests. /// @@ -106,6 +129,90 @@ private async Task ListenLoop() /// internal Task WhenIdle() => Task.WhenAll(_inflightTasks.Values); + /// + /// Waits for the receiver to go quiet — no in-flight tasks and no new POSTs for a short + /// stable window — up to . Intended for the session-end boundary + /// where the SUT's BatchSpanProcessor may still have unflushed spans queued; without + /// this, fast tests can finish and tear down their AppHost before exporters drain, dropping + /// the trailing telemetry the report is meant to show. + /// + /// + /// Best-effort heuristic: the drain returns as soon as no new requests arrive for the + /// stable window. Spans the SUT exports after the drain returns can still be missed — + /// this isn't an explicit OTel flush, since exporters in another process can't be + /// signalled directly. Increase TUNIT_OTLP_DRAIN_MS if your exporter's batch + /// schedule is longer than the default 2s. + /// + /// The internal stable window (250 ms of inactivity) is fixed; only the total cap + /// is configurable via TUNIT_OTLP_DRAIN_MS. + /// + /// + /// Maximum total time to wait. Defaults to . + /// Stops the wait early. + public async Task DrainAsync(TimeSpan? window = null, CancellationToken cancellationToken = default) + { + var stableFor = TimeSpan.FromMilliseconds(250); + var totalWindow = window ?? DefaultDrainWindow; + var clock = Stopwatch.StartNew(); + + while (!cancellationToken.IsCancellationRequested) + { + var beforeCount = Volatile.Read(ref _diagnostics.TotalRequests); + + try + { + await WhenIdle().ConfigureAwait(false); + } + catch + { + // Individual request failures already logged via Trace.WriteLine; the drain + // is best-effort and shouldn't surface them. + } + + var remaining = totalWindow - clock.Elapsed; + if (remaining <= TimeSpan.Zero) + { + return; + } + + try + { + await Task.Delay(stableFor < remaining ? stableFor : remaining, cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + + var afterCount = Volatile.Read(ref _diagnostics.TotalRequests); + if (afterCount == beforeCount && _inflightTasks.IsEmpty) + { + return; + } + } + } + + /// + /// Default drain window applied by when no value is supplied. + /// Honours the TUNIT_OTLP_DRAIN_MS environment variable, captured once at type + /// init so repeated reads don't pay env-var lookup cost. + /// + public static TimeSpan DefaultDrainWindow { get; } = ResolveDefaultDrainWindow(); + + private static TimeSpan ResolveDefaultDrainWindow() + { + var raw = Environment.GetEnvironmentVariable(DrainWindowEnvVar); + if (!string.IsNullOrEmpty(raw) + && int.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var ms) + && ms >= 0) + { + return TimeSpan.FromMilliseconds(ms); + } + + return TimeSpan.FromSeconds(2); + } + private void TrackTask(Task task) { var id = Interlocked.Increment(ref _taskIdCounter); @@ -141,6 +248,35 @@ private async Task ProcessRequestAsync(HttpListenerContext context) return; } + var path = request.Url?.AbsolutePath ?? ""; + + if (LooksLikeGrpc(request.ContentType, path)) + { + // HttpListener is HTTP/1.1-only — most gRPC clients won't even reach us, but + // h2c-fallback or grpc-web requests can. Reject explicitly with 415 so the + // SUT exporter logs an error instead of silently retrying, and surface the + // mismatch in the diagnostic dump. Fix is to set + // OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf (already injected by + // TUnit.Aspire on every ProjectResource). + Interlocked.Increment(ref _diagnostics.GrpcRejected); + Interlocked.Increment(ref _diagnostics.TotalRequests); + // Only record the path under "unknown" when path itself triggered detection; + // otherwise we'd add /v1/traces to the unknown-paths map on every gRPC-by- + // content-type rejection, which is actively misleading in the dump. + if (path.StartsWith("/opentelemetry.proto.collector.", StringComparison.Ordinal)) + { + _diagnostics.RecordUnknownPath(path); + } + + response.StatusCode = 415; + response.ContentType = "text/plain"; + ReadOnlySpan msg = "TUnit OTLP receiver does not support gRPC. Set OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf.\n"u8; + response.ContentLength64 = msg.Length; + response.OutputStream.Write(msg); + response.Close(); + return; + } + // ContentLength64 is -1 for chunked; size-known path avoids MemoryStream growth copies. byte[] body; if (request.ContentLength64 >= 0) @@ -170,15 +306,26 @@ private async Task ProcessRequestAsync(HttpListenerContext context) body = ms.ToArray(); } - var path = request.Url?.AbsolutePath ?? ""; - if (path == "/v1/logs") { - ProcessLogs(body); + Interlocked.Increment(ref _diagnostics.LogsRequests); + ProcessLogs(body, _diagnostics); } else if (path == "/v1/traces") { - ProcessTraces(body); + Interlocked.Increment(ref _diagnostics.TracesRequests); + ProcessTraces(body, _diagnostics); + } + else if (path == "/v1/metrics") + { + // Standard OTLP/HTTP signal — accepted and forwarded upstream, but we don't + // render metrics in the report so there's nothing to parse. + Interlocked.Increment(ref _diagnostics.MetricsRequests); + } + else + { + Interlocked.Increment(ref _diagnostics.OtherRequests); + _diagnostics.RecordUnknownPath(path); } var upstream = Volatile.Read(ref _upstreamEndpoint); @@ -187,7 +334,7 @@ private async Task ProcessRequestAsync(HttpListenerContext context) TrackTask(ForwardAsync(upstream, path, body, request.ContentType)); } - Interlocked.Increment(ref _requestCount); + Interlocked.Increment(ref _diagnostics.TotalRequests); response.StatusCode = 200; response.ContentType = "application/x-protobuf"; @@ -209,11 +356,12 @@ private async Task ProcessRequestAsync(HttpListenerContext context) } } - private static void ProcessTraces(byte[] body) + private static void ProcessTraces(byte[] body, OtlpReceiverDiagnostics diag) { var sink = ExternalSpanSink.Current; if (sink is null) { + Interlocked.Increment(ref diag.TracesNoSink); return; } @@ -224,16 +372,51 @@ private static void ProcessTraces(byte[] body) } catch (Exception ex) { + Interlocked.Increment(ref diag.TracesParseFailures); Trace.WriteLine($"[TUnit.OpenTelemetry] Failed to parse /v1/traces body: {ex.GetType().Name}: {ex.Message}"); return; } + Interlocked.Add(ref diag.TracesSpansParsed, spans.Count); + + // Dedupe registration attempts per batch — without this, every span in an + // already-known trace would bump the diagnostic counter, making + // "50 spans, all in a known trace" indistinguishable from "50 spans, 50 misses". + var registrationAttempted = spans.Count > 1 + ? new HashSet(StringComparer.OrdinalIgnoreCase) + : null; + foreach (var span in spans) { + if (registrationAttempted is null || registrationAttempted.Add(span.TraceId)) + { + RegisterDerivedTrace(span, diag); + } + sink(ToSpanData(span)); } } + private static void RegisterDerivedTrace(OtlpSpanRecord span, OtlpReceiverDiagnostics diag) + { + if (TraceRegistry.IsRegistered(span.TraceId)) + { + Interlocked.Increment(ref diag.TracesAlreadyRegistered); + return; + } + + foreach (var link in span.Links) + { + if (TraceRegistry.TryRegisterDerivedTrace(span.TraceId, link.TraceId)) + { + Interlocked.Increment(ref diag.TracesRegisteredViaLink); + return; + } + } + + Interlocked.Increment(ref diag.TracesNoMatch); + } + private static SpanData ToSpanData(OtlpSpanRecord span) { ReportKeyValue[]? tags = null; @@ -333,7 +516,7 @@ private static string MapStatusCode(int code) => ? ((ActivityStatusCode)code).ToString() : nameof(ActivityStatusCode.Unset); - private static void ProcessLogs(byte[] body) + private static void ProcessLogs(byte[] body, OtlpReceiverDiagnostics diag) { List records; try @@ -342,14 +525,18 @@ private static void ProcessLogs(byte[] body) } catch (Exception ex) { + Interlocked.Increment(ref diag.LogsParseFailures); Trace.WriteLine($"[TUnit.OpenTelemetry] Failed to parse /v1/logs body: {ex.GetType().Name}: {ex.Message}"); return; } + Interlocked.Add(ref diag.LogsRecordsParsed, records.Count); + foreach (var record in records) { if (string.IsNullOrEmpty(record.TraceId)) { + Interlocked.Increment(ref diag.LogsRecordsNoTraceId); continue; } @@ -357,12 +544,14 @@ private static void ProcessLogs(byte[] body) var contextId = TraceRegistry.GetContextId(record.TraceId); if (contextId is null) { + Interlocked.Increment(ref diag.LogsRecordsTraceNotRegistered); continue; } var testContext = TestContext.GetById(contextId); if (testContext is null) { + Interlocked.Increment(ref diag.LogsRecordsTestContextMissing); continue; } @@ -373,25 +562,50 @@ private static void ProcessLogs(byte[] body) : $"[{record.ResourceName}] "; testContext.Output.WriteLine($"{prefix}[{severity}] {record.Body}"); + Interlocked.Increment(ref diag.LogsRecordsRouted); } } - private static async Task ForwardAsync(string upstream, string path, byte[] body, string? contentType) + private async Task ForwardAsync(string upstream, string path, byte[] body, string? contentType) { try { - using var content = new ByteArrayContent(body); + using var request = new HttpRequestMessage(HttpMethod.Post, $"{upstream}{path}"); + request.Content = new ByteArrayContent(body); if (contentType is not null) { - content.Headers.TryAddWithoutValidation("Content-Type", contentType); + request.Content.Headers.TryAddWithoutValidation("Content-Type", contentType); } - await s_forwardingClient.PostAsync( - $"{upstream}{path}", - content).ConfigureAwait(false); + var headers = Volatile.Read(ref _upstreamHeaders); + if (headers is not null) + { + foreach (var header in headers) + { + request.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + + using var response = await s_forwardingClient + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead) + .ConfigureAwait(false); + if (response.IsSuccessStatusCode) + { + Interlocked.Increment(ref _diagnostics.UpstreamForwardSuccess); + } + else + { + Interlocked.Increment(ref _diagnostics.UpstreamForwardFailures); + // Strip CR/LF from ReasonPhrase before logging — it's an HTTP response header + // value and could otherwise inject newlines into the trace output, breaking + // log parsing tools (CodeQL log-injection rule). + var safePhrase = response.ReasonPhrase?.Replace('\r', ' ').Replace('\n', ' '); + Trace.WriteLine($"[TUnit.OpenTelemetry] Upstream {path} returned {(int)response.StatusCode} {safePhrase}."); + } } catch (Exception ex) { + Interlocked.Increment(ref _diagnostics.UpstreamForwardFailures); Trace.WriteLine($"[TUnit.OpenTelemetry] OTLP forwarding to upstream failed: {ex.Message}"); } } @@ -433,9 +647,57 @@ public async ValueTask DisposeAsync() // Best effort — individual failures already logged via Trace.WriteLine } + // Distinguish "SUT didn't export anything" from "SUT exported but TUnit dropped it + // on the floor" — opt-in via TUNIT_OTLP_DEBUG=1 so production runs stay quiet. + if (IsDiagnosticsDumpEnabled()) + { + try + { + Console.Error.WriteLine(_diagnostics.FormatSummary(Port)); + } + catch + { + // Best effort — Console may be redirected/closed in some hosts + } + } + _cts.Dispose(); } + /// + /// Returns true if a request looks like gRPC — either by content-type + /// (application/grpc, application/grpc-web, application/grpc+proto) + /// or by an OTLP gRPC service path (/opentelemetry.proto.collector.{trace,logs,metrics}.v1.*Service/Export). + /// + private static bool LooksLikeGrpc(string? contentType, string path) + { + if (contentType is not null && contentType.StartsWith("application/grpc", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + // OTLP gRPC service paths all live under /opentelemetry.proto.collector. We don't + // need to match the full service+method to recognise the misroute. + return path.StartsWith("/opentelemetry.proto.collector.", StringComparison.Ordinal); + } + + private static bool IsDiagnosticsDumpEnabled() + { + var value = Environment.GetEnvironmentVariable(DiagnosticsDumpEnvVar); + return !string.IsNullOrEmpty(value) + && !string.Equals(value, "0", StringComparison.Ordinal) + && !string.Equals(value, "false", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Writes a one-line-per-counter summary of receiver activity. Intended for the + /// diagnostic dump path — call manually from a test to inspect counters. + /// + internal void WriteDiagnosticsSummary(TextWriter writer) + { + writer.Write(_diagnostics.FormatSummary(Port)); + } + /// /// Creates an bound to a free port. Uses a retry loop to /// handle the TOCTOU window between discovering a free port and binding to it. @@ -443,6 +705,106 @@ public async ValueTask DisposeAsync() private static (HttpListener Listener, int Port) CreateListener() => LoopbackHttpListenerFactory.Create(); } +/// +/// Counter snapshot for an . Helps diagnose silent drops by +/// distinguishing between requests that never arrived, parse failures, and spans/logs +/// that arrived but didn't match any registered test trace. +/// +internal sealed class OtlpReceiverDiagnostics +{ + private const int MaxTrackedUnknownPaths = 16; + + private readonly ConcurrentDictionary _unknownPaths = new(StringComparer.Ordinal); + + // Counters are mutated via Interlocked from the listener task and read at session end. + // No snapshot consistency across fields — readers may observe a partially-updated batch. + // Acceptable for a best-effort diagnostic dump; do not use for live decision-making. + // Fields are internal (rather than public) because ref access requires assembly-internal + // visibility at most, and OtlpReceiver lives in the same assembly. + internal int TotalRequests; + internal int LogsRequests; + internal int TracesRequests; + internal int MetricsRequests; + internal int OtherRequests; + internal int GrpcRejected; + + internal int LogsParseFailures; + internal int LogsRecordsParsed; + internal int LogsRecordsNoTraceId; + internal int LogsRecordsTraceNotRegistered; + internal int LogsRecordsTestContextMissing; + internal int LogsRecordsRouted; + + internal int TracesNoSink; + internal int TracesParseFailures; + internal int TracesSpansParsed; + // Per-trace (deduped per batch): how many distinct traces fell into each bucket + // when ProcessTraces attempted registration. + internal int TracesAlreadyRegistered; + internal int TracesRegisteredViaLink; + internal int TracesNoMatch; + + // Upstream forwarding (Aspire dashboard etc.) — distinguish "didn't try" from + // "tried and the dashboard rejected" so users can tell whether the proxy step + // is actually reaching the dashboard. + internal int UpstreamForwardSuccess; + internal int UpstreamForwardFailures; + + /// + /// Records a request path that didn't match any known OTLP signal endpoint. Caps the + /// distinct-path set at to avoid unbounded growth + /// if a misbehaving client cycles through generated URLs. + /// + public void RecordUnknownPath(string path) + { + if (string.IsNullOrEmpty(path)) + { + path = "(empty)"; + } + + // Soft cap: ContainsKey/Count are checked outside the dictionary lock, so concurrent + // callers may push a few entries past MaxTrackedUnknownPaths under a burst. Acceptable + // for a diagnostic-only path — exact enforcement isn't worth the contention. + if (_unknownPaths.ContainsKey(path) || _unknownPaths.Count < MaxTrackedUnknownPaths) + { + _unknownPaths.AddOrUpdate(path, 1, static (_, c) => c + 1); + } + } + + public string FormatSummary(int port) + { + // Keep the layout grep-friendly so users can quickly paste a snippet into an issue. + var sb = new System.Text.StringBuilder(); + sb.AppendLine($"[TUnit.OpenTelemetry] OtlpReceiver diagnostics summary (port {port}):"); + sb.AppendLine($" requests.total = {TotalRequests}"); + sb.AppendLine($" requests.v1_logs = {LogsRequests}"); + sb.AppendLine($" requests.v1_traces = {TracesRequests}"); + sb.AppendLine($" requests.v1_metrics = {MetricsRequests}"); + sb.AppendLine($" requests.grpc_rejected = {GrpcRejected}"); + sb.AppendLine($" requests.other_path = {OtherRequests}"); + foreach (var entry in _unknownPaths) + { + sb.AppendLine($" other_path[{entry.Key}] = {entry.Value}"); + } + + sb.AppendLine($" logs.parse_failures = {LogsParseFailures}"); + sb.AppendLine($" logs.records_parsed = {LogsRecordsParsed}"); + sb.AppendLine($" logs.records_no_trace_id = {LogsRecordsNoTraceId}"); + sb.AppendLine($" logs.records_trace_not_registered = {LogsRecordsTraceNotRegistered}"); + sb.AppendLine($" logs.records_test_context_missing = {LogsRecordsTestContextMissing}"); + sb.AppendLine($" logs.records_routed_to_test = {LogsRecordsRouted}"); + sb.AppendLine($" traces.no_sink_registered = {TracesNoSink}"); + sb.AppendLine($" traces.parse_failures = {TracesParseFailures}"); + sb.AppendLine($" traces.spans_parsed = {TracesSpansParsed}"); + sb.AppendLine($" traces.unique_already_registered = {TracesAlreadyRegistered}"); + sb.AppendLine($" traces.unique_registered_via_link = {TracesRegisteredViaLink}"); + sb.AppendLine($" traces.unique_no_match = {TracesNoMatch}"); + sb.AppendLine($" upstream.forward_success = {UpstreamForwardSuccess}"); + sb.AppendLine($" upstream.forward_failures = {UpstreamForwardFailures}"); + return sb.ToString(); + } +} + /// /// Binds an to a free loopback port, retrying if the /// port is taken between probing and binding (TOCTOU window). @@ -473,7 +835,7 @@ internal static (HttpListener Listener, int Port) Create() throw new InvalidOperationException($"Could not bind loopback HttpListener after {MaxPortBindingAttempts} attempts."); } - private static int FindFreePort() + internal static int FindFreePort() { using var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); 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 7023b1601f..6d96c41c58 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 @@ -2,6 +2,7 @@ [assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] +[assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(@".Microsoft, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] @@ -2974,11 +2975,16 @@ 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 727930ed31..87db8453d8 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 @@ -2,6 +2,7 @@ [assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] +[assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(@".Microsoft, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] @@ -2974,11 +2975,16 @@ 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 7a7fe98dc6..966ca22248 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 @@ -2,6 +2,7 @@ [assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] +[assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(@".Microsoft, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] @@ -2974,11 +2975,16 @@ 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 5a2f24547d..1bee3ea610 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 @@ -2,6 +2,7 @@ [assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] +[assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(@".Microsoft, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] [assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] @@ -2895,11 +2896,16 @@ 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 287fdbd63b..df4ced08be 100644 --- a/TUnit.UnitTests/TUnitSettingsTests.cs +++ b/TUnit.UnitTests/TUnitSettingsTests.cs @@ -18,6 +18,7 @@ public class TUnitSettingsTests private int? _savedMaximumParallelTests; private bool _savedDetailedStackTrace; private bool _savedFailFast; + private bool _savedExpandClassTimeline; [Before(HookType.Test)] public void SnapshotSettings() @@ -29,6 +30,7 @@ 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)] @@ -41,6 +43,7 @@ public void RestoreSettings() TUnitSettings.Default.Parallelism.MaximumParallelTests = _savedMaximumParallelTests; TUnitSettings.Default.Display.DetailedStackTrace = _savedDetailedStackTrace; TUnitSettings.Default.Execution.FailFast = _savedFailFast; + TUnitSettings.Default.Report.ExpandClassTimeline = _savedExpandClassTimeline; } [Test] @@ -53,6 +56,7 @@ 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]