From c8b60dca5c72a3164a4f65f0c817aee59496887f Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 18 May 2026 00:01:31 +0100 Subject: [PATCH 01/12] feat(html-report): rewrite report as split-pane design template Replaces the hand-rolled 2.4k-line emitter with an embedded HTML template that ships the new split-pane renderer (sticky banner, filterable rail, sticky detail pane with Output/Trace/Properties/Source tabs, run-view dashboard with Gantt, failure clustering, retry attempts, and OTel insights). The generator now maps `ReportData` to the renderer's JSON shape, gzip+base64-encodes it for compactness on large suites, and substitutes a single placeholder + the page title. The renderer is a `", + "", 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] diff --git a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs index f7b6a4097c..1df615b99e 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs @@ -1,2469 +1,413 @@ using System.Globalization; -using System.IO.Compression; +using System.IO; using System.Net; using System.Text; using System.Text.Json; -using System.Text.RegularExpressions; +using TUnit.Core; 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__"; + // The template ships with a baked-in title from the design preview; + // we replace it with the actual assembly name at render time. + private const string TemplateTitleMarker = "Test Report — CloudShop.Tests"; + + private static readonly Lazy Template = new(LoadTemplate); + 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 title = "Test Report — " + WebUtility.HtmlEncode(data.AssemblyName); - AppendHead(sb, data); - AppendBody(sb, data); - - sb.AppendLine(""); - return sb.ToString(); + return template + .Replace(TemplateTitleMarker, title) + .Replace(DataPlaceholder, compressed); } - private static void AppendHead(StringBuilder sb, ReportData data) + private static string GzipBase64(string json) { - 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 bytes = Encoding.UTF8.GetBytes(json); + using var output = new MemoryStream(); + using (var gz = new System.IO.Compression.GZipStream(output, System.IO.Compression.CompressionLevel.Optimal, leaveOpen: true)) + { + gz.Write(bytes, 0, bytes.Length); + } + return Convert.ToBase64String(output.GetBuffer(), 0, (int)output.Length); } - private static void AppendBody(StringBuilder sb, ReportData data) + private static string LoadTemplate() { - 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 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(); } - private static void AppendHeader(StringBuilder sb, ReportData data) + private static string SerializeReport(ReportData data) { - 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("
"); + var totalTests = 0; + foreach (var g in data.Groups) + { + totalTests += g.Tests.Length; + } - 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)) + // 1) First pass: parse each test's unix-ms start, track run bounds, and stash + // raw (id, absStartMs, dur) so the second pass can subtract runStartMs once. + long runStartMs = long.MaxValue; + long runEndMs = long.MinValue; + var rawStarts = new (string Id, long? AbsStartMs, double Dur)[totalTests]; + var ti = 0; + foreach (var g in data.Groups) { - AppendMetaChip(sb, "filter", data.Filter!); + foreach (var t in g.Tests) + { + var sms = TryParseUnixMs(t.StartTime); + rawStarts[ti++] = (t.Id, sms, t.DurationMs); + if (sms is { } x) + { + if (x < runStartMs) runStartMs = x; + var end = x + (long)Math.Round(t.DurationMs); + if (end > runEndMs) runEndMs = end; + } + } } + if (runStartMs == long.MaxValue) + { + runStartMs = 0; + runEndMs = (long)Math.Round(data.TotalDurationMs); + } + long wallMs = Math.Max((long)Math.Round(data.TotalDurationMs), runEndMs - runStartMs); - if (!string.IsNullOrEmpty(data.Branch)) + // 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]; + for (var i = 0; i < totalTests; i++) { - AppendMetaChip(sb, "branch", data.Branch!); + var (id, abs, dur) = rawStarts[i]; + ordered[i] = (id, abs is { } a ? a - runStartMs : 0L, dur); } + Array.Sort(ordered, static (a, b) => a.StartRel.CompareTo(b.StartRel)); - if (!string.IsNullOrEmpty(data.CommitSha)) + var laneEnd = new List(); + var testWorker = new Dictionary(totalTests, StringComparer.Ordinal); + foreach (var (id, startRel, dur) in ordered) { - var shortSha = data.CommitSha!.Length > 7 ? data.CommitSha[..7] : data.CommitSha; - if (!string.IsNullOrEmpty(data.RepositorySlug)) + var lane = -1; + for (var i = 0; i < laneEnd.Count; i++) { - AppendMetaChipLink(sb, "commit", shortSha, $"https://github.com/{data.RepositorySlug}/commit/{data.CommitSha}"); + if (laneEnd[i] <= startRel + 0.5) + { + lane = i; + break; + } } - else + if (lane == -1) { - AppendMetaChip(sb, "commit", shortSha); + lane = laneEnd.Count; + laneEnd.Add(0); } + laneEnd[lane] = startRel + dur; + testWorker[id] = lane; } + var workers = Math.Max(1, laneEnd.Count); - if (!string.IsNullOrEmpty(data.PullRequestNumber)) + // 3) Bucket spans by traceId so we can nest them under their owning test. + Dictionary>? spansByTrace = null; + if (data.Spans is { Length: > 0 } spans) { - if (!string.IsNullOrEmpty(data.RepositorySlug)) + spansByTrace = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var s in spans) { - AppendMetaChipLink(sb, "pr", $"PR #{data.PullRequestNumber}", $"https://github.com/{data.RepositorySlug}/pull/{data.PullRequestNumber}"); - } - else - { - AppendMetaChip(sb, "pr", $"PR #{data.PullRequestNumber}"); + if (!spansByTrace.TryGetValue(s.TraceId, out var list)) + { + list = new List(); + spansByTrace[s.TraceId] = list; + } + list.Add(s); } } - sb.AppendLine("
"); - - // Theme toggle button - sb.AppendLine(""); - - sb.AppendLine("
"); - } - - private static void AppendMetaChip(StringBuilder sb, string icon, string text) - { - sb.Append(""); - sb.Append(WebUtility.HtmlEncode(text)); - 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(); + var idx = 0; + foreach (var g in data.Groups) + { + foreach (var t in g.Tests) + { + var abs = rawStarts[idx++].AbsStartMs; + var startRel = abs is { } a ? a - runStartMs : 0L; + WriteTest(w, t, g, runStartMs, startRel, testWorker, spansByTrace); + } + } + w.WriteEndArray(); - 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, (int)ms.Length); } - private static void AppendSummaryDashboard(StringBuilder sb, ReportSummary summary, double totalDurationMs) + private static void WriteTest( + Utf8JsonWriter w, + ReportTestResult t, + ReportTestGroup g, + long runStartMs, + long startRel, + Dictionary testWorker, + Dictionary>? spansByTrace) { - 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; - - 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) + 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)); + + // properties — design schema is an object map; dedupe duplicate keys (last wins). + w.WritePropertyName("properties"); + w.WriteStartObject(); + if (t.CustomProperties is { Length: > 0 } props) { - AppendRingSegment(sb, "var(--orange)", flakyLen, offset, circumference); - offset += flakyLen; + // First occurrence of a duplicated key wins. + var emitted = new HashSet(StringComparer.Ordinal); + foreach (var p in props) + { + if (emitted.Add(p.Key)) w.WriteString(p.Key, p.Value); + } } + w.WriteEndObject(); - if (failLen > 0) + w.WritePropertyName("categories"); + w.WriteStartArray(); + if (t.Categories is { Length: > 0 } cats) { - AppendRingSegment(sb, "var(--rose)", failLen, offset, circumference); - offset += failLen; + foreach (var c in cats) w.WriteStringValue(c); } + w.WriteEndArray(); - if (skipLen > 0) - { - AppendRingSegment(sb, "var(--amber)", skipLen, offset, circumference); - offset += skipLen; - } + w.WriteString("stdout", t.Output ?? string.Empty); + // Surface skip reasons in stderr so the Output tab has something useful to show + // for skipped tests that otherwise had no captured output. + w.WriteString("stderr", t.ErrorOutput ?? t.SkipReason ?? string.Empty); - if (cancelLen > 0) + if (t.Exception is not null) { - AppendRingSegment(sb, "var(--slate)", cancelLen, offset, circumference); + w.WritePropertyName("error"); + WriteException(w, t.Exception); } - 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("
"); + 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(); - // 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) + if (t.RetryAttempt > 0) { - AppendStatCard(sb, "flaky", summary.Flaky.ToString(), "Flaky", "var(--orange)"); + w.WriteNumber("retryCount", t.RetryAttempt); } - 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(""); - } - - private static void AppendStatCard(StringBuilder sb, string cls, string count, string label, string? accent) - { - sb.Append("
0 } extra) + { + foreach (var tid in extra) WriteTraceSpans(w, tid, runStartMs, spansByTrace); + } } + w.WriteEndArray(); - sb.AppendLine(">"); - sb.Append(""); - sb.Append(count); - sb.Append(""); - sb.Append(label); - sb.AppendLine("
"); + w.WriteEndObject(); } - private static void AppendSearchAndFilters(StringBuilder sb, ReportSummary summary) + private static void WriteTraceSpans( + Utf8JsonWriter w, + string? traceId, + long runStartMs, + Dictionary> spansByTrace) { - 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("
"); - - sb.AppendLine("
"); - } - - private static void AppendTestGroups(StringBuilder sb, ReportData data) - { - if (data.Summary.Total == 0) + if (string.IsNullOrEmpty(traceId)) return; + if (!spansByTrace.TryGetValue(traceId!, out var list)) return; + foreach (var s in list) { - sb.AppendLine("
No tests were discovered.
"); - return; + WriteSpan(w, s, runStartMs); } - - sb.AppendLine("
"); - sb.AppendLine("
"); } - private static void AppendJsonData(StringBuilder sb, ReportData data) + private static void WriteSpan(Utf8JsonWriter w, SpanData s, long runStartMs) { - 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 + w.WriteStartObject(); + w.WriteString("id", s.SpanId); + if (s.ParentSpanId is { Length: > 0 }) { - JsonSerializer.Serialize(compressor, data, HtmlReportJsonContext.Default.ReportData); + w.WriteString("parent", s.ParentSpanId); } - - var rawBuffer = ms.GetBuffer(); - var length = checked((int)ms.Length); - var base64 = Convert.ToBase64String(rawBuffer, 0, length); - - sb.Append(""); - } - - private static void AppendJavaScript(StringBuilder sb) - { - sb.AppendLine(""); - } - - private static string FormatDuration(double ms) - { - if (ms < 1) + else { - return "<1ms"; + w.WriteNull("parent"); } - - // Show milliseconds for anything under 1 second (avoids rounding 999ms to "1.00s") - if (Math.Round(ms) < 1000) + 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) { - return ms.ToString("F0", CultureInfo.InvariantCulture) + "ms"; + var seen = new HashSet(StringComparer.Ordinal); + foreach (var tag in tags) + { + if (seen.Add(tag.Key)) w.WriteString(tag.Key, tag.Value); + } } - - if (ms < 60000) + w.WriteEndObject(); + if (s.Status is { Length: > 0 } && !string.Equals(s.Status, "Unset", StringComparison.Ordinal)) { - return (ms / 1000).ToString("F2", CultureInfo.InvariantCulture) + "s"; + w.WriteString("status", s.Status); } - - return (ms / 60000).ToString("F1", CultureInfo.InvariantCulture) + "m"; - } - - private static string MinifyCss(string css) - { - css = CssCommentsRegex().Replace(css, string.Empty); - css = CssWhitespaceRegex().Replace(css, " "); - css = CssSeparatorsRegex().Replace(css, "$1"); - css = css.Replace(";}", "}"); - return css.Trim(); - } - - 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() - { - 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} - -/* ── 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} - -/* ── 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} -} - -/* ── 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; -} - -/* ── Accessibility: Sort Group ───────────────────── */ -.sort-group{display:flex;gap:4px;align-items:center} -.grp-toggle{display:flex;gap:4px;align-items:center} - -/* ── 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} -} - -/* ── 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} -} - -/* ── 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} -} - -/* ── 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 WriteException(Utf8JsonWriter w, ReportExceptionData ex) { - 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; - } - 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); - } - }); - 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; - } - if (t.endTime) { - const e = new Date(t.endTime); - if (!maxEnd || e > maxEnd) maxEnd = e; - } - }); - 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); - } - 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; - } - }); - // 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; - } - 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); - }); + 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); }); -} - -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(); + 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"; + } } - const el = container.querySelector('.grp[data-gi="'+dgi+'"]'); - if (el) { - el.scrollIntoView({behavior:'smooth', block:'start'}); - ensureGroupOpen(el); + 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"; } - if (window.innerWidth < 769) closeMinimap(); + return string.IsNullOrEmpty(s.Source) ? "test" : s.Source.ToLowerInvariant(); } - 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 string MapStatus(string status) => status switch + { + "passed" => "pass", + "failed" or "error" or "timedOut" => "fail", + "skipped" => "skip", + "cancelled" => "cancel", + _ => "skip", + }; + + 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/TestReport.template.html b/TUnit.Engine/Reporters/Html/TestReport.template.html new file mode 100644 index 0000000000..4e8ecab96c --- /dev/null +++ b/TUnit.Engine/Reporters/Html/TestReport.template.html @@ -0,0 +1,2812 @@ + + + + + +Test Report — CloudShop.Tests + + + + + + + + + + +
+
+ + +
+
+
+
+ +
+

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
+
Runnerrunnervmrw5os
+
OSUbuntu 24.04.4 LTS
+
Runtime.NET 10.0.8
+
TUnit1.0.0
+
Branchfeat/test-categories-demo
+
Commit3e5d27d
+
PR#5945
+
+
+
+ +
+
+ + 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 + + From 8d1750ed0a796ab1324d7b907bdb23e9777783c3 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 18 May 2026 00:22:03 +0100 Subject: [PATCH 02/12] fix(html-report): tooltip overflow, stderr noise, view-switch blank rail, flat sort - Tooltip enforces max-width by allowing long span-attr values (node uids, paths) to wrap (`width: max-content`, `overflow-wrap: anywhere`); detail pane gets `overflow-x: hidden` as a safety net. - Engine-emitted advisories like "[TUnit] External span cap reached" are stripped from stderr before serialisation so they no longer light up the "N err" tab badge or render as a red error block. - Jumping into a test from the Run view called `selectTest` before `switchView`, so `renderRailWindow` ran while the rail had `clientHeight: 0` and rendered zero rows. `switchView('tests')` now re-paints the rail on the next frame. - `groupTests` short-circuited for Flat grouping without calling `sortTests`, so the Sort selector did nothing in that mode. --- .../Reporters/Html/HtmlReportGenerator.cs | 23 ++++++++++-- .../Reporters/Html/TestReport.template.html | 36 +++++++++++++++---- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs index 1df615b99e..fa6cccfe1f 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs @@ -215,9 +215,11 @@ private static void WriteTest( w.WriteEndArray(); w.WriteString("stdout", t.Output ?? string.Empty); - // Surface skip reasons in stderr so the Output tab has something useful to show - // for skipped tests that otherwise had no captured output. - w.WriteString("stderr", t.ErrorOutput ?? t.SkipReason ?? 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); if (t.Exception is not null) { @@ -399,6 +401,21 @@ private static string MapSpanService(SpanData s) _ => "skip", }; + private 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); + } + return sb.Length == 0 ? null : sb.ToString(); + } + private static long? TryParseUnixMs(string? iso) { if (string.IsNullOrEmpty(iso)) return null; diff --git a/TUnit.Engine/Reporters/Html/TestReport.template.html b/TUnit.Engine/Reporters/Html/TestReport.template.html index 4e8ecab96c..bfd5c6dac8 100644 --- a/TUnit.Engine/Reporters/Html/TestReport.template.html +++ b/TUnit.Engine/Reporters/Html/TestReport.template.html @@ -369,7 +369,7 @@ .test-dur { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text-dim); white-space: nowrap; } /* ---- right detail ---- */ - .detail { overflow-y: auto; min-width: 0; background: var(--bg); } + .detail { overflow-y: auto; overflow-x: hidden; min-width: 0; background: var(--bg); } .detail-empty { display: grid; place-items: center; height: 100%; color: var(--text-dim); padding: 40px; @@ -840,12 +840,14 @@ position: fixed; z-index: 100; pointer-events: none; background: var(--surface); color: var(--text); border: 1px solid var(--border-strong); border-radius: 8px; padding: 10px 12px; font-size: 11px; box-shadow: var(--shadow-pop); - max-width: 360px; opacity: 0; transition: opacity 80ms; + width: max-content; max-width: 360px; opacity: 0; transition: opacity 80ms; + overflow-wrap: anywhere; word-break: break-word; } .tt[data-show="true"] { opacity: 1; } - .tt .title { font-weight: 600; margin-bottom: 6px; font-size: 12px; font-family: 'JetBrains Mono', monospace; word-break: break-word; } - .tt .row { display: flex; justify-content: space-between; gap: 14px; font-family: 'JetBrains Mono', monospace; color: var(--text-muted); } - .tt .row b { color: var(--text); font-weight: 500; } + .tt .title { font-weight: 600; margin-bottom: 6px; font-size: 12px; font-family: 'JetBrains Mono', monospace; } + .tt .row { display: flex; justify-content: space-between; gap: 14px; font-family: 'JetBrains Mono', monospace; color: var(--text-muted); min-width: 0; } + .tt .row > span { flex-shrink: 0; } + .tt .row b { color: var(--text); font-weight: 500; min-width: 0; text-align: right; } /* kbd hint */ .kbd-hint { @@ -1814,7 +1816,11 @@

Categories

} function groupTests(list) { - if (STATE.group === 'none') return [{ key: '_all', title: 'All tests', ns: '', tests: list }]; + if (STATE.group === 'none') { + const flat = list.slice(); + sortTests(flat); + return [{ key: '_all', title: 'All tests', ns: '', tests: flat }]; + } const buckets = new Map(); list.forEach(t => { if (STATE.group === 'category') { @@ -2692,6 +2698,24 @@

Select a test

STATE.view = v; $$('.view').forEach(el => el.classList.toggle('active', el.id === 'view-' + v)); $$('.view-toggle button').forEach(b => b.classList.toggle('active', b.dataset.view === v)); + // When the tests view becomes visible the rail list finally has a non-zero clientHeight, + // so any earlier renderRailWindow() that ran while hidden (e.g. from a Run-view click + // calling selectTest before switchView) computed an empty visible range. Re-render now. + if (v === 'tests') { + requestAnimationFrame(() => { + const list = $('#railList'); + if (STATE.selectedId && RAIL.layout) { + const entry = RAIL.layout.find(e => e.kind === 'test' && e.t.id === STATE.selectedId); + if (entry && list) { + const top = entry.top, bot = top + entry.h; + if (top < list.scrollTop || bot > list.scrollTop + list.clientHeight) { + list.scrollTop = Math.max(0, top - 60); + } + } + } + renderRailWindow(); + }); + } } /* ---------- keyboard ---------- */ From a7821c6233eb07f98ef60db2ff81759bac3502a0 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 18 May 2026 00:29:39 +0100 Subject: [PATCH 03/12] fix(html-report): self-contained report, placeholder substitutions, flat sort, status default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR review: - Drop Google Fonts CDN links; the CSS already has a complete system-font fallback stack so reports work offline / in air-gapped CI. - Replace fragile "Test Report — CloudShop.Tests" title sentinel and the hardcoded `id="projectName"` text with proper `__REPORT_TITLE__` / `__REPORT_PROJECT__` placeholders that the generator substitutes. - Strip the standalone `generateSampleData()` preview block at generation time via `/* SAMPLE_DATA_BEGIN ... SAMPLE_DATA_END */` markers, with a defensive fallback so the template stays previewable in dev. Shaves ~14KB off every shipped report. - `MapStatus` default for unknown engine statuses now maps to `fail` instead of silently flattening them into `skip`, so anomalies stay visible. - Sort the caller's `filteredTests()` result in place for Flat grouping instead of cloning (no other reader; saves an allocation per render). - Fix contradictory dedupe comment ("last wins" → "first wins"). --- .../Reporters/Html/HtmlReportGenerator.cs | 33 ++++++++++++++----- .../Reporters/Html/TestReport.template.html | 23 +++++++------ 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs index fa6cccfe1f..46f62d7db5 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs @@ -11,24 +11,39 @@ internal static class HtmlReportGenerator { private const string TemplateResourceName = "TUnit.Engine.Reporters.Html.TestReport.template.html"; private const string DataPlaceholder = "__REPORT_DATA__"; - // The template ships with a baked-in title from the design preview; - // we replace it with the actual assembly name at render time. - private const string TemplateTitleMarker = "Test Report — CloudShop.Tests"; + 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(LoadTemplate); + private static readonly Lazy Template = new(LoadAndStripTemplate); internal static string GenerateHtml(ReportData data) { var template = Template.Value; var json = SerializeReport(data); var compressed = GzipBase64(json); - var title = "Test Report — " + WebUtility.HtmlEncode(data.AssemblyName); + var encodedName = WebUtility.HtmlEncode(data.AssemblyName); return template - .Replace(TemplateTitleMarker, title) + .Replace(TitlePlaceholder, "Test Report — " + encodedName) + .Replace(ProjectPlaceholder, encodedName) .Replace(DataPlaceholder, compressed); } + private static string LoadAndStripTemplate() + { + 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. + var begin = raw.IndexOf(SampleDataBeginMarker, StringComparison.Ordinal); + if (begin < 0) return raw; + var end = raw.IndexOf(SampleDataEndMarker, begin, StringComparison.Ordinal); + if (end < 0) return raw; + return raw.Remove(begin, end + SampleDataEndMarker.Length - begin); + } + private static string GzipBase64(string json) { var bytes = Encoding.UTF8.GetBytes(json); @@ -192,7 +207,7 @@ private static void WriteTest( w.WriteNumber("duration", t.DurationMs); w.WriteString("worker", "worker-" + (testWorker.GetValueOrDefault(t.Id, 0) + 1)); - // properties — design schema is an object map; dedupe duplicate keys (last wins). + // properties — design schema is an object map; dedupe duplicate keys (first wins). w.WritePropertyName("properties"); w.WriteStartObject(); if (t.CustomProperties is { Length: > 0 } props) @@ -398,7 +413,9 @@ private static string MapSpanService(SpanData s) "failed" or "error" or "timedOut" => "fail", "skipped" => "skip", "cancelled" => "cancel", - _ => "skip", + // Unknown statuses (future engine values, "unknown") map to fail so they + // stay visible in the UI rather than being silently buried under skipped. + _ => "fail", }; private static string? FilterEngineNotices(string? stderr) diff --git a/TUnit.Engine/Reporters/Html/TestReport.template.html b/TUnit.Engine/Reporters/Html/TestReport.template.html index bfd5c6dac8..ae42503f9e 100644 --- a/TUnit.Engine/Reporters/Html/TestReport.template.html +++ b/TUnit.Engine/Reporters/Html/TestReport.template.html @@ -3,10 +3,7 @@ -Test Report — CloudShop.Tests - - - +__REPORT_TITLE__