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(""); + // 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(""); - sb.Append(""); - sb.Append(""); - sb.Append(""); - sb.Append(""); - sb.Append(""); - 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(""); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine("
"); - sb.AppendLine(""); - sb.AppendLine("Sort:"); - sb.AppendLine("
"); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - 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 + '
'; -} - -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 = '
'; - ex += 'Source: ' + esc(s.source) + ' · Kind: ' + esc(s.kind); - if (s.tags && s.tags.length) { ex += '
Tags:'; s.tags.forEach(t => { ex += '
' + esc(t.key) + '=' + esc(t.value) + '
'; }); ex += '
'; } - if (s.events && s.events.length) { ex += '
Events:'; s.events.forEach(e => { ex += '
' + esc(e.name) + '
'; if (e.tags && e.tags.length) e.tags.forEach(t => { ex += '
' + esc(t.key) + '=' + esc(t.value) + '
'; }); }); ex += '
'; } - 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 += ''; - 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 testj
' - +'
Previous testk
' - +'
Toggle detailEnter
' - +'
Close / clearEsc
' - +'
Focus search/
' - +'
This help?
' - +'
Toggle minimapm
' - +'' - +'
'; - 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.

+
+
+
+
+
+ + +
+
+
+
Ran
+
Runner
+
OS
+
Runtime
+
TUnit
+
Branch
+
Commit
+
PR
+
+
+
+ +
+
+ + Pass rate +
+
+
+
+ +
+
Duration
+
+ s +
+
+
Wall
+
CPU sum
+
Parallelism
+
Workers
+
Median
+
P95
+
+
+ +
+
Outcome timeline
+
+
+ start + + end +
+
+
+ +
+
+
+

Failures

+ 0 +
+
+
+
+
+

Slowest tests

+ Top 8 +
+
+
+
+ + + + + + + + + +
+
+

Parallel execution

+
+ workers + Wall + CPU sum + Efficiency +
+
+
+
+ +
+
+

Categories

+ +
+
+
+
+
+ +
+ +
+ / search · jk 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 + +