@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.
package/assets/app.jsx ADDED
@@ -0,0 +1,860 @@
1
+ /* global React, ReactDOM, lucide */
2
+ // =============================================================
3
+ // Kensho viewer — root App + router + keyboard shortcuts.
4
+ // Loaded LAST: depends on every other window.* component.
5
+ // =============================================================
6
+
7
+ const { useState: useStateA, useEffect: useEffectA } = React;
8
+
9
+ // Stable null-context for the static-report path (mirrors the pattern in
10
+ // components.jsx / tree-detail.jsx). Always calling useContext keeps the
11
+ // hook order stable across the static and embedded mount paths.
12
+ const _kvAppNullCtx = React.createContext(null);
13
+
14
+ function fmtDuration(ms) { return window._kenshoFmtDuration ? window._kenshoFmtDuration(ms) : ms + 'ms'; }
15
+ function relTime(iso) { return window._kenshoRelTime ? window._kenshoRelTime(iso) : ''; }
16
+
17
+ const PAGE_NAMES = ['overview','suites','graphs','timeline','categories','flaky','behaviors','packages','history'];
18
+
19
+ // Parse `#/case/<id>` or `#/page/<name>` (defaults: page=overview, no case).
20
+ function parseHash() {
21
+ const h = (window.location.hash || '').replace(/^#\/?/, '');
22
+ if (!h) return { page: 'overview', caseId: null };
23
+ const parts = h.split('/');
24
+ if (parts[0] === 'case' && parts[1]) return { page: null, caseId: decodeURIComponent(parts[1]) };
25
+ if (parts[0] === 'page' && parts[1] && PAGE_NAMES.includes(parts[1])) return { page: parts[1], caseId: null };
26
+ // Bare page name in hash (e.g. #suites) — accept for niceness.
27
+ if (PAGE_NAMES.includes(parts[0])) return { page: parts[0], caseId: null };
28
+ return { page: 'overview', caseId: null };
29
+ }
30
+
31
+ // Build the long URL form so users can copy a permalink to a specific case.
32
+ function caseHashHref(id) {
33
+ return '#/case/' + encodeURIComponent(id);
34
+ }
35
+
36
+ // SummaryKpi — single tile inside the Summary hero's KPI band. Used as a
37
+ // 3×2 grid; we draw separators with `border` prop ("left" / "top" / "top-left")
38
+ // to avoid double-borders between adjacent tiles.
39
+ function SummaryKpi({ label, value, hint, accent, border, onClick }) {
40
+ const clickable = !!onClick;
41
+ return (
42
+ <div
43
+ onClick={onClick || undefined}
44
+ style={{
45
+ position:'relative', padding:'14px 18px',
46
+ borderLeft: border === 'left' || border === 'top-left' ? '1px solid var(--line)' : 'none',
47
+ borderTop: border === 'top' || border === 'top-left' ? '1px solid var(--line)' : 'none',
48
+ cursor: clickable ? 'pointer' : 'default',
49
+ transition:'background var(--dur-fast)',
50
+ minWidth:0,
51
+ }}
52
+ onMouseEnter={clickable ? e => e.currentTarget.style.background='var(--bg-hover)' : undefined}
53
+ onMouseLeave={clickable ? e => e.currentTarget.style.background='transparent' : undefined}
54
+ >
55
+ <div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:6 }}>
56
+ <span style={{ width:6, height:6, borderRadius:999, background: accent || 'var(--fg3)' }}/>
57
+ <span style={{ fontFamily:'var(--font-mono)', fontSize:10.5, color:'var(--fg3)', letterSpacing:'.12em', textTransform:'uppercase' }}>{label}</span>
58
+ </div>
59
+ <div style={{ fontFamily:'var(--font-display)', fontSize:24, fontWeight:700, letterSpacing:-0.4, color:'var(--fg1)', lineHeight:1, fontVariantNumeric:'tabular-nums', marginBottom: hint ? 6 : 0 }}>
60
+ {value}
61
+ </div>
62
+ {hint && (
63
+ <div style={{ fontFamily:'var(--font-body)', fontSize:11.5, color:'var(--fg3)', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }} title={hint}>
64
+ {hint}
65
+ </div>
66
+ )}
67
+ </div>
68
+ );
69
+ }
70
+
71
+ // =============================================================
72
+ // Toast host — minimal, top-right, auto-dismiss. Used by the
73
+ // "copy link" affordance and could be reused by future actions.
74
+ // =============================================================
75
+ function ToastHost() {
76
+ const [toasts, setToasts] = useStateA([]);
77
+ useEffectA(() => {
78
+ window.__kenshoToast = (msg) => {
79
+ const id = Math.random().toString(36).slice(2, 8);
80
+ setToasts(t => [...t, { id, msg }]);
81
+ setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), 2200);
82
+ };
83
+ return () => { delete window.__kenshoToast; };
84
+ }, []);
85
+ return (
86
+ <div style={{
87
+ position:'fixed', top:20, right:20, display:'flex', flexDirection:'column', gap:8,
88
+ zIndex: 700, pointerEvents:'none',
89
+ }}>
90
+ {toasts.map(t => (
91
+ <div key={t.id} style={{
92
+ background:'var(--fg1)', color:'var(--bg-elev)',
93
+ fontFamily:'var(--font-body)', fontSize:13, fontWeight:500,
94
+ padding:'10px 14px', borderRadius:8, boxShadow:'0 6px 20px rgba(0,0,0,0.15)',
95
+ display:'flex', alignItems:'center', gap:8,
96
+ animation: 'kvToastIn 200ms ease-out',
97
+ }}>
98
+ <span style={{ color:'var(--status-passed)', fontWeight:700 }}>✓</span>{t.msg}
99
+ </div>
100
+ ))}
101
+ </div>
102
+ );
103
+ }
104
+
105
+ // =============================================================
106
+ // ShortcutsOverlay — opened by `?`. Plain modal, dismiss on Esc/click.
107
+ // =============================================================
108
+ function ShortcutsOverlay({ open, onClose }) {
109
+ if (!open) return null;
110
+ const ROWS = [
111
+ ['Navigation', [
112
+ ['/', 'Focus search on the current tree page'],
113
+ ['j / k', 'Move selection down / up among visible tests'],
114
+ ['Enter', 'Open the selected test in the detail pane'],
115
+ ['Esc', 'Close detail pane or clear search'],
116
+ ]],
117
+ ['Go to page', [
118
+ ['g o', 'Overview'],
119
+ ['g s', 'Suites'],
120
+ ['g g', 'Graphs'],
121
+ ['g f', 'Flaky'],
122
+ ['g h', 'History'],
123
+ ]],
124
+ ['Help', [
125
+ ['?', 'Toggle this overlay'],
126
+ ]],
127
+ ];
128
+ return (
129
+ <div
130
+ onClick={onClose}
131
+ style={{
132
+ position:'fixed', inset:0, background:'rgba(0,0,0,0.45)',
133
+ display:'flex', alignItems:'center', justifyContent:'center',
134
+ zIndex: 600,
135
+ }}
136
+ >
137
+ <div
138
+ onClick={e => e.stopPropagation()}
139
+ style={{
140
+ background:'var(--bg-elev)', border:'1px solid var(--line)', borderRadius:12,
141
+ width:'min(540px, 92vw)', padding:'24px 28px',
142
+ boxShadow:'0 20px 60px rgba(0,0,0,0.35)',
143
+ }}
144
+ >
145
+ <div style={{ display:'flex', alignItems:'baseline', gap:10, marginBottom:18 }}>
146
+ <h2 style={{ margin:0, fontFamily:'var(--font-display)', fontSize:20, fontWeight:700, color:'var(--fg1)' }}>Keyboard shortcuts</h2>
147
+ <span style={{ fontFamily:'var(--font-mono)', fontSize:11, color:'var(--fg3)' }}>press <kbd style={kbdStyle()}>Esc</kbd> to close</span>
148
+ </div>
149
+ {ROWS.map(([group, items]) => (
150
+ <div key={group} style={{ marginBottom:16 }}>
151
+ <div className="k-overline" style={{ marginBottom:8 }}>{group}</div>
152
+ <div style={{ display:'grid', gridTemplateColumns:'120px 1fr', rowGap:6, columnGap:14 }}>
153
+ {items.map(([keys, desc]) => (
154
+ <React.Fragment key={keys}>
155
+ <div style={{ display:'flex', gap:4, alignItems:'center' }}>
156
+ {keys.split(' ').map((k, i) => (
157
+ <React.Fragment key={i}>
158
+ {i > 0 && <span style={{ fontFamily:'var(--font-mono)', fontSize:10, color:'var(--fg3)' }}>then</span>}
159
+ <kbd style={kbdStyle()}>{k}</kbd>
160
+ </React.Fragment>
161
+ ))}
162
+ </div>
163
+ <div style={{ fontFamily:'var(--font-body)', fontSize:13, color:'var(--fg2)' }}>{desc}</div>
164
+ </React.Fragment>
165
+ ))}
166
+ </div>
167
+ </div>
168
+ ))}
169
+ </div>
170
+ </div>
171
+ );
172
+ }
173
+ function kbdStyle() {
174
+ return {
175
+ fontFamily:'var(--font-mono)', fontSize:11, fontWeight:600,
176
+ padding:'2px 6px', borderRadius:4,
177
+ border:'1px solid var(--line)', background:'var(--bg-sunken)', color:'var(--fg1)',
178
+ minWidth:18, display:'inline-flex', justifyContent:'center', alignItems:'center',
179
+ };
180
+ }
181
+
182
+ // =============================================================
183
+ // App — root component.
184
+ // =============================================================
185
+ function App() {
186
+ // Pull embed-mode extras / callbacks from the host's context (if any).
187
+ // Static-report path: ctx === null → behave as before (hash routing,
188
+ // keyboard shortcuts, no extras).
189
+ const ctx = React.useContext(window.__KenshoContext || _kvAppNullCtx);
190
+ const ownKeyboard = !!ctx?.ownKeyboard;
191
+ const extraSidebar = ctx?.extraSidebar || [];
192
+
193
+ const initial = parseHash();
194
+ const [page, setPage] = useStateA(ctx?.page ?? (initial.page || 'overview'));
195
+ const [selected, setSelected] = useStateA(null);
196
+ const [tab, setTab] = useStateA('steps');
197
+ const [shortcutsOpen, setShortcutsOpen] = useStateA(false);
198
+
199
+ // Icons render inline via the Icon component (see components.jsx); no
200
+ // global lucide.createIcons() pass — it would rewrite the host page's
201
+ // <i data-lucide=> elements when embedded inside another React app.
202
+
203
+ // When the host pushes a new page/case via context (controlled mode),
204
+ // sync local state. Only active when ownKeyboard is set.
205
+ useEffectA(() => {
206
+ if (!ownKeyboard) return;
207
+ if (ctx?.page && ctx.page !== page) setPage(ctx.page);
208
+ }, [ctx?.page, ownKeyboard]);
209
+
210
+ // Open a test by id, returning whether we found one.
211
+ const openTestById = React.useCallback((testId) => {
212
+ const t = window.RICH_TESTS?.[testId];
213
+ if (!t) return false;
214
+ setSelected({ ns: '', name: t.name, status: t.status, duration: t.dur, retries: t.retries, richId: t.id });
215
+ setTab('steps');
216
+ return true;
217
+ }, []);
218
+
219
+ // ---- Hash router ----
220
+ // Treat the URL hash as the source of truth for "page" + "open case" so
221
+ // the back/forward buttons and copyable permalinks both work without
222
+ // pulling in a routing library.
223
+ // When the host owns navigation (`ownKeyboard: true`), skip hash routing
224
+ // — the host updates the URL itself and we react to its callbacks.
225
+ useEffectA(() => {
226
+ if (ownKeyboard) return;
227
+ const onHash = () => {
228
+ const { page: p, caseId } = parseHash();
229
+ if (caseId) {
230
+ const ok = openTestById(caseId);
231
+ if (!ok) setSelected(null);
232
+ } else if (p) {
233
+ setSelected(null);
234
+ setPage(p);
235
+ }
236
+ };
237
+ window.addEventListener('hashchange', onHash);
238
+ onHash();
239
+ return () => window.removeEventListener('hashchange', onHash);
240
+ }, [openTestById, ownKeyboard]);
241
+
242
+ // ---- Global navigation hooks ----
243
+ // Mirror page/test changes back into the URL hash so refreshes preserve state.
244
+ // Embed-mode (ownKeyboard) skips the URL write and fires onCaseOpen /
245
+ // onPageChange instead.
246
+ useEffectA(() => {
247
+ const navTo = (p) => {
248
+ setSelected(null);
249
+ setPage(p);
250
+ if (ownKeyboard) {
251
+ ctx?.onPageChange?.(p);
252
+ } else {
253
+ const next = '#/page/' + p;
254
+ if (window.location.hash !== next) history.pushState(null, '', next);
255
+ }
256
+ };
257
+ const openTest = (testId) => {
258
+ if (!openTestById(testId)) return;
259
+ if (ownKeyboard) {
260
+ ctx?.onCaseOpen?.(testId);
261
+ } else {
262
+ const next = caseHashHref(testId);
263
+ if (window.location.hash !== next) history.pushState(null, '', next);
264
+ }
265
+ };
266
+ window.__navTo = navTo;
267
+ window.__openTest = openTest;
268
+ return () => { delete window.__navTo; delete window.__openTest; };
269
+ }, [openTestById, ownKeyboard, ctx]);
270
+
271
+ // ---- Keyboard shortcuts ----
272
+ // Skipped entirely when the host owns the keyboard (embed mode) so we
273
+ // don't intercept their app-level chords.
274
+ useEffectA(() => {
275
+ if (ownKeyboard) return;
276
+ let pendingG = false;
277
+ let gTimer = null;
278
+ const isTextInput = (el) => {
279
+ if (!el) return false;
280
+ const tag = el.tagName;
281
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
282
+ if (el.isContentEditable) return true;
283
+ return false;
284
+ };
285
+ const focusTreeSearch = () => {
286
+ const el = document.querySelector('.kv-tree-search input, [data-kv-search] input');
287
+ if (el) { el.focus(); el.select?.(); return true; }
288
+ return false;
289
+ };
290
+ const moveSelection = (delta) => {
291
+ const ev = new CustomEvent('kensho:move-selection', { detail: { delta } });
292
+ window.dispatchEvent(ev);
293
+ };
294
+ const enterSelection = () => {
295
+ const ev = new CustomEvent('kensho:open-selection');
296
+ window.dispatchEvent(ev);
297
+ };
298
+ const escapeAction = () => {
299
+ // 1. close shortcuts overlay if open
300
+ // 2. close detail pane (back to tree placeholder)
301
+ // 3. otherwise dispatch clear-search to active tree page
302
+ if (shortcutsOpen) { setShortcutsOpen(false); return; }
303
+ if (selected) { setSelected(null); history.pushState(null, '', '#/page/' + page); return; }
304
+ window.dispatchEvent(new CustomEvent('kensho:clear-search'));
305
+ };
306
+
307
+ const onKeyDown = (e) => {
308
+ if (isTextInput(e.target)) {
309
+ // Allow Esc in text input → blur + clear search via custom event.
310
+ if (e.key === 'Escape') {
311
+ e.target.blur();
312
+ window.dispatchEvent(new CustomEvent('kensho:clear-search'));
313
+ }
314
+ return;
315
+ }
316
+ // Modifier-laden combos belong to the browser/OS.
317
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
318
+
319
+ // `g` chord — wait up to 1.5s for the next key.
320
+ if (pendingG) {
321
+ const code = e.key.toLowerCase();
322
+ const map = { o:'overview', s:'suites', g:'graphs', f:'flaky', h:'history' };
323
+ if (map[code]) {
324
+ e.preventDefault();
325
+ window.__navTo?.(map[code]);
326
+ }
327
+ pendingG = false;
328
+ clearTimeout(gTimer);
329
+ return;
330
+ }
331
+
332
+ switch (e.key) {
333
+ case '/': {
334
+ e.preventDefault();
335
+ if (!focusTreeSearch()) {
336
+ // No tree search on the current page — jump to Suites and try again.
337
+ window.__navTo?.('suites');
338
+ setTimeout(focusTreeSearch, 50);
339
+ }
340
+ return;
341
+ }
342
+ case 'j': e.preventDefault(); moveSelection(+1); return;
343
+ case 'k': e.preventDefault(); moveSelection(-1); return;
344
+ case 'Enter': e.preventDefault(); enterSelection(); return;
345
+ case 'Escape': e.preventDefault(); escapeAction(); return;
346
+ case '?': e.preventDefault(); setShortcutsOpen(o => !o); return;
347
+ case 'g': pendingG = true; gTimer = setTimeout(() => { pendingG = false; }, 1500); return;
348
+ default: return;
349
+ }
350
+ };
351
+ window.addEventListener('keydown', onKeyDown);
352
+ return () => { window.removeEventListener('keydown', onKeyDown); clearTimeout(gTimer); };
353
+ }, [page, selected, shortcutsOpen, ownKeyboard]);
354
+
355
+ const RUN = window.RUN;
356
+ const project = window.KENSHO_INDEX?.project || { name: 'Kensho' };
357
+
358
+ return (
359
+ <div className="app">
360
+ <Sidebar active={page} onNav={p => { window.__navTo?.(p); }} />
361
+ <div className="right-col">
362
+ <TopBar
363
+ crumbs={selected
364
+ ? ['Run ' + RUN.id, 'Tests', selected.ns + selected.name]
365
+ : ['Run ' + RUN.id, page === 'flaky' ? 'Flaky tests' : page[0].toUpperCase() + page.slice(1)]}
366
+ onRerun={() => alert('Re-run hooks are configured by the integrating CI. Wire to your runner.')}
367
+ project={project}
368
+ />
369
+ <div className="main">
370
+ {(() => {
371
+ if (selected) return <Detail test={selected} onBack={() => { setSelected(null); if (!ownKeyboard) history.pushState(null, '', '#/page/' + page); }} />;
372
+ const ex = extraSidebar.find(x => x.id === page);
373
+ if (ex) return ex.render();
374
+ switch (page) {
375
+ case 'graphs': return <GraphsPage/>;
376
+ case 'timeline': return <TimelinePage/>;
377
+ case 'categories': return <CategoriesPage/>;
378
+ case 'flaky': return <FlakyPage/>;
379
+ case 'behaviors': return <BehaviorsPage/>;
380
+ case 'packages': return <PackagesPage/>;
381
+ case 'history': return <HistoryPage/>;
382
+ case 'suites': return <SuitesView onOpen={(t) => window.__openTest?.(t.richId)} />;
383
+ default: return <Overview onOpen={(t) => window.__openTest?.(t.richId)} />;
384
+ }
385
+ })()}
386
+ </div>
387
+ </div>
388
+ <ToastHost/>
389
+ <ShortcutsOverlay open={shortcutsOpen} onClose={() => setShortcutsOpen(false)}/>
390
+ </div>
391
+ );
392
+ }
393
+
394
+ // =============================================================
395
+ // Overview — drag-to-reorder card grid.
396
+ // =============================================================
397
+ function SingleRunTrend() {
398
+ const RUN = window.RUN;
399
+ const c = RUN.counts;
400
+ const total = c.passed + c.failed + c.broken + c.skipped || 1;
401
+ const passRate = Math.round((c.passed / total) * 100);
402
+ const SEGS = [
403
+ ['passed', c.passed, 'var(--status-passed)'],
404
+ ['skipped', c.skipped, 'var(--status-skipped)'],
405
+ ['broken', c.broken, 'var(--status-broken)'],
406
+ ['failed', c.failed, 'var(--status-failed)'],
407
+ ].filter(([, n]) => n > 0);
408
+ return (
409
+ <div>
410
+ <div style={{
411
+ display:'grid', gridTemplateColumns:'1fr auto auto auto auto', gap:18,
412
+ alignItems:'baseline', padding:'4px 4px 14px', borderBottom:'1px solid var(--line)',
413
+ marginBottom:14,
414
+ }}>
415
+ <div style={{ minWidth:0 }}>
416
+ <div style={{ fontFamily:'var(--font-mono)', fontSize:10.5, color:'var(--fg3)', letterSpacing:1.2, textTransform:'uppercase' }}>Current run</div>
417
+ <div style={{ display:'flex', alignItems:'baseline', gap:8, marginTop:4 }}>
418
+ <span style={{ fontFamily:'var(--font-display)', fontSize:26, fontWeight:700, color:'var(--fg1)', letterSpacing:-0.5, lineHeight:1, fontVariantNumeric:'tabular-nums' }}>
419
+ {passRate}<span style={{ fontSize:14, color:'var(--fg3)', marginLeft:2 }}>%</span>
420
+ </span>
421
+ <span style={{ fontFamily:'var(--font-mono)', fontSize:11, color:'var(--fg3)' }}>pass rate</span>
422
+ </div>
423
+ </div>
424
+ {[
425
+ ['passed', c.passed, 'var(--status-passed)'],
426
+ ['skipped', c.skipped, 'var(--status-skipped)'],
427
+ ['broken', c.broken, 'var(--status-broken)'],
428
+ ['failed', c.failed, 'var(--status-failed)'],
429
+ ].map(([k, n, color]) => (
430
+ <div key={k} style={{ textAlign:'right', minWidth:60 }}>
431
+ <div style={{ display:'flex', alignItems:'center', gap:5, justifyContent:'flex-end', fontFamily:'var(--font-mono)', fontSize:10, color:'var(--fg3)', textTransform:'uppercase', letterSpacing:0.5 }}>
432
+ <span style={{ width:8, height:8, borderRadius:2, background:color }}/>{k}
433
+ </div>
434
+ <div style={{ fontFamily:'var(--font-mono)', fontSize:15, fontWeight:600, color:'var(--fg1)', fontVariantNumeric:'tabular-nums', marginTop:2 }}>{n}</div>
435
+ </div>
436
+ ))}
437
+ </div>
438
+
439
+ <div style={{ height:48, background:'var(--bg-sunken)', borderRadius:6, display:'flex', overflow:'hidden', border:'1px solid var(--line)' }}>
440
+ {SEGS.map(([k, n, color]) => (
441
+ <div key={k} title={`${n} ${k}`}
442
+ style={{
443
+ width: `${(n/total)*100}%`, background: color,
444
+ display:'flex', alignItems:'center', justifyContent:'flex-start',
445
+ padding:'0 10px', color:'#fff', fontFamily:'var(--font-mono)', fontSize:12, fontWeight:700,
446
+ }}>
447
+ {(n/total) >= 0.05 ? n : ''}
448
+ </div>
449
+ ))}
450
+ </div>
451
+ <div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginTop:10, fontFamily:'var(--font-mono)', fontSize:11, color:'var(--fg3)' }}>
452
+ <span>{total} test{total === 1 ? '' : 's'} · 1 run total</span>
453
+ <span style={{ display:'inline-flex', alignItems:'center', gap:6 }}>
454
+ <span style={{ width:6, height:6, borderRadius:999, background:'var(--brand-blue-500)' }}/>
455
+ History will populate after the next <code style={{ fontFamily:'var(--font-mono)', fontSize:10.5, background:'var(--bg-sunken)', padding:'1px 6px', borderRadius:3, color:'var(--fg2)' }}>kensho generate</code>
456
+ </span>
457
+ </div>
458
+ </div>
459
+ );
460
+ }
461
+
462
+ function EnvEmptyState() {
463
+ const SUPPORTED = [
464
+ ['Source control', ['branch', 'commit', 'author', 'commitMsg', 'repoUrl']],
465
+ ['CI', ['ci', 'runUrl', 'workers', 'trigger']],
466
+ ['App under test', ['stage', 'baseUrl', 'appVersion', 'release']],
467
+ ['Browser / device',['browsers', 'device', 'viewport', 'locale']],
468
+ ['System', ['os', 'osVersion', 'arch', 'timezone']],
469
+ ['Custom', ['vars (open key/value bag)']],
470
+ ];
471
+ return (
472
+ <div style={{ padding:'8px 0 4px' }}>
473
+ <div style={{ color:'var(--fg2)', fontFamily:'var(--font-body)', fontSize:13, marginBottom:14, lineHeight:1.5 }}>
474
+ No environment variables in this run.
475
+ Populate <code style={{ fontFamily:'var(--font-mono)', fontSize:12, background:'var(--bg-sunken)', padding:'1px 6px', borderRadius:3 }}>run.env.*</code> from your reporter to see them here.
476
+ </div>
477
+ <div style={{ display:'flex', flexDirection:'column', gap:10 }}>
478
+ {SUPPORTED.map(([group, keys]) => (
479
+ <div key={group}>
480
+ <div style={{ fontFamily:'var(--font-mono)', fontSize:10.5, color:'var(--fg3)', letterSpacing:'.08em', textTransform:'uppercase', marginBottom:4 }}>{group}</div>
481
+ <div style={{ display:'flex', flexWrap:'wrap', gap:4 }}>
482
+ {keys.map(k => (
483
+ <span key={k} style={{
484
+ fontFamily:'var(--font-mono)', fontSize:11, color:'var(--fg2)',
485
+ background:'var(--bg-sunken)', border:'1px solid var(--line)',
486
+ borderRadius:4, padding:'2px 6px',
487
+ }}>{k}</span>
488
+ ))}
489
+ </div>
490
+ </div>
491
+ ))}
492
+ </div>
493
+ </div>
494
+ );
495
+ }
496
+
497
+ function TestsCard({ tests, onOpen }) {
498
+ const [filter, setFilter] = useStateA('all');
499
+ const [page, setPage] = useStateA(0);
500
+ const PAGE_SIZE = 20;
501
+
502
+ const counts = { all: tests.length, passed:0, failed:0, broken:0, skipped:0 };
503
+ for (const t of tests) counts[t.status] = (counts[t.status] || 0) + 1;
504
+
505
+ const SEV_RANK = { blocker:0, critical:1, normal:2, minor:3, trivial:4 };
506
+ const STATUS_RANK = { failed:0, broken:1, skipped:2, passed:3 };
507
+
508
+ const filtered = filter === 'all'
509
+ ? [...tests].sort((a,b) => {
510
+ const oa = STATUS_RANK[a.status] ?? 9;
511
+ const ob = STATUS_RANK[b.status] ?? 9;
512
+ if (oa !== ob) return oa - ob;
513
+ if (a.status === 'failed' || a.status === 'broken') {
514
+ return (SEV_RANK[a.severity] ?? 9) - (SEV_RANK[b.severity] ?? 9);
515
+ }
516
+ return (a.order || 0) - (b.order || 0);
517
+ })
518
+ : tests.filter(t => t.status === filter);
519
+
520
+ const total = filtered.length;
521
+ const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
522
+ const safePage = Math.min(page, pages - 1);
523
+ const start = safePage * PAGE_SIZE;
524
+ const end = Math.min(total, start + PAGE_SIZE);
525
+ const visible = filtered.slice(start, end);
526
+
527
+ const PILLS = [
528
+ ['all', 'All'],
529
+ ['failed', 'Failed'],
530
+ ['broken', 'Broken'],
531
+ ['skipped', 'Skipped'],
532
+ ['passed', 'Passed'],
533
+ ].filter(([id]) => id === 'all' || (counts[id] || 0) > 0);
534
+
535
+ const setF = f => { setFilter(f); setPage(0); };
536
+
537
+ return (
538
+ <div>
539
+ <div style={{ display:'flex', gap:6, padding:'2px 0 12px', flexWrap:'wrap' }}>
540
+ {PILLS.map(([id, label]) => {
541
+ const active = filter === id;
542
+ const tone = id === 'all' ? null : id;
543
+ return (
544
+ <button
545
+ key={id}
546
+ onClick={() => setF(id)}
547
+ style={{
548
+ display:'inline-flex', alignItems:'center', gap:6, padding:'4px 10px', borderRadius:999,
549
+ border: '1px solid ' + (active ? 'var(--brand-blue-500)' : 'var(--line)'),
550
+ background: active
551
+ ? 'var(--brand-blue-500)'
552
+ : (tone ? `var(--status-${tone}-bg)` : 'var(--bg-elev)'),
553
+ color: active
554
+ ? '#fff'
555
+ : (tone ? `var(--status-${tone})` : 'var(--fg2)'),
556
+ fontFamily:'var(--font-body)', fontSize:12, fontWeight:600, cursor:'pointer',
557
+ transition:'background var(--dur-fast), color var(--dur-fast), border-color var(--dur-fast)',
558
+ }}
559
+ >
560
+ {label}
561
+ <span style={{ fontFamily:'var(--font-mono)', fontSize:11, opacity:0.9 }}>{counts[id]}</span>
562
+ </button>
563
+ );
564
+ })}
565
+ </div>
566
+
567
+ <div style={{ marginLeft:-20, marginRight:-20 }}>
568
+ {visible.length === 0 ? (
569
+ <div style={{ padding:'30px 20px', textAlign:'center', color:'var(--fg3)', fontFamily:'var(--font-mono)', fontSize:12 }}>
570
+ No {filter === 'all' ? '' : filter + ' '}tests in this run.
571
+ </div>
572
+ ) : visible.map((t) => (
573
+ <TestRow key={t.id} test={{
574
+ ns: '', name: t.name, status: t.status, duration: t.dur, last: t.lastRun, retries: t.retries,
575
+ richId: t.id,
576
+ }} onOpen={() => onOpen({ ns:'', name: t.name, status: t.status, duration: t.dur, retries: t.retries, richId: t.id })}/>
577
+ ))}
578
+ </div>
579
+
580
+ {total > PAGE_SIZE && (
581
+ <div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', padding:'12px 0 0', marginTop:8, borderTop:'1px solid var(--line)', flexWrap:'wrap', gap:8 }}>
582
+ <div style={{ fontFamily:'var(--font-mono)', fontSize:12, color:'var(--fg3)' }}>
583
+ Showing <b style={{ color:'var(--fg1)' }}>{start+1}–{end}</b> of <b style={{ color:'var(--fg1)' }}>{total}</b>
584
+ {filter !== 'all' ? ' ' + filter : ''}
585
+ {filter !== 'all' ? <span> · </span> : null}
586
+ {filter !== 'all' ? <span>{tests.length} total</span> : null}
587
+ </div>
588
+ <div style={{ display:'flex', gap:4, alignItems:'center' }}>
589
+ <button className="btn btn-ghost" style={{ height:28, fontSize:12 }} disabled={safePage === 0} onClick={() => setPage(p => Math.max(0, p-1))}>← Prev</button>
590
+ <span style={{ fontFamily:'var(--font-mono)', fontSize:11, color:'var(--fg3)', padding:'0 6px' }}>page {safePage + 1} / {pages}</span>
591
+ <button className="btn btn-ghost" style={{ height:28, fontSize:12 }} disabled={safePage >= pages - 1} onClick={() => setPage(p => Math.min(pages - 1, p+1))}>Next →</button>
592
+ <button className="btn btn-secondary" style={{ height:28, fontSize:12, marginLeft:8 }} onClick={() => window.__navTo?.('suites')}>View all in Suites →</button>
593
+ </div>
594
+ </div>
595
+ )}
596
+ </div>
597
+ );
598
+ }
599
+
600
+ function Overview({ onOpen }) {
601
+ const RUN = window.RUN;
602
+ const SUITES = window.SUITES || [];
603
+ const ENV = window.ENV || [];
604
+ const allTests = Object.values(window.RICH_TESTS || {}).sort((a,b) => a.order - b.order);
605
+ const counts = { passed:0, failed:0, broken:0, skipped:0 };
606
+ for (const t of allTests) counts[t.status] = (counts[t.status] || 0) + 1;
607
+
608
+ const sevCounts = { blocker:0, critical:0, normal:0, minor:0, trivial:0 };
609
+ for (const t of allTests) {
610
+ const s = (t.severity || 'normal').toLowerCase();
611
+ if (sevCounts[s] != null) sevCounts[s]++; else sevCounts.normal++;
612
+ }
613
+ const sevTotal = Object.values(sevCounts).reduce((a,b) => a+b, 0);
614
+
615
+ const total = allTests.length || 1;
616
+ const passRate = Math.round((counts.passed / total) * 100);
617
+ const durSamples = allTests.filter(t => t.durMs > 0).map(t => t.durMs);
618
+ const meanDur = durSamples.length ? Math.round(durSamples.reduce((a,b) => a+b, 0) / durSamples.length) : 0;
619
+ const slowest = [...allTests].filter(t => t.durMs > 0).sort((a,b) => b.durMs - a.durMs)[0];
620
+ const retriedCount = allTests.filter(t => (t.retries || 0) > 0).length;
621
+ const flakyCount = allTests.filter(t => (t.retries || 0) > 0 || t.status === 'broken').length;
622
+ const fmt = window._kenshoFmtDuration || (ms => ms + 'ms');
623
+
624
+ const cards = {
625
+ summary: {
626
+ title:'Run summary', meta: RUN.duration, span:2,
627
+ body: (
628
+ <div style={{ display:'grid', gridTemplateColumns:'minmax(280px, 360px) 1fr', gap:28, alignItems:'center' }}>
629
+ <div style={{ display:'flex', gap:18, alignItems:'center' }}>
630
+ <div>
631
+ <div className="statnum">{allTests.length}</div>
632
+ <div className="statlbl">test cases</div>
633
+ </div>
634
+ <div style={{ flex:1 }}><StatusDonut counts={counts} /></div>
635
+ </div>
636
+
637
+ <div style={{
638
+ display:'grid', gridTemplateColumns:'repeat(3, 1fr)', gap:0,
639
+ border:'1px solid var(--line)', borderRadius:10, overflow:'hidden',
640
+ background:'var(--bg-elev)',
641
+ }}>
642
+ <SummaryKpi
643
+ label="Pass rate" value={`${passRate}%`}
644
+ accent={passRate >= 95 ? 'var(--status-passed)' : passRate >= 80 ? 'var(--status-broken)' : 'var(--status-failed)'}
645
+ />
646
+ <SummaryKpi
647
+ label="Mean duration" value={meanDur ? fmt(meanDur) : '—'}
648
+ accent="var(--brand-blue-500)"
649
+ border="left"
650
+ />
651
+ <SummaryKpi
652
+ label="Slowest"
653
+ value={slowest ? slowest.dur : '—'}
654
+ hint={slowest ? slowest.name : ''}
655
+ accent="var(--status-broken)"
656
+ border="left"
657
+ onClick={slowest ? () => window.__openTest?.(slowest.id) : null}
658
+ />
659
+ <SummaryKpi
660
+ label="Failures" value={counts.failed}
661
+ accent="var(--status-failed)"
662
+ border="top"
663
+ onClick={counts.failed > 0 ? () => window.__navTo?.('categories') : null}
664
+ />
665
+ <SummaryKpi
666
+ label="Skipped" value={counts.skipped}
667
+ accent="var(--status-skipped-fg)"
668
+ border="top-left"
669
+ />
670
+ <SummaryKpi
671
+ label="Flaky" value={flakyCount}
672
+ hint={flakyCount > 0 ? `${retriedCount} retried · ${counts.broken} broken` : 'clean run'}
673
+ accent={flakyCount > 0 ? 'var(--status-broken)' : 'var(--status-passed)'}
674
+ border="top-left"
675
+ onClick={flakyCount > 0 ? () => window.__navTo?.('flaky') : null}
676
+ />
677
+ </div>
678
+ </div>
679
+ ),
680
+ },
681
+ trend: {
682
+ title:'Trend', meta: (window.TREND_RUNS?.length || 0) + ' run' + (window.TREND_RUNS?.length === 1 ? '' : 's') + ' · stacked', span:1,
683
+ body: window.TREND_RUNS?.length
684
+ ? <TrendChartV2 runs={window.TREND_RUNS}/>
685
+ : <div style={{ padding:30, color:'var(--fg3)', textAlign:'center', fontFamily:'var(--font-mono)', fontSize:12 }}>Run history will populate after the next generate.</div>,
686
+ },
687
+ severity: {
688
+ title:'Severity distribution', meta: `${sevTotal} test${sevTotal === 1 ? '' : 's'}`, span:1,
689
+ body: <SeverityDistribution tests={allTests}/>,
690
+ },
691
+ slowest: {
692
+ title:'Slowest tests', meta: 'top 6 by duration', span:1,
693
+ action: (
694
+ <a className="btn btn-ghost" style={{height:24,padding:'0 8px',fontSize:12,cursor:'pointer'}}
695
+ onClick={(e) => { e.stopPropagation(); window.__navTo?.('timeline'); }}>
696
+ See timeline →
697
+ </a>
698
+ ),
699
+ body: <SlowestTestsList tests={allTests} limit={6} onOpen={onOpen}/>,
700
+ },
701
+ suites: {
702
+ title:`Suites · ${SUITES.length} items total`, span:1,
703
+ action: <a className="btn btn-ghost" style={{height:24,padding:'0 8px',fontSize:12,cursor:'pointer'}} onClick={(e) => { e.stopPropagation(); window.__navTo?.('suites'); }}>Show all →</a>,
704
+ body: <>{SUITES.slice(0, 8).map(s => <SuiteBar key={s.name} {...s} />)}</>,
705
+ },
706
+ environment: {
707
+ title:'Environment', meta: ENV.length ? ENV.length + ' vars' : 'no vars', span:1,
708
+ body: ENV.length ? <EnvTable env={ENV} /> : <EnvEmptyState/>,
709
+ },
710
+ tests: {
711
+ title:`Tests · ${allTests.length} items total`, span:2,
712
+ action: (
713
+ <a className="btn btn-ghost" style={{height:24,padding:'0 8px',fontSize:12,cursor:'pointer'}}
714
+ onClick={(e) => { e.stopPropagation(); window.__navTo?.('suites'); }}>
715
+ View all in Suites →
716
+ </a>
717
+ ),
718
+ body: <TestsCard tests={allTests} onOpen={onOpen} />,
719
+ },
720
+ };
721
+
722
+ const DEFAULT_ORDER = ['summary','trend','severity','slowest','suites','environment','tests'];
723
+ const [order, setOrder] = useStateA(() => {
724
+ try {
725
+ const stored = JSON.parse(localStorage.getItem('kensho.overview.order') || 'null');
726
+ if (!Array.isArray(stored)) return DEFAULT_ORDER;
727
+ const valid = stored.filter(id => DEFAULT_ORDER.includes(id));
728
+ const missing = DEFAULT_ORDER.filter(id => !valid.includes(id));
729
+ return [...valid, ...missing];
730
+ } catch { return DEFAULT_ORDER; }
731
+ });
732
+ const [dragId, setDragId] = useStateA(null);
733
+ const [overId, setOverId] = useStateA(null);
734
+
735
+ React.useEffect(() => { localStorage.setItem('kensho.overview.order', JSON.stringify(order)); }, [order]);
736
+
737
+ const onDragStart = (id) => (e) => { setDragId(id); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', id); };
738
+ const onDragOver = (id) => (e) => { e.preventDefault(); if (id !== dragId) setOverId(id); };
739
+ const onDragEnd = () => { setDragId(null); setOverId(null); };
740
+ const onDrop = (id) => (e) => {
741
+ e.preventDefault();
742
+ if (!dragId || dragId === id) { onDragEnd(); return; }
743
+ const next = [...order];
744
+ const fromIdx = next.indexOf(dragId);
745
+ const toIdx = next.indexOf(id);
746
+ next.splice(fromIdx, 1);
747
+ next.splice(toIdx, 0, dragId);
748
+ setOrder(next);
749
+ onDragEnd();
750
+ };
751
+
752
+ const project = window.KENSHO_INDEX?.project || {};
753
+
754
+ return (
755
+ <div>
756
+ <div style={{ display:'flex', alignItems:'baseline', justifyContent:'space-between', marginBottom: 18 }}>
757
+ <div>
758
+ <div className="k-overline">{project.name || 'Kensho'} · {RUN.startedAt}</div>
759
+ <h1 className="k-h1" style={{ marginTop: 4 }}>Run {RUN.id} <span style={{ color:'var(--fg3)', fontWeight:500 }}>· {RUN.branch}{RUN.commit ? ' · ' + RUN.commit : ''}</span></h1>
760
+ </div>
761
+ <div style={{ display:'flex', gap:8 }}>
762
+ {/* Branch + commit chips ONLY render when env.repoUrl is set so they
763
+ can actually link somewhere. Locally (no repoUrl) the same info
764
+ still appears as inline metadata in the H1 above; rendering
765
+ dead buttons would add chrome without adding affordance. */}
766
+ {(() => {
767
+ const repo = (RUN.repoUrl || '').replace(/\/$/, '');
768
+ if (!repo) return null;
769
+ const branchUrl = `${repo}/tree/${encodeURIComponent(RUN.branch)}`;
770
+ const commitUrl = RUN.commitFull ? `${repo}/commit/${RUN.commitFull}` : '';
771
+ return (
772
+ <>
773
+ <a className="btn btn-secondary" href={branchUrl} target="_blank" rel="noopener noreferrer" title={`Open ${RUN.branch} on the repo`}><Icon name="git-branch" />{RUN.branch}</a>
774
+ {commitUrl && <a className="btn btn-secondary" href={commitUrl} target="_blank" rel="noopener noreferrer" title={`Open commit ${RUN.commit}`}><Icon name="git-commit" />{RUN.commit}</a>}
775
+ </>
776
+ );
777
+ })()}
778
+ <button className="btn btn-ghost" title="Reset card order" onClick={() => setOrder(DEFAULT_ORDER)}><Icon name="rotate-ccw" size={14}/></button>
779
+ </div>
780
+ </div>
781
+
782
+ <div style={{ display:'grid', gridTemplateColumns:'1.1fr 1.6fr', gap: 16, alignItems:'start' }}>
783
+ {order.map(id => {
784
+ const c = cards[id];
785
+ if (!c) return null;
786
+ const isDrag = dragId === id;
787
+ const isOver = overId === id;
788
+ return (
789
+ <div
790
+ key={id}
791
+ draggable
792
+ onDragStart={onDragStart(id)}
793
+ onDragOver={onDragOver(id)}
794
+ onDrop={onDrop(id)}
795
+ onDragEnd={onDragEnd}
796
+ className="card"
797
+ style={{
798
+ gridColumn: c.span === 2 ? 'span 2' : 'auto',
799
+ opacity: isDrag ? 0.4 : 1,
800
+ outline: isOver ? '2px solid var(--accent)' : 'none',
801
+ outlineOffset: isOver ? -2 : 0,
802
+ transition:'outline var(--dur-fast), opacity var(--dur-fast)',
803
+ cursor: 'default',
804
+ }}
805
+ >
806
+ <div className="hd" style={{ alignItems:'center' }}>
807
+ <span style={{ display:'inline-flex', alignItems:'center', gap:6, cursor:'grab', color:'var(--fg3)', userSelect:'none' }} title="Drag to reorder">
808
+ <Icon name="grip-vertical" size={14}/>
809
+ </span>
810
+ <h3 style={{ margin:0, flex:1 }}>{c.title}</h3>
811
+ {c.meta && <div className="meta">{c.meta}</div>}
812
+ {c.action}
813
+ </div>
814
+ {c.body}
815
+ </div>
816
+ );
817
+ })}
818
+ </div>
819
+ </div>
820
+ );
821
+ }
822
+
823
+ function Detail({ test, onBack }) {
824
+ const richId = test.richId || test.id;
825
+ const richTest = window.RICH_TESTS?.[richId];
826
+ if (!richTest) return (
827
+ <div className="card" style={{ padding:30, textAlign:'center', color:'var(--fg3)', fontFamily:'var(--font-mono)', fontSize:13 }}>
828
+ Test not found in this run.
829
+ <div><button className="btn btn-ghost" onClick={onBack} style={{marginTop:14}}><Icon name="arrow-left"/>Back</button></div>
830
+ </div>
831
+ );
832
+ return (
833
+ <div>
834
+ <button className="btn btn-ghost" style={{ marginBottom: 12 }} onClick={onBack}>
835
+ <Icon name="arrow-left" />Back to overview
836
+ </button>
837
+ <div style={{ background:'var(--bg-elev)', border:'1px solid var(--line)', borderRadius:'var(--r-lg)', display:'flex', flexDirection:'column', minHeight:'calc(100vh - 220px)', overflow:'hidden' }}>
838
+ <DetailPane test={richTest} defaultTab="steps"/>
839
+ </div>
840
+ </div>
841
+ );
842
+ }
843
+
844
+ // Boot: wait for data-bridge to populate window globals, THEN mount React.
845
+ // Mount target is configurable so embedders can host the viewer without
846
+ // renaming their own #app root. The static report ships index.html with
847
+ // `<div id="app">` and no override, so this still defaults correctly.
848
+ window.__KENSHO_BOOT.then(() => {
849
+ const target = window.__KENSHO_MOUNT
850
+ || document.querySelector('[data-kensho-viewer-mount]')
851
+ || document.getElementById('app');
852
+ if (!target) { console.error('[kensho] no mount target found'); return; }
853
+ ReactDOM.createRoot(target).render(<App />);
854
+ });
855
+
856
+ // Tiny CSS for the toast animation — injected at boot so we don't need a
857
+ // separate stylesheet just for one keyframe.
858
+ const _styleEl = document.createElement('style');
859
+ _styleEl.textContent = `@keyframes kvToastIn { from { opacity:0; transform: translateY(-6px); } to { opacity:1; transform: translateY(0); } }`;
860
+ document.head.appendChild(_styleEl);