diff --git a/TUnit.Engine.Tests/HtmlReporterTests.cs b/TUnit.Engine.Tests/HtmlReporterTests.cs
index 0006979d1f..ded0ba1eb1 100644
--- a/TUnit.Engine.Tests/HtmlReporterTests.cs
+++ b/TUnit.Engine.Tests/HtmlReporterTests.cs
@@ -132,10 +132,9 @@ public void OrderTestsForDisplay_SortsByStartTime_ThenName()
[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.
+ // Pins the renderer's data contract: a "test body" span with a child span survives
+ // serialisation into the embedded JSON under the owning test (matched by traceId),
+ // with the design's per-test `spans[]` shape (`parent` rather than `parentSpanId`).
const string traceId = "0123456789abcdef0123456789abcdef";
var spans = new[]
{
@@ -161,25 +160,44 @@ public void GenerateHtml_RoundTrips_TestBodySpans_AndChildren_Through_EmbeddedDa
RuntimeVersion = ".NET 10.0",
TotalDurationMs = 0,
Summary = new ReportSummary(),
- Groups = [],
+ Groups =
+ [
+ new ReportTestGroup
+ {
+ ClassName = "SampleTests",
+ Namespace = "Tests",
+ Summary = new ReportSummary(),
+ Tests =
+ [
+ new ReportTestResult
+ {
+ Id = "t1", DisplayName = "t1", MethodName = "t1",
+ ClassName = "SampleTests", Status = "passed",
+ TraceId = traceId,
+ },
+ ],
+ },
+ ],
Spans = spans,
});
var embedded = ExtractEmbeddedReportJson(html);
embedded.ShouldContain("\"name\":\"test body\"");
embedded.ShouldContain("\"name\":\"wiremock-call\"");
- embedded.ShouldContain("\"parentSpanId\":\"aaaaaaaaaaaaaaaa\"");
+ embedded.ShouldContain("\"parent\":\"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);
+ match.Success.ShouldBeTrue("Expected embedded report-data script in rendered HTML.");
+ var payload = match.Groups["payload"].Value.Trim();
+ var compressed = Convert.FromBase64String(payload);
using var ms = new MemoryStream(compressed);
using var gz = new GZipStream(ms, CompressionMode.Decompress);
using var reader = new StreamReader(gz, Encoding.UTF8);
@@ -258,8 +276,7 @@ public void GenerateHtml_RoundTrips_ClassTimeline_CustomProperty_OnTest()
});
var embedded = ExtractEmbeddedReportJson(html);
- embedded.ShouldContain("\"key\":\"tunit.report.timeline\"");
- embedded.ShouldContain("\"value\":\"FullExecution\"");
+ embedded.ShouldContain("\"tunit.report.timeline\":\"FullExecution\"");
}
[Test]
@@ -299,6 +316,140 @@ public void ExtractTestResult_SortsTestMetadataProperty_Into_Categories_And_Cust
result.CustomProperties[0].Value.ShouldBe("TeamA");
}
+ [Test]
+ public void GenerateHtml_StripsSampleDataGeneratorBlock_FromShippedReports()
+ {
+ // The template carries a generateSampleData() preview block bounded by
+ // /* SAMPLE_DATA_BEGIN ... SAMPLE_DATA_END */ markers; LoadAndStripTemplate
+ // must remove it so the rendered report doesn't ship hundreds of lines of
+ // CloudShop fixture data. This test pins that contract.
+ 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 = [],
+ });
+
+ html.ShouldNotContain("SAMPLE_DATA_BEGIN");
+ html.ShouldNotContain("SAMPLE_DATA_END");
+ html.ShouldNotContain("function generateSampleData()");
+ // Dev-machine / preview-only values must never leak into a shipped report.
+ // These live outside the SAMPLE_DATA markers and used to be hardcoded into
+ // the meta-strip; the JS now populates the strip from REPORT data at runtime.
+ html.ShouldNotContain("CloudShop");
+ html.ShouldNotContain("runnervmrw5os");
+ html.ShouldNotContain("Ubuntu 24.04.4 LTS");
+ html.ShouldNotContain("feat/test-categories-demo");
+ html.ShouldNotContain("3e5d27d");
+ html.ShouldNotContain("#5945");
+ }
+
+ [Test]
+ public void GenerateHtml_SubstitutesTitleAndProjectPlaceholders()
+ {
+ // If the placeholders ever drift between template and generator the report
+ // would ship literal "__REPORT_TITLE__" / "__REPORT_PROJECT__" — pin them.
+ var html = HtmlReportGenerator.GenerateHtml(new ReportData
+ {
+ AssemblyName = "MyProject.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 = [],
+ });
+
+ html.ShouldNotContain("__REPORT_TITLE__");
+ html.ShouldNotContain("__REPORT_PROJECT__");
+ html.ShouldContain("
Test Report — MyProject.Tests ");
+ html.ShouldContain("id=\"projectName\">MyProject.Tests<");
+ }
+
+ [Test]
+ public void GenerateHtml_EmitsAttemptsArray_WhenTestWasRetried()
+ {
+ // Per-attempt status/duration drives the renderer's flaky panel + per-test
+ // Attempts strip. Pin that the array survives serialisation when present.
+ 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 = "FlakyTests",
+ Namespace = "Sample",
+ Summary = new ReportSummary(),
+ Tests =
+ [
+ new ReportTestResult
+ {
+ Id = "t1", DisplayName = "t1", MethodName = "t1",
+ ClassName = "FlakyTests", Status = "passed",
+ RetryAttempt = 1,
+ Attempts =
+ [
+ new ReportAttempt { Status = "failed", DurationMs = 120, ExceptionType = "System.TimeoutException", ExceptionMessage = "transient" },
+ new ReportAttempt { Status = "passed", DurationMs = 200 },
+ ],
+ },
+ ],
+ },
+ ],
+ });
+
+ var embedded = ExtractEmbeddedReportJson(html);
+ embedded.ShouldContain("\"attempts\":[");
+ embedded.ShouldContain("\"status\":\"fail\"");
+ embedded.ShouldContain("\"status\":\"pass\"");
+ embedded.ShouldContain("System.TimeoutException");
+ }
+
+ [Test]
+ public void FilterEngineNotices_StripsTUnitPrefixedLines()
+ {
+ // Engine-emitted advisories ("[TUnit] External span cap reached…") are written
+ // to Console.Error and get captured into the running test's stderr. Pin that
+ // the strip removes prefix-matched lines but leaves user output alone.
+ var stderr = "user error before\n[TUnit] External span cap of 100 reached; subsequent spans will be dropped.\nuser error after\nincidental [TUnit] mention in middle";
+ var filtered = HtmlReportGenerator.FilterEngineNotices(stderr);
+ filtered.ShouldBe("user error before\nuser error after\nincidental [TUnit] mention in middle");
+ }
+
+ [Test]
+ public void FilterEngineNotices_ReturnsNull_WhenAllLinesStripped()
+ {
+ // When the test only ever wrote engine-advisory lines, the cleaned stderr
+ // becomes null so the upstream "?? SkipReason ?? string.Empty" fallback fires
+ // instead of an empty error block.
+ var stderr = "[TUnit] notice one\n[TUnit] notice two";
+ HtmlReportGenerator.FilterEngineNotices(stderr).ShouldBeNull();
+ }
+
+ [Test]
+ public void FilterEngineNotices_PassesThroughWhenNoTUnitPrefix()
+ {
+ // Fast-path: no '[TUnit]' substring at all means we return the input unchanged.
+ var stderr = "plain stderr with no engine notices";
+ HtmlReportGenerator.FilterEngineNotices(stderr).ShouldBe(stderr);
+ }
+
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 5589f26169..51069173da 100644
--- a/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs
+++ b/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs
@@ -3,6 +3,10 @@
namespace TUnit.Engine.Reporters.Html;
+// NOTE: the [JsonPropertyName] attributes below are documentation-only. JSON output
+// is hand-written via Utf8JsonWriter in HtmlReportGenerator.SerializeReport; renaming
+// an attribute here will not change the emitted property name. Update the writer.
+
internal sealed class ReportData
{
[JsonPropertyName("assemblyName")]
@@ -149,6 +153,9 @@ internal sealed class ReportTestResult
[JsonPropertyName("retryAttempt")]
public int RetryAttempt { get; init; }
+ [JsonPropertyName("attempts")]
+ public ReportAttempt[]? Attempts { get; init; }
+
[JsonPropertyName("traceId")]
public string? TraceId { get; init; }
@@ -159,6 +166,21 @@ internal sealed class ReportTestResult
public string[]? AdditionalTraceIds { get; init; }
}
+internal sealed class ReportAttempt
+{
+ [JsonPropertyName("status")]
+ public required string Status { get; init; }
+
+ [JsonPropertyName("durationMs")]
+ public double DurationMs { get; init; }
+
+ [JsonPropertyName("exceptionType")]
+ public string? ExceptionType { get; init; }
+
+ [JsonPropertyName("exceptionMessage")]
+ public string? ExceptionMessage { get; init; }
+}
+
internal sealed class ReportExceptionData
{
[JsonPropertyName("type")]
diff --git a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
index f7b6a4097c..4298f11857 100644
--- a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
+++ b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
@@ -1,2469 +1,792 @@
using System.Globalization;
+using System.IO;
using System.IO.Compression;
using System.Net;
using System.Text;
using System.Text.Json;
-using System.Text.RegularExpressions;
+using TUnit.Core;
+using TUnit.Core.Enums;
namespace TUnit.Engine.Reporters.Html;
-internal static partial class HtmlReportGenerator
+internal static class HtmlReportGenerator
{
+ private const string TemplateResourceName = "TUnit.Engine.Reporters.Html.TestReport.template.html";
+ private const string DataPlaceholder = "__REPORT_DATA__";
+ private const string TitlePlaceholder = "__REPORT_TITLE__";
+ private const string ProjectPlaceholder = "__REPORT_PROJECT__";
+ private const string SampleDataBeginMarker = "/* SAMPLE_DATA_BEGIN";
+ private const string SampleDataEndMarker = "/* SAMPLE_DATA_END */";
+
+ private static readonly Lazy Template = new(LoadAndStripTemplate);
+
internal static string GenerateHtml(ReportData data)
{
- var sb = new StringBuilder(96 * 1024);
- sb.AppendLine("");
- sb.AppendLine("");
+ var template = Template.Value;
+ var json = SerializeReport(data);
+ var compressed = GzipBase64(json);
+ var encodedName = WebUtility.HtmlEncode(data.AssemblyName);
- AppendHead(sb, data);
- AppendBody(sb, data);
-
- sb.AppendLine("");
- return sb.ToString();
+ return template
+ .Replace(TitlePlaceholder, "Test Report — " + encodedName)
+ .Replace(ProjectPlaceholder, encodedName)
+ .Replace(DataPlaceholder, compressed);
}
- private static void AppendHead(StringBuilder sb, ReportData data)
+ private static string LoadAndStripTemplate()
{
- sb.AppendLine("");
- sb.AppendLine(" ");
- sb.AppendLine(" ");
- sb.AppendLine(" ");
- sb.Append("Test Report \u2014 ");
- sb.Append(WebUtility.HtmlEncode(data.AssemblyName));
- sb.AppendLine(" ");
- sb.AppendLine("");
- sb.AppendLine("");
+ var raw = LoadTemplate();
+ // The template carries a generateSampleData() block so devs can preview
+ // it standalone in a browser. Strip it from shipped reports — production
+ // reports always have real data and the fallback never fires. Both
+ // markers must be present and match: silent passthrough here means
+ // hundreds of lines of fixture data ship in every report on disk.
+ var begin = raw.IndexOf(SampleDataBeginMarker, StringComparison.Ordinal);
+ if (begin < 0)
+ {
+ throw new InvalidOperationException(
+ $"Template is missing '{SampleDataBeginMarker}' — the sample-data strip would no-op.");
+ }
+ var end = raw.IndexOf(SampleDataEndMarker, begin, StringComparison.Ordinal);
+ if (end < 0)
+ {
+ throw new InvalidOperationException(
+ $"Template has '{SampleDataBeginMarker}' but no matching '{SampleDataEndMarker}'.");
+ }
+ return raw.Remove(begin, end + SampleDataEndMarker.Length - begin);
}
- private static void AppendBody(StringBuilder sb, ReportData data)
+ private static string GzipBase64(string json)
{
- sb.AppendLine("");
-
- // Skip-to-content link for keyboard/screen-reader users
- sb.AppendLine("Skip to test results ");
-
- // Ambient background grain
- sb.AppendLine("
");
-
- // Feature 8: Sticky mini-header
- sb.Append("");
- sb.Append("
");
- sb.Append(WebUtility.HtmlEncode(data.AssemblyName));
- sb.Append(" ");
- sb.Append("
");
- sb.Append("");
- sb.Append(data.Summary.Passed);
- sb.Append(" ");
- sb.Append("");
- sb.Append(data.Summary.Failed + data.Summary.TimedOut);
- sb.Append(" ");
- sb.Append("");
- sb.Append(data.Summary.Skipped);
- sb.Append(" ");
- sb.Append(" ");
- sb.AppendLine("
");
- sb.AppendLine("
");
-
- sb.AppendLine("");
-
- AppendHeader(sb, data);
-
- sb.AppendLine("
");
- AppendSummaryDashboard(sb, data.Summary, data.TotalDurationMs);
- AppendSearchAndFilters(sb, data.Summary);
-
- // Quick-access sections populated by JS
- sb.AppendLine("
");
- sb.AppendLine("
");
- sb.AppendLine("
");
- sb.AppendLine("
");
-
- AppendTestGroups(sb, data);
- sb.AppendLine(" ");
-
- AppendJsonData(sb, data);
- AppendJavaScript(sb);
-
- sb.AppendLine("
");
-
- // Minimap sidebar navigator
- sb.AppendLine("
");
- sb.AppendLine("");
- sb.AppendLine("
Navigator
");
- sb.AppendLine("
");
- sb.AppendLine("
");
-
- sb.AppendLine("");
+ var bytes = Encoding.UTF8.GetBytes(json);
+ using var output = new MemoryStream();
+#if NET
+ using (var gz = new GZipStream(output, CompressionLevel.SmallestSize, leaveOpen: true))
+#else
+ using (var gz = new GZipStream(output, CompressionLevel.Optimal, leaveOpen: true))
+#endif
+ {
+ gz.Write(bytes, 0, bytes.Length);
+ }
+ return Convert.ToBase64String(output.GetBuffer(), 0, checked((int)output.Length));
}
- private static void AppendHeader(StringBuilder sb, ReportData data)
+ private static string LoadTemplate()
{
- sb.AppendLine("");
- sb.AppendLine("");
- // TUnit logo mark
- sb.AppendLine("
");
- sb.AppendLine("");
- sb.AppendLine("");
- sb.AppendLine(" ");
- sb.AppendLine(" ");
- sb.AppendLine("
");
- sb.Append("
");
- sb.Append(WebUtility.HtmlEncode(data.AssemblyName));
- sb.AppendLine(" ");
- sb.AppendLine("Test Report ");
- sb.AppendLine("");
- sb.AppendLine("
");
-
- sb.AppendLine("");
- AppendMetaChip(sb, "clock", data.Timestamp);
- AppendMetaChip(sb, "cpu", data.MachineName);
- AppendMetaChip(sb, "os", data.OperatingSystem);
- AppendMetaChip(sb, "runtime", data.RuntimeVersion);
- AppendMetaChip(sb, "tag", "TUnit " + data.TUnitVersion);
- if (!string.IsNullOrEmpty(data.Filter))
- {
- AppendMetaChip(sb, "filter", data.Filter!);
- }
+ var asm = typeof(HtmlReportGenerator).Assembly;
+ using var stream = asm.GetManifestResourceStream(TemplateResourceName)
+ ?? throw new InvalidOperationException("Embedded HTML template not found: " + TemplateResourceName);
+ using var reader = new StreamReader(stream, Encoding.UTF8);
+ return reader.ReadToEnd();
+ }
- if (!string.IsNullOrEmpty(data.Branch))
+ private static string SerializeReport(ReportData data)
+ {
+ var totalTests = 0;
+ foreach (var g in data.Groups)
{
- AppendMetaChip(sb, "branch", data.Branch!);
+ totalTests += g.Tests.Length;
}
- if (!string.IsNullOrEmpty(data.CommitSha))
+ // 1) First pass: parse each test's unix-ms start, track run bounds, and
+ // record the absolute start in a dictionary keyed by test id so the
+ // later emission pass can look it up directly — without relying on
+ // enumeration order matching this loop.
+ long runStartMs = long.MaxValue;
+ long runEndMs = long.MinValue;
+ var absStartByTestId = new Dictionary(totalTests, StringComparer.Ordinal);
+ foreach (var g in data.Groups)
{
- var shortSha = data.CommitSha!.Length > 7 ? data.CommitSha[..7] : data.CommitSha;
- if (!string.IsNullOrEmpty(data.RepositorySlug))
+ foreach (var t in g.Tests)
{
- AppendMetaChipLink(sb, "commit", shortSha, $"https://github.com/{data.RepositorySlug}/commit/{data.CommitSha}");
+ var sms = TryParseUnixMs(t.StartTime);
+ absStartByTestId[t.Id] = sms;
+ if (sms is { } x)
+ {
+ if (x < runStartMs) runStartMs = x;
+ var end = x + (long)Math.Round(t.DurationMs);
+ if (end > runEndMs) runEndMs = end;
+ }
}
- else
+ }
+ if (runStartMs == long.MaxValue)
+ {
+ runStartMs = 0;
+ runEndMs = (long)Math.Round(data.TotalDurationMs);
+ }
+ long wallMs = Math.Max((long)Math.Round(data.TotalDurationMs), runEndMs - runStartMs);
+
+ // 2) Resolve relative starts and sort for greedy lane assignment.
+ // TUnit doesn't currently emit worker IDs per test, but the Gantt
+ // chart shows per-worker lanes — derive them from start/duration.
+ var ordered = new (string Id, long StartRel, double Dur)[totalTests];
+ var oi = 0;
+ foreach (var g in data.Groups)
+ {
+ foreach (var t in g.Tests)
{
- AppendMetaChip(sb, "commit", shortSha);
+ var startRel = absStartByTestId[t.Id] is { } a ? a - runStartMs : 0L;
+ ordered[oi++] = (t.Id, startRel, t.DurationMs);
}
}
+ Array.Sort(ordered, static (a, b) => a.StartRel.CompareTo(b.StartRel));
- if (!string.IsNullOrEmpty(data.PullRequestNumber))
+ var laneEnd = new List();
+ var testWorker = new Dictionary(totalTests, StringComparer.Ordinal);
+ foreach (var (id, startRel, dur) in ordered)
{
- if (!string.IsNullOrEmpty(data.RepositorySlug))
+ var lane = -1;
+ for (var i = 0; i < laneEnd.Count; i++)
{
- AppendMetaChipLink(sb, "pr", $"PR #{data.PullRequestNumber}", $"https://github.com/{data.RepositorySlug}/pull/{data.PullRequestNumber}");
+ if (laneEnd[i] <= startRel + 0.5)
+ {
+ lane = i;
+ break;
+ }
}
- else
+ if (lane == -1)
{
- AppendMetaChip(sb, "pr", $"PR #{data.PullRequestNumber}");
+ lane = laneEnd.Count;
+ laneEnd.Add(0);
}
+ laneEnd[lane] = startRel + dur;
+ testWorker[id] = lane;
}
+ var workers = Math.Max(1, laneEnd.Count);
- sb.AppendLine("
");
-
- // Theme toggle button
- sb.AppendLine("");
- sb.AppendLine(" ");
- sb.AppendLine(" ");
- sb.AppendLine(" ");
+ // 3) Bucket spans by traceId so we can nest them under their owning test.
+ Dictionary>? spansByTrace = null;
+ if (data.Spans is { Length: > 0 } spans)
+ {
+ spansByTrace = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ foreach (var s in spans)
+ {
+ if (!spansByTrace.TryGetValue(s.TraceId, out var list))
+ {
+ list = new List();
+ spansByTrace[s.TraceId] = list;
+ }
+ list.Add(s);
+ }
+ }
- sb.AppendLine(" ");
- }
+ using var ms = new MemoryStream();
+ using (var w = new Utf8JsonWriter(ms, new JsonWriterOptions { Indented = false }))
+ {
+ w.WriteStartObject();
+ w.WriteString("project", data.AssemblyName);
+ w.WriteString("when", data.Timestamp);
+ w.WriteString("runner", data.MachineName);
+ w.WriteString("os", data.OperatingSystem);
+ w.WriteString("runtime", data.RuntimeVersion);
+ w.WriteString("tunit", data.TUnitVersion);
+ if (!string.IsNullOrEmpty(data.Branch)) w.WriteString("branch", data.Branch);
+ if (!string.IsNullOrEmpty(data.CommitSha)) w.WriteString("commit", data.CommitSha);
+ if (!string.IsNullOrEmpty(data.PullRequestNumber)) w.WriteString("pr", "#" + data.PullRequestNumber);
+ if (!string.IsNullOrEmpty(data.RepositorySlug)) w.WriteString("repository", data.RepositorySlug);
+ if (!string.IsNullOrEmpty(data.Filter)) w.WriteString("filter", data.Filter);
+
+ w.WriteNumber("startMs", runStartMs);
+ w.WriteNumber("wallMs", wallMs);
+ w.WriteNumber("workers", workers);
+
+ w.WritePropertyName("tests");
+ w.WriteStartArray();
+ foreach (var g in data.Groups)
+ {
+ foreach (var t in g.Tests)
+ {
+ var startRel = absStartByTestId[t.Id] is { } a ? a - runStartMs : 0L;
+ WriteTest(w, t, g, runStartMs, startRel, testWorker, spansByTrace);
+ }
+ }
+ w.WriteEndArray();
- private static void AppendMetaChip(StringBuilder sb, string icon, string text)
- {
- sb.Append(" ");
- sb.Append(WebUtility.HtmlEncode(text));
- sb.AppendLine(" ");
- }
+#if NET
+ // Global / per-class timelines require span-type and tag constants from
+ // TUnitActivitySource, which is `#if NET`. On netstandard2.0 no spans are
+ // collected anyway (ActivityCollector is also `#if NET`), so there's
+ // nothing to emit here.
+ WriteTimelines(w, data, spansByTrace, runStartMs);
+#endif
- private static void AppendMetaChipLink(StringBuilder sb, string icon, string text, string href)
- {
- sb.Append(" ");
- sb.Append(WebUtility.HtmlEncode(text));
- sb.AppendLine(" ");
+ w.WriteEndObject();
+ }
+ return Encoding.UTF8.GetString(ms.GetBuffer(), 0, checked((int)ms.Length));
}
- private static void AppendSummaryDashboard(StringBuilder sb, ReportSummary summary, double totalDurationMs)
+#if NET
+ // The old report had two trace views the per-test Trace tab doesn't cover:
+ // • Global "Execution Timeline" — session/assembly/suite + shared init/dispose spans
+ // • Per-class timeline — opt-in via [ClassTimeline(...)] on the class
+ // We re-emit both as top-level JSON so the renderer can show them in the Run view.
+ // This block (plus FindTagValue / WithParent / BuildTraceIndex) references span-type
+ // and tag constants on TUnitActivitySource, which is itself `#if NET`. The matching
+ // call site in SerializeReport is gated the same way.
+ private static void WriteTimelines(Utf8JsonWriter w, ReportData data, Dictionary>? spansByTrace, long runStartMs)
{
- var passRate = summary.Total > 0 ? (double)summary.Passed / summary.Total * 100 : 0;
-
- sb.AppendLine("");
-
- // Ring chart — SVG
- var circumference = 2 * Math.PI * 54; // r=54
- var cleanPassLen = summary.Total > 0 ? circumference * summary.CleanPassed / summary.Total : 0;
- var flakyLen = summary.Total > 0 ? circumference * summary.Flaky / summary.Total : 0;
- var failLen = summary.Total > 0 ? circumference * summary.TotalFailed / summary.Total : 0;
- var skipLen = summary.Total > 0 ? circumference * summary.Skipped / summary.Total : 0;
- var cancelLen = summary.Total > 0 ? circumference * summary.Cancelled / summary.Total : 0;
+ if (spansByTrace is null || spansByTrace.Count == 0) return;
- sb.AppendLine("");
- sb.AppendLine("
");
- // Track
- sb.AppendLine("");
-
- // Segments — stacked with dasharray/dashoffset
- double offset = 0;
- if (cleanPassLen > 0)
- {
- AppendRingSegment(sb, "var(--emerald)", cleanPassLen, offset, circumference);
- offset += cleanPassLen;
- }
-
- if (flakyLen > 0)
- {
- AppendRingSegment(sb, "var(--orange)", flakyLen, offset, circumference);
- offset += flakyLen;
- }
-
- if (failLen > 0)
+ // Single pass: collect global-scope spans and the per-class suite anchor map.
+ var globalSpans = new List();
+ var suiteByClass = new Dictionary(StringComparer.Ordinal);
+ foreach (var bucket in spansByTrace.Values)
{
- AppendRingSegment(sb, "var(--rose)", failLen, offset, circumference);
- offset += failLen;
+ foreach (var s in bucket)
+ {
+ if (IsGlobalTimelineSpan(s)) globalSpans.Add(s);
+ if (string.Equals(s.SpanType, TUnitActivitySource.SpanTestSuite, StringComparison.Ordinal))
+ {
+ var cls = FindTagValue(s, TUnitActivitySource.TagTestClass) ?? s.Name;
+ if (!string.IsNullOrEmpty(cls) && !suiteByClass.ContainsKey(cls)) suiteByClass[cls] = s;
+ }
+ }
}
- if (skipLen > 0)
+ // Session/assembly/suite spans are emitted on the LifecycleSource using the
+ // caller's ActivityContext as parent. When Activity.Current is unset at the
+ // moment a hook fires, the parent context falls back to default and the
+ // resulting ParentSpanId may not match any span we collected — leaving the
+ // assembly visually flattened next to its session in the timeline. Repair
+ // the chain so the renderer can draw the session → assembly → suite tree.
+ globalSpans = RepairGlobalTimelineParents(globalSpans);
+
+ w.WritePropertyName("globalSpans");
+ w.WriteStartArray();
+ foreach (var s in globalSpans) WriteSpan(w, s, runStartMs);
+ w.WriteEndArray();
+
+ // Per-class timeline: classes that opted in via [ClassTimeline] carry the
+ // ClassTimelineAttribute.ClassTimelinePropertyKey custom property on every test.
+ var classModes = new Dictionary(StringComparer.Ordinal);
+ foreach (var g in data.Groups)
{
- AppendRingSegment(sb, "var(--amber)", skipLen, offset, circumference);
- offset += skipLen;
+ foreach (var t in g.Tests)
+ {
+ if (t.CustomProperties is null) continue;
+ foreach (var p in t.CustomProperties)
+ {
+ if (string.Equals(p.Key, ClassTimelineAttribute.ClassTimelinePropertyKey, StringComparison.Ordinal))
+ {
+ classModes[g.ClassName] = p.Value;
+ break;
+ }
+ }
+ if (classModes.ContainsKey(g.ClassName)) break;
+ }
}
- if (cancelLen > 0)
+ w.WritePropertyName("classTimelines");
+ w.WriteStartObject();
+ // Cache the parent/child index per trace so multiple opted-in classes sharing a
+ // trace don't rebuild it each time.
+ var traceIndexCache = new Dictionary BySpanId, Dictionary> ByParent)>(StringComparer.OrdinalIgnoreCase);
+ foreach (var kv in classModes)
{
- AppendRingSegment(sb, "var(--slate)", cancelLen, offset, circumference);
- }
+ if (!suiteByClass.TryGetValue(kv.Key, out var suite)) continue;
+ if (!spansByTrace.TryGetValue(suite.TraceId, out var traceSpans)) continue;
- sb.AppendLine(" ");
- sb.Append("
");
- sb.Append(passRate.ToString("F0", CultureInfo.InvariantCulture));
- sb.Append("% ");
- sb.Append(summary.Total > 0 ? "pass rate" : "no tests");
- sb.AppendLine("
");
- sb.AppendLine("
");
+ if (!traceIndexCache.TryGetValue(suite.TraceId, out var index))
+ {
+ index = BuildTraceIndex(traceSpans);
+ traceIndexCache[suite.TraceId] = index;
+ }
- // Stat cards
- sb.AppendLine("");
- AppendStatCard(sb, "total", summary.Total.ToString(), "Total", null);
- AppendStatCard(sb, "passed", summary.CleanPassed.ToString(), "Passed", "var(--emerald)");
- if (summary.Flaky > 0)
- {
- AppendStatCard(sb, "flaky", summary.Flaky.ToString(), "Flaky", "var(--orange)");
+ var classSpans = BuildClassTimeline(index.BySpanId, index.ByParent, suite.SpanId, kv.Value);
+ if (classSpans.Count == 0) continue;
+
+ w.WritePropertyName(kv.Key);
+ w.WriteStartObject();
+ w.WriteString("mode", kv.Value);
+ w.WritePropertyName("spans");
+ w.WriteStartArray();
+ foreach (var s in classSpans) WriteSpan(w, s, runStartMs);
+ w.WriteEndArray();
+ w.WriteEndObject();
}
-
- AppendStatCard(sb, "failed", summary.TotalFailed.ToString(), "Failed", "var(--rose)");
- AppendStatCard(sb, "skipped", summary.Skipped.ToString(), "Skipped", "var(--amber)");
- AppendStatCard(sb, "cancelled", summary.Cancelled.ToString(), "Cancelled", "var(--slate)");
- sb.AppendLine("
");
-
- // Duration
- sb.AppendLine("");
- sb.Append("
");
- sb.Append(FormatDuration(totalDurationMs));
- sb.AppendLine(" ");
- sb.AppendLine("
duration ");
- sb.AppendLine("
");
- sb.AppendLine("
");
-
- sb.AppendLine(" ");
- }
-
- private static void AppendRingSegment(StringBuilder sb, string color, double len, double offset, double circumference)
- {
- sb.Append(" ");
+ w.WriteEndObject();
}
- private static void AppendStatCard(StringBuilder sb, string cls, string count, string label, string? accent)
+ private static (Dictionary BySpanId, Dictionary> ByParent) BuildTraceIndex(List traceSpans)
{
- sb.Append("(traceSpans.Count, StringComparer.OrdinalIgnoreCase);
+ var byParent = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ foreach (var s in traceSpans)
{
- sb.Append(" style=\"--accent:");
- sb.Append(accent);
- sb.Append("\"");
+ bySpanId[s.SpanId] = s;
+ if (s.ParentSpanId is null) continue;
+ if (!byParent.TryGetValue(s.ParentSpanId, out var kids))
+ {
+ kids = new List();
+ byParent[s.ParentSpanId] = kids;
+ }
+ kids.Add(s);
}
-
- sb.AppendLine(">");
- sb.Append("");
- sb.Append(count);
- sb.Append(" ");
- sb.Append(label);
- sb.AppendLine("
");
+ return (bySpanId, byParent);
}
- private static void AppendSearchAndFilters(StringBuilder sb, ReportSummary summary)
+ private static List RepairGlobalTimelineParents(List spans)
{
- sb.AppendLine("");
- sb.AppendLine("
");
- // Search icon inline SVG
- sb.AppendLine("
");
- sb.AppendLine("
");
- sb.AppendLine("
× ");
- sb.AppendLine("
");
- sb.AppendLine("
");
- sb.Append("All ");
- sb.Append(summary.Total);
- sb.AppendLine(" ");
- sb.Append(" Passed ");
- sb.Append(summary.CleanPassed);
- sb.AppendLine(" ");
- sb.Append(" Flaky ");
- sb.Append(summary.Flaky);
- sb.AppendLine(" ");
- sb.Append(" Failed ");
- sb.Append(summary.TotalFailed);
- sb.AppendLine(" ");
- sb.Append(" Skipped ");
- sb.Append(summary.Skipped);
- sb.AppendLine(" ");
- sb.Append(" Cancelled ");
- sb.Append(summary.Cancelled);
- sb.AppendLine(" ");
- sb.AppendLine("
");
-
- // Feature 2: Expand/Collapse All + Feature 3: Sort Toggle
- sb.AppendLine("
");
- sb.AppendLine("
");
- sb.AppendLine("
");
- sb.AppendLine("
");
- sb.AppendLine("
");
- sb.AppendLine("
Group: ");
- sb.AppendLine("
");
- sb.AppendLine("Class ");
- sb.AppendLine("Namespace ");
- sb.AppendLine("Status ");
- sb.AppendLine("
");
- sb.AppendLine("
");
- sb.AppendLine("
Sort: ");
- sb.AppendLine("
");
- sb.AppendLine("Default ");
- sb.AppendLine("Duration ");
- sb.AppendLine("Name ");
- sb.AppendLine("
");
- sb.AppendLine("
");
-
- sb.AppendLine("
");
- sb.AppendLine("
");
+ if (spans.Count == 0) return spans;
- sb.AppendLine("
");
- }
-
- private static void AppendTestGroups(StringBuilder sb, ReportData data)
- {
- if (data.Summary.Total == 0)
+ SpanData? session = null;
+ SpanData? firstAssembly = null;
+ var presentIds = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var s in spans)
{
- sb.AppendLine("No tests were discovered.
");
- return;
+ presentIds.Add(s.SpanId);
+ if (session is null && string.Equals(s.SpanType, TUnitActivitySource.SpanTestSession, StringComparison.Ordinal)) session = s;
+ if (firstAssembly is null && string.Equals(s.SpanType, TUnitActivitySource.SpanTestAssembly, StringComparison.Ordinal)) firstAssembly = s;
}
- sb.AppendLine("
");
- sb.AppendLine("
");
- }
-
- private static void AppendJsonData(StringBuilder sb, ReportData data)
- {
- using var ms = new MemoryStream();
-#if NET
- using (var compressor = new GZipStream(ms, CompressionLevel.SmallestSize, leaveOpen: true))
-#else
- using (var compressor = new GZipStream(ms, CompressionLevel.Optimal, leaveOpen: true))
-#endif
+ var repaired = new List(spans.Count);
+ foreach (var s in spans)
{
- JsonSerializer.Serialize(compressor, data, HtmlReportJsonContext.Default.ReportData);
+ if (ReferenceEquals(s, session)
+ || (!string.IsNullOrEmpty(s.ParentSpanId) && presentIds.Contains(s.ParentSpanId)))
+ {
+ repaired.Add(s);
+ continue;
+ }
+ var fallback = string.Equals(s.SpanType, TUnitActivitySource.SpanTestSuite, StringComparison.Ordinal)
+ ? firstAssembly?.SpanId ?? session?.SpanId
+ : session?.SpanId;
+ repaired.Add(fallback is not null ? WithParent(s, fallback) : s);
}
-
- var rawBuffer = ms.GetBuffer();
- var length = checked((int)ms.Length);
- var base64 = Convert.ToBase64String(rawBuffer, 0, length);
-
- sb.Append("");
+ return repaired;
}
- private static void AppendJavaScript(StringBuilder sb)
+ private static bool IsGlobalTimelineSpan(SpanData s)
{
- sb.AppendLine("");
+ var t = s.SpanType ?? string.Empty;
+ if (t == TUnitActivitySource.SpanTestSession
+ || t == TUnitActivitySource.SpanTestAssembly
+ || t == TUnitActivitySource.SpanTestSuite) return true;
+ if (t.StartsWith("initialize ", StringComparison.Ordinal) || t.StartsWith("dispose ", StringComparison.Ordinal))
+ {
+ var scope = FindTagValue(s, TUnitActivitySource.TagTraceScope);
+ return !string.Equals(scope, "test", StringComparison.Ordinal);
+ }
+ return false;
}
- private static string FormatDuration(double ms)
+ private static List BuildClassTimeline(Dictionary bySpanId, Dictionary> byParent, string suiteSpanId, string mode)
{
- if (ms < 1)
+ var descendants = new List();
+ var stack = new Stack();
+ stack.Push(suiteSpanId);
+ var visited = new HashSet(StringComparer.OrdinalIgnoreCase);
+ while (stack.Count > 0)
{
- return "<1ms";
+ var id = stack.Pop();
+ if (!visited.Add(id)) continue;
+ if (byParent.TryGetValue(id, out var kids))
+ {
+ foreach (var k in kids)
+ {
+ descendants.Add(k);
+ stack.Push(k.SpanId);
+ }
+ }
}
- // Show milliseconds for anything under 1 second (avoids rounding 999ms to "1.00s")
- if (Math.Round(ms) < 1000)
+ var result = new List();
+ if (bySpanId.TryGetValue(suiteSpanId, out var suite)) result.Add(suite);
+
+ if (string.Equals(mode, nameof(TimelineMode.FullExecution), StringComparison.Ordinal))
{
- return ms.ToString("F0", CultureInfo.InvariantCulture) + "ms";
+ // Include test-case spans and their non-'test body' children, with
+ // 'test body' wrappers collapsed (children re-parented to the owning
+ // test-case) so the timeline isn't dominated by plumbing nodes.
+ var testBodyIds = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var s in descendants)
+ if (string.Equals(s.Name, TUnitActivitySource.SpanTestBody, StringComparison.Ordinal)) testBodyIds.Add(s.SpanId);
+ foreach (var s in descendants)
+ {
+ if (testBodyIds.Contains(s.SpanId)) continue;
+ if (s.ParentSpanId is not null && testBodyIds.Contains(s.ParentSpanId))
+ {
+ var newParent = bySpanId.TryGetValue(s.ParentSpanId, out var body) ? body.ParentSpanId : null;
+ result.Add(WithParent(s, newParent));
+ }
+ else
+ {
+ result.Add(s);
+ }
+ }
}
-
- if (ms < 60000)
+ else
{
- return (ms / 1000).ToString("F2", CultureInfo.InvariantCulture) + "s";
+ // Default: class-level infrastructure only — drop test-case spans and
+ // everything beneath them so the timeline shows suite + init/dispose only.
+ var testCaseIds = new List();
+ foreach (var s in descendants)
+ if (string.Equals(s.SpanType, TUnitActivitySource.SpanTestCase, StringComparison.Ordinal)) testCaseIds.Add(s.SpanId);
+ var excluded = new HashSet(testCaseIds, StringComparer.OrdinalIgnoreCase);
+ foreach (var tcId in testCaseIds)
+ {
+ var inner = new Stack();
+ inner.Push(tcId);
+ while (inner.Count > 0)
+ {
+ var id = inner.Pop();
+ if (!byParent.TryGetValue(id, out var kids)) continue;
+ foreach (var k in kids) { if (excluded.Add(k.SpanId)) inner.Push(k.SpanId); }
+ }
+ }
+ foreach (var s in descendants)
+ if (!excluded.Contains(s.SpanId)) result.Add(s);
}
- return (ms / 60000).ToString("F1", CultureInfo.InvariantCulture) + "m";
+ return result;
}
- private static string MinifyCss(string css)
+ private static SpanData WithParent(SpanData s, string? parentSpanId) => new()
{
- css = CssCommentsRegex().Replace(css, string.Empty);
- css = CssWhitespaceRegex().Replace(css, " ");
- css = CssSeparatorsRegex().Replace(css, "$1");
- css = css.Replace(";}", "}");
- return css.Trim();
+ TraceId = s.TraceId,
+ SpanId = s.SpanId,
+ ParentSpanId = parentSpanId,
+ Name = s.Name,
+ SpanType = s.SpanType,
+ Source = s.Source,
+ Kind = s.Kind,
+ StartTimeMs = s.StartTimeMs,
+ DurationMs = s.DurationMs,
+ Status = s.Status,
+ StatusMessage = s.StatusMessage,
+ Tags = s.Tags,
+ Events = s.Events,
+ Links = s.Links,
+ };
+
+ private static string? FindTagValue(SpanData s, string key)
+ {
+ if (s.Tags is null) return null;
+ foreach (var t in s.Tags)
+ if (string.Equals(t.Key, key, StringComparison.Ordinal)) return t.Value;
+ return null;
}
-
- private const string CssCommentsPattern = @"/\*[\s\S]*?\*/";
- private const string CssWhitespacePattern = @"\s+";
- private const string CssSeparatorsPattern = @"\s*([{}:;,>~+])\s*";
-
-#if NET
- [System.Text.RegularExpressions.GeneratedRegex(CssCommentsPattern)]
- private static partial Regex CssCommentsRegex();
-
- [System.Text.RegularExpressions.GeneratedRegex(CssWhitespacePattern)]
- private static partial Regex CssWhitespaceRegex();
-
- [System.Text.RegularExpressions.GeneratedRegex(CssSeparatorsPattern)]
- private static partial Regex CssSeparatorsRegex();
-#else
- private static readonly Regex CssCommentsRegexInstance = new(CssCommentsPattern, RegexOptions.Compiled);
- private static readonly Regex CssWhitespaceRegexInstance = new(CssWhitespacePattern, RegexOptions.Compiled);
- private static readonly Regex CssSeparatorsRegexInstance = new(CssSeparatorsPattern, RegexOptions.Compiled);
-
- private static Regex CssCommentsRegex() => CssCommentsRegexInstance;
- private static Regex CssWhitespaceRegex() => CssWhitespaceRegexInstance;
- private static Regex CssSeparatorsRegex() => CssSeparatorsRegexInstance;
#endif
- private static readonly string MinifiedCss = MinifyCss(GetCss());
-
- private static string GetCss()
+ private static void WriteTest(
+ Utf8JsonWriter w,
+ ReportTestResult t,
+ ReportTestGroup g,
+ long runStartMs,
+ long startRel,
+ Dictionary testWorker,
+ Dictionary>? spansByTrace)
{
- return """
-/* ═══════════════════════════════════════════════════════
- TUnit — Dark Observatory Report Theme
- ═══════════════════════════════════════════════════════ */
-
-/* ── Design Tokens ─────────────────────────────────── */
-:root {
- --bg: #0b0d11;
- --surface-0: #12151c;
- --surface-1: #181c25;
- --surface-2: #1f2430;
- --surface-3: #282e3a;
- --border: rgba(255,255,255,.06);
- --border-h: rgba(255,255,255,.10);
-
- --text: #e2e4e9;
- --text-2: #9ba1b0;
- --text-3: #5f6678;
-
- --emerald: #34d399;
- --emerald-d: rgba(52,211,153,.12);
- --rose: #fb7185;
- --rose-d: rgba(251,113,133,.12);
- --amber: #fbbf24;
- --amber-d: rgba(251,191,36,.10);
- --slate: #94a3b8;
- --slate-d: rgba(148,163,184,.10);
- --indigo: #818cf8;
- --indigo-d: rgba(129,140,248,.10);
- --violet: #a78bfa;
- --orange: #fb923c;
- --orange-d: rgba(251,146,60,.12);
-
- --font: 'Segoe UI Variable','Segoe UI',-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
- --mono: 'Cascadia Code','JetBrains Mono','Fira Code','SF Mono',ui-monospace,monospace;
-
- --r: 8px;
- --r-lg: 14px;
- --ease: cubic-bezier(.4,0,.2,1);
-}
-
-/* ── Light Theme ──────────────────────────────────── */
-:root[data-theme="light"]{
- --bg:#f8f9fb;--surface-0:#ffffff;--surface-1:#f0f1f4;--surface-2:#e4e6eb;--surface-3:#d1d5de;
- --border:rgba(0,0,0,.08);--border-h:rgba(0,0,0,.14);
- --text:#1a1d24;--text-2:#5a5f6e;--text-3:#8b91a0;
- --emerald-d:rgba(52,211,153,.15);--rose-d:rgba(251,113,133,.15);
- --amber-d:rgba(251,191,36,.12);--slate-d:rgba(148,163,184,.12);
- --indigo-d:rgba(129,140,248,.12);--violet:#7c3aed;
- --orange-d:rgba(251,146,60,.15);
-}
-:root[data-theme="light"] .grain{opacity:.008}
-
-/* ── Theme Transition ─────────────────────────────── */
-/* Register color tokens as typed properties so the browser can interpolate
- them directly on :root — every element using var(--x) then animates in
- sync with a single transition declaration, with zero per-element cost. */
-@property --bg { syntax:''; inherits:true; initial-value:#0b0d11 }
-@property --surface-0 { syntax:''; inherits:true; initial-value:#12151c }
-@property --surface-1 { syntax:''; inherits:true; initial-value:#181c25 }
-@property --surface-2 { syntax:''; inherits:true; initial-value:#1f2430 }
-@property --surface-3 { syntax:''; inherits:true; initial-value:#282e3a }
-@property --border { syntax:''; inherits:true; initial-value:rgba(255,255,255,.06) }
-@property --border-h { syntax:''; inherits:true; initial-value:rgba(255,255,255,.10) }
-@property --text { syntax:''; inherits:true; initial-value:#e2e4e9 }
-@property --text-2 { syntax:''; inherits:true; initial-value:#9ba1b0 }
-@property --text-3 { syntax:''; inherits:true; initial-value:#5f6678 }
-@property --emerald-d { syntax:''; inherits:true; initial-value:rgba(52,211,153,.12) }
-@property --rose-d { syntax:''; inherits:true; initial-value:rgba(251,113,133,.12) }
-@property --amber-d { syntax:''; inherits:true; initial-value:rgba(251,191,36,.10) }
-@property --slate-d { syntax:''; inherits:true; initial-value:rgba(148,163,184,.10) }
-@property --indigo-d { syntax:''; inherits:true; initial-value:rgba(129,140,248,.10) }
-@property --orange-d { syntax:''; inherits:true; initial-value:rgba(251,146,60,.12) }
-
-:root {
- transition:
- --bg .3s var(--ease), --surface-0 .3s var(--ease), --surface-1 .3s var(--ease),
- --surface-2 .3s var(--ease), --surface-3 .3s var(--ease),
- --border .3s var(--ease), --border-h .3s var(--ease),
- --text .3s var(--ease), --text-2 .3s var(--ease), --text-3 .3s var(--ease),
- --emerald-d .3s var(--ease), --rose-d .3s var(--ease), --amber-d .3s var(--ease),
- --slate-d .3s var(--ease), --indigo-d .3s var(--ease),
- --orange-d .3s var(--ease);
-}
-/* Suppress per-element transitions during theme switch so only the
- @property variable interpolations drive the animation — no stagger. */
-.theme-transitioning *,.theme-transitioning *::before,.theme-transitioning *::after{
- transition:none!important;
-}
-
-/* ── Reset & Base ──────────────────────────────────── */
-*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
-html{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}
-body{
- font-family:var(--font);background:var(--bg);color:var(--text);
- line-height:1.55;font-size:14px;min-height:100vh;
- overflow-x:hidden;
-}
-
-/* Film-grain overlay for atmosphere */
-.grain{
- position:fixed;inset:0;pointer-events:none;z-index:9999;opacity:.018;
- background:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
-}
-
-/* ── Shell ─────────────────────────────────────────── */
-.shell{max-width:1360px;margin:0 auto;padding:28px 24px 64px}
-
-/* ── Animations ────────────────────────────────────── */
-@keyframes fade-up{
- from{opacity:0;transform:translateY(14px)}
- to{opacity:1;transform:none}
-}
-@keyframes ring-draw{
- from{stroke-dashoffset:340}
-}
-[data-anim]{animation:fade-up .5s var(--ease) both}
-[data-anim]:nth-child(2){animation-delay:.08s}
-[data-anim]:nth-child(3){animation-delay:.16s}
-.ring-seg{animation:ring-draw .9s var(--ease) both}
-
-/* Stat card stagger */
-[data-anim] .stat{animation:fade-up .4s var(--ease) both}
-[data-anim] .stat:nth-child(1){animation-delay:0s}
-[data-anim] .stat:nth-child(2){animation-delay:.05s}
-[data-anim] .stat:nth-child(3){animation-delay:.1s}
-[data-anim] .stat:nth-child(4){animation-delay:.15s}
-[data-anim] .stat:nth-child(5){animation-delay:.2s}
-
-/* Test row stagger on group open */
-.grp.open .t-row{animation:fade-up .3s var(--ease) both;animation-delay:calc(var(--row-idx,0) * .03s)}
-
-/* Reduced motion */
-@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}}
-
-/* ── Header ────────────────────────────────────────── */
-.hdr{
- display:flex;align-items:center;flex-wrap:wrap;
- gap:10px 14px;margin-bottom:16px;
- animation:fade-up .5s var(--ease) both;
-}
-.hdr-brand{display:flex;align-items:center;gap:12px}
-.hdr-logo{width:38px;height:38px;flex-shrink:0}
-.hdr-name{
- font-size:1.35rem;font-weight:700;letter-spacing:-.02em;
- background:linear-gradient(135deg,#e2e4e9 30%,#818cf8);
- -webkit-background-clip:text;-webkit-text-fill-color:transparent;
- background-clip:text;
-}
-:root[data-theme="light"] .hdr-name{background:linear-gradient(135deg,#1a1d24 30%,#6366f1);-webkit-background-clip:text;background-clip:text}
-.hdr-sub{font-size:.78rem;color:var(--text-3);letter-spacing:.06em;text-transform:uppercase}
-.hdr-meta{display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-left:auto}
-.chip{
- display:inline-flex;align-items:center;gap:6px;
- padding:5px 12px;border-radius:100px;
- background:var(--surface-1);border:1px solid var(--border);
- font-size:.78rem;color:var(--text-2);white-space:nowrap;
-}
-.chip-link{
- text-decoration:none;cursor:pointer;
- transition:border-color .2s var(--ease),background .2s var(--ease);
-}
-.chip-link:hover{border-color:var(--indigo);background:var(--indigo-d);color:var(--text)}
-
-/* ── Theme Toggle ─────────────────────────────────── */
-.theme-btn{
- display:flex;align-items:center;justify-content:center;
- width:36px;height:36px;border-radius:100px;
- background:var(--surface-1);border:1px solid var(--border);
- color:var(--text-2);cursor:pointer;flex-shrink:0;
- transition:border-color .2s var(--ease),color .2s var(--ease),background .2s var(--ease);
-}
-.theme-btn:hover{border-color:var(--border-h);color:var(--text)}
-.theme-btn svg{width:18px;height:18px;transition:transform .3s var(--ease)}
-.theme-btn:active svg{transform:rotate(30deg)}
-[data-theme="dark"] .theme-sun{display:none}
-[data-theme="light"] .theme-moon{display:none}
-
-/* ── Dashboard ─────────────────────────────────────── */
-.dash{
- display:flex;align-items:center;gap:32px;flex-wrap:wrap;
- padding:28px;margin-bottom:24px;
- background:var(--surface-0);
- border:1px solid var(--border);border-radius:var(--r-lg);
- position:relative;overflow:hidden;
-}
-.dash::before{
- content:'';position:absolute;inset:0;
- background:radial-gradient(ellipse 60% 50% at 20% 50%,rgba(99,102,241,.04),transparent),
- radial-gradient(ellipse 40% 60% at 80% 30%,rgba(52,211,153,.03),transparent);
- pointer-events:none;
-}
-
-/* Ring */
-.ring-wrap{position:relative;width:130px;height:130px;flex-shrink:0}
-.ring{width:100%;height:100%}
-.ring-center{
- position:absolute;inset:0;display:flex;flex-direction:column;
- align-items:center;justify-content:center;
-}
-.ring-pct{font-size:1.7rem;font-weight:800;letter-spacing:-.03em;line-height:1}
-.ring-pct small{font-size:.55em;font-weight:600;opacity:.6}
-.ring-lbl{font-size:.68rem;color:var(--text-3);margin-top:2px;letter-spacing:.04em;text-transform:uppercase}
-
-/* Stat cards */
-.stats{display:flex;gap:10px;flex-wrap:wrap;flex:1}
-.stat{
- position:relative;flex:1;min-width:88px;
- padding:16px 14px;border-radius:var(--r);
- background:var(--surface-1);border:1px solid var(--border);
- text-align:center;transition:border-color .2s var(--ease),transform .2s var(--ease);
- overflow:hidden;
-}
-.stat::after{
- content:'';position:absolute;top:0;left:0;right:0;height:2px;
- background:var(--accent,var(--indigo));border-radius:2px 2px 0 0;
- opacity:.7;
-}
-.stat:hover{border-color:var(--border-h);transform:translateY(-1px)}
-.stat-n{display:block;font-size:1.65rem;font-weight:800;letter-spacing:-.03em;line-height:1.1;font-variant-numeric:tabular-nums}
-.stat-l{display:block;font-size:.72rem;color:var(--text-3);margin-top:4px;text-transform:uppercase;letter-spacing:.06em}
-
-/* coloured numbers */
-.stat.passed .stat-n{color:var(--emerald)}
-.stat.failed .stat-n{color:var(--rose)}
-.stat.skipped .stat-n{color:var(--amber)}
-
-/* Duration */
-.dash-dur{text-align:center;padding:4px 20px;flex-shrink:0}
-.dash-dur-val{display:block;font-size:1.5rem;font-weight:800;font-family:var(--mono);letter-spacing:-.02em}
-.dash-dur-lbl{display:block;font-size:.68rem;color:var(--text-3);text-transform:uppercase;letter-spacing:.06em;margin-top:2px}
-
-/* ── Toolbar (search + pills) ──────────────────────── */
-.bar{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:16px;justify-content:flex-end}
-.search{
- position:relative;flex:1;min-width:220px;
-}
-.search-icon{
- position:absolute;left:11px;top:50%;transform:translateY(-50%);
- width:16px;height:16px;color:var(--text-3);pointer-events:none;
-}
-.search input{
- width:100%;padding:9px 34px 9px 34px;
- background:var(--surface-1);border:1px solid var(--border);border-radius:var(--r);
- color:var(--text);font-size:.88rem;font-family:var(--font);
- transition:border-color .2s var(--ease),box-shadow .2s var(--ease);
- outline:none;
-}
-.search input::placeholder{color:var(--text-3)}
-.search input:focus{border-color:rgba(129,140,248,.4);box-shadow:0 0 0 3px rgba(129,140,248,.08)}
-.search-clear{
- position:absolute;right:8px;top:50%;transform:translateY(-50%);
- background:none;border:none;color:var(--text-3);font-size:1.15rem;
- cursor:pointer;display:none;line-height:1;padding:2px;
-}
-.search-clear:hover{color:var(--text)}
-
-/* Filter pills */
-.pills{display:flex;gap:5px}
-.pill{
- display:inline-flex;align-items:center;gap:5px;
- padding:7px 14px;border-radius:100px;
- background:var(--surface-1);border:1px solid var(--border);
- color:var(--text-2);font-size:.8rem;cursor:pointer;
- font-family:var(--font);
- transition:all .18s var(--ease);white-space:nowrap;
-}
-.pill:hover{border-color:var(--border-h);color:var(--text)}
-.pill.active{background:var(--indigo);border-color:var(--indigo);color:#fff}
-.pill.hidden{display:none}
-.dot{width:7px;height:7px;border-radius:50%;display:inline-block}
-.dot.emerald{background:var(--emerald)}
-.dot.rose{background:var(--rose)}
-.dot.amber{background:var(--amber)}
-.dot.slate{background:var(--slate)}
-.dot.orange{background:var(--orange)}
-.bar-info{font-size:.8rem;color:var(--text-3);margin-left:auto}
-
-/* Category filter pills — extends .pill with smaller sizing and violet accent */
-.cat-row{display:none;gap:6px;flex-wrap:wrap;align-items:center;margin-bottom:12px}
-.cat-row.visible{display:flex}
-.cat-lbl{font-size:.72rem;font-weight:700;text-transform:uppercase;color:var(--text-3);letter-spacing:.07em;margin-right:2px}
-.cat-pill{padding:5px 12px;font-size:.76rem;gap:4px}
-.cat-pill.active{background:var(--violet);border-color:var(--violet);color:#fff}
-.cat-pill .cat-count{font-size:.68rem;opacity:.7}
-.cat-link{cursor:pointer}
-.cat-more{
- font-size:.72rem;color:var(--text-3);cursor:pointer;
- background:none;border:none;font-family:var(--font);
- text-decoration:underline;text-underline-offset:2px;
-}
-.cat-more:hover{color:var(--text-2)}
-
-/* ── Groups ────────────────────────────────────────── */
-.groups{display:flex;flex-direction:column;gap:6px}
-.grp{
- background:var(--surface-0);border:1px solid var(--border);
- border-radius:var(--r);overflow:hidden;
- transition:border-color .2s var(--ease);
-}
-.grp:hover{border-color:var(--border-h)}
-.grp-hd{
- display:flex;align-items:center;gap:12px;padding:9px 16px;
- cursor:pointer;user-select:none;
- transition:background .15s var(--ease);
-}
-.grp-hd:hover{background:var(--surface-1)}
-.grp-arrow{
- width:16px;height:16px;color:var(--text-3);flex-shrink:0;
- transition:transform .2s var(--ease);
-}
-.grp.open .grp-arrow{transform:rotate(90deg)}
-.grp-name{font-weight:600;font-size:.9rem;flex:1;word-break:break-word}
-.grp-badges{display:flex;gap:6px;flex-shrink:0}
-.grp-b{
- display:inline-flex;align-items:center;gap:3px;
- font-size:.72rem;padding:2px 8px;border-radius:100px;
- font-weight:600;font-variant-numeric:tabular-nums;
-}
-.grp-b.gp{background:var(--emerald-d);color:var(--emerald)}
-.grp-b.gf{background:var(--rose-d);color:var(--rose)}
-.grp-b.gs{background:var(--amber-d);color:var(--amber)}
-.grp-b.gt{color:var(--text-3);font-weight:500}
-.grp-indicator{
- width:4px;height:18px;border-radius:2px;flex-shrink:0;
- background:var(--emerald);opacity:.6;
-}
-.grp-hd.fail .grp-indicator{background:var(--rose)}
-.grp-body{display:grid;grid-template-rows:0fr;transition:grid-template-rows .3s var(--ease)}
-.grp.open .grp-body{grid-template-rows:1fr}
-.grp-body-inner{overflow:hidden;min-height:0;content-visibility:auto;contain-intrinsic-size:auto 400px}
-.grp-body-pad{border-top:1px solid var(--border)}
-
-/* ── Test Rows ─────────────────────────────────────── */
-.t-row{
- display:flex;align-items:center;gap:10px;
- padding:9px 16px 9px 20px;
- border-bottom:1px solid var(--border);
- cursor:pointer;transition:background .12s var(--ease);
-}
-.t-row:last-child{border-bottom:none}
-.t-row:hover{background:rgba(255,255,255,.02)}
-.t-badge{
- font-size:.7rem;font-weight:700;padding:3px 9px;border-radius:100px;
- text-transform:uppercase;letter-spacing:.04em;white-space:nowrap;
- line-height:1;
-}
-.t-badge.passed{background:var(--emerald-d);color:var(--emerald);box-shadow:0 0 6px rgba(52,211,153,.15)}
-.t-badge.failed,.t-badge.error,.t-badge.timedOut{background:var(--rose-d);color:var(--rose);box-shadow:0 0 6px rgba(251,113,133,.15)}
-.t-badge.skipped{background:var(--amber-d);color:var(--amber);box-shadow:0 0 6px rgba(251,191,36,.12)}
-.t-badge.cancelled{background:var(--slate-d);color:var(--slate)}
-.t-badge.flaky{background:var(--orange-d);color:var(--orange);box-shadow:0 0 6px rgba(251,146,60,.15)}
-.t-badge.inProgress,.t-badge.unknown{background:var(--surface-2);color:var(--text-3)}
-.t-name{flex:1;font-size:.88rem;word-break:break-word;color:var(--text)}
-.t-dur{font-size:.78rem;color:var(--text-3);font-family:var(--mono);white-space:nowrap;font-variant-numeric:tabular-nums}
-.retry-tag{
- font-size:.65rem;font-weight:700;padding:2px 7px;border-radius:4px;
- background:var(--amber-d);color:var(--amber);white-space:nowrap;
-}
-
-/* ── Test Detail Panel ─────────────────────────────── */
-.t-detail{
- display:grid;grid-template-rows:0fr;
- transition:grid-template-rows .3s var(--ease);
-}
-.t-detail.open{grid-template-rows:1fr}
-.t-detail-inner{overflow:hidden;min-height:0}
-.t-detail-pad{padding:14px 18px 14px 22px;background:var(--surface-1);border-bottom:1px solid var(--border)}
-.d-sec{margin-bottom:14px}
-.d-sec:last-child{margin-bottom:0}
-.d-info{
- display:flex;gap:20px;flex-wrap:wrap;
- padding:10px 14px;border-radius:var(--r);
- background:var(--surface-0);border:1px solid var(--border);
-}
-.d-info-item{font-size:.82rem;color:var(--text-2)}
-.d-info-label{font-size:.68rem;font-weight:700;text-transform:uppercase;color:var(--text-3);letter-spacing:.07em;margin-right:4px}
-.d-collapsible .d-collapse-toggle{
- display:flex;align-items:center;gap:6px;cursor:pointer;user-select:none;
- font-size:.68rem;font-weight:700;text-transform:uppercase;
- color:var(--text-3);letter-spacing:.07em;margin-bottom:5px;
- transition:color .15s var(--ease);
-}
-.d-collapsible .d-collapse-toggle:hover{color:var(--text)}
-.d-collapsible .d-collapse-toggle .tl-arrow{transition:transform .2s var(--ease);flex-shrink:0}
-.d-collapsible.d-col-open .d-collapse-toggle .tl-arrow{transform:rotate(90deg)}
-.d-collapsible .d-collapse-content{display:grid;grid-template-rows:0fr;transition:grid-template-rows .3s var(--ease)}
-.d-collapsible.d-col-open .d-collapse-content{grid-template-rows:1fr}
-.d-collapse-inner{overflow:hidden;min-height:0}
-.d-lbl{
- font-size:.68rem;font-weight:700;text-transform:uppercase;
- color:var(--text-3);margin-bottom:5px;letter-spacing:.07em;
-}
-.d-pre{
- background:var(--surface-0);border:1px solid var(--border);
- border-radius:var(--r);padding:10px 12px;
- font-family:var(--mono);font-size:.8rem;color:var(--text-2);
- white-space:pre-wrap;word-break:break-word;
- max-height:320px;overflow:auto;line-height:1.5;
- border-left:2px solid var(--indigo);
-}
-.d-pre.err{color:var(--rose);border-color:rgba(251,113,133,.15);border-left:2px solid var(--rose)}
-.d-pre.stack{color:var(--text-3);font-size:.76rem;border-left-color:var(--text-3)}
-.d-tags{display:flex;gap:6px;flex-wrap:wrap}
-.d-tag{
- padding:3px 10px;border-radius:100px;font-size:.76rem;
- background:var(--surface-2);color:var(--text-2);border:1px solid var(--border);
-}
-.d-tag.kv{font-family:var(--mono);font-size:.72rem}
-
-.d-src{font-size:.78rem;color:var(--text-3);font-family:var(--mono)}
-
-/* ── Copy Button ──────────────────────────────────── */
-.d-pre-wrap{position:relative}
-.d-pre-wrap .d-pre{margin:0}
-.copy-btn{
- position:absolute;top:6px;right:6px;
- background:var(--surface-2);border:1px solid var(--border);border-radius:var(--r);
- color:var(--text-3);cursor:pointer;padding:4px 6px;
- opacity:0;transition:opacity .15s var(--ease),color .15s var(--ease),border-color .15s var(--ease);
- display:flex;align-items:center;justify-content:center;
-}
-.d-pre-wrap:hover .copy-btn{opacity:1}
-.copy-btn:hover{color:var(--text);border-color:var(--border-h)}
-.copy-btn.copied{color:var(--emerald);border-color:var(--emerald)}
-.copy-btn svg{width:14px;height:14px}
-
-/* ── Trace Timeline ────────────────────────────────── */
-.trace{margin-top:6px}
-.sp-row{display:flex;align-items:center;gap:6px;padding:2px 0;font-size:.78rem;cursor:pointer}
-.sp-row:hover .sp-bar{filter:brightness(1.2)}
-.sp-lbl{flex:0 0 240px;display:flex;align-items:center;gap:4px;min-width:0}
-.sp-track{flex:1;position:relative;height:14px;min-width:0}
-.sp-bar{position:absolute;top:0;height:100%;border-radius:3px;min-width:3px;transition:filter .15s}
-.sp-bar.ok{background:linear-gradient(90deg,rgba(52,211,153,.6),var(--emerald))}
-.sp-bar.err{background:linear-gradient(90deg,rgba(251,113,133,.6),var(--rose))}
-.sp-bar.unk{background:linear-gradient(90deg,rgba(148,163,184,.4),var(--slate))}
-.sp-name{color:var(--text-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
-.sp-dur{font-family:var(--mono);color:var(--text-3);font-size:.72rem;flex-shrink:0}
-.sp-extra{
- display:none;padding:6px 10px;margin:2px 0 4px;
- background:var(--surface-0);border:1px solid var(--border);border-radius:var(--r);
- font-size:.76rem;color:var(--text-2);
-}
-.sp-extra.open{display:block;animation:fade-up .2s var(--ease)}
-.sp-tag,.sp-evt{padding-left:1em}.sp-evt-tag{padding-left:2em}
-.global-trace,.suite-trace{
- background:var(--surface-1);border:1px solid var(--border);border-radius:var(--r-lg);
- padding:0;margin-bottom:16px;overflow:hidden;
-}
-.suite-trace{margin:0 0 12px;background:var(--surface-0);border-radius:var(--r)}
-.tl-toggle{
- display:flex;align-items:center;gap:6px;padding:12px 16px;cursor:pointer;
- user-select:none;font-size:.82rem;font-weight:600;color:var(--text-2);
- transition:color .15s var(--ease);
-}
-.suite-trace .tl-toggle{padding:10px 14px;font-size:.78rem}
-.tl-toggle:hover{color:var(--text)}
-.tl-toggle .tl-arrow{transition:transform .2s var(--ease);flex-shrink:0}
-.tl-open .tl-arrow{transform:rotate(90deg)}
-.tl-content{display:grid;grid-template-rows:0fr;transition:grid-template-rows .3s var(--ease)}
-.tl-open .tl-content{grid-template-rows:1fr}
-.tl-content-inner{overflow:hidden;min-height:0}
-.tl-content-pad{padding:0 16px 14px}
-.suite-trace .tl-content-pad{padding:0 14px 10px}
-
-/* ── Group Summary ─────────────────────────────────── */
-.grp-summary{
- display:flex;gap:20px;flex-wrap:wrap;align-items:center;
- padding:10px 16px;background:var(--surface-1);
- border-bottom:1px solid var(--border);
- font-size:.82rem;color:var(--text-2);
-}
-.grp-summary .d-info-label{font-size:.68rem;font-weight:700;text-transform:uppercase;color:var(--text-3);letter-spacing:.07em;margin-right:4px}
-.grp-summary .grp-sum-dur{font-family:var(--mono);font-weight:600;color:var(--text)}
-
-/* ── Quick-Access Sections ─────────────────────────── */
-.qa-section{
- background:var(--surface-0);border:1px solid var(--border);border-radius:var(--r-lg);
- margin-bottom:16px;overflow:hidden;
-}
-.qa-section .tl-toggle{padding:12px 16px;font-size:.82rem;font-weight:600;color:var(--text-2)}
-.qa-section .tl-content-pad{padding:0 16px 12px}
-.qa-item{
- display:flex;align-items:center;gap:10px;padding:8px 12px;
- border-radius:var(--r);cursor:pointer;
- transition:background .12s var(--ease);
-}
-.qa-item:hover{background:var(--surface-2)}
-.qa-info{flex:1;min-width:0}
-.qa-info-name{font-size:.86rem;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
-.qa-info-class{font-size:.72rem;color:var(--text-3)}
-.qa-err{
- font-size:.74rem;color:var(--rose);font-family:var(--mono);
- white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
- max-width:340px;
-}
-.qa-dur{font-size:.78rem;color:var(--text-3);font-family:var(--mono);white-space:nowrap;flex-shrink:0;font-variant-numeric:tabular-nums}
-
-/* Slowest tests */
-.slow-rank{
- font-size:.72rem;font-weight:800;color:var(--text-3);
- min-width:22px;text-align:center;flex-shrink:0;
- font-variant-numeric:tabular-nums;
-}
-.slow-bar-track{flex:1;height:6px;border-radius:3px;background:var(--surface-2);overflow:hidden;min-width:60px}
-.slow-bar-fill{height:100%;border-radius:3px;background:linear-gradient(90deg,var(--amber),var(--rose));min-width:2px}
-
-/* ── Highlight Flash ───────────────────────────────── */
-@keyframes flash-highlight{
- 0%{background:rgba(129,140,248,.18)}
- 100%{background:transparent}
-}
-.qa-highlight{animation:flash-highlight 1.5s var(--ease)}
-
-/* ── Empty State ───────────────────────────────────── */
-.empty{
- text-align:center;padding:64px 24px;
- color:var(--text-3);font-size:1rem;
- background:var(--surface-0);border:1px solid var(--border);border-radius:var(--r-lg);
-}
-
-/* ── Responsive ────────────────────────────────────── */
-@media(max-width:768px){
- .shell{padding:16px 12px 48px}
- .dash{flex-direction:column;align-items:stretch;gap:20px;padding:20px}
- .ring-wrap{align-self:center}
- .stats{justify-content:center}
- .bar{flex-direction:column;align-items:stretch}
- .search{max-width:none}
- .hdr{flex-direction:column}
- .qa-err{display:none}
-}
-
-/* ── Print ─────────────────────────────────────────── */
-@media print{
- :root{--bg:#fff;--surface-0:#fff;--surface-1:#f9f9f9;--surface-2:#f0f0f0;--surface-3:#e0e0e0;
- --border:rgba(0,0,0,.1);--border-h:rgba(0,0,0,.2);--text:#000;--text-2:#333;--text-3:#666}
- body{background:#fff;color:#000}
- .grain,.bar,.theme-btn{display:none}
- .grp-body{grid-template-rows:1fr!important}
- .t-detail{grid-template-rows:1fr!important;background:#f9f9f9}
- .shell{max-width:none;padding:0}
-}
-
-/* ── Scrollbar ─────────────────────────────────────── */
-::-webkit-scrollbar{width:6px;height:6px}
-::-webkit-scrollbar-track{background:transparent}
-::-webkit-scrollbar-thumb{background:var(--surface-3);border-radius:3px}
-::-webkit-scrollbar-thumb:hover{background:var(--text-3)}
-
-/* ── Light Mode Hover Adjustments ─────────────────── */
-:root[data-theme="light"] .t-row:hover{background:rgba(0,0,0,.02)}
-:root[data-theme="light"] .dash::before{
- background:radial-gradient(ellipse 60% 50% at 20% 50%,rgba(99,102,241,.06),transparent),
- radial-gradient(ellipse 40% 60% at 80% 30%,rgba(52,211,153,.04),transparent);
-}
-
-/* ── Feature 1: Pill Counts ───────────────────────── */
-.pill-count{font-size:.72rem;opacity:.7;font-variant-numeric:tabular-nums;margin-left:2px}
-.pill.active .pill-count{opacity:.85}
-
-/* ── Feature 2: Expand/Collapse All ──────────────── */
-.bar-actions{display:flex;align-items:center;gap:6px;flex-basis:100%;justify-content:flex-end}
-.bar-btn{
- display:flex;align-items:center;justify-content:center;
- width:32px;height:32px;border-radius:var(--r);
- background:var(--surface-1);border:1px solid var(--border);
- color:var(--text-3);cursor:pointer;
- transition:border-color .2s var(--ease),color .2s var(--ease),background .2s var(--ease);
-}
-.bar-btn:hover{border-color:var(--border-h);color:var(--text)}
-
-/* ── Feature 3: Sort Toggle ──────────────────────── */
-.bar-sep{width:1px;height:18px;background:var(--border);margin:0 4px}
-.bar-lbl{font-size:.72rem;color:var(--text-3);text-transform:uppercase;letter-spacing:.06em;white-space:nowrap}
-.sort-btn{
- padding:5px 10px;border-radius:100px;font-size:.74rem;
- background:var(--surface-1);border:1px solid var(--border);
- color:var(--text-2);cursor:pointer;font-family:var(--font);
- transition:all .18s var(--ease);white-space:nowrap;
-}
-.sort-btn:hover{border-color:var(--border-h);color:var(--text)}
-.sort-btn.active{background:var(--indigo);border-color:var(--indigo);color:#fff}
-
-/* ── Feature 4: Search Highlighting ──────────────── */
-mark{background:rgba(251,191,36,.25);color:inherit;border-radius:2px;padding:0 1px}
-:root[data-theme="light"] mark{background:rgba(251,191,36,.35)}
-
-/* ── Feature 5: Copy Deep-Link Button ────────────── */
-.t-link-btn{
- display:inline-flex;align-items:center;justify-content:center;
- width:24px;height:24px;border-radius:4px;
- background:transparent;border:none;color:var(--text-3);
- cursor:pointer;opacity:0;transition:opacity .15s var(--ease),color .15s var(--ease);
- flex-shrink:0;position:relative;
-}
-.t-row:hover .t-link-btn{opacity:.6}
-.t-link-btn:hover{opacity:1!important;color:var(--indigo)}
-.t-link-copied{
- position:absolute;bottom:100%;left:50%;transform:translateX(-50%);
- padding:2px 8px;border-radius:4px;font-size:.68rem;white-space:nowrap;
- background:var(--surface-3);color:var(--text);pointer-events:none;
- animation:fade-up .2s var(--ease);
-}
-
-/* ── Feature 6: Diff-Friendly Error Display ──────── */
-.err-expected{color:var(--emerald);font-weight:600}
-.err-actual{color:var(--rose);font-weight:600}
-
-/* ── Feature 7: Keyboard Navigation ──────────────── */
-.t-row.kb-focus{outline:2px solid var(--indigo);outline-offset:-2px;background:var(--indigo-d)}
-.kb-overlay{position:fixed;inset:0;z-index:9998;background:rgba(0,0,0,.5);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center}
-.kb-modal{
- background:var(--surface-0);border:1px solid var(--border);border-radius:var(--r-lg);
- padding:24px 28px;max-width:380px;width:90%;box-shadow:0 24px 48px rgba(0,0,0,.3);
-}
-.kb-modal h3{font-size:1rem;font-weight:700;margin-bottom:14px;color:var(--text)}
-.kb-row{display:flex;justify-content:space-between;align-items:center;padding:5px 0;font-size:.84rem;color:var(--text-2)}
-.kb-key{
- display:inline-flex;align-items:center;justify-content:center;
- min-width:24px;height:24px;padding:0 7px;border-radius:4px;
- background:var(--surface-2);border:1px solid var(--border);
- font-family:var(--mono);font-size:.74rem;font-weight:600;color:var(--text);
-}
-:root[data-theme="light"] .kb-overlay{background:rgba(0,0,0,.3)}
-
-/* ── Feature 8: Sticky Mini-Header ───────────────── */
-.sticky-bar{
- position:sticky;top:0;z-index:100;
- display:flex;align-items:center;gap:12px;
- padding:8px 24px;
- background:rgba(11,13,17,.85);backdrop-filter:blur(12px);
- border-bottom:1px solid var(--border);
- transform:translateY(-100%);opacity:0;
- transition:transform .25s var(--ease),opacity .25s var(--ease);
-}
-.sticky-bar.visible{transform:none;opacity:1}
-.sticky-name{font-size:.84rem;font-weight:700;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
-.sticky-badges{display:flex;gap:6px;flex-shrink:0}
-.sticky-b{
- font-size:.7rem;font-weight:700;padding:2px 8px;border-radius:100px;
- font-variant-numeric:tabular-nums;
-}
-.sb-pass{background:var(--emerald-d);color:var(--emerald)}
-.sb-fail{background:var(--rose-d);color:var(--rose)}
-.sb-skip{background:var(--amber-d);color:var(--amber)}
-.sticky-search-btn{
- margin-left:auto;display:flex;align-items:center;justify-content:center;
- width:28px;height:28px;border-radius:var(--r);
- background:var(--surface-2);border:1px solid var(--border);
- color:var(--text-3);cursor:pointer;
- transition:color .15s var(--ease),border-color .15s var(--ease);
-}
-.sticky-search-btn:hover{color:var(--text);border-color:var(--border-h)}
-:root[data-theme="light"] .sticky-bar{background:rgba(248,249,251,.85)}
-
-/* ── Feature 9: 100% Pass Celebration ────────────── */
-@keyframes ring-glow{
- 0%,100%{filter:drop-shadow(0 0 6px rgba(52,211,153,.3))}
- 50%{filter:drop-shadow(0 0 16px rgba(52,211,153,.6))}
-}
-.dash.celebrate .ring{animation:ring-glow 2.5s ease-in-out infinite}
-@media(prefers-reduced-motion:reduce){
- .dash.celebrate .ring{animation:none}
-}
-
-/* ── Feature 10: Duration Histogram ──────────────── */
-.dur-hist{display:flex;align-items:flex-end;gap:2px;height:36px;margin-top:8px}
-.dur-hist-bar{
- flex:1;min-width:0;border-radius:2px 2px 0 0;
- background:linear-gradient(to top,var(--indigo),rgba(129,140,248,.5));
- position:relative;cursor:default;
- transition:filter .15s var(--ease);
-}
-.dur-hist-bar:hover{filter:brightness(1.3)}
-.dur-hist-bar::after{
- content:attr(data-tip);
- position:absolute;bottom:100%;left:50%;transform:translateX(-50%);
- padding:3px 8px;border-radius:4px;font-size:.66rem;white-space:nowrap;
- background:var(--surface-3);color:var(--text);pointer-events:none;
- opacity:0;transition:opacity .15s var(--ease);
-}
-.dur-hist-bar:hover::after{opacity:1}
-
-/* ── Failure Clusters ──────────────────────────── */
-.fc-cluster{margin-bottom:4px}
-.fc-hd{display:flex;align-items:center;gap:10px;padding:10px 14px;cursor:pointer;transition:background .12s var(--ease);border-radius:var(--r)}
-.fc-hd:hover{background:var(--surface-2)}
-.fc-hd .tl-arrow{transition:transform .2s var(--ease);flex-shrink:0}
-.fc-cluster.open .fc-hd .tl-arrow{transform:rotate(90deg)}
-.fc-type{font-family:var(--mono);font-size:.8rem;font-weight:600;color:var(--rose);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:300px}
-.fc-frame{font-family:var(--mono);font-size:.74rem;color:var(--text-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
-.fc-count{font-size:.72rem;font-weight:700;padding:2px 9px;border-radius:100px;background:var(--rose-d);color:var(--rose);white-space:nowrap;flex-shrink:0;font-variant-numeric:tabular-nums}
-.fc-body{display:grid;grid-template-rows:0fr;transition:grid-template-rows .3s var(--ease)}
-.fc-cluster.open .fc-body{grid-template-rows:1fr}
-.fc-body-inner{overflow:hidden;min-height:0}
-.fc-tests{padding:0 14px 8px}
-.fc-test{display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:var(--r);cursor:pointer;transition:background .12s var(--ease);font-size:.84rem}
-.fc-test:hover{background:var(--surface-2)}
-.fc-test-name{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--text)}
-.fc-test-class{font-size:.72rem;color:var(--text-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:200px}
-.fc-test-dur{font-size:.76rem;color:var(--text-3);font-family:var(--mono);white-space:nowrap;font-variant-numeric:tabular-nums}
-.fc-msg{font-family:var(--mono);font-size:.76rem;color:var(--text-3);padding:4px 14px 8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
-
-@media(max-width:768px){
- .fc-frame{display:none}
- .fc-type{max-width:200px}
-}
-
-/* ── Lazy Sentinel ──────────────────────────────── */
-.lazy-sentinel{display:flex;align-items:center;justify-content:center;padding:16px;color:var(--text-3);font-size:.82rem}
-
-/* ── Accessibility: Skip Link ────────────────────── */
-.skip-link{
- position:absolute;top:-100%;left:16px;
- padding:8px 16px;border-radius:var(--r);
- background:var(--indigo);color:#fff;font-size:.84rem;font-weight:600;
- z-index:10000;text-decoration:none;
- transition:top .2s var(--ease);
-}
-.skip-link:focus{top:8px}
+ w.WriteStartObject();
+ w.WriteString("id", t.Id);
+ w.WriteString("name", t.DisplayName);
+ w.WriteString("cls", g.ClassName);
+ w.WriteString("ns", g.Namespace);
+ w.WriteString("status", MapStatus(t.Status));
+ w.WriteNumber("start", startRel);
+ w.WriteNumber("duration", t.DurationMs);
+ w.WriteString("worker", "worker-" + (testWorker.GetValueOrDefault(t.Id, 0) + 1));
+ if (!string.IsNullOrEmpty(t.TraceId)) w.WriteString("traceId", t.TraceId);
+
+ // properties — design schema is an object map; dedupe duplicate keys (first wins).
+ w.WritePropertyName("properties");
+ w.WriteStartObject();
+ if (t.CustomProperties is { Length: > 0 } props)
+ {
+ // First occurrence of a duplicated key wins. Tests typically carry 0–3
+ // custom properties, so a linear "have we seen this?" scan over the
+ // already-written prefix beats allocating a HashSet on the hot path.
+ for (var i = 0; i < props.Length; i++)
+ {
+ if (IsDuplicateKey(props, i, props[i].Key)) continue;
+ w.WriteString(props[i].Key, props[i].Value);
+ }
+ }
+ w.WriteEndObject();
-/* ── Accessibility: Focus-Visible ────────────────── */
-:focus-visible{outline:2px solid var(--indigo);outline-offset:2px;border-radius:var(--r)}
-.pill:focus-visible,.sort-btn:focus-visible{outline-offset:0}
-.search input:focus-visible{outline:none} /* uses custom box-shadow instead */
-.t-row:focus-visible{outline-offset:-2px}
+ w.WritePropertyName("categories");
+ w.WriteStartArray();
+ if (t.Categories is { Length: > 0 } cats)
+ {
+ foreach (var c in cats) w.WriteStringValue(c);
+ }
+ w.WriteEndArray();
-/* ── Accessibility: Touch Targets ────────────────── */
-@media(pointer:coarse){
- .theme-btn{width:44px;height:44px}
- .bar-btn{width:40px;height:40px}
- .pill{padding:10px 16px}
- .sort-btn{padding:8px 14px}
- .t-row{padding:12px 16px 12px 20px;min-height:44px}
- .t-link-btn{width:36px;height:36px;opacity:.5}
- .grp-hd{padding:12px 16px;min-height:44px}
- .sticky-search-btn{width:36px;height:36px}
-}
+ w.WriteString("stdout", t.Output ?? string.Empty);
+ // Engine-emitted advisories (e.g. "[TUnit] External span cap reached...") are written
+ // to Console.Error and end up in whichever test was running. They aren't test failures,
+ // so strip them before the renderer counts them in the "N err" tab badge.
+ var stderr = FilterEngineNotices(t.ErrorOutput) ?? t.SkipReason ?? string.Empty;
+ w.WriteString("stderr", stderr);
-/* ── Accessibility: Contrast Boost ───────────────── */
-/* Dark theme: bump secondary/tertiary text to meet WCAG AA */
-:root{
- --text-2:#a8aebb;
- --text-3:#717a8c;
-}
-:root[data-theme="light"]{
- --text-2:#4a5060;
- --text-3:#6b7280;
-}
+ if (t.Exception is not null)
+ {
+ w.WritePropertyName("error");
+ WriteException(w, t.Exception);
+ }
-/* ── Accessibility: Sort Group ───────────────────── */
-.sort-group{display:flex;gap:4px;align-items:center}
-.grp-toggle{display:flex;gap:4px;align-items:center}
+ w.WritePropertyName("source");
+ w.WriteStartObject();
+ if (!string.IsNullOrEmpty(t.FilePath)) w.WriteString("path", t.FilePath);
+ if (t.LineNumber is { } ln) w.WriteNumber("line", ln);
+ w.WriteEndObject();
-/* ── Mobile Improvements ─────────────────────────── */
-@media(max-width:768px){
- .bar-actions{width:100%;justify-content:flex-end}
- .bar-sep{display:none}
- .sticky-bar{padding:8px 12px;gap:8px}
- .sticky-name{font-size:.78rem;max-width:120px}
-}
-@media(max-width:480px){
- .pills{flex-wrap:wrap}
- .pill .pill-count{display:none}
- .sort-group{flex-wrap:wrap}
- .grp-toggle{flex-wrap:wrap}
-}
+ if (t.RetryAttempt > 0)
+ {
+ w.WriteNumber("retryCount", t.RetryAttempt);
+ }
-/* ── Print Improvements ──────────────────────────── */
-@media print{
- .skip-link,.sticky-bar,.bar-actions,.t-link-btn,.search,.copy-btn,.theme-btn{display:none!important}
- .grp{break-inside:avoid}
- .t-row{break-inside:avoid}
- .t-badge{-webkit-print-color-adjust:exact;print-color-adjust:exact}
- .grp-b{-webkit-print-color-adjust:exact;print-color-adjust:exact}
- .dot{-webkit-print-color-adjust:exact;print-color-adjust:exact}
- .ring-seg{stroke-opacity:1!important}
-}
+ if (t.Attempts is { Length: > 1 } atts)
+ {
+ w.WritePropertyName("attempts");
+ w.WriteStartArray();
+ foreach (var a in atts)
+ {
+ w.WriteStartObject();
+ w.WriteString("status", MapStatus(a.Status));
+ w.WriteNumber("duration", a.DurationMs);
+ if (!string.IsNullOrEmpty(a.ExceptionType) || !string.IsNullOrEmpty(a.ExceptionMessage))
+ {
+ w.WritePropertyName("error");
+ w.WriteStartObject();
+ if (!string.IsNullOrEmpty(a.ExceptionType)) w.WriteString("type", a.ExceptionType!);
+ if (!string.IsNullOrEmpty(a.ExceptionMessage)) w.WriteString("message", a.ExceptionMessage!);
+ w.WriteEndObject();
+ }
+ w.WriteEndObject();
+ }
+ w.WriteEndArray();
+ }
-/* ── High Contrast Mode ──────────────────────────── */
-@media(forced-colors:active){
- .pill.active{border:2px solid LinkText}
- .sort-btn.active{border:2px solid LinkText}
- .t-badge{border:1px solid CanvasText}
- .grp-indicator{forced-color-adjust:none}
- .t-row.kb-focus{outline:2px solid LinkText}
-}
+ w.WritePropertyName("spans");
+ w.WriteStartArray();
+ if (spansByTrace is not null)
+ {
+ WriteTraceSpans(w, t.TraceId, runStartMs, spansByTrace, linked: false);
+ if (t.AdditionalTraceIds is { Length: > 0 } extra)
+ {
+ foreach (var tid in extra) WriteTraceSpans(w, tid, runStartMs, spansByTrace, linked: true);
+ }
+ }
+ w.WriteEndArray();
-/* ── Minimap Sidebar Navigator ─────────── */
-.minimap-toggle{display:none}
-.minimap-toggle.visible{display:flex}
-.minimap-toggle.active{background:var(--indigo);border-color:var(--indigo);color:#fff}
-.minimap{
- position:fixed;right:0;top:0;bottom:0;
- width:180px;z-index:90;
- background:var(--surface-0);border-left:1px solid var(--border);
- overflow-y:auto;overflow-x:hidden;
- transform:translateX(100%);
- transition:transform .25s var(--ease);
- scrollbar-width:thin;
-}
-.minimap.open{transform:none;box-shadow:-4px 0 24px rgba(0,0,0,.15)}
-.minimap-hd{
- padding:8px 10px 6px;font-size:.62rem;font-weight:700;
- text-transform:uppercase;letter-spacing:.06em;
- color:var(--text-3);border-bottom:1px solid var(--border);
- position:sticky;top:0;background:var(--surface-0);z-index:1;
-}
-.minimap-row{
- display:flex;align-items:center;
- padding:2px 10px;cursor:pointer;
- transition:background .1s var(--ease);
-}
-.minimap-row:hover{background:var(--surface-1)}
-.minimap-row:hover .minimap-bar{filter:brightness(1.3)}
-.minimap-row.active{background:var(--indigo-d)}
-.minimap-bar{
- height:5px;border-radius:2px;min-width:6px;
- transition:filter .12s var(--ease);
-}
-.minimap-bar.mm-ok{background:var(--emerald)}
-.minimap-bar.mm-fail{background:var(--rose)}
-.minimap-bar.mm-warn{background:var(--amber)}
-.minimap-bar.mm-mix{
- background:linear-gradient(90deg,var(--rose) 0%,var(--rose) var(--fail-pct),var(--emerald) var(--fail-pct),var(--emerald) 100%);
-}
-.minimap-backdrop{position:fixed;inset:0;z-index:89;background:rgba(0,0,0,.4);backdrop-filter:blur(2px);display:none}
-.minimap-backdrop.open{display:block}
-@media(min-width:769px){.minimap-backdrop{display:none!important}}
-@media(max-width:768px){.minimap{width:200px}}
-@media(pointer:coarse){.minimap-row{padding:4px 10px}}
-@media print{.minimap,.minimap-toggle,.minimap-backdrop{display:none!important}}
-@media(forced-colors:active){.minimap-toggle.active{border:2px solid LinkText}}
-""";
+ w.WriteEndObject();
}
- private static string GetJavaScript()
+ private static void WriteTraceSpans(
+ Utf8JsonWriter w,
+ string? traceId,
+ long runStartMs,
+ Dictionary> spansByTrace,
+ bool linked = false)
{
- return """
-(async function(){
-'use strict';
-const raw = document.getElementById('test-data');
-if (!raw) return;
-let data;
-const compression = raw.getAttribute('data-compressed');
-if (compression && typeof DecompressionStream !== 'undefined') {
- const binary = atob(raw.textContent.trim());
- const bytes = new Uint8Array(binary.length);
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
- const readable = new Blob([bytes]).stream().pipeThrough(new DecompressionStream(compression));
- data = JSON.parse(await new Response(readable).text());
-} else if (compression) {
- document.getElementById('testGroups').innerHTML = 'This browser does not support DecompressionStream. Please open this report in a modern browser (Chrome 80+, Edge 80+, Firefox 113+, Safari 16.4+).
';
- return;
-} else {
- data = JSON.parse(raw.textContent);
-}
-const groups = data.groups || [];
-const testById = Object.create(null);
-groups.forEach(function(g){g.tests.forEach(function(t){testById[t.id]=t;});});
-const spans = data.spans || [];
-const container = document.getElementById('testGroups');
-const searchInput = document.getElementById('searchInput');
-const clearBtn = document.getElementById('clearSearch');
-const filterBtns = document.getElementById('filterButtons');
-const filterSummary = document.getElementById('filterSummary');
-const mmToggle = document.getElementById('minimapToggle');
-const mmContent = document.getElementById('minimapContent');
-const mm = document.getElementById('minimap');
-const mmBackdrop = document.getElementById('minimapBackdrop');
-let activeFilter = 'all';
-let searchText = '';
-let debounceTimer;
-let sortMode = 'default';
-let groupMode = 'class';
-let activeCategories = new Set();
-let renderLimit = 20;
-let kbIdx = -1;
-let minimapOpen = localStorage.getItem('tunit-minimap') === 'open';
-let spyObs = null;
-
-const catCounts = {};
-groups.forEach(function(g){g.tests.forEach(function(t){
- if(t.categories){t.categories.forEach(function(c){catCounts[c]=(catCounts[c]||0)+1;});}
-});});
-const catNames = Object.keys(catCounts).sort();
-const catRow = document.getElementById('categoryPills');
-const catLimit = 8;
-let catExpanded = false;
-function buildCatPills(){
- while(catRow.firstChild) catRow.removeChild(catRow.firstChild);
- if(!catNames.length) return;
- var lbl = document.createElement('span');
- lbl.className='cat-lbl';
- lbl.textContent='Categories:';
- catRow.appendChild(lbl);
- var showing = catExpanded ? catNames : catNames.slice(0, catLimit);
- showing.forEach(function(c){
- var btn = document.createElement('button');
- var isActive = activeCategories.has(c);
- btn.className = 'pill cat-pill' + (isActive ? ' active' : '');
- btn.setAttribute('data-category', c);
- btn.setAttribute('aria-pressed', isActive ? 'true' : 'false');
- btn.textContent = c + ' ';
- var cnt = document.createElement('span');
- cnt.className='cat-count';
- cnt.textContent='('+catCounts[c]+')';
- btn.appendChild(cnt);
- catRow.appendChild(btn);
- });
- if(!catExpanded && catNames.length > catLimit){
- var more = document.createElement('button');
- more.className='cat-more';
- more.textContent='+'+(catNames.length-catLimit)+' more';
- more.addEventListener('click',function(){catExpanded=true;buildCatPills();});
- catRow.appendChild(more);
- } else if(catExpanded && catNames.length > catLimit){
- var less = document.createElement('button');
- less.className='cat-more';
- less.textContent='Show less';
- less.addEventListener('click',function(){catExpanded=false;buildCatPills();});
- catRow.appendChild(less);
- }
- catRow.classList.add('visible');
-}
-
-const spansByTrace = {};
-const bySpanId = {};
-spans.forEach(s => {
- if (!spansByTrace[s.traceId]) spansByTrace[s.traceId] = [];
- spansByTrace[s.traceId].push(s);
- bySpanId[s.spanId] = s;
-});
-
-// Build suite span lookup: className -> span
-const suiteSpanByClass = {};
-spans.forEach(s => {
- if (s.spanType !== 'test suite') return;
- const tag = (s.tags||[]).find(t => t.key === 'test.suite.name');
- if (tag) suiteSpanByClass[tag.value] = s;
-});
-
-function isFlaky(t) { return t.status === 'passed' && t.retryAttempt > 0; }
-function badgeLabel(t) { return isFlaky(t) ? 'flaky' : t.status; }
-function matchesFilter(t) {
- if (activeFilter !== 'all') {
- if (activeFilter === 'flaky') {
- if (!isFlaky(t)) return false;
- } else if (activeFilter === 'passed') {
- if (t.status !== 'passed' || isFlaky(t)) return false;
- } else if (activeFilter === 'failed') {
- if (t.status !== 'failed' && t.status !== 'error' && t.status !== 'timedOut') return false;
- } else if (t.status !== activeFilter) return false;
- }
- if (activeCategories.size > 0) {
- if (!t.categories || !t.categories.some(function(c){ return activeCategories.has(c); })) return false;
- }
- if (searchText) {
- const q = searchText.toLowerCase();
- const h = (t.displayName + ' ' + t.className + ' ' + (t.categories||[]).join(' ') + ' ' + (t.traceId||'') + ' ' + (t.spanId||'')).toLowerCase();
- if (!h.includes(q)) return false;
- }
- return true;
-}
-
-function fmt(ms) {
- if (ms < 1) return '<1ms';
- if (Math.round(ms) < 1000) return Math.round(ms) + 'ms';
- if (ms < 60000) return (ms/1000).toFixed(2) + 's';
- return (ms/60000).toFixed(1) + 'm';
-}
-
-function esc(s) {
- if (!s) return '';
- const d = document.createElement('div');
- d.textContent = s;
- return d.innerHTML;
-}
-
-// Feature 4: Search highlight helper
-function highlight(text, query) {
- if (!query) return esc(text);
- const escaped = esc(text);
- const re = new RegExp('(' + query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
- return escaped.replace(re, '$1 ');
-}
-
-// Feature 3: Sort comparator
-const statusOrder = {failed:0,error:0,timedOut:1,inProgress:2,unknown:3,passed:4,skipped:5,cancelled:6};
-function sortTests(tests) {
- if (sortMode === 'duration') return [...tests].sort((a,b) => b.durationMs - a.durationMs);
- if (sortMode === 'name') return [...tests].sort((a,b) => a.displayName.localeCompare(b.displayName));
- return [...tests].sort((a,b) => (statusOrder[a.status]??9) - (statusOrder[b.status]??9));
-}
-
-function countStatuses(tests) {
- const c = {p:0,f:0,s:0};
- tests.forEach(function(t) {
- if (t.status==='passed') c.p++;
- else if (t.status==='failed'||t.status==='error'||t.status==='timedOut') c.f++;
- else if (t.status==='skipped') c.s++;
- });
- return c;
-}
-
-function computeDisplayGroups() {
- if (groupMode === 'namespace') {
- const map = {};
- groups.forEach(function(g) {
- const ns = g.namespace || '(no namespace)';
- if (!map[ns]) map[ns] = {label: ns, tests: [], className: null, namespace: ns};
- g.tests.forEach(function(t) { map[ns].tests.push(t); });
- });
- return Object.values(map);
- }
- if (groupMode === 'status') {
- const buckets = {Failed:[],Passed:[],Skipped:[],Cancelled:[]};
- groups.forEach(function(g) {
- g.tests.forEach(function(t) {
- if (t.status==='failed'||t.status==='error'||t.status==='timedOut') buckets.Failed.push(t);
- else if (t.status==='passed') buckets.Passed.push(t);
- else if (t.status==='skipped') buckets.Skipped.push(t);
- else buckets.Cancelled.push(t);
- });
- });
- var out = [];
- ['Failed','Passed','Skipped','Cancelled'].forEach(function(k) {
- if (buckets[k].length) out.push({label: k, tests: buckets[k], className: null});
- });
- return out;
- }
- return groups.map(function(g) { return {label: g.className, tests: g.tests, className: g.className, namespace: g.namespace}; });
-}
-
-// Feature 6: Diff-friendly error formatting
-function formatAssertionMessage(msg) {
- if (!msg) return '';
- let s = esc(msg);
- // Pattern: "Expected: X\nActual: Y" or "Expected: X\r\nActual: Y"
- s = s.replace(/^(Expected:\s*)(.+)$/gm, '$1$2 ');
- s = s.replace(/^(Actual:\s*)(.+)$/gm, '$1$2 ');
- // Pattern: "expected X but was Y"
- s = s.replace(/(expected\s+)(.+?)(\s+but was\s+)(.+)/gi, '$1$2 $3$4 ');
- return s;
-}
-
-// Feature 5: Link icon SVG
-const linkIcon = ' ';
-
-const arrow = ' ';
-
-function fmtTime(iso) {
- if (!iso) return '—';
- const d = new Date(iso);
- return d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit',fractionalSecondDigits:3});
-}
-
-// Strip anything that isn't a valid CSS identifier character before using
-// a value as a class name — guards against future status values with spaces/quotes.
-function safeClass(s) { return (s||'').replace(/[^a-zA-Z0-9_-]/g,''); }
-
-const copyIcon = ' ';
-const checkIcon = ' ';
-
-function wrapPre(content, cls) {
- return '' + content + '
'+copyIcon+' ';
-}
-
-function renderDetail(t) {
- let h = '';
-
- // Summary info row
- h += '';
- h += 'Started ' + fmtTime(t.startTime) + ' ';
- h += 'Ended ' + fmtTime(t.endTime) + ' ';
- h += 'Duration ' + fmt(t.durationMs) + ' ';
- if (t.retryAttempt > 0) {
- h += 'Retry #'+t.retryAttempt+' ';
- }
- h += '
';
-
- if (t.exception) {
- h += 'Exception
';
- h += wrapPre(esc(t.exception.type) + ': ' + formatAssertionMessage(t.exception.message), 'err');
- if (t.exception.stackTrace) h += wrapPre(esc(t.exception.stackTrace), 'stack');
- let inner = t.exception.innerException;
- while (inner) {
- h += '
Inner Exception
';
- h += wrapPre(esc(inner.type) + ': ' + formatAssertionMessage(inner.message), 'err');
- if (inner.stackTrace) h += wrapPre(esc(inner.stackTrace), 'stack');
- inner = inner.innerException;
+ if (string.IsNullOrEmpty(traceId)) return;
+ if (!spansByTrace.TryGetValue(traceId!, out var list)) return;
+ foreach (var s in list)
+ {
+ WriteSpan(w, s, runStartMs, linked);
}
- h += '
';
- }
- if (t.skipReason) {
- h += 'Skip Reason
';
- h += wrapPre(esc(t.skipReason)) + '
';
}
- if (t.output) {
- h += '' + tlArrow + 'Standard Output
';
- h += '
' + wrapPre(esc(t.output)) + '
';
- }
- if (t.errorOutput) {
- h += '' + tlArrow + 'Error Output
';
- h += '
' + wrapPre(esc(t.errorOutput), 'err') + '
';
- }
- if (t.categories && t.categories.length > 0) {
- h += 'Categories
';
- t.categories.forEach(c => { h += ''+esc(c)+' '; });
- h += '
';
- }
- if (t.customProperties && t.customProperties.length > 0) {
- h += 'Properties
';
- t.customProperties.forEach(p => { h += ''+esc(p.key)+'='+esc(p.value)+' '; });
- h += '
';
- }
- if (t.filePath) {
- h += ''+esc(t.filePath);
- if (t.lineNumber) h += ':'+t.lineNumber;
- h += '
';
- }
- if (t.traceId && t.spanId && spansByTrace[t.traceId]) h += renderTrace(t.traceId, t.spanId);
- if (t.additionalTraceIds && t.additionalTraceIds.length) {
- t.additionalTraceIds.forEach(function(tid) {
- if (spansByTrace[tid]) h += renderExternalTrace(tid);
- });
- }
- return h;
-}
-// Collect descendants of a span within a trace
-function getDescendants(traceSpans, rootId) {
- const children = {};
- traceSpans.forEach(s => {
- if (s.parentSpanId) {
- if (!children[s.parentSpanId]) children[s.parentSpanId] = [];
- children[s.parentSpanId].push(s.spanId);
+ private static void WriteSpan(Utf8JsonWriter w, SpanData s, long runStartMs, bool linked = false)
+ {
+ w.WriteStartObject();
+ w.WriteString("id", s.SpanId);
+ if (s.ParentSpanId is { Length: > 0 })
+ {
+ w.WriteString("parent", s.ParentSpanId);
}
- });
- const included = new Set();
- function walk(sid) {
- if (included.has(sid)) return;
- included.add(sid);
- (children[sid] || []).forEach(walk);
- }
- walk(rootId);
- 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 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 = {};
- 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 '';
- const mn = Math.min(...sp.map(s => s.startTimeMs));
- const mx = Math.max(...sp.map(s => s.startTimeMs + s.durationMs));
- const dur = mx - mn || 1;
- const idSet = new Set(sp.map(s => s.spanId));
- const depth = {};
- function gd(s) {
- if (depth[s.spanId] !== undefined) return depth[s.spanId];
- if (!s.parentSpanId || !bySpanId[s.parentSpanId] || !idSet.has(s.parentSpanId)) { depth[s.spanId] = 0; return 0; }
- depth[s.spanId] = gd(bySpanId[s.parentSpanId]) + 1; return depth[s.spanId];
- }
- sp.forEach(gd);
- const sorted = [...sp].sort((a, b) => a.startTimeMs - b.startTimeMs);
- let h = '';
- sorted.forEach((s, i) => {
- const d = depth[s.spanId] || 0;
- const l = ((s.startTimeMs - mn) / dur * 100).toFixed(2);
- const w = Math.max((s.durationMs / dur * 100), .5).toFixed(2);
- const cls = s.status === 'Error' ? 'err' : s.status === 'Ok' ? 'ok' : 'unk';
- h += '
';
- h += '
';
- h += '' + esc(s.name) + ' ';
- h += '' + fmt(s.durationMs) + ' ';
- h += '
';
- h += '
';
- h += '
';
- let ex = '';
- h += ex;
- });
- h += '
';
- return h;
-}
-
-// Per-test trace: test case span + its descendants
-function renderTrace(tid, rootSpanId) {
- const allSpans = spansByTrace[tid];
- if (!allSpans || !allSpans.length) return '';
- let sp = collapseTestBodySpans(getDescendants(allSpans, rootSpanId));
- if (!sp.length) return '';
- if (sp.length <= 1) return '';
- return 'Trace Timeline
' + renderSpanRows(sp, 't-' + rootSpanId) + '
';
-}
-
-// Render an external (linked) trace as a flat timeline
-function renderExternalTrace(tid) {
- const sp = spansByTrace[tid];
- if (!sp || !sp.length) return '';
- // Determine a label from the most common source name
- const srcCounts = {};
- sp.forEach(function(s) { srcCounts[s.source] = (srcCounts[s.source] || 0) + 1; });
- let topSrc = tid.substring(0, 8);
- let topCount = 0;
- for (var src in srcCounts) { if (srcCounts[src] > topCount) { topCount = srcCounts[src]; topSrc = src; } }
- return 'Linked Trace: ' + esc(topSrc) + '
' + renderSpanRows(sp, 'ext-' + tid) + '
';
-}
-
-const tlArrow = ' ';
-
-// Suite-level trace: test suite span + non-test-case children (hooks, setup, teardown)
-function renderClassSummary(g, ft) {
- // Compute start/end from suite span if available, else from tests
- const suite = suiteSpanByClass[g.className];
- let startIso = null, endIso = null, durMs = 0;
- if (suite) {
- // Suite span times are relative ms — use the earliest test's startTime as anchor
- durMs = suite.durationMs;
- }
- // Derive from test timestamps
- let minStart = null, maxEnd = null;
- ft.forEach(function(t){
- if (t.startTime) {
- const s = new Date(t.startTime);
- if (!minStart || s < minStart) minStart = s;
+ else
+ {
+ w.WriteNull("parent");
}
- if (t.endTime) {
- const e = new Date(t.endTime);
- if (!maxEnd || e > maxEnd) maxEnd = e;
+ w.WriteString("name", s.Name);
+ w.WriteString("service", MapSpanService(s));
+ var startRel = s.StartTimeMs - runStartMs;
+ w.WriteNumber("start", startRel < 0 ? 0 : startRel);
+ w.WriteNumber("dur", s.DurationMs);
+ w.WritePropertyName("attrs");
+ w.WriteStartObject();
+ if (s.Tags is { Length: > 0 } tags)
+ {
+ for (var i = 0; i < tags.Length; i++)
+ {
+ if (IsDuplicateKey(tags, i, tags[i].Key)) continue;
+ w.WriteString(tags[i].Key, tags[i].Value);
+ }
}
- });
- startIso = minStart;
- endIso = maxEnd;
- if (!durMs && minStart && maxEnd) durMs = maxEnd - minStart;
-
- let h = '';
- h += 'Started ' + (startIso ? fmtTime(startIso.toISOString()) : '\u2014') + ' ';
- h += 'Ended ' + (endIso ? fmtTime(endIso.toISOString()) : '\u2014') + ' ';
- h += 'Duration ' + fmt(durMs) + ' ';
- h += 'Tests ' + ft.length + ' ';
- h += '
';
- return h;
-}
-
-// 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 (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);
- } 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) {
- if (!filtered.some(s => s.spanId === ancestor.spanId)) filtered.unshift(ancestor);
- ancestor = ancestor.parentSpanId ? bySpanId[ancestor.parentSpanId] : null;
- }
- if (filtered.length <= 1) return '';
- return '' + tlArrow + 'Class Timeline
' + renderSpanRows(filtered, 'suite-' + className) + '
';
-}
-
-// Global timeline: session + assembly + suite + shared init/dispose spans (not per-test)
-function isGlobalTimelineSpan(s) {
- var t = s.spanType;
- if (t === 'test session' || t === 'test assembly' || t === 'test suite') return true;
- // Include init/dispose spans that are NOT per-test scope (shared prerequisites)
- if (t && (t.startsWith('initialize ') || t.startsWith('dispose '))) {
- var scopeTag = (s.tags||[]).find(function(tag){ return tag.key === 'tunit.trace.scope'; });
- return scopeTag && scopeTag.value !== 'test';
- }
- return false;
-}
-function renderGlobalTimeline() {
- const topSpans = spans.filter(isGlobalTimelineSpan);
- if (!topSpans.length) return '';
- return '' + tlArrow + 'Execution Timeline
' + renderSpanRows(topSpans, 'global') + '
';
-}
-
-function renderFailedSection() {
- const sec = document.getElementById('failedSection');
- if (!sec) return;
- const failed = [];
- groups.forEach(function(g){
- g.tests.forEach(function(t){
- if (t.status==='failed'||t.status==='error'||t.status==='timedOut') failed.push({t:t,cls:g.className});
- });
- });
- if (!failed.length) { sec.innerHTML=''; return; }
- let h = ''+tlArrow+' Failed Tests ('+failed.length+')
';
- failed.forEach(function(f){
- const errMsg = f.t.exception ? (f.t.exception.type+': '+f.t.exception.message) : '';
- const truncErr = errMsg.length > 120 ? errMsg.substring(0,120)+'…' : errMsg;
- h += '
';
- var bl=badgeLabel(f.t);h += '
'+esc(bl)+' ';
- h += '
'+esc(f.t.displayName)+'
';
- h += '
'+esc(f.cls)+'
';
- if (truncErr) h += '
'+esc(truncErr)+' ';
- h += '
'+fmt(f.t.durationMs)+' ';
- h += '
';
- });
- h += '
';
- sec.innerHTML = h;
-}
-
-function renderSlowestSection() {
- const sec = document.getElementById('slowestSection');
- if (!sec) return;
- const all = [];
- groups.forEach(function(g){
- g.tests.forEach(function(t){ all.push({t:t,cls:g.className}); });
- });
- all.sort(function(a,b){ return b.t.durationMs - a.t.durationMs; });
- const top = all.slice(0,10);
- if (!top.length) { sec.innerHTML=''; return; }
- const maxMs = top[0].t.durationMs || 1;
- let h = ''+tlArrow+' Top 10 Slowest Tests
';
- top.forEach(function(f,i){
- const pct = Math.max((f.t.durationMs/maxMs)*100,1).toFixed(1);
- h += '
';
- h += '
#'+(i+1)+' ';
- h += '
'+esc(f.t.displayName)+'
';
- h += '
'+esc(f.cls)+'
';
- h += '
';
- h += '
'+fmt(f.t.durationMs)+' ';
- h += '
';
- });
- h += '
';
- sec.innerHTML = h;
-}
-
-function renderFlakySection() {
- const sec = document.getElementById('flakySection');
- if (!sec) return;
- const flaky = [];
- groups.forEach(function(g){
- g.tests.forEach(function(t){
- if (isFlaky(t)) flaky.push({t:t,cls:g.className});
- });
- });
- // Update pill visibility and count
- const pill = document.getElementById('flakyPill');
- const pillCount = document.getElementById('flakyPillCount');
- if (pill) pill.classList.toggle('hidden', !flaky.length);
- if (pillCount) pillCount.textContent = flaky.length;
- if (!flaky.length) { sec.innerHTML=''; return; }
- flaky.sort(function(a,b){ return b.t.retryAttempt - a.t.retryAttempt; });
- let h = ''+tlArrow+' Flaky Tests ('+flaky.length+')
';
- flaky.forEach(function(f){
- h += '
';
- h += '
flaky ';
- h += '
'+esc(f.t.displayName)+'
';
- h += '
'+esc(f.cls)+'
';
- h += '
'+f.t.retryAttempt+' '+(f.t.retryAttempt===1?'retry':'retries')+' ';
- h += '
'+fmt(f.t.durationMs)+' ';
- h += '
';
- });
- h += '
';
- sec.innerHTML = h;
-}
-
-function sortGroups(grps) {
- if (sortMode === 'duration') {
- const maxDur = new Map(grps.map(g => [g, g.tests.length ? Math.max(...g.tests.map(t => t.durationMs)) : 0]));
- return [...grps].sort((a,b) => maxDur.get(b) - maxDur.get(a));
- }
- if (sortMode === 'name') return [...grps].sort((a,b) => a.label.localeCompare(b.label));
- const minStatus = new Map(grps.map(g => [g, g.tests.length ? Math.min(...g.tests.map(t => statusOrder[t.status] ?? 9)) : 9]));
- return [...grps].sort((a,b) => minStatus.get(a) - minStatus.get(b));
-}
-
-function render() {
- let total = 0;
- let html = '';
- const displayGroups = sortGroups(computeDisplayGroups());
- const limited = displayGroups.slice(0, renderLimit);
- limited.forEach((g,gi)=>{
- const ft = sortTests(g.tests.filter(matchesFilter));
- if (!ft.length) return;
- total += ft.length;
- const c = countStatuses(ft);
- const fail = c.f > 0;
- const open = fail || searchText;
- html += '';
- html += '
';
- html += '
';
- html += arrow;
- html += '
'+(searchText?highlight(g.label,searchText):esc(g.label))+' ';
- html += '
';
- if(c.p) html += ''+c.p+' ';
- if(c.f) html += ''+c.f+' ';
- if(c.s) html += ''+c.s+' ';
- html += ''+ft.length+' ';
- html += ' ';
- html += '
';
- if (groupMode === 'class') {
- html += renderClassSummary(g, ft);
- html += renderSuiteTrace(g);
+ w.WriteEndObject();
+ if (s.Status is { Length: > 0 } && !string.Equals(s.Status, "Unset", StringComparison.Ordinal))
+ {
+ w.WriteString("status", s.Status);
}
- ft.forEach((t,ti)=>{
- html += '
';
- var bl=badgeLabel(t);html += ''+esc(bl)+' ';
- html += ''+(searchText?highlight(t.displayName,searchText):esc(t.displayName))+' ';
- if(t.retryAttempt>0) html += 'retry '+t.retryAttempt+' ';
- html += ''+linkIcon+' ';
- html += ''+fmt(t.durationMs)+' ';
- html += '
';
- html += '
';
- });
- html += '
';
- });
- if (displayGroups.length > renderLimit) {
- html += 'Loading more\u2026
';
- }
- container.innerHTML = html;
- observeSentinel();
- filterSummary.textContent = (activeFilter!=='all'||searchText||activeCategories.size>0)
- ? 'Showing '+total+' of '+data.summary.total+' tests' : '';
- kbIdx = -1;
- updateMinimap(displayGroups);
-}
-
-function syncHash() {
- const p = [];
- if (activeFilter !== 'all') p.push('filter=' + encodeURIComponent(activeFilter));
- if (sortMode !== 'default') p.push('sort=' + encodeURIComponent(sortMode));
- if (searchText) p.push('search=' + encodeURIComponent(searchText));
- if (groupMode !== 'class') p.push('group=' + encodeURIComponent(groupMode));
- if (activeCategories.size > 0) p.push('category=' + Array.from(activeCategories).map(encodeURIComponent).join(','));
- history.replaceState(null, '', p.length ? '#' + p.join('&') : location.pathname);
-}
-
-function loadFromHash() {
- const h = location.hash;
- if (!h || h.length < 2) return;
- const raw = h.substring(1);
- if (raw.startsWith('test-')) return; // deep-link takes priority
- const pairs = raw.split('&');
- pairs.forEach(function(pair) {
- const eq = pair.indexOf('=');
- if (eq < 0) return;
- const k = decodeURIComponent(pair.substring(0, eq));
- const rawV = pair.substring(eq + 1);
- if (k === 'category') {
- rawV.split(',').forEach(function(c){ c = decodeURIComponent(c); if(c && catCounts[c]) activeCategories.add(c); });
- } else {
- const v = decodeURIComponent(rawV);
- if (k === 'filter') activeFilter = v;
- else if (k === 'sort') sortMode = v;
- else if (k === 'search') { searchText = v; searchInput.value = v; clearBtn.style.display = v ? 'block' : 'none'; }
- else if (k === 'group') groupMode = v;
+ if (s.Events is { Length: > 0 } events)
+ {
+ w.WritePropertyName("events");
+ w.WriteStartArray();
+ foreach (var ev in events)
+ {
+ w.WriteStartObject();
+ w.WriteString("name", ev.Name);
+ w.WriteNumber("time", ev.TimestampMs - runStartMs);
+ if (ev.Tags is { Length: > 0 } evTags)
+ {
+ w.WritePropertyName("attrs");
+ w.WriteStartObject();
+ for (var i = 0; i < evTags.Length; i++)
+ {
+ if (IsDuplicateKey(evTags, i, evTags[i].Key)) continue;
+ w.WriteString(evTags[i].Key, evTags[i].Value);
+ }
+ w.WriteEndObject();
+ }
+ w.WriteEndObject();
+ }
+ w.WriteEndArray();
}
- });
- // Sync button active states
- filterBtns.querySelectorAll('.pill').forEach(function(b) {
- const isActive = b.dataset.filter === activeFilter;
- b.classList.toggle('active', isActive);
- b.setAttribute('aria-pressed', isActive ? 'true' : 'false');
- });
- document.querySelectorAll('.sort-group .sort-btn').forEach(function(b) {
- const isActive = b.dataset.sort === sortMode;
- b.classList.toggle('active', isActive);
- b.setAttribute('aria-checked', isActive ? 'true' : 'false');
- });
- document.querySelectorAll('.grp-toggle .sort-btn').forEach(function(b) {
- const isActive = b.dataset.group === groupMode;
- b.classList.toggle('active', isActive);
- b.setAttribute('aria-checked', isActive ? 'true' : 'false');
- });
-}
-
-let lazyObs = null;
-function observeSentinel() {
- if (lazyObs) lazyObs.disconnect();
- const el = document.getElementById('lazySentinel');
- if (!el) return;
- lazyObs = new IntersectionObserver(function(entries) {
- if (entries[0].isIntersecting) { renderLimit += 20; render(); }
- }, {rootMargin: '200px'});
- lazyObs.observe(el);
-}
-
-function ensureGroupOpen(grp) {
- if (!grp || grp.classList.contains('open')) return;
- grp.classList.add('open');
- const hd = grp.querySelector('.grp-hd');
- if (hd) hd.setAttribute('aria-expanded','true');
-}
-
-function scrollToTest(testId) {
- const row = document.getElementById('test-' + testId);
- if (!row) return;
- ensureGroupOpen(row.closest('.grp'));
- // Expand detail panel
- const det = row.nextElementSibling;
- if (det && det.classList.contains('t-detail') && !det.classList.contains('open')) { ensureDetailRendered(det); det.classList.add('open'); }
- // Scroll into view
- row.scrollIntoView({behavior:'smooth',block:'center'});
- // Flash highlight
- row.classList.add('qa-highlight');
- setTimeout(function(){row.classList.remove('qa-highlight');},1500);
- // Update hash
- history.replaceState(null,'','#test-'+testId);
-}
-
-function checkHash() {
- const h = location.hash;
- if (h && h.startsWith('#test-')) {
- const testId = h.substring(6);
- setTimeout(function(){scrollToTest(testId);},100);
- }
-}
-
-// Toggle for collapsible sections (timelines & output panels)
-document.addEventListener('click',function(e){
- const tl = e.target.closest('.tl-toggle');
- if(tl){tl.parentElement.classList.toggle('tl-open');return;}
- const ct = e.target.closest('.d-collapse-toggle');
- if(ct){ct.parentElement.classList.toggle('d-col-open');return;}
-});
-
-function ensureDetailRendered(det) {
- if (!det || det.dataset.rendered) return;
- det.dataset.rendered = '1';
- var t = testById[det.dataset.tid];
- if (!t) return;
- var pad = det.querySelector('.t-detail-pad');
- if (pad) pad.innerHTML = renderDetail(t);
-}
-
-container.addEventListener('click',function(e){
- // Feature 5: Deep-link copy button
- const lb = e.target.closest('.t-link-btn');
- if(lb){
- e.stopPropagation();
- const tid = lb.dataset.linkTid;
- const url = location.origin + location.pathname + '#test-' + tid;
- navigator.clipboard.writeText(url).then(function(){
- const tip = document.createElement('span');
- tip.className='t-link-copied';tip.textContent='Copied!';
- lb.appendChild(tip);
- setTimeout(function(){tip.remove();},1200);
- });
- return;
- }
- const catLink = e.target.closest('.cat-link');
- if(catLink){
- e.stopPropagation();
- toggleCategory(catLink.getAttribute('data-category'));
- return;
+ if (linked) w.WriteBoolean("linked", true);
+ w.WriteEndObject();
}
- const hd = e.target.closest('.grp-hd');
- if(hd){const grp=hd.parentElement;grp.classList.toggle('open');hd.setAttribute('aria-expanded',grp.classList.contains('open')?'true':'false');return;}
- const row = e.target.closest('.t-row');
- if(row){
- const det = container.querySelector('.t-detail[data-gi="'+row.dataset.gi+'"][data-ti="'+row.dataset.ti+'"]');
- if(det) { ensureDetailRendered(det); det.classList.toggle('open'); }
- if(row.dataset.tid) history.replaceState(null,'','#test-'+row.dataset.tid);
- return;
- }
- const sr = e.target.closest('.sp-row');
- if(sr){const nx=sr.nextElementSibling;if(nx&&nx.classList.contains('sp-extra'))nx.classList.toggle('open');}
-});
-
-filterBtns.addEventListener('click',function(e){
- const btn=e.target.closest('.pill');
- if(!btn)return;
- filterBtns.querySelectorAll('.pill').forEach(function(b){b.classList.remove('active');b.setAttribute('aria-pressed','false');});
- btn.classList.add('active');
- btn.setAttribute('aria-pressed','true');
- activeFilter=btn.dataset.filter;
- renderLimit=20;render();
- syncHash();
-});
-function toggleCategory(cat){
- if(activeCategories.has(cat)) activeCategories.delete(cat);
- else activeCategories.add(cat);
- buildCatPills();renderLimit=20;render();syncHash();
-}
-catRow.addEventListener('click',function(e){
- const btn=e.target.closest('.cat-pill');
- if(!btn)return;
- toggleCategory(btn.getAttribute('data-category'));
-});
-
-searchInput.addEventListener('input',function(){
- clearTimeout(debounceTimer);
- clearBtn.style.display=searchInput.value?'block':'none';
- debounceTimer=setTimeout(function(){searchText=searchInput.value.trim();renderLimit=20;render();syncHash();},150);
-});
-clearBtn.addEventListener('click',function(){searchInput.value='';clearBtn.style.display='none';searchText='';renderLimit=20;render();syncHash();});
-
-// Feature 2: Expand/Collapse All
-document.getElementById('expandAll').addEventListener('click',function(){
- container.querySelectorAll('.grp').forEach(function(g){g.classList.add('open');});
- container.querySelectorAll('.t-detail').forEach(function(d){ensureDetailRendered(d);d.classList.add('open');});
- document.querySelectorAll('.qa-section').forEach(function(s){s.classList.add('tl-open');});
-});
-document.getElementById('collapseAll').addEventListener('click',function(){
- container.querySelectorAll('.grp').forEach(function(g){g.classList.remove('open');});
- container.querySelectorAll('.t-detail').forEach(function(d){d.classList.remove('open');});
- document.querySelectorAll('.qa-section').forEach(function(s){s.classList.remove('tl-open');});
-});
-
-// Feature 3: Sort Toggle
-document.querySelectorAll('.sort-group .sort-btn').forEach(function(btn){
- btn.addEventListener('click',function(){
- document.querySelectorAll('.sort-group .sort-btn').forEach(function(b){b.classList.remove('active');b.setAttribute('aria-checked','false');});
- btn.classList.add('active');
- btn.setAttribute('aria-checked','true');
- sortMode = btn.dataset.sort;
- renderLimit=20;render();
- syncHash();
- });
-});
-
-// Group-By Toggle
-document.querySelectorAll('.grp-toggle .sort-btn').forEach(function(btn){
- btn.addEventListener('click',function(){
- document.querySelectorAll('.grp-toggle .sort-btn').forEach(function(b){b.classList.remove('active');b.setAttribute('aria-checked','false');});
- btn.classList.add('active');
- btn.setAttribute('aria-checked','true');
- groupMode = btn.dataset.group;
- renderLimit=20;render();
- syncHash();
- });
-});
-
-// Quick-access section click delegation
-document.addEventListener('click',function(e){
- const qi = e.target.closest('.qa-item');
- if(qi && qi.dataset.scrollTid){scrollToTest(qi.dataset.scrollTid);return;}
- const cb = e.target.closest('.copy-btn');
- if(cb){
- const wrap = cb.closest('.d-pre-wrap');
- const pre = wrap && wrap.querySelector('.d-pre');
- if(pre){
- navigator.clipboard.writeText(pre.textContent).then(function(){
- cb.innerHTML = checkIcon;
- cb.classList.add('copied');
- setTimeout(function(){cb.innerHTML=copyIcon;cb.classList.remove('copied');},1500);
- });
+ private static void WriteException(Utf8JsonWriter w, ReportExceptionData ex)
+ {
+ w.WriteStartObject();
+ w.WriteString("type", ex.Type);
+ w.WriteString("message", ex.Message);
+ if (!string.IsNullOrEmpty(ex.StackTrace)) w.WriteString("stack", ex.StackTrace);
+ TryExtractExpectedActual(ex.Message, out var expected, out var actual);
+ if (expected is not null) w.WriteString("expected", expected);
+ if (actual is not null) w.WriteString("actual", actual);
+ if (ex.InnerException is not null)
+ {
+ w.WritePropertyName("innerException");
+ WriteException(w, ex.InnerException);
}
+ w.WriteEndObject();
}
-});
-
-// Theme initialization
-const savedTheme = localStorage.getItem('tunit-theme');
-const prefersDark = window.matchMedia('(prefers-color-scheme:dark)').matches;
-const initTheme = savedTheme || (prefersDark ? 'dark' : 'light');
-document.documentElement.setAttribute('data-theme', initTheme);
-
-// Render global execution timeline (static, doesn't change with filters)
-document.getElementById('globalTimeline').innerHTML = renderGlobalTimeline();
-
-loadFromHash();
-if(catNames.length > 0) buildCatPills();
-render();
-renderFailedSection();
-renderFlakySection();
-renderFailureClusters();
-renderSlowestSection();
-checkHash();
-
-// Theme toggle handler
-document.getElementById('themeToggle').addEventListener('click', function(){
- const root = document.documentElement;
- // Suppress element-level transitions so only @property variable
- // interpolations drive the animation — prevents stagger.
- root.classList.add('theme-transitioning');
- const next = root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
- root.setAttribute('data-theme', next);
- localStorage.setItem('tunit-theme', next);
- setTimeout(function(){root.classList.remove('theme-transitioning');}, 350);
-});
-
-// ── Feature 7: Keyboard Navigation ──────────────────
-function getVisibleRows(){return Array.from(container.querySelectorAll('.t-row'));}
-function setKbFocus(idx){
- const rows = getVisibleRows();
- const old = container.querySelector('.t-row.kb-focus');
- if(old) old.classList.remove('kb-focus');
- if(idx<0||idx>=rows.length){kbIdx=-1;return;}
- kbIdx=idx;
- const row=rows[idx];
- row.classList.add('kb-focus');
- const grp=row.closest('.grp');
- if(grp&&!grp.classList.contains('open')) grp.classList.add('open');
- row.scrollIntoView({behavior:'smooth',block:'nearest'});
-}
-function showKbHelp(){
- let ov=document.getElementById('kbOverlay');
- if(ov){ov.remove();return;}
- ov=document.createElement('div');ov.id='kbOverlay';ov.className='kb-overlay';
- ov.setAttribute('role','dialog');ov.setAttribute('aria-modal','true');ov.setAttribute('aria-label','Keyboard shortcuts');
- ov.innerHTML='Keyboard Shortcuts '
- +'
Next test j
'
- +'
Previous test k
'
- +'
Toggle detail Enter
'
- +'
Close / clear Esc
'
- +'
Focus search /
'
- +'
This help ?
'
- +'
Toggle minimap m
'
- +'
× '
- +'
';
- ov.addEventListener('click',function(ev){if(ev.target===ov)ov.remove();});
- document.body.appendChild(ov);
- document.getElementById('kbClose').focus();
- document.getElementById('kbClose').addEventListener('click',function(){ov.remove();});
-}
-document.addEventListener('keydown',function(e){
- const tag=e.target.tagName;
- if(tag==='INPUT'||tag==='TEXTAREA'||tag==='SELECT'){
- if(e.key==='Escape'){e.target.blur();if(e.target===searchInput){searchInput.value='';clearBtn.style.display='none';searchText='';renderLimit=20;render();syncHash();}}
- return;
- }
- const ov=document.getElementById('kbOverlay');
- if(ov&&e.key==='Escape'){ov.remove();return;}
- if(e.key==='j'){e.preventDefault();const rows=getVisibleRows();setKbFocus(Math.min(kbIdx+1,rows.length-1));return;}
- if(e.key==='k'){e.preventDefault();setKbFocus(Math.max(kbIdx-1,0));return;}
- if(e.key==='Enter'&&kbIdx>=0){
- e.preventDefault();const rows=getVisibleRows();const row=rows[kbIdx];
- if(row){const det=container.querySelector('.t-detail[data-gi="'+row.dataset.gi+'"][data-ti="'+row.dataset.ti+'"]');if(det){ensureDetailRendered(det);det.classList.toggle('open');}}
- return;
- }
- if(e.key==='Escape'){
- const focused=container.querySelector('.t-row.kb-focus');
- if(focused)focused.classList.remove('kb-focus');
- kbIdx=-1;
- container.querySelectorAll('.t-detail.open').forEach(function(d){d.classList.remove('open');});
- return;
- }
- if(e.key==='/'){e.preventDefault();searchInput.focus();return;}
- if(e.key==='m'){e.preventDefault();if(mmToggle&&mmToggle.classList.contains('visible'))toggleMinimap();return;}
- if(e.key==='?'){e.preventDefault();showKbHelp();return;}
-});
-
-// ── Feature 8: Sticky Mini-Header ───────────────────
-(function(){
- const dash=document.querySelector('.dash');
- const bar=document.getElementById('stickyBar');
- if(!dash||!bar)return;
- const obs=new IntersectionObserver(function(entries){
- entries.forEach(function(en){bar.classList.toggle('visible',!en.isIntersecting);});
- },{threshold:0});
- obs.observe(dash);
- var ssb=document.getElementById('stickySearchBtn');
- if(ssb) ssb.addEventListener('click',function(){searchInput.focus();searchInput.scrollIntoView({behavior:'smooth',block:'center'});});
-})();
-// ── Failure Clustering ───────────────────────────────
-function renderFailureClusters() {
- const sec = document.getElementById('failureClusters');
- if (!sec) return;
- const failed = [];
- groups.forEach(function(g){
- g.tests.forEach(function(t){
- if (t.status==='failed'||t.status==='error'||t.status==='timedOut') failed.push({t:t,cls:g.className});
- });
- });
- if (failed.length < 2) { sec.innerHTML=''; return; }
- // Cluster by exception type + top stack frame
- const clusters = {};
- failed.forEach(function(f){
- const ex = f.t.exception;
- if (!ex) return;
- const type = ex.type || 'Unknown';
- let frame = '';
- if (ex.stackTrace) {
- const lines = ex.stackTrace.split('\n');
- for (let i = 0; i < lines.length; i++) {
- const l = lines[i].trim();
- if (l.startsWith('at ')) { frame = l.substring(3).replace(/\s+in\s+.+$/, '').replace(/\[.+\]/, '').trim(); break; }
+ // TUnit assertion messages often follow "Expected: X\n Actual: Y" — light parsing
+ // lets the renderer show a real Expected/Actual diff block instead of raw text.
+ private static void TryExtractExpectedActual(string message, out string? expected, out string? actual)
+ {
+ expected = null;
+ actual = null;
+ if (string.IsNullOrEmpty(message)) return;
+ var lines = message.Replace("\r\n", "\n").Split('\n');
+ foreach (var raw in lines)
+ {
+ var line = raw.TrimStart();
+ if (expected is null && line.StartsWith("Expected:", StringComparison.OrdinalIgnoreCase))
+ {
+ expected = line.Substring("Expected:".Length).Trim();
+ }
+ else if (actual is null && (line.StartsWith("Actual:", StringComparison.OrdinalIgnoreCase) || line.StartsWith("But was:", StringComparison.OrdinalIgnoreCase)))
+ {
+ var prefixLen = line.StartsWith("Actual:", StringComparison.OrdinalIgnoreCase) ? "Actual:".Length : "But was:".Length;
+ actual = line.Substring(prefixLen).Trim();
}
}
- const key = JSON.stringify([type, frame]);
- if (!clusters[key]) clusters[key] = {type: type, frame: frame, tests: [], msg: ex.message || ''};
- clusters[key].tests.push(f);
- });
- // Filter to clusters with 2+ tests, sort by count descending
- const sorted = Object.values(clusters).filter(c => c.tests.length >= 2).sort((a,b) => b.tests.length - a.tests.length);
- if (!sorted.length) { sec.innerHTML=''; return; }
- let h = ''+tlArrow+' Failure Clusters ('+sorted.length+')
';
- sorted.forEach(function(c, ci){
- const truncMsg = c.msg.length > 100 ? c.msg.substring(0,100)+'\u2026' : c.msg;
- h += '
';
- h += '
';
- h += tlArrow;
- h += ''+esc(c.type)+' ';
- if (c.frame) h += ''+esc(c.frame)+' ';
- h += ''+c.tests.length+' tests ';
- h += '
';
- if (truncMsg) h += '
'+esc(truncMsg)+'
';
- h += '
';
- c.tests.forEach(function(f){
- h += '
';
- var bl=badgeLabel(f.t);h += ''+esc(bl)+' ';
- h += ''+esc(f.t.displayName)+' ';
- h += ''+esc(f.cls)+' ';
- h += ''+fmt(f.t.durationMs)+' ';
- h += '
';
- });
- h += '
';
- });
- h += '
';
- sec.innerHTML = h;
-}
-
-// Click handler for failure cluster expand/collapse
-document.addEventListener('click', function(e){
- const hd = e.target.closest('.fc-hd');
- if (hd) { hd.closest('.fc-cluster').classList.toggle('open'); return; }
- const ft = e.target.closest('.fc-test');
- if (ft && ft.dataset.scrollTid) { scrollToTest(ft.dataset.scrollTid); }
-});
-
-// ── Feature 9: 100% Pass Celebration ────────────────
-if(data.summary.passed===data.summary.total&&data.summary.total>0){
- const dash=document.querySelector('.dash');
- if(dash) dash.classList.add('celebrate');
-}
-
-// ── Feature 10: Duration Histogram ──────────────────
-(function(){
- const hist=document.getElementById('durationHist');
- if(!hist)return;
- const durations=[];
- groups.forEach(function(g){g.tests.forEach(function(t){durations.push(t.durationMs);});});
- if(!durations.length)return;
- const mn=Math.min.apply(null,durations);
- const mx=Math.max.apply(null,durations);
- if(mx<=mn){hist.innerHTML='
';return;}
- const bins=10;const step=(mx-mn)/bins;const buckets=new Array(bins).fill(0);
- durations.forEach(function(d){var i=Math.min(Math.floor((d-mn)/step),bins-1);buckets[i]++;});
- const maxB=Math.max.apply(null,buckets)||1;
- let h='';
- for(var i=0;i';
+ if (expected is { Length: 0 }) expected = null;
+ if (actual is { Length: 0 }) actual = null;
}
- hist.innerHTML=h;
-})();
-// ── Minimap Sidebar Navigator ──────────
-function updateMinimap(allDisplayGroups) {
- if (!mmToggle || !mmContent) return;
- const visibleGroups = [];
- allDisplayGroups.forEach(function(g, idx) {
- const ft = g.tests.filter(matchesFilter);
- if (ft.length) visibleGroups.push({group: g, tests: ft, counts: countStatuses(ft), dgIdx: idx});
- });
- mmToggle.classList.toggle('visible', visibleGroups.length > 10);
- let maxTests = 0;
- visibleGroups.forEach(function(item) {
- if (item.tests.length > maxTests) maxTests = item.tests.length;
- });
- let h = '';
- visibleGroups.forEach(function(item) {
- const c = item.counts;
- let barCls = 'mm-ok';
- if (c.f && c.p) barCls = 'mm-mix';
- else if (c.f) barCls = 'mm-fail';
- else if (c.s && !c.p) barCls = 'mm-warn';
- const pct = maxTests > 0 ? Math.max(Math.round((item.tests.length / maxTests) * 100), 8) : 100;
- const counts = [];
- if (c.p) counts.push(c.p + ' passed');
- if (c.f) counts.push(c.f + ' failed');
- if (c.s) counts.push(c.s + ' skipped');
- const tip = esc(item.group.label + ' \u2014 ' + counts.join(', ')).replace(/"/g,'"');
- h += '';
- if (barCls === 'mm-mix') {
- const failPct = Math.round((c.f / item.tests.length) * 100);
- h += ' ';
- } else {
- h += ' ';
- }
- h += '
';
- });
- mmContent.innerHTML = h;
- const rowsByGi = new Map();
- mmContent.querySelectorAll('.minimap-row[data-dgi]').forEach(function(row) {
- rowsByGi.set(row.dataset.dgi, row);
- });
- if (spyObs) spyObs.disconnect();
- const grpEls = container.querySelectorAll('.grp[data-gi]');
- if (!grpEls.length) return;
- spyObs = new IntersectionObserver(function(entries) {
- entries.forEach(function(en) {
- const mmRow = rowsByGi.get(en.target.dataset.gi);
- if (mmRow) {
- mmRow.classList.toggle('active', en.isIntersecting);
- if (en.isIntersecting && minimapOpen) {
- mmRow.scrollIntoView({block:'nearest', behavior:'auto'});
+ private static string MapSpanService(SpanData s)
+ {
+ if (s.Tags is { Length: > 0 } tags)
+ {
+ string? dbSystem = null;
+ string? msgSystem = null;
+ var hasHttp = false;
+ foreach (var t in tags)
+ {
+ switch (t.Key)
+ {
+ case "db.system":
+ dbSystem = t.Value;
+ break;
+ case "messaging.system":
+ msgSystem = t.Value;
+ break;
+ default:
+ if (t.Key.StartsWith("http.", StringComparison.Ordinal)) hasHttp = true;
+ break;
}
}
- });
- }, {rootMargin: '0px 0px -75% 0px', threshold: 0});
- grpEls.forEach(function(el) { spyObs.observe(el); });
-}
+ if (!string.IsNullOrEmpty(dbSystem))
+ {
+ return string.Equals(dbSystem, "postgresql", StringComparison.OrdinalIgnoreCase) ? "npgsql" : dbSystem!;
+ }
+ if (!string.IsNullOrEmpty(msgSystem)) return msgSystem!;
+ if (hasHttp)
+ {
+ return string.Equals(s.Kind, "Server", StringComparison.OrdinalIgnoreCase) ? "http.server" : "http.client";
+ }
+ }
+ if (!string.IsNullOrEmpty(s.Source))
+ {
+ var src = s.Source.ToLowerInvariant();
+ if (src.Contains("npgsql")) return "npgsql";
+ if (src.Contains("httpclient") || src.Contains("http.client")) return "http.client";
+ if (src.Contains("aspnetcore") || src.Contains("kestrel") || src.Contains("http.server")) return "http.server";
+ if (src.Contains("rabbit")) return "rabbitmq";
+ if (src.Contains("tunit")) return "test";
+ }
+ if (s.Name is { Length: > 0 } name)
+ {
+ if (name.StartsWith("hook", StringComparison.OrdinalIgnoreCase) || name.IndexOf("hook:", StringComparison.OrdinalIgnoreCase) >= 0) return "hook";
+ }
+ return string.IsNullOrEmpty(s.Source) ? "test" : s.Source.ToLowerInvariant();
+ }
-function openMinimap() {
- minimapOpen = true;
- if (mm) mm.classList.add('open');
- if (mmToggle) { mmToggle.classList.add('active'); mmToggle.setAttribute('aria-expanded','true'); }
- if (mmBackdrop) mmBackdrop.classList.add('open');
- localStorage.setItem('tunit-minimap', 'open');
-}
-function closeMinimap() {
- minimapOpen = false;
- if (mm) mm.classList.remove('open');
- if (mmToggle) { mmToggle.classList.remove('active'); mmToggle.setAttribute('aria-expanded','false'); }
- if (mmBackdrop) mmBackdrop.classList.remove('open');
- localStorage.setItem('tunit-minimap', 'closed');
-}
-function toggleMinimap() {
- if (minimapOpen) closeMinimap(); else openMinimap();
-}
-(function(){
- if (!mmToggle || !mm || !mmContent) return;
- mmToggle.addEventListener('click', toggleMinimap);
- if (mmBackdrop) mmBackdrop.addEventListener('click', closeMinimap);
- function navigateToRow(row) {
- const dgi = parseInt(row.dataset.dgi, 10);
- if (dgi >= renderLimit) {
- renderLimit = dgi + 5;
- render();
+ private static string MapStatus(string status) => status switch
+ {
+ "passed" => "pass",
+ "failed" or "error" or "timedOut" => "fail",
+ "skipped" => "skip",
+ "cancelled" => "cancel",
+ // Mid-run snapshots can carry `inProgress` / `unknown` — these are not
+ // failures, just states that didn't reach a verdict. Map to `skip` so
+ // the report doesn't claim phantom failures in dashboards.
+ "inProgress" or "unknown" => "skip",
+ // Anything else is a genuinely unexpected engine value; keep it visible.
+ _ => "fail",
+ };
+
+ // O(n) linear scan over an already-written prefix. Avoids a HashSet allocation
+ // for the common case of small N (tests carry 0–3 properties, spans 0–5 tags).
+ private static bool IsDuplicateKey(ReportKeyValue[] items, int index, string key)
+ {
+ for (var i = 0; i < index; i++)
+ {
+ if (string.Equals(items[i].Key, key, StringComparison.Ordinal)) return true;
}
- const el = container.querySelector('.grp[data-gi="'+dgi+'"]');
- if (el) {
- el.scrollIntoView({behavior:'smooth', block:'start'});
- ensureGroupOpen(el);
+ return false;
+ }
+
+ internal static string? FilterEngineNotices(string? stderr)
+ {
+ if (string.IsNullOrEmpty(stderr)) return stderr;
+ if (stderr!.IndexOf("[TUnit]", StringComparison.Ordinal) < 0) return stderr;
+ var lines = stderr.Replace("\r\n", "\n").Split('\n');
+ var sb = new StringBuilder(stderr.Length);
+ foreach (var line in lines)
+ {
+ if (line.TrimStart().StartsWith("[TUnit]", StringComparison.Ordinal)) continue;
+ if (sb.Length > 0) sb.Append('\n');
+ sb.Append(line);
}
- if (window.innerWidth < 769) closeMinimap();
+ return sb.Length == 0 ? null : sb.ToString();
}
- mmContent.addEventListener('click', function(e) {
- const row = e.target.closest('.minimap-row');
- if (row) navigateToRow(row);
- });
- mmContent.addEventListener('keydown', function(e) {
- if (e.key !== 'Enter' && e.key !== ' ') return;
- const row = e.target.closest('.minimap-row');
- if (row) { e.preventDefault(); navigateToRow(row); }
- });
- if (minimapOpen) openMinimap();
-})();
-})().catch(e => console.error('TUnit report init failed:', e));
-""";
+
+ private static long? TryParseUnixMs(string? iso)
+ {
+ if (string.IsNullOrEmpty(iso)) return null;
+ return DateTimeOffset.TryParse(
+ iso,
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
+ out var dt)
+ ? dt.ToUnixTimeMilliseconds()
+ : null;
}
}
diff --git a/TUnit.Engine/Reporters/Html/HtmlReportJsonContext.cs b/TUnit.Engine/Reporters/Html/HtmlReportJsonContext.cs
deleted file mode 100644
index c6274ce2b3..0000000000
--- a/TUnit.Engine/Reporters/Html/HtmlReportJsonContext.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-using System.Text.Json.Serialization;
-using TUnit.Core;
-
-namespace TUnit.Engine.Reporters.Html;
-
-[JsonSerializable(typeof(ReportData))]
-[JsonSerializable(typeof(ReportSummary))]
-[JsonSerializable(typeof(ReportTestGroup))]
-[JsonSerializable(typeof(ReportTestResult))]
-[JsonSerializable(typeof(ReportExceptionData))]
-[JsonSerializable(typeof(ReportKeyValue))]
-[JsonSerializable(typeof(SpanData))]
-[JsonSerializable(typeof(SpanEvent))]
-[JsonSerializable(typeof(SpanLink))]
-[JsonSourceGenerationOptions(
- PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
- DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
- WriteIndented = false)]
-internal sealed partial class HtmlReportJsonContext : JsonSerializerContext;
diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs
index 922bff23e8..88e8cca37c 100644
--- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs
+++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs
@@ -248,24 +248,40 @@ private ReportData BuildReportData()
spanId = spanInfo.SpanId;
}
- // Track retry attempts by counting final-state updates.
- // A non-retried test has exactly 1 final-state update; each retry adds another.
+ // Walk all updates in order so we capture each retry attempt's status and
+ // timing — not just the count. The renderer's flaky/retry UI needs the
+ // per-attempt list, and we have all of it sitting on the update messages.
+ ReportAttempt[]? attempts = null;
var retryAttempt = 0;
if (_updates.TryGetValue(kvp.Key, out var allUpdates))
{
- var finalStateCount = 0;
+ List? attemptList = null;
foreach (var update in allUpdates)
{
var state = update.TestNode.Properties.SingleOrDefault();
- if (state is not null and not InProgressTestNodeStateProperty and not DiscoveredTestNodeStateProperty)
+ if (state is null or InProgressTestNodeStateProperty or DiscoveredTestNodeStateProperty)
{
- finalStateCount++;
+ continue;
}
+
+ var (attemptStatus, attemptException, _) = ExtractStatus(state);
+ var attemptDuration = update.TestNode.Properties.AsEnumerable()
+ .OfType()
+ .FirstOrDefault()?.GlobalTiming.Duration.TotalMilliseconds ?? 0;
+ attemptList ??= new List();
+ attemptList.Add(new ReportAttempt
+ {
+ Status = attemptStatus,
+ DurationMs = attemptDuration,
+ ExceptionType = attemptException?.Type,
+ ExceptionMessage = attemptException?.Message,
+ });
}
- if (finalStateCount > 1)
+ if (attemptList is { Count: > 1 })
{
- retryAttempt = finalStateCount - 1;
+ retryAttempt = attemptList.Count - 1;
+ attempts = attemptList.ToArray();
}
}
@@ -276,7 +292,7 @@ private ReportData BuildReportData()
string[]? additionalTraceIdsForResult = null;
#endif
- var testResult = ExtractTestResult(kvp.Key, testNode, traceId, spanId, retryAttempt, additionalTraceIdsForResult);
+ var testResult = ExtractTestResult(kvp.Key, testNode, traceId, spanId, retryAttempt, additionalTraceIdsForResult, attempts);
AccumulateStatus(summary, testResult);
@@ -484,7 +500,7 @@ private static DateTimeOffset ParseStartTimeForSort(string? raw)
: DateTimeOffset.MaxValue;
}
- internal static ReportTestResult ExtractTestResult(string testId, TestNode testNode, string? traceId, string? spanId, int retryAttempt, string[]? additionalTraceIds)
+ internal static ReportTestResult ExtractTestResult(string testId, TestNode testNode, string? traceId, string? spanId, int retryAttempt, string[]? additionalTraceIds, ReportAttempt[]? attempts = null)
{
IProperty? stateProperty = null;
TestMethodIdentifierProperty? testMethodIdentifier = null;
@@ -566,6 +582,7 @@ internal static ReportTestResult ExtractTestResult(string testId, TestNode testN
LineNumber = fileLocation?.LineSpan.Start.Line,
SkipReason = skipReason,
RetryAttempt = retryAttempt,
+ Attempts = attempts,
TraceId = traceId,
SpanId = spanId,
AdditionalTraceIds = additionalTraceIds
diff --git a/TUnit.Engine/Reporters/Html/TestReport.template.html b/TUnit.Engine/Reporters/Html/TestReport.template.html
new file mode 100644
index 0000000000..9ea3edb95b
--- /dev/null
+++ b/TUnit.Engine/Reporters/Html/TestReport.template.html
@@ -0,0 +1,3050 @@
+
+
+
+
+
+__REPORT_TITLE__
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Select a test
+
Pick a test from the rail to see its output, OpenTelemetry trace, properties and source location. Press j / k to navigate, f to jump to next failure.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Outcome timeline
+
+
+ start
+
+ end
+
+
+
+
+
+
+
+
+
Slowest tests
+ Top 8
+
+
+
+
+
+
+
+
Flaky in this run
+ 0
+
+
+
+
+
+
+
Time spent
+
+ — spans
+ across — services
+ CPU sum —
+
+
+
+
+
+
+
+
Database operations
+
+
+
+
+
+
+
+
+
Execution timeline
+ —
+
+
+
+
+
+
+
Class timelines
+ —
+
+
+
+
+
+
+
Parallel execution
+
+ — workers
+ Wall —
+ CPU sum —
+ Efficiency —
+
+
+
+
+
+
+
+
+
+
+
+
+ / search · j k navigate · f next failure · v view
+
+
+
+
+
+
+
diff --git a/TUnit.Engine/TUnit.Engine.csproj b/TUnit.Engine/TUnit.Engine.csproj
index 1da56c3721..111dd8f8a3 100644
--- a/TUnit.Engine/TUnit.Engine.csproj
+++ b/TUnit.Engine/TUnit.Engine.csproj
@@ -17,6 +17,11 @@
+
+
+ TUnit.Engine.Reporters.Html.TestReport.template.html
+
+