@kaizenreport/kensho-viewer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,822 @@
1
+ /* global React, TreeDetailPage, TrendChartV2, HBars, DurationHistogram, FlakeScatter, TimelineGantt, SuiteHeatmap, RetryWaterfall */
2
+ const { useState: useStateP } = React;
3
+
4
+ // ============== GRAPHS PAGE ==============
5
+ function GraphsPage() {
6
+ const TREND_RUNS = window.TREND_RUNS || [];
7
+ const HISTOGRAM = window.HISTOGRAM || [];
8
+ const RICH_TESTS = window.RICH_TESTS || {};
9
+ const CATEGORIES = window.CATEGORIES || [];
10
+ const fmt = window._kenshoFmtDuration || (ms => ms + 'ms');
11
+ const [showAllSuites, setShowAllSuites] = useStateP(false);
12
+ const SUITE_CAP = 12;
13
+
14
+ // Highlight banner derivations — surface the three "punchiest" facts
15
+ // about the run so a stakeholder can scan and act in 5 seconds.
16
+ const allRich = Object.values(RICH_TESTS);
17
+ const slowest = allRich.filter(t => t.durMs > 0).sort((a,b) => b.durMs - a.durMs)[0];
18
+ const mostRetried = allRich.filter(t => t.retries > 0).sort((a,b) => b.retries - a.retries)[0];
19
+ const topCategory = CATEGORIES[0];
20
+
21
+ // Derive "Status by suite" from RICH_TESTS, grouped by first suite segment.
22
+ const suiteBuckets = {};
23
+ Object.values(RICH_TESTS).forEach(t => {
24
+ const suiteName = (t.suite || '').split('›')[0].trim() || 'Default';
25
+ if (!suiteBuckets[suiteName]) suiteBuckets[suiteName] = { passed:0, failed:0, broken:0, skipped:0 };
26
+ if (suiteBuckets[suiteName][t.status] != null) suiteBuckets[suiteName][t.status]++;
27
+ });
28
+ const suiteStatusBars = Object.entries(suiteBuckets)
29
+ .map(([label, b]) => {
30
+ const segs = ['failed','broken','skipped','passed']
31
+ .filter(k => b[k] > 0)
32
+ .map(k => ({ k, n: b[k] }));
33
+ const total = b.passed + b.failed + b.broken + b.skipped;
34
+ return { label, segs, total, failures: b.failed + b.broken };
35
+ })
36
+ .sort((a, b) => b.failures - a.failures || b.total - a.total);
37
+ const visibleSuites = showAllSuites ? suiteStatusBars : suiteStatusBars.slice(0, SUITE_CAP);
38
+ const hiddenSuiteCount = suiteStatusBars.length - visibleSuites.length;
39
+
40
+ const totalTests = Object.values(RICH_TESTS).length;
41
+ const hasFlake = Object.values(RICH_TESTS).some(t => t.flakeRate > 0);
42
+
43
+ return (
44
+ <div>
45
+ <h1 className="k-h1" style={{ marginBottom: 4 }}>Graphs</h1>
46
+ <div className="k-meta" style={{ marginBottom: 18 }}>Distributions and trends across the active run · {totalTests} tests</div>
47
+
48
+ {/* Highlight banner — three at-a-glance hero stats for the run.
49
+ Each card is clickable when it can resolve to a target view. */}
50
+ {(slowest || mostRetried || topCategory) && (
51
+ <div style={{ display:'grid', gridTemplateColumns:'repeat(3, 1fr)', gap:16, marginBottom:16 }}>
52
+ {slowest ? (
53
+ <HighlightStat
54
+ overline="Slowest test"
55
+ value={slowest.dur}
56
+ valueColor="var(--status-broken-fg)"
57
+ subtitle={slowest.name}
58
+ accent="var(--status-broken)"
59
+ onClick={() => window.__openTest?.(slowest.id)}
60
+ title={`Open ${slowest.name} →`}
61
+ />
62
+ ) : (
63
+ <HighlightStat overline="Slowest test" value="—" subtitle="No timing data" accent="var(--line)"/>
64
+ )}
65
+ {mostRetried ? (
66
+ <HighlightStat
67
+ overline="Most retried"
68
+ value={`${mostRetried.retries}×`}
69
+ valueColor="#B69CFF"
70
+ subtitle={mostRetried.name}
71
+ accent="#7C5CFF"
72
+ onClick={() => window.__openTest?.(mostRetried.id)}
73
+ title={`Open ${mostRetried.name} →`}
74
+ />
75
+ ) : (
76
+ <HighlightStat overline="Most retried" value="0" subtitle="No retries this run — clean execution" accent="var(--line)"/>
77
+ )}
78
+ {topCategory ? (
79
+ <HighlightStat
80
+ overline="Top failure category"
81
+ value={String(topCategory.count)}
82
+ valueColor="var(--status-failed-fg)"
83
+ subtitle={topCategory.kind}
84
+ accent="var(--status-failed)"
85
+ onClick={() => window.__navTo?.('categories')}
86
+ title="Open Categories →"
87
+ />
88
+ ) : (
89
+ <HighlightStat overline="Top failure category" value="0" subtitle="No failures this run" accent="var(--status-passed)"/>
90
+ )}
91
+ </div>
92
+ )}
93
+
94
+ <div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:16 }}>
95
+ <div className="card" style={{ gridColumn:'span 2' }}>
96
+ <div className="hd"><h3>Trend · {TREND_RUNS.length} run{TREND_RUNS.length === 1 ? '' : 's'}</h3><div className="meta">stacked status counts</div></div>
97
+ {TREND_RUNS.length > 0 ? (
98
+ <TrendChartV2 runs={TREND_RUNS}/>
99
+ ) : (
100
+ <div style={{ padding:30, color:'var(--fg3)', textAlign:'center', fontFamily:'var(--font-mono)', fontSize:12 }}>
101
+ No run history yet. Run kensho generate over multiple runs to populate.
102
+ </div>
103
+ )}
104
+ </div>
105
+
106
+ <div className="card">
107
+ <div className="hd">
108
+ <h3>Status by suite</h3>
109
+ <div className="meta">{showAllSuites ? `all ${suiteStatusBars.length}` : `top ${visibleSuites.length} of ${suiteStatusBars.length}`} · sorted by failures</div>
110
+ </div>
111
+ <div style={{ display:'flex', flexDirection:'column', gap:8 }}>
112
+ {visibleSuites.map((s,i) => (
113
+ <div key={i} style={{ display:'grid', gridTemplateColumns:'160px 1fr 30px', alignItems:'center', gap:10 }}>
114
+ <div style={{ fontFamily:'var(--font-mono)', fontSize:12, color:'var(--fg1)', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{s.label}</div>
115
+ <div style={{ height:14, background:'var(--bg-sunken)', borderRadius:3, display:'flex', overflow:'hidden' }}>
116
+ {s.segs.map((g,j)=>(<div key={j} style={{ width:`${(g.n/s.total)*100}%`, background:`var(--status-${g.k})` }}/>))}
117
+ </div>
118
+ <div style={{ fontFamily:'var(--font-mono)', fontSize:11, color:'var(--fg3)', textAlign:'right' }}>{s.total}</div>
119
+ </div>
120
+ ))}
121
+ </div>
122
+ {hiddenSuiteCount > 0 && (
123
+ <div style={{ display:'flex', justifyContent:'center', marginTop:14, paddingTop:12, borderTop:'1px solid var(--line)' }}>
124
+ <button className="btn btn-ghost" style={{ height:28, fontSize:12 }} onClick={() => setShowAllSuites(true)}>
125
+ Show {hiddenSuiteCount} more suites →
126
+ </button>
127
+ </div>
128
+ )}
129
+ {showAllSuites && suiteStatusBars.length > SUITE_CAP && (
130
+ <div style={{ display:'flex', justifyContent:'center', marginTop:14, paddingTop:12, borderTop:'1px solid var(--line)' }}>
131
+ <button className="btn btn-ghost" style={{ height:28, fontSize:12 }} onClick={() => setShowAllSuites(false)}>
132
+ ↑ Collapse to top {SUITE_CAP}
133
+ </button>
134
+ </div>
135
+ )}
136
+ </div>
137
+
138
+ <div className="card">
139
+ <div className="hd"><h3>Duration distribution</h3><div className="meta">all {totalTests} tests</div></div>
140
+ <DurationHistogram buckets={HISTOGRAM}/>
141
+ </div>
142
+
143
+ <div className="card" style={{ gridColumn:'span 2' }}>
144
+ <div className="hd"><h3>Flake rate vs duration</h3><div className="meta">last 80 runs · circle size = sample count</div></div>
145
+ {hasFlake ? (
146
+ <FlakeScatter tests={Object.values(RICH_TESTS)
147
+ .filter(t => t.flakeRate > 0)
148
+ .map(t => ({ name: t.name, runs: 80, flakeRate: t.flakeRate, avgDur: t.avgDurMs }))
149
+ }/>
150
+ ) : (
151
+ <div style={{ padding:30, color:'var(--fg3)', textAlign:'center', fontFamily:'var(--font-mono)', fontSize:12 }}>
152
+ Flake-rate analysis requires run history. Run kensho generate over multiple runs to populate.
153
+ </div>
154
+ )}
155
+ </div>
156
+ </div>
157
+ </div>
158
+ );
159
+ }
160
+
161
+ // ============== TIMELINE PAGE ==============
162
+ function TimelinePage() {
163
+ const TIMELINE_TESTS = window.TIMELINE_TESTS || [];
164
+ const RICH_TESTS = window.RICH_TESTS || {};
165
+ const KENSHO_INDEX = window.KENSHO_INDEX || {};
166
+ const fmt = window._kenshoFmtDuration || (ms => ms + 'ms');
167
+
168
+ // View modes — at scale (>50 tests) the Gantt becomes unreadable. Default to
169
+ // 'top' (longest 25), with toggles for failures-only and full view.
170
+ const [mode, setMode] = useStateP(TIMELINE_TESTS.length > 50 ? 'top' : 'all');
171
+
172
+ const failuresOnly = TIMELINE_TESTS.filter(t => t.status === 'failed' || t.status === 'broken');
173
+ let visibleTests;
174
+ if (mode === 'top') {
175
+ visibleTests = [...TIMELINE_TESTS].sort((a, b) => b.durMs - a.durMs).slice(0, 25);
176
+ } else if (mode === 'failures') {
177
+ visibleTests = failuresOnly;
178
+ } else {
179
+ visibleTests = TIMELINE_TESTS;
180
+ }
181
+
182
+ // Re-base start times so the Gantt fills the canvas regardless of mode —
183
+ // showing 25 longest tests with sparse start times leaves dead space at
184
+ // the front of the chart otherwise.
185
+ const minStart = visibleTests.length ? Math.min(...visibleTests.map(t => t.start)) : 0;
186
+ const rebased = visibleTests.map(t => ({ ...t, start: t.start - minStart }));
187
+ const totalMs = rebased.length
188
+ ? Math.max(...rebased.map(t => t.start + t.durMs)) + 200
189
+ : 1000;
190
+ const fullDurationMs = TIMELINE_TESTS.length
191
+ ? Math.max(...TIMELINE_TESTS.map(t => t.start + t.durMs))
192
+ : 0;
193
+
194
+ const hasHistory = (KENSHO_INDEX.history?.length || 0) > 0;
195
+ const hasRetries = Object.values(RICH_TESTS).some(t => t.retries > 0);
196
+
197
+ const runId = KENSHO_INDEX.runId ? '#' + KENSHO_INDEX.runId : '';
198
+ const workers = KENSHO_INDEX.env?.workers || 1;
199
+
200
+ const MODES = [
201
+ ['top', 'Top 25 longest', TIMELINE_TESTS.length > 0 ? Math.min(25, TIMELINE_TESTS.length) : 0],
202
+ ['failures', 'Failures only', failuresOnly.length],
203
+ ['all', 'All', TIMELINE_TESTS.length],
204
+ ].filter(([id, _, n]) => n > 0);
205
+
206
+ return (
207
+ <div>
208
+ <h1 className="k-h1" style={{ marginBottom: 4 }}>Timeline</h1>
209
+ <div className="k-meta" style={{ marginBottom: 18 }}>Run {runId} · {workers} parallel worker{workers !== 1 ? 's' : ''} · total {fmt(fullDurationMs)}</div>
210
+
211
+ <div className="card">
212
+ <div className="hd">
213
+ <h3>Per-test execution · Gantt</h3>
214
+ <div className="meta">click any bar to open the test in detail</div>
215
+ </div>
216
+ <div style={{ display:'flex', gap:6, marginBottom:14, flexWrap:'wrap' }}>
217
+ {MODES.map(([id, label, n]) => {
218
+ const active = mode === id;
219
+ return (
220
+ <button key={id} onClick={() => setMode(id)} style={{
221
+ display:'inline-flex', alignItems:'center', gap:6, padding:'4px 10px', borderRadius:999,
222
+ border:'1px solid ' + (active ? 'var(--brand-blue-500)' : 'var(--line)'),
223
+ background: active ? 'var(--brand-blue-500)' : 'var(--bg-elev)',
224
+ color: active ? '#fff' : 'var(--fg2)',
225
+ fontFamily:'var(--font-body)', fontSize:12, fontWeight:600, cursor:'pointer',
226
+ transition:'all var(--dur-fast)',
227
+ }}>
228
+ {label}<span style={{ fontFamily:'var(--font-mono)', fontSize:11, opacity:0.9 }}>{n}</span>
229
+ </button>
230
+ );
231
+ })}
232
+ </div>
233
+ {rebased.length > 0 ? (
234
+ <TimelineGantt
235
+ tests={rebased}
236
+ totalMs={totalMs}
237
+ onOpen={(t) => {
238
+ if (t.id && RICH_TESTS[t.id]) { window.__openTest?.(t.id); return; }
239
+ const match = Object.values(RICH_TESTS).find(r => r.name === t.name);
240
+ if (match) window.__openTest?.(match.id);
241
+ }}
242
+ />
243
+ ) : (
244
+ <div style={{ padding:30, color:'var(--fg3)', textAlign:'center', fontFamily:'var(--font-mono)', fontSize:12 }}>
245
+ {mode === 'failures' ? 'No failing tests in this run.' : 'No tests with timing data in this run.'}
246
+ </div>
247
+ )}
248
+ </div>
249
+
250
+ <div className="card" style={{ marginTop: 16 }}>
251
+ <div className="hd"><h3>Suite execution heatmap</h3><div className="meta">last 8 runs</div></div>
252
+ {hasHistory ? (
253
+ <div style={{ padding:30, color:'var(--fg3)', textAlign:'center', fontFamily:'var(--font-mono)', fontSize:12 }}>
254
+ Heatmap view coming soon. History is populated.
255
+ </div>
256
+ ) : (
257
+ <div style={{ padding:30, color:'var(--fg3)', textAlign:'center', fontFamily:'var(--font-mono)', fontSize:12 }}>
258
+ Run history not yet populated.
259
+ </div>
260
+ )}
261
+ </div>
262
+
263
+ <div className="card" style={{ marginTop: 16 }}>
264
+ <div className="hd"><h3>Retry waterfall</h3><div className="meta">attempt-by-attempt status &amp; duration</div></div>
265
+ {hasRetries ? (
266
+ <div style={{ padding:30, color:'var(--fg3)', textAlign:'center', fontFamily:'var(--font-mono)', fontSize:12 }}>
267
+ Retry attempt details coming soon. Open a retried test from the suites view.
268
+ </div>
269
+ ) : (
270
+ <div style={{ padding:30, color:'var(--fg3)', textAlign:'center', fontFamily:'var(--font-mono)', fontSize:12 }}>
271
+ Run history not yet populated.
272
+ </div>
273
+ )}
274
+ </div>
275
+ </div>
276
+ );
277
+ }
278
+
279
+ // ============== CATEGORIES PAGE — error-type classification ==============
280
+ function CategoriesPage() {
281
+ const ERROR_TYPES = window.CATEGORIES || [];
282
+ const RICH_TESTS = window.RICH_TESTS || {};
283
+
284
+ const [selectedKind, setKind] = useStateP(ERROR_TYPES[0]?.kind ?? null);
285
+
286
+ if (ERROR_TYPES.length === 0) {
287
+ return (
288
+ <div className="card" style={{ padding:30, textAlign:'center', color:'var(--fg3)', fontFamily:'var(--font-mono)', fontSize:13 }}>
289
+ No failures in this run. Nothing to categorize.
290
+ </div>
291
+ );
292
+ }
293
+
294
+ const sel = ERROR_TYPES.find(e => e.kind === selectedKind) || ERROR_TYPES[0];
295
+ const totalIssues = ERROR_TYPES.reduce((a,b) => a + b.count, 0);
296
+
297
+ return (
298
+ <div>
299
+ <div style={{ display:'flex', alignItems:'baseline', justifyContent:'space-between', marginBottom: 14 }}>
300
+ <div>
301
+ <h1 className="k-h1" style={{ marginBottom:2 }}>Categories</h1>
302
+ <div className="k-meta">Failures grouped by error type · {ERROR_TYPES.length} types · {totalIssues} tests</div>
303
+ </div>
304
+ <div style={{ display:'flex', gap:6 }}>
305
+ <span className="badge b-failed"><span className="dot"></span>{ERROR_TYPES.filter(e=>e.family==='failed').reduce((a,b)=>a+b.count,0)} product</span>
306
+ <span className="badge b-broken"><span className="dot"></span>{ERROR_TYPES.filter(e=>e.family==='broken').reduce((a,b)=>a+b.count,0)} test-defects</span>
307
+ <span className="badge b-skipped"><span className="dot"></span>{ERROR_TYPES.filter(e=>e.family==='skipped').reduce((a,b)=>a+b.count,0)} environment</span>
308
+ </div>
309
+ </div>
310
+
311
+ <div className="card" style={{ marginBottom: 16 }}>
312
+ <div className="hd"><h3>Distribution by error type</h3><div className="meta">{totalIssues} test failures</div></div>
313
+ <div style={{ display:'flex', flexDirection:'column', gap:8 }}>
314
+ {ERROR_TYPES.map(e => (
315
+ <div key={e.kind} style={{ display:'grid', gridTemplateColumns:'minmax(220px, 280px) 1fr 36px', alignItems:'center', gap:10 }}>
316
+ <div style={{ display:'flex', alignItems:'center', gap:6, fontFamily:'var(--font-mono)', fontSize:12.5, color:'var(--fg1)' }}>
317
+ <span style={{ width:8, height:8, borderRadius:2, background:e.color, flexShrink:0 }}/>
318
+ {e.kind}
319
+ </div>
320
+ <div style={{ height:18, background:'var(--bg-sunken)', borderRadius:3, position:'relative', overflow:'hidden' }}>
321
+ <div style={{ width:`${(e.count/totalIssues)*100}%`, height:'100%', background:e.color }}/>
322
+ </div>
323
+ <div style={{ fontFamily:'var(--font-mono)', fontSize:12, color:'var(--fg2)', textAlign:'right', fontVariantNumeric:'tabular-nums' }}>{e.count}</div>
324
+ </div>
325
+ ))}
326
+ </div>
327
+ </div>
328
+
329
+ <div style={{ display:'grid', gridTemplateColumns:'minmax(260px, 320px) 1fr', gap:0, background:'var(--bg-elev)', border:'1px solid var(--line)', borderRadius:12, overflow:'hidden', minHeight:480 }}>
330
+ <div style={{ borderRight:'1px solid var(--line)' }}>
331
+ {ERROR_TYPES.map(e => (
332
+ <div key={e.kind} onClick={()=>setKind(e.kind)} style={{
333
+ display:'grid', gridTemplateColumns:'1fr auto', alignItems:'center', gap:10, padding:'12px 14px',
334
+ cursor:'pointer', borderLeft: selectedKind===e.kind ? `2px solid ${e.color}` : '2px solid transparent',
335
+ background: selectedKind===e.kind ? 'var(--accent-soft)' : 'transparent',
336
+ borderBottom:'1px solid var(--line)',
337
+ }}>
338
+ <div>
339
+ <div style={{ display:'flex', alignItems:'center', gap:6, fontFamily:'var(--font-mono)', fontSize:12.5, fontWeight:600, color:'var(--fg1)' }}>
340
+ <span style={{ width:8, height:8, borderRadius:2, background:e.color, flexShrink:0 }}/>{e.kind}
341
+ </div>
342
+ <div style={{ fontSize:11, color:'var(--fg3)', marginTop:3, textTransform:'uppercase', letterSpacing:'.08em' }}>{e.family}</div>
343
+ </div>
344
+ <span style={{ background:e.color, color:'#fff', fontSize:11, fontWeight:700, padding:'2px 8px', borderRadius:3, fontFamily:'var(--font-mono)' }}>{e.count}</span>
345
+ </div>
346
+ ))}
347
+ </div>
348
+ <div style={{ padding:24 }}>
349
+ {sel && (
350
+ <>
351
+ <div className="k-overline" style={{ marginBottom:6 }}>{sel.family} · error type</div>
352
+ <h2 className="k-h2" style={{ fontSize:22, fontFamily:'var(--font-mono)', fontWeight:600, marginBottom:8 }}>{sel.kind}</h2>
353
+ <p className="k-body" style={{ marginBottom:18 }}>{sel.description}</p>
354
+
355
+ <div className="k-overline" style={{ marginBottom:8 }}>Affected tests · {sel.count}</div>
356
+ <div style={{ border:'1px solid var(--line)', borderRadius:8, overflow:'hidden' }}>
357
+ {sel.tests.map((tid, i) => {
358
+ const t = RICH_TESTS[tid];
359
+ const name = t ? t.name : tid;
360
+ const status = t ? t.status : sel.family;
361
+ const file = t ? t.file : '';
362
+ const msg = t?.error?.message || '';
363
+ const canOpen = !!t;
364
+ return (
365
+ <div
366
+ key={i}
367
+ onClick={canOpen ? () => window.__openTest?.(t.id) : undefined}
368
+ title={canOpen ? `Open ${name} →` : 'Test not found in this run'}
369
+ style={{
370
+ display:'grid', gridTemplateColumns:'24px 1fr auto', gap:10,
371
+ padding:'12px 14px', borderTop: i ? '1px solid var(--line)' : 'none',
372
+ alignItems:'flex-start', cursor: canOpen ? 'pointer' : 'default',
373
+ transition:'background 120ms',
374
+ }}
375
+ onMouseEnter={canOpen ? e => e.currentTarget.style.background='var(--bg-hover)' : undefined}
376
+ onMouseLeave={canOpen ? e => e.currentTarget.style.background='transparent' : undefined}
377
+ >
378
+ <span className={`s-icon ${status}`} style={{ marginTop:2 }}>{status==='passed'?'✓':status==='failed'?'✕':status==='broken'?'!':'⊘'}</span>
379
+ <div style={{ minWidth:0 }}>
380
+ <div style={{ fontFamily:'var(--font-body)', fontSize:13, fontWeight:600, color:'var(--fg1)' }}>{name}</div>
381
+ {file && <div style={{ fontFamily:'var(--font-mono)', fontSize:11, color:'var(--fg3)', marginTop:2 }}>{file}</div>}
382
+ {msg && <div style={{ fontFamily:'var(--font-mono)', fontSize:11.5, color:'var(--status-failed-fg)', marginTop:6, padding:'6px 8px', background:'var(--status-failed-bg)', border:'1px solid var(--status-failed-border)', borderRadius:4 }}>{msg}</div>}
383
+ </div>
384
+ {canOpen && (
385
+ <span style={{
386
+ alignSelf:'center', fontFamily:'var(--font-mono)', fontSize:11, color:'var(--fg3)',
387
+ display:'inline-flex', alignItems:'center', gap:4, whiteSpace:'nowrap',
388
+ }}>
389
+ Open <span style={{ fontSize:13 }}>→</span>
390
+ </span>
391
+ )}
392
+ </div>
393
+ );
394
+ })}
395
+ </div>
396
+ </>
397
+ )}
398
+ </div>
399
+ </div>
400
+ </div>
401
+ );
402
+ }
403
+
404
+ // ============== FLAKY PAGE ==============
405
+ //
406
+ // Single-run flake derivation — Kensho is the OSS, single-run report; rolling
407
+ // flake-rate analysis lives in Kaizen (the SaaS platform). What we CAN derive
408
+ // from one run is meaningful enough to act on:
409
+ //
410
+ // · "Recovered" — passed on retry (real flake — non-deterministic test)
411
+ // · "Broken" — status=broken (test infra failed mid-execution)
412
+ // · "Failed retry" — failed even after one or more retries (flaky AND broken)
413
+ //
414
+ // Each card explains the signal in plain language so users understand the
415
+ // difference between a flake and a regular failure. Severity ordering:
416
+ // recovered > broken > failed-with-retry, since recoveries are pure flakes.
417
+ function FlakyPage() {
418
+ const RICH_TESTS = window.RICH_TESTS || {};
419
+ const allTests = Object.values(RICH_TESTS);
420
+ const fmt = window._kenshoFmtDuration || (ms => ms + 'ms');
421
+
422
+ // Bucket every test into one of three flake categories (or none).
423
+ const buckets = { recovered: [], broken: [], failedWithRetries: [] };
424
+ for (const t of allTests) {
425
+ if (t.retries > 0 && t.status === 'passed') buckets.recovered.push(t);
426
+ else if (t.status === 'broken') buckets.broken.push(t);
427
+ else if (t.retries > 0 && (t.status === 'failed' || t.status === 'broken')) buckets.failedWithRetries.push(t);
428
+ }
429
+ const flakeTotal = buckets.recovered.length + buckets.broken.length + buckets.failedWithRetries.length;
430
+
431
+ const [filter, setFilter] = useStateP('all');
432
+
433
+ // Stable order for rendering — recoveries first (highest signal-to-noise),
434
+ // then broken, then failed-with-retries. Within each bucket, sort by retry
435
+ // count desc so the highest-friction tests bubble up.
436
+ const ALL_FLAKY = [
437
+ ...buckets.recovered.map(t => ({ ...t, _bucket:'recovered' })),
438
+ ...buckets.broken.map(t => ({ ...t, _bucket:'broken' })),
439
+ ...buckets.failedWithRetries.map(t => ({ ...t, _bucket:'failedWithRetries' })),
440
+ ].sort((a, b) => (b.retries || 0) - (a.retries || 0));
441
+
442
+ const visible = filter === 'all' ? ALL_FLAKY : ALL_FLAKY.filter(t => t._bucket === filter);
443
+
444
+ // j/k/Enter shortcuts on the Flaky list — keep selection local; pressing
445
+ // Enter delegates to the global window.__openTest hook from app.jsx.
446
+ const [selectedIdx, setSelectedIdx] = useStateP(-1);
447
+ React.useEffect(() => {
448
+ const onMove = (e) => {
449
+ if (visible.length === 0) return;
450
+ const delta = e.detail?.delta || 0;
451
+ setSelectedIdx(prev => {
452
+ const idx = prev === -1 ? (delta > 0 ? 0 : visible.length - 1) : prev + delta;
453
+ return Math.max(0, Math.min(visible.length - 1, idx));
454
+ });
455
+ };
456
+ const onOpen = () => {
457
+ if (selectedIdx >= 0 && visible[selectedIdx]) window.__openTest?.(visible[selectedIdx].id);
458
+ };
459
+ window.addEventListener('kensho:move-selection', onMove);
460
+ window.addEventListener('kensho:open-selection', onOpen);
461
+ return () => {
462
+ window.removeEventListener('kensho:move-selection', onMove);
463
+ window.removeEventListener('kensho:open-selection', onOpen);
464
+ };
465
+ }, [selectedIdx, visible]);
466
+
467
+ const passRate = allTests.length > 0
468
+ ? Math.round(((allTests.length - flakeTotal) / allTests.length) * 100)
469
+ : 100;
470
+ const flakeRate = allTests.length > 0
471
+ ? ((flakeTotal / allTests.length) * 100).toFixed(1)
472
+ : '0';
473
+
474
+ if (allTests.length === 0) {
475
+ return <div className="card" style={{padding:30,textAlign:'center',color:'var(--fg3)'}}>No tests in this report.</div>;
476
+ }
477
+
478
+ if (flakeTotal === 0) {
479
+ return (
480
+ <div>
481
+ <h1 className="k-h1" style={{ marginBottom: 4 }}>Flaky tests</h1>
482
+ <div className="k-meta" style={{ marginBottom: 24 }}>Tests that retried, recovered, or broke during execution</div>
483
+ <div className="card" style={{ padding:'48px 32px', textAlign:'center', background:'linear-gradient(180deg, var(--status-passed-bg), transparent)' }}>
484
+ <div style={{ width:64, height:64, borderRadius:999, background:'var(--status-passed-bg)', border:'2px solid var(--status-passed-border)', display:'inline-flex', alignItems:'center', justifyContent:'center', marginBottom:16 }}>
485
+ <span style={{ fontSize:30, color:'var(--status-passed)' }}>✓</span>
486
+ </div>
487
+ <h2 className="k-h2" style={{ marginBottom:8, fontSize:22 }}>No flaky tests detected</h2>
488
+ <p className="k-body" style={{ maxWidth:460, margin:'0 auto', color:'var(--fg2)' }}>
489
+ Every test ran cleanly on the first attempt — no retries, no broken executions.
490
+ Flaky-test detection here is single-run; rolling flake-rate analysis (last N runs)
491
+ lives in the Kaizen platform.
492
+ </p>
493
+ </div>
494
+ </div>
495
+ );
496
+ }
497
+
498
+ const FILTERS = [
499
+ ['all', 'All flaky', flakeTotal],
500
+ ['recovered', 'Recovered', buckets.recovered.length],
501
+ ['broken', 'Broken', buckets.broken.length],
502
+ ['failedWithRetries', 'Failed retry', buckets.failedWithRetries.length],
503
+ ].filter(([id, _, n]) => id === 'all' || n > 0);
504
+
505
+ return (
506
+ <div>
507
+ <div style={{ display:'flex', alignItems:'baseline', justifyContent:'space-between', marginBottom: 14, flexWrap:'wrap', gap:16 }}>
508
+ <div>
509
+ <h1 className="k-h1" style={{ marginBottom: 4 }}>Flaky tests</h1>
510
+ <div className="k-meta">{flakeTotal} of {allTests.length} tests showed instability · {flakeRate}% flake rate</div>
511
+ </div>
512
+ <div style={{ fontFamily:'var(--font-mono)', fontSize:11, color:'var(--fg3)', maxWidth:380, textAlign:'right', lineHeight:1.55 }}>
513
+ Single-run signal — derived from <code style={{ color:'var(--fg2)' }}>retries</code> + <code style={{ color:'var(--fg2)' }}>broken</code>.
514
+ For rolling flake-rate over many runs, use the Kaizen platform.
515
+ </div>
516
+ </div>
517
+
518
+ {/* Hero stat band — three chunky stats with a gradient backdrop */}
519
+ <div style={{
520
+ display:'grid', gridTemplateColumns:'repeat(3, 1fr)', gap:16, marginBottom:18,
521
+ }}>
522
+ <FlakyStatCard
523
+ accent="var(--status-broken)"
524
+ icon="rotate-ccw"
525
+ label="Recovered"
526
+ value={buckets.recovered.length}
527
+ desc="Passed on retry — a real flake (non-deterministic). Investigate the test for hidden timing or state assumptions."
528
+ onClick={buckets.recovered.length > 0 ? () => setFilter('recovered') : null}
529
+ />
530
+ <FlakyStatCard
531
+ accent="#7C5CFF"
532
+ icon="zap-off"
533
+ label="Broken"
534
+ value={buckets.broken.length}
535
+ desc="Test infrastructure failed mid-execution (setup, teardown, or fixture). Often unrelated to product behavior."
536
+ onClick={buckets.broken.length > 0 ? () => setFilter('broken') : null}
537
+ />
538
+ <FlakyStatCard
539
+ accent="var(--status-failed)"
540
+ icon="alert-triangle"
541
+ label="Failed retry"
542
+ value={buckets.failedWithRetries.length}
543
+ desc="Failed even after one or more retries. Could be a real defect masquerading as a flake — prioritize."
544
+ onClick={buckets.failedWithRetries.length > 0 ? () => setFilter('failedWithRetries') : null}
545
+ />
546
+ </div>
547
+
548
+ {/* Filter pills */}
549
+ <div className="card" style={{ marginBottom: 16 }}>
550
+ <div className="hd">
551
+ <h3>Affected tests</h3>
552
+ <div className="meta">{visible.length} shown · sorted by retry count</div>
553
+ </div>
554
+
555
+ <div style={{ display:'flex', gap:6, padding:'2px 0 14px', flexWrap:'wrap' }}>
556
+ {FILTERS.map(([id, label, n]) => {
557
+ const active = filter === id;
558
+ return (
559
+ <button key={id} onClick={() => setFilter(id)} style={{
560
+ display:'inline-flex', alignItems:'center', gap:6, padding:'4px 10px', borderRadius:999,
561
+ border:'1px solid ' + (active ? 'var(--brand-blue-500)' : 'var(--line)'),
562
+ background: active ? 'var(--brand-blue-500)' : 'var(--bg-elev)',
563
+ color: active ? '#fff' : 'var(--fg2)',
564
+ fontFamily:'var(--font-body)', fontSize:12, fontWeight:600, cursor:'pointer',
565
+ transition:'all var(--dur-fast)',
566
+ }}>
567
+ {label}<span style={{ fontFamily:'var(--font-mono)', fontSize:11, opacity:0.9 }}>{n}</span>
568
+ </button>
569
+ );
570
+ })}
571
+ </div>
572
+
573
+ {visible.length === 0 ? (
574
+ <div style={{ padding:'30px 0', textAlign:'center', color:'var(--fg3)', fontFamily:'var(--font-mono)', fontSize:12 }}>
575
+ No tests in this category.
576
+ </div>
577
+ ) : (
578
+ <div style={{ display:'flex', flexDirection:'column' }}>
579
+ {visible.map((t, i) => (
580
+ <FlakyTestRow key={t.id} test={t} index={i} selected={i === selectedIdx} />
581
+ ))}
582
+ </div>
583
+ )}
584
+ </div>
585
+ </div>
586
+ );
587
+ }
588
+
589
+ // FlakyStatCard — stat banner card. Subtle gradient + accent stripe.
590
+ function FlakyStatCard({ accent, icon, label, value, desc, onClick }) {
591
+ const clickable = !!onClick;
592
+ return (
593
+ <div
594
+ onClick={onClick || undefined}
595
+ style={{
596
+ position:'relative', padding:'18px 20px',
597
+ background:'var(--bg-elev)', border:'1px solid var(--line)', borderRadius:12,
598
+ cursor: clickable ? 'pointer' : 'default', overflow:'hidden',
599
+ transition:'transform var(--dur-fast), border-color var(--dur-fast), background var(--dur-fast)',
600
+ }}
601
+ onMouseEnter={clickable ? e => { e.currentTarget.style.borderColor = accent; e.currentTarget.style.transform = 'translateY(-1px)'; } : undefined}
602
+ onMouseLeave={clickable ? e => { e.currentTarget.style.borderColor = 'var(--line)'; e.currentTarget.style.transform = 'translateY(0)'; } : undefined}
603
+ >
604
+ <div style={{ position:'absolute', left:0, top:0, bottom:0, width:4, background:accent }}/>
605
+ <div style={{
606
+ position:'absolute', right:-30, top:-30, width:120, height:120, borderRadius:'50%',
607
+ background:`radial-gradient(circle, ${accent}22 0%, transparent 70%)`,
608
+ }}/>
609
+ <div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:8, position:'relative' }}>
610
+ <span style={{
611
+ width:28, height:28, borderRadius:8, background:`${accent}1F`, color:accent,
612
+ display:'inline-flex', alignItems:'center', justifyContent:'center',
613
+ }}>
614
+ <Icon name={icon} size={14}/>
615
+ </span>
616
+ <span style={{ fontFamily:'var(--font-mono)', fontSize:10.5, color:'var(--fg3)', letterSpacing:'.14em', textTransform:'uppercase' }}>{label}</span>
617
+ </div>
618
+ <div style={{ fontFamily:'var(--font-display)', fontSize:42, fontWeight:700, letterSpacing:-0.8, color:'var(--fg1)', lineHeight:1, marginBottom:10 }}>
619
+ {value}
620
+ </div>
621
+ <div style={{ fontFamily:'var(--font-body)', fontSize:12.5, color:'var(--fg2)', lineHeight:1.55 }}>
622
+ {desc}
623
+ </div>
624
+ </div>
625
+ );
626
+ }
627
+
628
+ // FlakyTestRow — single affected test in the list.
629
+ function FlakyTestRow({ test, index, selected }) {
630
+ const BUCKET_META = {
631
+ recovered: { color:'var(--status-broken)', label:'RECOVERED', pillBg:'var(--status-broken-bg)', pillFg:'var(--status-broken-fg)', icon:'rotate-ccw' },
632
+ broken: { color:'#7C5CFF', label:'BROKEN', pillBg:'rgba(124,92,255,0.15)', pillFg:'#B69CFF', icon:'zap-off' },
633
+ failedWithRetries:{ color:'var(--status-failed)', label:'FAILED RETRY', pillBg:'var(--status-failed-bg)', pillFg:'var(--status-failed-fg)', icon:'alert-triangle' },
634
+ };
635
+ const m = BUCKET_META[test._bucket] || BUCKET_META.broken;
636
+
637
+ // Compute a rough "stability score" for visual weight: lower = flakier.
638
+ // 100 base, –20 per retry, –30 if broken, –10 if failed.
639
+ let stability = 100 - (test.retries * 20);
640
+ if (test.status === 'broken') stability -= 30;
641
+ else if (test.status === 'failed') stability -= 10;
642
+ stability = Math.max(0, Math.min(100, stability));
643
+ const stColor = stability >= 60 ? 'var(--status-broken)' : 'var(--status-failed)';
644
+
645
+ return (
646
+ <div
647
+ onClick={() => window.__openTest?.(test.id)}
648
+ style={{
649
+ display:'grid', gridTemplateColumns:'auto 110px 1fr 110px auto', gap:14,
650
+ alignItems:'center', padding:'12px 4px', cursor:'pointer',
651
+ borderTop: index ? '1px solid var(--line)' : 'none',
652
+ background: selected ? 'var(--accent-soft)' : 'transparent',
653
+ borderLeft: selected ? '2px solid var(--brand-blue-500)' : '2px solid transparent',
654
+ transition:'background var(--dur-fast)',
655
+ }}
656
+ onMouseEnter={e => { if (!selected) e.currentTarget.style.background='var(--bg-hover)'; }}
657
+ onMouseLeave={e => { if (!selected) e.currentTarget.style.background='transparent'; }}
658
+ >
659
+ <span style={{
660
+ width:32, height:32, borderRadius:8, background:`${m.color}1F`, color:m.color,
661
+ display:'inline-flex', alignItems:'center', justifyContent:'center', flexShrink:0,
662
+ }}>
663
+ <Icon name={m.icon} size={14}/>
664
+ </span>
665
+ <span style={{
666
+ display:'inline-flex', alignItems:'center', justifyContent:'center', padding:'3px 0',
667
+ background:m.pillBg, color:m.pillFg, fontFamily:'var(--font-mono)', fontSize:10.5, fontWeight:700,
668
+ letterSpacing:0.5, borderRadius:4,
669
+ }}>{m.label}</span>
670
+ <div style={{ minWidth:0 }}>
671
+ <div style={{ fontFamily:'var(--font-body)', fontSize:13.5, fontWeight:600, color:'var(--fg1)', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
672
+ {test.name}
673
+ </div>
674
+ <div style={{ fontFamily:'var(--font-mono)', fontSize:11, color:'var(--fg3)', marginTop:3, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
675
+ {test.suite ? <span>{test.suite}</span> : null}
676
+ {test.suite && test.file ? <span> · </span> : null}
677
+ {test.file ? <span>{test.file}</span> : null}
678
+ </div>
679
+ </div>
680
+ <div style={{ display:'flex', flexDirection:'column', gap:4, alignItems:'flex-end' }}>
681
+ <div style={{ fontFamily:'var(--font-mono)', fontSize:11, color:'var(--fg3)' }}>
682
+ {test.retries > 0 ? `${test.retries} ${test.retries === 1 ? 'retry' : 'retries'}` : 'no retries'}
683
+ </div>
684
+ <div style={{ width:90, height:4, background:'var(--bg-sunken)', borderRadius:999, overflow:'hidden' }}>
685
+ <div style={{ width:`${stability}%`, height:'100%', background:stColor, transition:'width var(--dur-fast)' }}/>
686
+ </div>
687
+ <div style={{ fontFamily:'var(--font-mono)', fontSize:10, color:'var(--fg4)', letterSpacing:'.08em', textTransform:'uppercase' }}>
688
+ stability {stability}%
689
+ </div>
690
+ </div>
691
+ <div style={{ fontFamily:'var(--font-mono)', fontSize:12, color:'var(--fg3)' }}>{test.dur}</div>
692
+ </div>
693
+ );
694
+ }
695
+
696
+ // ============== SUITES PAGE — uses TreeDetailPage ==============
697
+ function SuitesView({ onOpen }) {
698
+ const tree = window.SUITE_TREE || [];
699
+ if (tree.length === 0) {
700
+ return <div className="card" style={{padding:30,textAlign:'center',color:'var(--fg3)'}}>No suites in this report.</div>;
701
+ }
702
+ return <TreeDetailPage title="Suites" subtitle="Tests grouped by suite organization" tree={tree}/>;
703
+ }
704
+
705
+ // ============== PACKAGES PAGE — file-path tree, opens detail in-place ==============
706
+ function PackagesPage() {
707
+ const RICH_TESTS = window.RICH_TESTS || {};
708
+ const allTests = Object.values(RICH_TESTS);
709
+
710
+ if (allTests.length === 0) {
711
+ return <div className="card" style={{padding:30,textAlign:'center',color:'var(--fg3)'}}>No tests in this report.</div>;
712
+ }
713
+
714
+ // Build a tree from RICH_TESTS file paths, e.g. src/test/auth/login.spec.ts → src › test › auth › login.spec.ts
715
+ const root = { _children: {} };
716
+ allTests.forEach(t => {
717
+ if (!t.file) return;
718
+ const parts = t.file.split(':')[0].split('/');
719
+ let node = root;
720
+ parts.forEach((p, i) => {
721
+ if (!node._children[p]) node._children[p] = { _children: {}, _name: p, _isFile: i === parts.length - 1 };
722
+ node = node._children[p];
723
+ if (node._isFile) node._test = t.id;
724
+ });
725
+ });
726
+
727
+ const toTree = (node, prefix='') => Object.entries(node._children).map(([name, child]) => {
728
+ const id = prefix + '/' + name;
729
+ if (child._isFile) {
730
+ return { id, testId: child._test };
731
+ }
732
+ return { id, name, children: toTree(child, id) };
733
+ });
734
+
735
+ // Collapse single-child folders for cleaner display
736
+ const collapse = nodes => nodes.map(n => {
737
+ if (!n.children) return n;
738
+ let kids = collapse(n.children);
739
+ while (kids.length === 1 && kids[0].children) {
740
+ n = { ...n, name: n.name + ' › ' + kids[0].name, children: kids[0].children, id: kids[0].id };
741
+ kids = n.children;
742
+ }
743
+ return { ...n, children: kids };
744
+ });
745
+
746
+ const tree = collapse(toTree(root));
747
+
748
+ if (tree.length === 0) {
749
+ return <div className="card" style={{padding:30,textAlign:'center',color:'var(--fg3)'}}>No source-file metadata in this report. Add filePath to your test cases to populate this tree.</div>;
750
+ }
751
+
752
+ return <TreeDetailPage title="Packages" subtitle="Tests grouped by source-code package" tree={tree}/>;
753
+ }
754
+
755
+ // ============== BEHAVIORS PAGE — Epic › Feature › Story tree ==============
756
+ function BehaviorsPage() {
757
+ const tree = window.BEHAVIOR_TREE || [];
758
+ if (tree.length === 0) {
759
+ return <div className="card" style={{padding:30,textAlign:'center',color:'var(--fg3)'}}>No BDD/behavior annotations in this report. Add behavior.epic/feature/scenario to your test cases to populate this tree.</div>;
760
+ }
761
+ return <TreeDetailPage
762
+ title="Behaviors"
763
+ subtitle="BDD tree · Epic › Feature › Story · Given/When/Then in test details"
764
+ tree={tree}
765
+ />;
766
+ }
767
+
768
+ // ============== HISTORY PAGE ==============
769
+ function HistoryPage() {
770
+ const HISTORY_RUNS = window.HISTORY_RUNS || [];
771
+ const TREND_RUNS = window.TREND_RUNS || [];
772
+
773
+ return (
774
+ <div>
775
+ <h1 className="k-h1" style={{ marginBottom: 4 }}>History</h1>
776
+ <div className="k-meta" style={{ marginBottom: 18 }}>Last {HISTORY_RUNS.length} run{HISTORY_RUNS.length !== 1 ? 's' : ''} · main &amp; PR branches</div>
777
+
778
+ <div className="card" style={{ marginBottom: 16 }}>
779
+ <div className="hd"><h3>Pass-rate trend</h3><div className="meta">last {TREND_RUNS.length} run{TREND_RUNS.length !== 1 ? 's' : ''}</div></div>
780
+ {TREND_RUNS.length > 0 ? (
781
+ <TrendChartV2 runs={TREND_RUNS}/>
782
+ ) : (
783
+ <div style={{ padding:30, color:'var(--fg3)', textAlign:'center', fontFamily:'var(--font-mono)', fontSize:12 }}>
784
+ No run history yet. Run kensho generate over multiple runs to populate.
785
+ </div>
786
+ )}
787
+ </div>
788
+
789
+ <div className="card kv-flush">
790
+ <div style={{ display:'grid', gridTemplateColumns:'24px 200px 1fr 110px 110px 80px', gap:12, padding:'10px 20px', background:'var(--bg-sunken)', borderBottom:'1px solid var(--line)', fontSize:11, letterSpacing:'.12em', textTransform:'uppercase', fontWeight:700, color:'var(--fg3)' }}>
791
+ <div></div><div>Run</div><div>Distribution</div><div>Branch · actor</div><div>Duration</div><div style={{textAlign:'right'}}>When</div>
792
+ </div>
793
+ {HISTORY_RUNS.map((r, i) => {
794
+ const total = r.passed + r.failed + r.broken + r.skipped;
795
+ return (
796
+ <div key={i} style={{ display:'grid', gridTemplateColumns:'24px 200px 1fr 110px 110px 80px', gap:12, padding:'12px 20px', borderBottom: i < HISTORY_RUNS.length-1 ? '1px solid var(--line)' : 'none', alignItems:'center', cursor:'pointer' }}>
797
+ <span className={`s-icon ${r.status}`}>{r.status === 'passed' ? '✓' : '✕'}</span>
798
+ <div>
799
+ <div style={{ fontFamily:'var(--font-mono)', fontSize:12, color:'var(--fg1)' }}>{r.id}</div>
800
+ <div style={{ fontFamily:'var(--font-mono)', fontSize:11, color:'var(--fg3)' }}>{r.passed} passed · {r.failed} failed · {r.broken} broken · {r.skipped} skipped</div>
801
+ </div>
802
+ <div style={{ height:12, background:'var(--bg-sunken)', borderRadius:3, display:'flex', overflow:'hidden' }}>
803
+ {r.passed > 0 && <div style={{ width:`${(r.passed/total)*100}%`, background:'var(--status-passed)' }}/>}
804
+ {r.failed > 0 && <div style={{ width:`${(r.failed/total)*100}%`, background:'var(--status-failed)' }}/>}
805
+ {r.broken > 0 && <div style={{ width:`${(r.broken/total)*100}%`, background:'var(--status-broken)' }}/>}
806
+ {r.skipped > 0 && <div style={{ width:`${(r.skipped/total)*100}%`, background:'var(--status-skipped)' }}/>}
807
+ </div>
808
+ <div>
809
+ <div style={{ fontFamily:'var(--font-mono)', fontSize:12, color:'var(--fg1)' }}>{r.branch}</div>
810
+ <div style={{ fontFamily:'var(--font-mono)', fontSize:11, color:'var(--fg3)' }}>{r.actor}</div>
811
+ </div>
812
+ <div style={{ fontFamily:'var(--font-mono)', fontSize:12, color:'var(--fg2)' }}>{r.dur}</div>
813
+ <div style={{ fontFamily:'var(--font-mono)', fontSize:11, color:'var(--fg3)', textAlign:'right' }}>{r.when}</div>
814
+ </div>
815
+ );
816
+ })}
817
+ </div>
818
+ </div>
819
+ );
820
+ }
821
+
822
+ Object.assign(window, { GraphsPage, TimelinePage, CategoriesPage, BehaviorsPage, PackagesPage, HistoryPage, SuitesView, FlakyPage });