@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/LICENSE +21 -0
- package/README.md +19 -0
- package/assets/app.js +1396 -0
- package/assets/app.jsx +860 -0
- package/assets/charts.js +1156 -0
- package/assets/charts.jsx +593 -0
- package/assets/colors_and_type.css +219 -0
- package/assets/components.js +894 -0
- package/assets/components.jsx +520 -0
- package/assets/data-bridge.js +49 -0
- package/assets/data-bridge.jsx +55 -0
- package/assets/data-loader.js +543 -0
- package/assets/kaizen-mark.svg +5 -0
- package/assets/kensho-wordmark.svg +18 -0
- package/assets/pages.js +1472 -0
- package/assets/pages.jsx +822 -0
- package/assets/test-detail.js +1058 -0
- package/assets/test-detail.jsx +502 -0
- package/assets/tokens.css +357 -0
- package/assets/tree-detail.js +1705 -0
- package/assets/tree-detail.jsx +947 -0
- package/dist/component.d.ts +115 -0
- package/dist/component.js +698 -0
- package/index.html +35 -0
- package/package.json +65 -0
- package/scripts/build.js +117 -0
- package/src/component.d.ts +115 -0
- package/src/component.jsx +340 -0
- package/src/data.js +538 -0
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
/* global React */
|
|
2
|
+
const { useState } = React;
|
|
3
|
+
|
|
4
|
+
// Stable no-op context for the static-report path. Hooks must be called
|
|
5
|
+
// unconditionally; passing this when window.__KenshoContext is undefined
|
|
6
|
+
// keeps consumers seeing `null` and behaving exactly as the static OSS
|
|
7
|
+
// report did before the embed refactor.
|
|
8
|
+
const _kvCompNullCtx = React.createContext(null);
|
|
9
|
+
|
|
10
|
+
// Icon wrapper — renders the SVG directly from lucide's icon registry rather
|
|
11
|
+
// than dropping an `<i data-lucide="…">` placeholder + relying on a global
|
|
12
|
+
// `lucide.createIcons()` pass. The global pass scans the whole document and
|
|
13
|
+
// rewrites <i> elements anywhere it finds them — fine for the static report
|
|
14
|
+
// (we own the page) but breaks when the viewer is embedded in a host page
|
|
15
|
+
// that uses its own icon library: lucide rewrites the host's <i> tags, the
|
|
16
|
+
// host's React reconciler then crashes with "removeChild: not a child of
|
|
17
|
+
// this node". Self-contained inline SVG sidesteps the whole class of bugs.
|
|
18
|
+
const _kvIconCache = new Map();
|
|
19
|
+
// Inline-SVG fallbacks for the icons the viewer cares about, used when the
|
|
20
|
+
// lucide UMD bundle either failed to load or rearranged its registry shape.
|
|
21
|
+
// Without this fallback the theme-toggle, export, and run-id chip render as
|
|
22
|
+
// blank squares — the user sees an "empty box" between the breadcrumb and
|
|
23
|
+
// the Export button.
|
|
24
|
+
const _KV_ICON_FALLBACKS = {
|
|
25
|
+
'sun': '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>',
|
|
26
|
+
'moon': '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>',
|
|
27
|
+
'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
|
|
28
|
+
'external-link': '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
|
|
29
|
+
'chevron-right': '<polyline points="9 18 15 12 9 6"/>',
|
|
30
|
+
'chevron-down': '<polyline points="6 9 12 15 18 9"/>',
|
|
31
|
+
'chevron-up': '<polyline points="18 15 12 9 6 15"/>',
|
|
32
|
+
'chevron-left': '<polyline points="15 18 9 12 15 6"/>',
|
|
33
|
+
'circle': '<circle cx="12" cy="12" r="10"/>',
|
|
34
|
+
'layout-dashboard': '<rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/>',
|
|
35
|
+
'folder-tree': '<path d="M20 10a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-2.5a1 1 0 0 1-.8-.4l-.9-1.2A1 1 0 0 0 15 3h-2a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z"/><path d="M20 21a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1h-2.9a1 1 0 0 1-.88-.55l-.42-.85a1 1 0 0 0-.92-.6H13a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1Z"/><path d="M3 5a2 2 0 0 0 2 2h3"/><path d="M3 3v13a2 2 0 0 0 2 2h3"/>',
|
|
36
|
+
'bar-chart-3': '<path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/>',
|
|
37
|
+
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
|
|
38
|
+
'tags': '<path d="m20.59 13.41-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/>',
|
|
39
|
+
'activity': '<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>',
|
|
40
|
+
'list-tree': '<path d="M21 12h-8"/><path d="M21 6H8"/><path d="M21 18h-8"/><path d="M3 6v4c0 1.1.9 2 2 2h3"/><path d="M3 10v6c0 1.1.9 2 2 2h3"/>',
|
|
41
|
+
'package': '<path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>',
|
|
42
|
+
'history': '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
|
|
43
|
+
};
|
|
44
|
+
function _kvFallbackSvg(name, size) {
|
|
45
|
+
const inner = _KV_ICON_FALLBACKS[name];
|
|
46
|
+
if (!inner) return null;
|
|
47
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">${inner}</svg>`;
|
|
48
|
+
}
|
|
49
|
+
function lucideSvg(name, size) {
|
|
50
|
+
const cacheKey = name + ':' + size;
|
|
51
|
+
if (_kvIconCache.has(cacheKey)) return _kvIconCache.get(cacheKey);
|
|
52
|
+
// Prefer the inline fallback — lucide's UMD bundle has shifted shapes
|
|
53
|
+
// across versions (sometimes [tag,attrs,children], sometimes a string,
|
|
54
|
+
// sometimes an icon factory). The fallback set covers every icon the
|
|
55
|
+
// viewer renders, so we use it deterministically.
|
|
56
|
+
const fallback = _kvFallbackSvg(name, size);
|
|
57
|
+
if (fallback) {
|
|
58
|
+
_kvIconCache.set(cacheKey, fallback);
|
|
59
|
+
return fallback;
|
|
60
|
+
}
|
|
61
|
+
let svg = '';
|
|
62
|
+
try {
|
|
63
|
+
const reg = window.lucide && (window.lucide.icons || window.lucide);
|
|
64
|
+
const pascal = name.split('-').map(s => s[0].toUpperCase() + s.slice(1)).join('');
|
|
65
|
+
const node = reg && (reg[pascal] || reg[name]);
|
|
66
|
+
if (node && Array.isArray(node)) {
|
|
67
|
+
const [tag, attrs, children] = node;
|
|
68
|
+
const attrStr = Object.entries({
|
|
69
|
+
...attrs, width: size, height: size,
|
|
70
|
+
}).map(([k, v]) => `${k}="${v}"`).join(' ');
|
|
71
|
+
const inner = (children || []).map(c => {
|
|
72
|
+
const [ct, ca] = c;
|
|
73
|
+
const at = Object.entries(ca).map(([k, v]) => `${k}="${v}"`).join(' ');
|
|
74
|
+
return `<${ct} ${at}/>`;
|
|
75
|
+
}).join('');
|
|
76
|
+
if (inner) svg = `<${tag} ${attrStr}>${inner}</${tag}>`;
|
|
77
|
+
}
|
|
78
|
+
} catch (_e) {}
|
|
79
|
+
if (!svg) svg = `<svg width="${size}" height="${size}"></svg>`;
|
|
80
|
+
_kvIconCache.set(cacheKey, svg);
|
|
81
|
+
return svg;
|
|
82
|
+
}
|
|
83
|
+
const Icon = ({ name, size = 16 }) => (
|
|
84
|
+
<span
|
|
85
|
+
style={{ display:'inline-flex', alignItems:'center', justifyContent:'center', width: size, height: size, lineHeight: 0, verticalAlign:'middle' }}
|
|
86
|
+
dangerouslySetInnerHTML={{ __html: lucideSvg(name, size) }}
|
|
87
|
+
/>
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
function Sidebar({ active, onNav }) {
|
|
91
|
+
// Show a small badge next to "Flaky" when this run has flaky signals
|
|
92
|
+
// (retries > 0 or status === 'broken'). Helps users discover the feature.
|
|
93
|
+
const flakyCount = Object.values(window.RICH_TESTS || {}).filter(t => (t.retries > 0) || t.status === 'broken').length;
|
|
94
|
+
const items = [
|
|
95
|
+
['overview', 'Overview', 'layout-dashboard'],
|
|
96
|
+
['suites', 'Suites', 'folder-tree'],
|
|
97
|
+
['graphs', 'Graphs', 'bar-chart-3'],
|
|
98
|
+
['timeline', 'Timeline', 'clock'],
|
|
99
|
+
['categories', 'Categories', 'tags'],
|
|
100
|
+
['flaky', 'Flaky', 'activity', flakyCount],
|
|
101
|
+
['behaviors', 'Behaviors', 'list-tree'],
|
|
102
|
+
['packages', 'Packages', 'package'],
|
|
103
|
+
['history', 'History', 'history'],
|
|
104
|
+
];
|
|
105
|
+
// Pull host-provided extras from the embed context, if present. In the
|
|
106
|
+
// static-report path window.__KenshoContext is undefined → useContext on
|
|
107
|
+
// the local null-ctx returns null → no extras, sidebar renders as before.
|
|
108
|
+
const ctx = React.useContext(window.__KenshoContext || _kvCompNullCtx);
|
|
109
|
+
const extras = ctx?.extraSidebar || [];
|
|
110
|
+
return (
|
|
111
|
+
<aside className="sb">
|
|
112
|
+
<div className="brand">
|
|
113
|
+
<img src={(window.__KENSHO_ASSETS_BASE || 'assets/') + 'kaizen-mark.svg'} alt="" style={{ width: 28, height: 28 }} />
|
|
114
|
+
<div className="name" style={{ display:'inline-flex', alignItems:'baseline', gap:8 }}>
|
|
115
|
+
Kensho<span className="accent">·</span>
|
|
116
|
+
<span style={{
|
|
117
|
+
fontSize:13, fontWeight:500, color:'var(--brand-green-400)',
|
|
118
|
+
opacity:0.85, fontFamily:'sans-serif', letterSpacing:0,
|
|
119
|
+
}} title="改善 · kaizen — continuous improvement">改善</span>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
<nav>
|
|
123
|
+
{items.map(([id, label, icon, badge]) => (
|
|
124
|
+
<a key={id} className={active === id ? 'active' : ''} onClick={() => onNav(id)}>
|
|
125
|
+
<Icon name={icon} size={18} />
|
|
126
|
+
<span style={{ flex:1 }}>{label}</span>
|
|
127
|
+
{badge > 0 && (
|
|
128
|
+
<span style={{
|
|
129
|
+
fontFamily:'var(--font-mono)', fontSize:10, fontWeight:700,
|
|
130
|
+
padding:'1px 6px', borderRadius:999,
|
|
131
|
+
background:'var(--status-broken-bg)', color:'var(--status-broken-fg)',
|
|
132
|
+
border:'1px solid var(--status-broken-border)',
|
|
133
|
+
lineHeight:1.4,
|
|
134
|
+
}}>{badge}</span>
|
|
135
|
+
)}
|
|
136
|
+
</a>
|
|
137
|
+
))}
|
|
138
|
+
{/* Host-injected extras (Kaizen platform: History, Triage, AI clusters…). */}
|
|
139
|
+
{extras.length > 0 && (
|
|
140
|
+
<div style={{ height:1, background:'var(--dark-line)', margin:'6px 12px' }}/>
|
|
141
|
+
)}
|
|
142
|
+
{extras.map(ex => (
|
|
143
|
+
<a key={ex.id} className={active === ex.id ? 'active' : ''} onClick={() => onNav(ex.id)}>
|
|
144
|
+
<Icon name={ex.icon || 'circle'} size={18} />
|
|
145
|
+
<span style={{ flex:1 }}>{ex.label}</span>
|
|
146
|
+
</a>
|
|
147
|
+
))}
|
|
148
|
+
</nav>
|
|
149
|
+
<div className="foot">v0.4.2 · build 28a91f</div>
|
|
150
|
+
</aside>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function ThemeToggle() {
|
|
155
|
+
const [theme, setTheme] = React.useState(() => {
|
|
156
|
+
try { return localStorage.getItem('kensho-theme') || 'light'; } catch (e) { return 'light'; }
|
|
157
|
+
});
|
|
158
|
+
React.useEffect(() => {
|
|
159
|
+
document.documentElement.setAttribute('data-theme', theme);
|
|
160
|
+
try { localStorage.setItem('kensho-theme', theme); } catch (e) {}
|
|
161
|
+
// No global lucide rewrite — Icon renders inline SVG (see top of file).
|
|
162
|
+
}, [theme]);
|
|
163
|
+
const next = theme === 'dark' ? 'light' : 'dark';
|
|
164
|
+
return (
|
|
165
|
+
<button
|
|
166
|
+
className="theme-toggle"
|
|
167
|
+
onClick={() => setTheme(next)}
|
|
168
|
+
title={`Switch to ${next} theme`}
|
|
169
|
+
aria-label={`Switch to ${next} theme`}
|
|
170
|
+
>
|
|
171
|
+
<Icon name={theme === 'dark' ? 'sun' : 'moon'} size={15} />
|
|
172
|
+
</button>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function TopBar({ crumbs, onRerun, project }) {
|
|
177
|
+
const RUN = window.RUN;
|
|
178
|
+
const failed = (RUN?.counts?.failed || 0) + (RUN?.counts?.broken || 0) > 0;
|
|
179
|
+
const branch = RUN?.branch || 'local';
|
|
180
|
+
const id = RUN?.id || '';
|
|
181
|
+
const runUrl = RUN?.runUrl || '';
|
|
182
|
+
|
|
183
|
+
// Export — fetch data/index.json and trigger a download.
|
|
184
|
+
// Honors gzip via the browser's Accept-Encoding (Python http.server doesn't
|
|
185
|
+
// emit Content-Encoding for .json so we just save the raw bytes).
|
|
186
|
+
const onExport = async () => {
|
|
187
|
+
try {
|
|
188
|
+
const res = await fetch('data/index.json', { cache: 'no-cache' });
|
|
189
|
+
const blob = await res.blob();
|
|
190
|
+
const url = URL.createObjectURL(blob);
|
|
191
|
+
const a = document.createElement('a');
|
|
192
|
+
a.href = url;
|
|
193
|
+
a.download = `kensho-${(RUN?.id || 'run').replace(/^#/, '')}.json`;
|
|
194
|
+
document.body.appendChild(a);
|
|
195
|
+
a.click();
|
|
196
|
+
a.remove();
|
|
197
|
+
URL.revokeObjectURL(url);
|
|
198
|
+
} catch (e) {
|
|
199
|
+
console.error('[kensho] export failed:', e);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<div className="tb">
|
|
205
|
+
<div className="crumb">
|
|
206
|
+
{crumbs.map((c, i) => (
|
|
207
|
+
<React.Fragment key={i}>
|
|
208
|
+
{i > 0 && <Icon name="chevron-right" size={14} />}
|
|
209
|
+
{i === crumbs.length - 1 ? <b>{c}</b> : <span>{c}</span>}
|
|
210
|
+
</React.Fragment>
|
|
211
|
+
))}
|
|
212
|
+
</div>
|
|
213
|
+
<div className="grow" />
|
|
214
|
+
{/* Run summary chip — static badge, not a dropdown. Multi-run history
|
|
215
|
+
lives in Kaizen, so a chevron here would imply functionality the OSS
|
|
216
|
+
report doesn't have. Click copies the run id to clipboard. */}
|
|
217
|
+
<div
|
|
218
|
+
className={`runsel${failed ? ' fail' : ''}`}
|
|
219
|
+
title={`${branch} · ${id} · click to copy run id`}
|
|
220
|
+
onClick={() => navigator.clipboard?.writeText(id.replace(/^#/, ''))}
|
|
221
|
+
style={{ cursor:'pointer' }}
|
|
222
|
+
>
|
|
223
|
+
<span className="dot"></span>
|
|
224
|
+
<span>{branch} · {id}</span>
|
|
225
|
+
</div>
|
|
226
|
+
<ThemeToggle />
|
|
227
|
+
<button className="btn btn-secondary" onClick={onExport} title="Download data/index.json">
|
|
228
|
+
<Icon name="download" size={14} />Export
|
|
229
|
+
</button>
|
|
230
|
+
{/* Re-run failed — only shown when (a) something failed AND (b) we have
|
|
231
|
+
a CI run URL to open. In OSS Kensho there's no server to trigger a
|
|
232
|
+
re-run from; the most useful action is to jump to the CI workflow. */}
|
|
233
|
+
{failed && runUrl && (
|
|
234
|
+
<a className="btn btn-primary" href={runUrl} target="_blank" rel="noopener noreferrer" title="Open the CI workflow that produced this run">
|
|
235
|
+
<Icon name="external-link" size={14} />Re-run failed
|
|
236
|
+
</a>
|
|
237
|
+
)}
|
|
238
|
+
</div>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function StatusDonut({ counts }) {
|
|
243
|
+
const total = counts.passed + counts.failed + counts.broken + counts.skipped;
|
|
244
|
+
const pct = Math.round((counts.passed / total) * 100);
|
|
245
|
+
const C = 2 * Math.PI * 42;
|
|
246
|
+
const segs = [
|
|
247
|
+
[counts.passed, 'var(--status-passed)'],
|
|
248
|
+
[counts.failed, 'var(--status-failed)'],
|
|
249
|
+
[counts.broken, 'var(--status-broken)'],
|
|
250
|
+
[counts.skipped, 'var(--status-skipped)'],
|
|
251
|
+
];
|
|
252
|
+
let off = 0;
|
|
253
|
+
return (
|
|
254
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 18 }}>
|
|
255
|
+
<div style={{ position: 'relative', width: 150, height: 150 }}>
|
|
256
|
+
<svg viewBox="0 0 100 100" width="150" height="150" style={{ transform: 'rotate(-90deg)' }}>
|
|
257
|
+
<circle cx="50" cy="50" r="42" stroke="var(--bg-sunken)" strokeWidth="14" fill="none" />
|
|
258
|
+
{segs.map(([n, color], i) => {
|
|
259
|
+
const len = (n / total) * C;
|
|
260
|
+
const dash = `${len} ${C}`;
|
|
261
|
+
const dashoff = -off;
|
|
262
|
+
off += len;
|
|
263
|
+
return n > 0 ? (
|
|
264
|
+
<circle key={i} cx="50" cy="50" r="42" stroke={color} strokeWidth="14" fill="none"
|
|
265
|
+
strokeDasharray={dash} strokeDashoffset={dashoff} />
|
|
266
|
+
) : null;
|
|
267
|
+
})}
|
|
268
|
+
</svg>
|
|
269
|
+
<div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
|
270
|
+
<div style={{ fontFamily: 'var(--font-display)', fontSize: 36, fontWeight: 800, letterSpacing: '-0.025em', color: 'var(--fg1)' }}>{pct}%</div>
|
|
271
|
+
<div style={{ fontSize: 10, letterSpacing: '0.12em', textTransform: 'uppercase', color: 'var(--fg3)', fontWeight: 600 }}>passed</div>
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
275
|
+
{[['passed', counts.passed, 'var(--status-passed)'],
|
|
276
|
+
['failed', counts.failed, 'var(--status-failed)'],
|
|
277
|
+
['broken', counts.broken, 'var(--status-broken)'],
|
|
278
|
+
['skipped', counts.skipped, 'var(--status-skipped)']].map(([k, v, c]) => (
|
|
279
|
+
<div key={k} style={{ display: 'flex', alignItems: 'center', gap: 8, fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--fg2)' }}>
|
|
280
|
+
<span style={{ width: 10, height: 10, borderRadius: 2, background: c }}></span>
|
|
281
|
+
<b style={{ color: 'var(--fg1)', fontFamily: 'var(--font-display)', fontWeight: 700, marginRight: 4, fontVariantNumeric: 'tabular-nums' }}>{v}</b> {k}
|
|
282
|
+
</div>
|
|
283
|
+
))}
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function TrendChart() {
|
|
290
|
+
return (
|
|
291
|
+
<svg viewBox="0 0 400 160" preserveAspectRatio="none" style={{ display: 'block', width: '100%', height: 160 }}>
|
|
292
|
+
{[1,2,3,4,5,6,7].map(i => (
|
|
293
|
+
<line key={i} x1={i*50} y1="10" x2={i*50} y2="150" stroke="var(--line)" strokeDasharray="2 3" />
|
|
294
|
+
))}
|
|
295
|
+
<path d="M0,140 L50,90 L100,30 L150,30 L200,30 L250,30 L300,30 L350,55 L400,60 L400,150 L0,150 Z"
|
|
296
|
+
fill="var(--status-passed)" fillOpacity="0.7" />
|
|
297
|
+
<path d="M0,150 L50,140 L100,110 L150,110 L200,105 L250,105 L300,105 L350,100 L400,98 L400,150 L0,150 Z"
|
|
298
|
+
fill="var(--status-failed)" fillOpacity="0.75" />
|
|
299
|
+
{[0,50,100,150,200,250,300,350].map((x,i) => (
|
|
300
|
+
<text key={i} x={x+25} y="158" textAnchor="middle" fontFamily="var(--font-mono)" fontSize="9" fill="var(--fg3)">#{2455086+i}</text>
|
|
301
|
+
))}
|
|
302
|
+
</svg>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function SuiteBar({ name, segs, total }) {
|
|
307
|
+
return (
|
|
308
|
+
<div style={{ display: 'grid', gridTemplateColumns: '260px 1fr 50px', alignItems: 'center', gap: 14, padding: '6px 0' }}>
|
|
309
|
+
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12.5, color: 'var(--fg1)' }}>{name}</div>
|
|
310
|
+
<div style={{ height: 18, background: 'var(--bg-sunken)', borderRadius: 4, overflow: 'hidden', display: 'flex' }}>
|
|
311
|
+
{segs.map((s, i) => (
|
|
312
|
+
<div key={i} style={{
|
|
313
|
+
width: `${(s.n/total)*100}%`,
|
|
314
|
+
background: `var(--status-${s.k})`,
|
|
315
|
+
color: '#fff', fontSize: 11, fontWeight: 600, display: 'flex', alignItems: 'center', justifyContent: 'flex-end', paddingRight: 6,
|
|
316
|
+
}}>{s.n}</div>
|
|
317
|
+
))}
|
|
318
|
+
</div>
|
|
319
|
+
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg3)', textAlign: 'right' }}>{total}</div>
|
|
320
|
+
</div>
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function TestRow({ test, onOpen }) {
|
|
325
|
+
return (
|
|
326
|
+
<div className="trow" onClick={() => onOpen(test)}>
|
|
327
|
+
<div><span className={`s-icon ${test.status}`}>{test.status === 'passed' ? '✓' : test.status === 'failed' ? '✕' : test.status === 'broken' ? '!' : '⊘'}</span></div>
|
|
328
|
+
<div className="id"><span className="ns">{test.ns}</span>{test.name}{test.retries ? <span style={{ marginLeft: 8, fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--status-broken)' }}>↻ {test.retries} retries</span> : null}</div>
|
|
329
|
+
<div className="dur">{test.duration}</div>
|
|
330
|
+
<div><span className={`badge b-${test.status}`}><span className="dot"></span>{test.status}</span></div>
|
|
331
|
+
<div className="dur">{test.last}</div>
|
|
332
|
+
<div style={{ color: 'var(--fg4)' }}>›</div>
|
|
333
|
+
</div>
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function EnvTable({ env }) {
|
|
338
|
+
return (
|
|
339
|
+
<div className="env">
|
|
340
|
+
{env.map(([k, v]) => (
|
|
341
|
+
<React.Fragment key={k}>
|
|
342
|
+
<div className="k">{k}</div>
|
|
343
|
+
<div className="v">{v}</div>
|
|
344
|
+
</React.Fragment>
|
|
345
|
+
))}
|
|
346
|
+
</div>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function StepTree({ steps }) {
|
|
351
|
+
return (
|
|
352
|
+
<div>
|
|
353
|
+
{steps.map((s, i) => (
|
|
354
|
+
<div key={i} className={`step ${s.status}`}>
|
|
355
|
+
<div className="head">
|
|
356
|
+
<span className={`s-icon ${s.status}`} style={{ width: 14, height: 14 }}>{s.status === 'passed' ? '✓' : s.status === 'failed' ? '✕' : '!'}</span>
|
|
357
|
+
<span className="name">{s.name}</span>
|
|
358
|
+
<span className="dur">{s.duration}</span>
|
|
359
|
+
</div>
|
|
360
|
+
{s.body && <div className="body">{s.body}</div>}
|
|
361
|
+
{s.children && <div className="children"><StepTree steps={s.children} /></div>}
|
|
362
|
+
</div>
|
|
363
|
+
))}
|
|
364
|
+
</div>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function LogPanel({ lines }) {
|
|
369
|
+
return (
|
|
370
|
+
<div className="log">
|
|
371
|
+
{lines.map((l, i) => (
|
|
372
|
+
<div key={i}>
|
|
373
|
+
<span className="ts">{l.ts}</span>
|
|
374
|
+
<span className={`lvl-${l.lvl}`}>{l.lvl.toUpperCase()}</span>{' '}
|
|
375
|
+
<span>{l.msg}</span>
|
|
376
|
+
</div>
|
|
377
|
+
))}
|
|
378
|
+
</div>
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// =============================================================
|
|
383
|
+
// SeverityDistribution — horizontal bar viz of test cases by
|
|
384
|
+
// severity. Each row: severity label · stretched bar · count.
|
|
385
|
+
// Blocker/critical use failure tones, Normal uses warning amber,
|
|
386
|
+
// Minor/trivial use muted gray. Rows with zero are hidden.
|
|
387
|
+
// =============================================================
|
|
388
|
+
function SeverityDistribution({ tests }) {
|
|
389
|
+
const ROWS = [
|
|
390
|
+
{ k:'blocker', label:'Blocker', fg:'var(--status-failed)', bg:'var(--status-failed)' },
|
|
391
|
+
{ k:'critical', label:'Critical', fg:'var(--status-failed-fg)', bg:'#E5848A' },
|
|
392
|
+
{ k:'normal', label:'Normal', fg:'var(--status-broken-fg)', bg:'var(--status-broken)' },
|
|
393
|
+
{ k:'minor', label:'Minor', fg:'var(--fg2)', bg:'var(--fg3)' },
|
|
394
|
+
{ k:'trivial', label:'Trivial', fg:'var(--fg3)', bg:'var(--fg4)' },
|
|
395
|
+
];
|
|
396
|
+
const counts = { blocker:0, critical:0, normal:0, minor:0, trivial:0 };
|
|
397
|
+
for (const t of tests) {
|
|
398
|
+
const sev = (t.severity || 'normal').toLowerCase();
|
|
399
|
+
if (counts[sev] != null) counts[sev]++;
|
|
400
|
+
else counts.normal++;
|
|
401
|
+
}
|
|
402
|
+
const max = Math.max(1, ...Object.values(counts));
|
|
403
|
+
const visible = ROWS.filter(r => counts[r.k] > 0);
|
|
404
|
+
if (visible.length === 0) {
|
|
405
|
+
return <div style={{ padding:'14px 0', color:'var(--fg3)', fontFamily:'var(--font-mono)', fontSize:12 }}>No severity metadata on tests.</div>;
|
|
406
|
+
}
|
|
407
|
+
return (
|
|
408
|
+
<div style={{ display:'flex', flexDirection:'column', gap:10 }}>
|
|
409
|
+
{visible.map(r => {
|
|
410
|
+
const n = counts[r.k];
|
|
411
|
+
const w = (n / max) * 100;
|
|
412
|
+
return (
|
|
413
|
+
<div key={r.k} style={{ display:'grid', gridTemplateColumns:'80px 1fr 40px', gap:14, alignItems:'center' }}>
|
|
414
|
+
<div style={{ fontFamily:'var(--font-body)', fontSize:13, fontWeight:600, color:r.fg }}>{r.label}</div>
|
|
415
|
+
<div style={{ position:'relative', height:14, background:'var(--bg-sunken)', borderRadius:999, overflow:'hidden' }}>
|
|
416
|
+
<div style={{
|
|
417
|
+
position:'absolute', inset:0, width: `${w}%`, background:r.bg, borderRadius:999,
|
|
418
|
+
transition:'width var(--dur-fast)',
|
|
419
|
+
}}/>
|
|
420
|
+
</div>
|
|
421
|
+
<div style={{ fontFamily:'var(--font-mono)', fontSize:13, fontWeight:700, color:'var(--fg1)', textAlign:'right', fontVariantNumeric:'tabular-nums' }}>{n}</div>
|
|
422
|
+
</div>
|
|
423
|
+
);
|
|
424
|
+
})}
|
|
425
|
+
</div>
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// =============================================================
|
|
430
|
+
// SlowestTestsList — top N tests by duration. Each row mirrors
|
|
431
|
+
// the design from the Allure / TestRail screenshots: status pill
|
|
432
|
+
// on the left, test name + suite path centered, duration right.
|
|
433
|
+
// Click row → opens the test detail.
|
|
434
|
+
// =============================================================
|
|
435
|
+
function SlowestTestsList({ tests, limit = 6, onOpen }) {
|
|
436
|
+
const ranked = [...tests]
|
|
437
|
+
.filter(t => t.durMs > 0)
|
|
438
|
+
.sort((a,b) => b.durMs - a.durMs)
|
|
439
|
+
.slice(0, limit);
|
|
440
|
+
if (ranked.length === 0) {
|
|
441
|
+
return <div style={{ padding:'14px 0', color:'var(--fg3)', fontFamily:'var(--font-mono)', fontSize:12 }}>No timing data captured for this run.</div>;
|
|
442
|
+
}
|
|
443
|
+
const PILL = {
|
|
444
|
+
passed: { bg:'var(--status-passed-bg)', fg:'var(--status-passed)', label:'PASS' },
|
|
445
|
+
failed: { bg:'var(--status-failed-bg)', fg:'var(--status-failed)', label:'FAIL' },
|
|
446
|
+
broken: { bg:'var(--status-broken-bg)', fg:'var(--status-broken)', label:'BROKEN' },
|
|
447
|
+
skipped: { bg:'var(--status-skipped-bg)', fg:'var(--status-skipped-fg)', label:'SKIP' },
|
|
448
|
+
};
|
|
449
|
+
return (
|
|
450
|
+
<div style={{ display:'flex', flexDirection:'column' }}>
|
|
451
|
+
{ranked.map((t, i) => {
|
|
452
|
+
const p = PILL[t.status] || PILL.passed;
|
|
453
|
+
return (
|
|
454
|
+
<div
|
|
455
|
+
key={t.id}
|
|
456
|
+
onClick={() => onOpen?.({ ns:'', name:t.name, status:t.status, duration:t.dur, retries:t.retries, richId:t.id })}
|
|
457
|
+
style={{
|
|
458
|
+
display:'grid', gridTemplateColumns:'62px 1fr auto', alignItems:'center', gap:14,
|
|
459
|
+
padding:'10px 4px', cursor:'pointer',
|
|
460
|
+
borderTop: i ? '1px solid var(--line)' : 'none',
|
|
461
|
+
transition:'background var(--dur-fast)',
|
|
462
|
+
}}
|
|
463
|
+
onMouseEnter={e => e.currentTarget.style.background='var(--bg-hover)'}
|
|
464
|
+
onMouseLeave={e => e.currentTarget.style.background='transparent'}
|
|
465
|
+
>
|
|
466
|
+
<span style={{
|
|
467
|
+
display:'inline-flex', alignItems:'center', justifyContent:'center', padding:'3px 0',
|
|
468
|
+
background:p.bg, color:p.fg, fontFamily:'var(--font-mono)', fontSize:10.5, fontWeight:700,
|
|
469
|
+
letterSpacing:0.5, borderRadius:4,
|
|
470
|
+
}}>{p.label}</span>
|
|
471
|
+
<div style={{ minWidth:0 }}>
|
|
472
|
+
<div style={{ fontFamily:'var(--font-body)', fontSize:13, color:'var(--fg1)', fontWeight:500, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
|
|
473
|
+
{t.suite ? <span style={{ color:'var(--fg3)' }}>{t.suite} › </span> : null}{t.name}
|
|
474
|
+
</div>
|
|
475
|
+
{t.file && (
|
|
476
|
+
<div style={{ fontFamily:'var(--font-mono)', fontSize:11, color:'var(--fg3)', marginTop:2, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{t.file}</div>
|
|
477
|
+
)}
|
|
478
|
+
</div>
|
|
479
|
+
<div style={{ fontFamily:'var(--font-mono)', fontSize:13, fontWeight:700, color:'var(--fg1)', fontVariantNumeric:'tabular-nums' }}>{t.dur}</div>
|
|
480
|
+
</div>
|
|
481
|
+
);
|
|
482
|
+
})}
|
|
483
|
+
</div>
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// =============================================================
|
|
488
|
+
// HighlightStat — accent-bordered "hero" stat card. Used on the
|
|
489
|
+
// Graphs page banner: Slowest test · Most retried · Top failure
|
|
490
|
+
// category. Big numeric, subtle subtitle, color-coded left border.
|
|
491
|
+
// =============================================================
|
|
492
|
+
function HighlightStat({ overline, value, valueColor, subtitle, accent, onClick, title }) {
|
|
493
|
+
return (
|
|
494
|
+
<div
|
|
495
|
+
onClick={onClick}
|
|
496
|
+
title={title}
|
|
497
|
+
style={{
|
|
498
|
+
position:'relative', padding:'18px 22px',
|
|
499
|
+
background:'var(--bg-elev)', border:'1px solid var(--line)', borderRadius:12,
|
|
500
|
+
cursor: onClick ? 'pointer' : 'default', overflow:'hidden',
|
|
501
|
+
transition:'transform var(--dur-fast), border-color var(--dur-fast)',
|
|
502
|
+
}}
|
|
503
|
+
onMouseEnter={onClick ? e => { e.currentTarget.style.borderColor = accent; e.currentTarget.style.transform = 'translateY(-1px)'; } : undefined}
|
|
504
|
+
onMouseLeave={onClick ? e => { e.currentTarget.style.borderColor = 'var(--line)'; e.currentTarget.style.transform = 'translateY(0)'; } : undefined}
|
|
505
|
+
>
|
|
506
|
+
<div style={{ position:'absolute', left:0, top:0, bottom:0, width:4, background:accent }}/>
|
|
507
|
+
<div style={{ fontFamily:'var(--font-mono)', fontSize:10.5, color:'var(--fg3)', letterSpacing:'.14em', textTransform:'uppercase', marginBottom:8 }}>
|
|
508
|
+
{overline}
|
|
509
|
+
</div>
|
|
510
|
+
<div style={{ fontFamily:'var(--font-display)', fontSize:38, fontWeight:700, letterSpacing:-0.8, color:valueColor || 'var(--fg1)', lineHeight:1, marginBottom:10 }}>
|
|
511
|
+
{value}
|
|
512
|
+
</div>
|
|
513
|
+
<div style={{ fontFamily:'var(--font-body)', fontSize:13, color:'var(--fg2)', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
|
|
514
|
+
{subtitle}
|
|
515
|
+
</div>
|
|
516
|
+
</div>
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
Object.assign(window, { Sidebar, TopBar, StatusDonut, TrendChart, SuiteBar, TestRow, EnvTable, StepTree, LogPanel, Icon, SeverityDistribution, SlowestTestsList, HighlightStat });
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/* Auto-generated from data-bridge.jsx by packages/viewer/scripts/build.js. Edit the .jsx — DO NOT edit this file. */
|
|
2
|
+
// ============================================================
|
|
3
|
+
// Kensho viewer — data bridge (static-report adapter).
|
|
4
|
+
//
|
|
5
|
+
// Thin shim that calls the pure `loadKenshoData(...)` from src/data.js
|
|
6
|
+
// (the same loader the embeddable React component uses) and writes
|
|
7
|
+
// the result to window globals so the static-report's pre-compiled
|
|
8
|
+
// .js components keep working unchanged. Boot promise lives at
|
|
9
|
+
// window.__KENSHO_BOOT — index.html awaits it before mounting.
|
|
10
|
+
// ============================================================
|
|
11
|
+
|
|
12
|
+
(function () {
|
|
13
|
+
// The static-report bundle inlines src/data.js below at build time. If
|
|
14
|
+
// that hasn't happened (e.g. running without scripts/build.js), fall back
|
|
15
|
+
// to a minimal noop so we don't crash the page silently.
|
|
16
|
+
const loader = window.__KENSHO_LOAD_DATA;
|
|
17
|
+
if (typeof loader !== 'function') {
|
|
18
|
+
console.error('[kensho] data loader missing — was the viewer built with scripts/build.js?');
|
|
19
|
+
window.__KENSHO_BOOT = Promise.reject(new Error('data loader missing'));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
async function init() {
|
|
23
|
+
const state = await loader('data');
|
|
24
|
+
Object.assign(window, {
|
|
25
|
+
KENSHO_INDEX: state.kenshoIndex,
|
|
26
|
+
KENSHO_REPORT_TYPE: state.reportType,
|
|
27
|
+
RUN: state.run,
|
|
28
|
+
ENV: state.env,
|
|
29
|
+
SUITES: state.suites,
|
|
30
|
+
TESTS: state.tests,
|
|
31
|
+
RICH_TESTS: state.richTests,
|
|
32
|
+
SUITE_TREE: state.suiteTree,
|
|
33
|
+
BEHAVIOR_TREE: state.behaviorTree,
|
|
34
|
+
CATEGORIES: state.categories,
|
|
35
|
+
TIMELINE_TESTS: state.timelineTests,
|
|
36
|
+
TREND_RUNS: state.trendRuns,
|
|
37
|
+
HISTOGRAM: state.histogram,
|
|
38
|
+
HISTORY_RUNS: state.historyRuns,
|
|
39
|
+
_kenshoEnsureCase: state.ensureCaseLoaded,
|
|
40
|
+
_kenshoLoadCase: state.loadCase,
|
|
41
|
+
_kenshoFmtDuration: state.fmtDuration,
|
|
42
|
+
_kenshoRelTime: state.relTime
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
window.__KENSHO_BOOT = init().catch(err => {
|
|
46
|
+
console.error('[kensho] boot failed:', err);
|
|
47
|
+
document.body.innerHTML = '<div style="font-family:sans-serif;padding:40px;color:#E5484D">' + '<h2>Failed to load report data</h2>' + '<pre style="background:#fcebec;padding:14px;border-radius:6px;">' + (err?.message || err) + '</pre>' + '<p>Make sure <code>data/index.json</code> exists and the report is served (not opened via file://).</p>' + '</div>';
|
|
48
|
+
});
|
|
49
|
+
})();
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Kensho viewer — data bridge (static-report adapter).
|
|
3
|
+
//
|
|
4
|
+
// Thin shim that calls the pure `loadKenshoData(...)` from src/data.js
|
|
5
|
+
// (the same loader the embeddable React component uses) and writes
|
|
6
|
+
// the result to window globals so the static-report's pre-compiled
|
|
7
|
+
// .js components keep working unchanged. Boot promise lives at
|
|
8
|
+
// window.__KENSHO_BOOT — index.html awaits it before mounting.
|
|
9
|
+
// ============================================================
|
|
10
|
+
|
|
11
|
+
(function () {
|
|
12
|
+
// The static-report bundle inlines src/data.js below at build time. If
|
|
13
|
+
// that hasn't happened (e.g. running without scripts/build.js), fall back
|
|
14
|
+
// to a minimal noop so we don't crash the page silently.
|
|
15
|
+
const loader = window.__KENSHO_LOAD_DATA;
|
|
16
|
+
if (typeof loader !== 'function') {
|
|
17
|
+
console.error('[kensho] data loader missing — was the viewer built with scripts/build.js?');
|
|
18
|
+
window.__KENSHO_BOOT = Promise.reject(new Error('data loader missing'));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function init() {
|
|
23
|
+
const state = await loader('data');
|
|
24
|
+
|
|
25
|
+
Object.assign(window, {
|
|
26
|
+
KENSHO_INDEX: state.kenshoIndex,
|
|
27
|
+
KENSHO_REPORT_TYPE: state.reportType,
|
|
28
|
+
RUN: state.run,
|
|
29
|
+
ENV: state.env,
|
|
30
|
+
SUITES: state.suites,
|
|
31
|
+
TESTS: state.tests,
|
|
32
|
+
RICH_TESTS: state.richTests,
|
|
33
|
+
SUITE_TREE: state.suiteTree,
|
|
34
|
+
BEHAVIOR_TREE: state.behaviorTree,
|
|
35
|
+
CATEGORIES: state.categories,
|
|
36
|
+
TIMELINE_TESTS: state.timelineTests,
|
|
37
|
+
TREND_RUNS: state.trendRuns,
|
|
38
|
+
HISTOGRAM: state.histogram,
|
|
39
|
+
HISTORY_RUNS: state.historyRuns,
|
|
40
|
+
_kenshoEnsureCase: state.ensureCaseLoaded,
|
|
41
|
+
_kenshoLoadCase: state.loadCase,
|
|
42
|
+
_kenshoFmtDuration: state.fmtDuration,
|
|
43
|
+
_kenshoRelTime: state.relTime,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
window.__KENSHO_BOOT = init().catch(err => {
|
|
48
|
+
console.error('[kensho] boot failed:', err);
|
|
49
|
+
document.body.innerHTML = '<div style="font-family:sans-serif;padding:40px;color:#E5484D">' +
|
|
50
|
+
'<h2>Failed to load report data</h2>' +
|
|
51
|
+
'<pre style="background:#fcebec;padding:14px;border-radius:6px;">' + (err?.message || err) + '</pre>' +
|
|
52
|
+
'<p>Make sure <code>data/index.json</code> exists and the report is served (not opened via file://).</p>' +
|
|
53
|
+
'</div>';
|
|
54
|
+
});
|
|
55
|
+
})();
|