Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ internal sealed class ReportSummary

[JsonPropertyName("timedOut")]
public int TimedOut { get; set; }

[JsonPropertyName("flaky")]
public int Flaky { get; set; }
}

internal sealed class ReportTestGroup
Expand Down
93 changes: 82 additions & 11 deletions TUnit.Engine/Reporters/Html/HtmlReportGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ private static void AppendBody(StringBuilder sb, ReportData data)

// Quick-access sections populated by JS
sb.AppendLine("<div id=\"failedSection\" role=\"region\" aria-label=\"Failed tests\"></div>");
sb.AppendLine("<div id=\"flakySection\" role=\"region\" aria-label=\"Flaky tests\"></div>");
sb.AppendLine("<div id=\"failureClusters\" role=\"region\" aria-label=\"Failure clusters\"></div>");
sb.AppendLine("<div id=\"slowestSection\" role=\"region\" aria-label=\"Slowest tests\"></div>");

Expand Down Expand Up @@ -190,12 +191,14 @@ private static void AppendMetaChipLink(StringBuilder sb, string icon, string tex
private static void AppendSummaryDashboard(StringBuilder sb, ReportSummary summary, double totalDurationMs)
{
var passRate = summary.Total > 0 ? (double)summary.Passed / summary.Total * 100 : 0;
var cleanPassed = summary.Passed - summary.Flaky;

sb.AppendLine("<section class=\"dash\" data-anim=\"fade-up\" aria-label=\"Test summary\">");

// Ring chart — SVG
var circumference = 2 * Math.PI * 54; // r=54
var passLen = summary.Total > 0 ? circumference * summary.Passed / summary.Total : 0;
var cleanPassLen = summary.Total > 0 ? circumference * cleanPassed / summary.Total : 0;
var flakyLen = summary.Total > 0 ? circumference * summary.Flaky / summary.Total : 0;
var failLen = summary.Total > 0 ? circumference * (summary.Failed + summary.TimedOut) / 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;
Expand All @@ -207,10 +210,16 @@ private static void AppendSummaryDashboard(StringBuilder sb, ReportSummary summa

// Segments — stacked with dasharray/dashoffset
double offset = 0;
if (passLen > 0)
if (cleanPassLen > 0)
{
AppendRingSegment(sb, "var(--emerald)", passLen, offset, circumference);
offset += passLen;
AppendRingSegment(sb, "var(--emerald)", cleanPassLen, offset, circumference);
offset += cleanPassLen;
}

if (flakyLen > 0)
{
AppendRingSegment(sb, "var(--orange)", flakyLen, offset, circumference);
offset += flakyLen;
}

if (failLen > 0)
Expand Down Expand Up @@ -241,7 +250,7 @@ private static void AppendSummaryDashboard(StringBuilder sb, ReportSummary summa
// Stat cards
sb.AppendLine("<div class=\"stats\">");
AppendStatCard(sb, "total", summary.Total.ToString(), "Total", null);
AppendStatCard(sb, "passed", summary.Passed.ToString(), "Passed", "var(--emerald)");
AppendStatCard(sb, "passed", cleanPassed.ToString(), "Passed", "var(--emerald)");
AppendStatCard(sb, "failed", (summary.Failed + summary.TimedOut).ToString(), "Failed", "var(--rose)");
AppendStatCard(sb, "skipped", summary.Skipped.ToString(), "Skipped", "var(--amber)");
AppendStatCard(sb, "cancelled", summary.Cancelled.ToString(), "Cancelled", "var(--slate)");
Expand All @@ -256,6 +265,8 @@ private static void AppendSummaryDashboard(StringBuilder sb, ReportSummary summa
sb.AppendLine("<div id=\"durationHist\" class=\"dur-hist\"></div>");
sb.AppendLine("</div>");

sb.AppendLine("<div class=\"flaky-indicator\" id=\"flakyIndicator\"></div>");

sb.AppendLine("</section>");
}

Expand Down Expand Up @@ -306,7 +317,10 @@ private static void AppendSearchAndFilters(StringBuilder sb, ReportSummary summa
sb.Append(summary.Total);
sb.AppendLine("</span></button>");
sb.Append("<button class=\"pill\" data-filter=\"passed\" aria-pressed=\"false\"><span class=\"dot emerald\"></span>Passed <span class=\"pill-count\">");
sb.Append(summary.Passed);
sb.Append(summary.Passed - summary.Flaky);
sb.AppendLine("</span></button>");
sb.Append("<button class=\"pill hidden\" data-filter=\"flaky\" aria-pressed=\"false\" id=\"flakyPill\"><span class=\"dot orange\"></span>Flaky <span class=\"pill-count\" id=\"flakyPillCount\">");
sb.Append(summary.Flaky);
sb.AppendLine("</span></button>");
sb.Append("<button class=\"pill\" data-filter=\"failed\" aria-pressed=\"false\"><span class=\"dot rose\"></span>Failed <span class=\"pill-count\">");
sb.Append(summary.Failed + summary.TimedOut);
Expand Down Expand Up @@ -473,6 +487,8 @@ private static string GetCss()
--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;
Expand All @@ -490,6 +506,7 @@ private static string GetCss()
--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}

Expand All @@ -512,6 +529,7 @@ private static string GetCss()
@property --amber-d { syntax:'<color>'; inherits:true; initial-value:rgba(251,191,36,.10) }
@property --slate-d { syntax:'<color>'; inherits:true; initial-value:rgba(148,163,184,.10) }
@property --indigo-d { syntax:'<color>'; inherits:true; initial-value:rgba(129,140,248,.10) }
@property --orange-d { syntax:'<color>'; inherits:true; initial-value:rgba(251,146,60,.12) }

:root {
transition:
Expand All @@ -520,7 +538,8 @@ private static string GetCss()
--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);
--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. */
Expand Down Expand Up @@ -669,6 +688,8 @@ @keyframes ring-draw{
.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}
.flaky-indicator{display:none;text-align:center;margin-top:6px;font-size:.78rem;font-weight:700;color:var(--orange)}
.flaky-indicator.visible{display:block}

/* ── Toolbar (search + pills) ──────────────────────── */
.bar{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:16px;justify-content:flex-end}
Expand Down Expand Up @@ -707,11 +728,13 @@ .search input{
}
.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 */
Expand Down Expand Up @@ -787,6 +810,7 @@ .search input{
.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}
Expand Down Expand Up @@ -1379,9 +1403,15 @@ function buildCatPills(){
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 === 'failed') {
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;
}
Expand Down Expand Up @@ -1745,7 +1775,7 @@ function renderFailedSection() {
const errMsg = f.t.exception ? (f.t.exception.type+': '+f.t.exception.message) : '';
const truncErr = errMsg.length > 120 ? errMsg.substring(0,120)+'…' : errMsg;
h += '<div class="qa-item" data-scroll-tid="'+f.t.id+'">';
h += '<span class="t-badge '+f.t.status+'">'+esc(f.t.status)+'</span>';
var bl=badgeLabel(f.t);h += '<span class="t-badge '+bl+'">'+esc(bl)+'</span>';
h += '<div class="qa-info"><div class="qa-info-name">'+esc(f.t.displayName)+'</div>';
h += '<div class="qa-info-class">'+esc(f.cls)+'</div></div>';
if (truncErr) h += '<span class="qa-err" title="'+esc(errMsg)+'">'+esc(truncErr)+'</span>';
Expand Down Expand Up @@ -1782,6 +1812,46 @@ function renderSlowestSection() {
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;
// Update dashboard indicator
const indicator = document.getElementById('flakyIndicator');
if (indicator) {
if (flaky.length) {
indicator.textContent = flaky.length + ' flaky';
indicator.classList.add('visible');
} else {
indicator.classList.remove('visible');
}
}
if (!flaky.length) { sec.innerHTML=''; return; }
flaky.sort(function(a,b){ return b.t.retryAttempt - a.t.retryAttempt; });
let h = '<div class="qa-section"><div class="tl-toggle">'+tlArrow+' Flaky Tests ('+flaky.length+')</div><div class="tl-content"><div class="tl-content-inner"><div class="tl-content-pad">';
flaky.forEach(function(f){
h += '<div class="qa-item" data-scroll-tid="'+f.t.id+'">';
h += '<span class="t-badge flaky">flaky</span>';
h += '<div class="qa-info"><div class="qa-info-name">'+esc(f.t.displayName)+'</div>';
h += '<div class="qa-info-class">'+esc(f.cls)+'</div></div>';
h += '<span class="retry-tag">'+f.t.retryAttempt+' '+(f.t.retryAttempt===1?'retry':'retries')+'</span>';
h += '<span class="qa-dur">'+fmt(f.t.durationMs)+'</span>';
h += '</div>';
});
h += '</div></div></div></div>';
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]));
Expand Down Expand Up @@ -1822,7 +1892,7 @@ function render() {
}
ft.forEach((t,ti)=>{
html += '<div class="t-row" id="test-'+t.id+'" data-gi="'+gi+'" data-ti="'+ti+'" data-tid="'+t.id+'" style="--row-idx:'+Math.min(ti,7)+'">';
html += '<span class="t-badge '+t.status+'">'+esc(t.status)+'</span>';
var bl=badgeLabel(t);html += '<span class="t-badge '+bl+'">'+esc(bl)+'</span>';
html += '<span class="t-name">'+(searchText?highlight(t.displayName,searchText):esc(t.displayName))+'</span>';
if(t.retryAttempt>0) html += '<span class="retry-tag">retry '+t.retryAttempt+'</span>';
html += '<button class="t-link-btn" data-link-tid="'+t.id+'" title="Copy link">'+linkIcon+'</button>';
Expand Down Expand Up @@ -2081,6 +2151,7 @@ function toggleCategory(cat){
if(catNames.length > 0) buildCatPills();
render();
renderFailedSection();
renderFlakySection();
renderFailureClusters();
renderSlowestSection();
checkHash();
Expand Down Expand Up @@ -2217,7 +2288,7 @@ function renderFailureClusters() {
h += '<div class="fc-body"><div class="fc-body-inner"><div class="fc-tests">';
c.tests.forEach(function(f){
h += '<div class="fc-test" data-scroll-tid="'+esc(f.t.id)+'">';
h += '<span class="t-badge '+safeClass(f.t.status)+'">'+esc(f.t.status)+'</span>';
var bl=badgeLabel(f.t);h += '<span class="t-badge '+safeClass(bl)+'">'+esc(bl)+'</span>';
h += '<span class="fc-test-name" title="'+esc(f.t.displayName)+'">'+esc(f.t.displayName)+'</span>';
h += '<span class="fc-test-class">'+esc(f.cls)+'</span>';
h += '<span class="fc-test-dur">'+fmt(f.t.durationMs)+'</span>';
Expand Down
12 changes: 8 additions & 4 deletions TUnit.Engine/Reporters/Html/HtmlReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ private ReportData BuildReportData()

var testResult = ExtractTestResult(kvp.Key, testNode, traceId, spanId, retryAttempt, additionalTraceIdsForResult);

AccumulateStatus(summary, testResult.Status);
AccumulateStatus(summary, testResult);

// Group by class name
var className = testResult.ClassName;
Expand Down Expand Up @@ -327,7 +327,7 @@ private ReportData BuildReportData()
var groupSummary = new ReportSummary();
foreach (var test in kvp.Value)
{
AccumulateStatus(groupSummary, test.Status);
AccumulateStatus(groupSummary, test);
}

groups[i++] = new ReportTestGroup
Expand Down Expand Up @@ -413,11 +413,15 @@ private static (string? CommitSha, string? Branch, string? PullRequestNumber, st
return (commitSha, branch, prNumber, repoSlug);
}

private static void AccumulateStatus(ReportSummary summary, string status)
private static void AccumulateStatus(ReportSummary summary, ReportTestResult testResult)
{
summary.Total++;
switch (status)
switch (testResult.Status)
{
case "passed" when testResult.RetryAttempt > 0:
summary.Passed++;
summary.Flaky++;
break;
case "passed":
summary.Passed++;
break;
Expand Down
Loading