@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,947 @@
|
|
|
1
|
+
/* global React, RetryWaterfall, TestHeader, StepTreeV2 */
|
|
2
|
+
const { useState: useStateT } = React;
|
|
3
|
+
|
|
4
|
+
// Stable no-op context for the static-report path. We always call
|
|
5
|
+
// React.useContext(...) (Rules of Hooks) but pass this null-ish context when
|
|
6
|
+
// the embed wrapper isn't present, so consumers see `null` and behave as
|
|
7
|
+
// before.
|
|
8
|
+
const _kvNullCtx = React.createContext(null);
|
|
9
|
+
|
|
10
|
+
// RICH_TESTS is owned by data-bridge.jsx and exposed on window.RICH_TESTS
|
|
11
|
+
// from real Kensho run data. Do NOT redefine it here.
|
|
12
|
+
|
|
13
|
+
// ============== Tree node ==============
|
|
14
|
+
function TreeNode({ node, depth, openIds, onToggle, selectedId, onSelect, leafLabel }) {
|
|
15
|
+
const isLeaf = !node.children;
|
|
16
|
+
const open = openIds.has(node.id);
|
|
17
|
+
const test = isLeaf ? window.RICH_TESTS[node.testId] : null;
|
|
18
|
+
const sumCounts = node.counts || {};
|
|
19
|
+
const indent = 12 + depth * 16;
|
|
20
|
+
return (
|
|
21
|
+
<div>
|
|
22
|
+
<div
|
|
23
|
+
onClick={() => isLeaf ? onSelect(node.testId) : onToggle(node.id)}
|
|
24
|
+
style={{
|
|
25
|
+
display:'grid',
|
|
26
|
+
gridTemplateColumns:'14px 1fr auto',
|
|
27
|
+
alignItems:'center',
|
|
28
|
+
gap:10,
|
|
29
|
+
padding:'7px 14px',
|
|
30
|
+
paddingLeft: indent,
|
|
31
|
+
cursor:'pointer',
|
|
32
|
+
background: selectedId === node.testId ? 'var(--accent-soft)' : 'transparent',
|
|
33
|
+
borderLeft: selectedId === node.testId ? '2px solid var(--brand-blue-500)' : '2px solid transparent',
|
|
34
|
+
fontSize: 13,
|
|
35
|
+
transition: 'background var(--dur-fast)',
|
|
36
|
+
}}
|
|
37
|
+
onMouseEnter={e => { if (selectedId !== node.testId) e.currentTarget.style.background = 'var(--bg-hover)'; }}
|
|
38
|
+
onMouseLeave={e => { if (selectedId !== node.testId) e.currentTarget.style.background = 'transparent'; }}
|
|
39
|
+
>
|
|
40
|
+
{isLeaf ? (
|
|
41
|
+
<span className={`s-icon ${test.status}`} style={{ width:14, height:14, fontSize:9 }}>
|
|
42
|
+
{test.status === 'passed' ? '✓' : test.status === 'failed' ? '✕' : test.status === 'broken' ? '!' : '⊘'}
|
|
43
|
+
</span>
|
|
44
|
+
) : (
|
|
45
|
+
<span style={{ color:'var(--fg3)', fontFamily:'var(--font-mono)', fontSize:12, lineHeight:1, transform: open ? 'rotate(90deg)' : 'none', transition:'transform var(--dur-fast)' }}>›</span>
|
|
46
|
+
)}
|
|
47
|
+
<span style={{ fontFamily: isLeaf ? 'var(--font-body)' : 'var(--font-mono)', fontSize: isLeaf ? 13 : 12.5, fontWeight: isLeaf ? 500 : 600, color:'var(--fg1)', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
|
|
48
|
+
{isLeaf && <span style={{ color:'var(--fg3)', marginRight:6, fontFamily:'var(--font-mono)', fontSize:11 }}>#{test.order}</span>}
|
|
49
|
+
{leafLabel && isLeaf ? leafLabel(test) : (isLeaf ? test.name : node.name)}
|
|
50
|
+
{isLeaf && test.retries > 0 && <span style={{ color: 'var(--status-broken)', marginLeft:6, fontSize:11, fontFamily:'var(--font-mono)' }}>↻{test.retries}</span>}
|
|
51
|
+
</span>
|
|
52
|
+
{isLeaf ? (
|
|
53
|
+
<span style={{ fontFamily:'var(--font-mono)', fontSize:11, color:'var(--fg3)', fontVariantNumeric:'tabular-nums' }}>{test.dur}</span>
|
|
54
|
+
) : (
|
|
55
|
+
<div style={{ display:'flex', gap:3 }}>
|
|
56
|
+
{sumCounts.failed > 0 && <span style={{ background:'var(--status-failed)', color:'#fff', fontSize:10, fontWeight:700, padding:'1px 6px', borderRadius:3 }}>{sumCounts.failed}</span>}
|
|
57
|
+
{sumCounts.broken > 0 && <span style={{ background:'var(--status-broken)', color:'#fff', fontSize:10, fontWeight:700, padding:'1px 6px', borderRadius:3 }}>{sumCounts.broken}</span>}
|
|
58
|
+
{sumCounts.passed > 0 && <span style={{ background:'var(--status-passed)', color:'#fff', fontSize:10, fontWeight:700, padding:'1px 6px', borderRadius:3 }}>{sumCounts.passed}</span>}
|
|
59
|
+
{sumCounts.skipped > 0 && <span style={{ background:'var(--status-skipped)', color:'#fff', fontSize:10, fontWeight:700, padding:'1px 6px', borderRadius:3 }}>{sumCounts.skipped}</span>}
|
|
60
|
+
</div>
|
|
61
|
+
)}
|
|
62
|
+
</div>
|
|
63
|
+
{!isLeaf && open && (
|
|
64
|
+
<div>
|
|
65
|
+
{node.children.map(c => (
|
|
66
|
+
<TreeNode key={c.id} node={c} depth={depth+1} openIds={openIds} onToggle={onToggle} selectedId={selectedId} onSelect={onSelect} leafLabel={leafLabel}/>
|
|
67
|
+
))}
|
|
68
|
+
</div>
|
|
69
|
+
)}
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ============== Status filter chips ==============
|
|
75
|
+
function StatusFilters({ counts, active, onToggle }) {
|
|
76
|
+
const all = ['passed','failed','broken','skipped','unknown'];
|
|
77
|
+
const glyph = { passed:'✓', failed:'✕', broken:'!', skipped:'⊘', unknown:'◌' };
|
|
78
|
+
return (
|
|
79
|
+
<div style={{ display:'flex', gap:6, alignItems:'center' }}>
|
|
80
|
+
<span style={{ fontSize:11, letterSpacing:'.12em', textTransform:'uppercase', fontWeight:600, color:'var(--fg3)', marginRight:4 }}>Status</span>
|
|
81
|
+
{all.map(k => (
|
|
82
|
+
<button key={k} onClick={() => onToggle(k)} style={{
|
|
83
|
+
display:'inline-flex', alignItems:'center', gap:5, height:24, padding:'0 8px', border:'1px solid var(--line)', borderRadius:6,
|
|
84
|
+
background: active.has(k) ? `var(--status-${k})` : '#fff',
|
|
85
|
+
color: active.has(k) ? '#fff' : 'var(--fg2)',
|
|
86
|
+
fontFamily:'var(--font-mono)', fontSize:11, fontWeight:600, cursor:'pointer', fontVariantNumeric:'tabular-nums',
|
|
87
|
+
transition:'background var(--dur-fast),color var(--dur-fast)',
|
|
88
|
+
}}>
|
|
89
|
+
<span style={{ fontSize: 9 }}>{glyph[k]}</span>{counts[k] || 0}
|
|
90
|
+
</button>
|
|
91
|
+
))}
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ============== Detail pane (right) ==============
|
|
97
|
+
|
|
98
|
+
// Steps are already mapped to the V2 StepTreeV2 shape by data-bridge.jsx
|
|
99
|
+
// (each step has { name, status, duration, type, logs?, children?, payload?,
|
|
100
|
+
// assertion?, request?, response? }). This is now a near-identity pass-through —
|
|
101
|
+
// we only recurse into children. No name-guessing, no synthetic logs/screenshots.
|
|
102
|
+
function enrichSteps(steps, _test) {
|
|
103
|
+
return steps.map(s => ({
|
|
104
|
+
...s,
|
|
105
|
+
...(s.children ? { children: enrichSteps(s.children, _test) } : {}),
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function DetailPane({ test, defaultTab='steps' }) {
|
|
110
|
+
const [tab, setTab] = useStateT(defaultTab);
|
|
111
|
+
const [loaded, setLoaded] = useStateT(0);
|
|
112
|
+
const scrollRef = React.useRef(null);
|
|
113
|
+
// Embed-mode extras. Static-report path: __KenshoContext is undefined →
|
|
114
|
+
// ctx === null → no extras. Use a stable no-op context so the hook order
|
|
115
|
+
// stays consistent in both modes.
|
|
116
|
+
const _kvCtx = React.useContext(window.__KenshoContext || _kvNullCtx);
|
|
117
|
+
const extraTabs = _kvCtx?.extraTabs || [];
|
|
118
|
+
// reset scroll to top whenever the selected test changes — otherwise the
|
|
119
|
+
// user can't tell the panel updated (and may think they hit a blank page)
|
|
120
|
+
React.useEffect(() => { if (scrollRef.current) scrollRef.current.scrollTop = 0; }, [test?.id]);
|
|
121
|
+
|
|
122
|
+
// Lazy-load full case data (steps/error/attachments/history) when the
|
|
123
|
+
// selection changes. data-bridge mutates the richTest in place and sets
|
|
124
|
+
// _stepsLoaded=true; we bump `loaded` to force a re-render once it's ready.
|
|
125
|
+
React.useEffect(() => {
|
|
126
|
+
if (!test) return;
|
|
127
|
+
if (test._stepsLoaded) { setLoaded(l => l + 1); return; }
|
|
128
|
+
window._kenshoEnsureCase(test).then(() => setLoaded(l => l + 1));
|
|
129
|
+
}, [test?.id]);
|
|
130
|
+
|
|
131
|
+
if (!test) {
|
|
132
|
+
// Useful empty state — shows run-level summary so the right pane isn't
|
|
133
|
+
// visually dead while the user explores the tree on the left.
|
|
134
|
+
const all = Object.values(window.RICH_TESTS || {});
|
|
135
|
+
const counts = { passed:0, failed:0, broken:0, skipped:0 };
|
|
136
|
+
for (const t of all) counts[t.status] = (counts[t.status] || 0) + 1;
|
|
137
|
+
const RUN = window.RUN || {};
|
|
138
|
+
const ROWS = [
|
|
139
|
+
['passed', counts.passed, 'var(--status-passed)'],
|
|
140
|
+
['failed', counts.failed, 'var(--status-failed)'],
|
|
141
|
+
['broken', counts.broken, 'var(--status-broken)'],
|
|
142
|
+
['skipped', counts.skipped, 'var(--status-skipped)'],
|
|
143
|
+
].filter(r => r[1] > 0);
|
|
144
|
+
return (
|
|
145
|
+
<div style={{ flex:1, padding:'56px 40px', overflow:'auto' }}>
|
|
146
|
+
<div style={{ maxWidth:420, margin:'0 auto' }}>
|
|
147
|
+
<div className="k-overline" style={{ marginBottom:6 }}>This run</div>
|
|
148
|
+
<div style={{ fontFamily:'var(--font-display)', fontSize:32, fontWeight:700, letterSpacing:-0.5, color:'var(--fg1)', marginBottom:6 }}>
|
|
149
|
+
{all.length} <span style={{ color:'var(--fg3)', fontWeight:500 }}>test{all.length === 1 ? '' : 's'}</span>
|
|
150
|
+
</div>
|
|
151
|
+
<div style={{ fontFamily:'var(--font-mono)', fontSize:12, color:'var(--fg3)', marginBottom:24 }}>
|
|
152
|
+
{RUN.duration || ''}{RUN.duration ? ' · ' : ''}{RUN.branch || ''}{RUN.commit ? ' · ' + RUN.commit : ''}
|
|
153
|
+
</div>
|
|
154
|
+
<div style={{ display:'flex', flexDirection:'column', gap:8, marginBottom:28 }}>
|
|
155
|
+
{ROWS.map(([k, n, color]) => (
|
|
156
|
+
<div key={k} style={{ display:'grid', gridTemplateColumns:'14px 80px 1fr 40px', gap:10, alignItems:'center', fontFamily:'var(--font-mono)', fontSize:12 }}>
|
|
157
|
+
<span style={{ width:10, height:10, borderRadius:2, background:color }}/>
|
|
158
|
+
<span style={{ color:'var(--fg2)', textTransform:'uppercase', letterSpacing:'.08em', fontSize:10.5 }}>{k}</span>
|
|
159
|
+
<div style={{ height:8, background:'var(--bg-sunken)', borderRadius:2, overflow:'hidden' }}>
|
|
160
|
+
<div style={{ width: `${(n/all.length)*100}%`, height:'100%', background:color }}/>
|
|
161
|
+
</div>
|
|
162
|
+
<span style={{ color:'var(--fg1)', fontWeight:600, textAlign:'right', fontVariantNumeric:'tabular-nums' }}>{n}</span>
|
|
163
|
+
</div>
|
|
164
|
+
))}
|
|
165
|
+
</div>
|
|
166
|
+
<div style={{
|
|
167
|
+
border:'1px dashed var(--line)', borderRadius:8, padding:'18px 20px',
|
|
168
|
+
color:'var(--fg2)', fontFamily:'var(--font-body)', fontSize:13, lineHeight:1.55,
|
|
169
|
+
}}>
|
|
170
|
+
Pick a test from the tree on the left to inspect its steps, attachments, history, and metadata.
|
|
171
|
+
<div style={{ marginTop:6, fontFamily:'var(--font-mono)', fontSize:11, color:'var(--fg3)' }}>
|
|
172
|
+
tip: search by name above, or click <b>Expand all</b> to flatten the tree.
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// adapt the rich-tree test shape into the TestHeader props
|
|
181
|
+
const headerTest = {
|
|
182
|
+
id: test.id,
|
|
183
|
+
title: test.name,
|
|
184
|
+
status: test.status,
|
|
185
|
+
duration: test.dur,
|
|
186
|
+
retries: test.retries,
|
|
187
|
+
severity: test.severity,
|
|
188
|
+
owner: (test.owner || '').replace(/^@/,''), // blank → row hides
|
|
189
|
+
suite: test.suite, // blank → row hides
|
|
190
|
+
epic: test.epic,
|
|
191
|
+
feature: test.feature,
|
|
192
|
+
story: test.story,
|
|
193
|
+
language: test.language,
|
|
194
|
+
framework: test.framework,
|
|
195
|
+
platform: test.platform,
|
|
196
|
+
lastRun: test.lastRun, // only show when supplied
|
|
197
|
+
file: test.file,
|
|
198
|
+
tags: test.tags || [],
|
|
199
|
+
links: test.links || [],
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// While case JSON hasn't been fetched yet, render the header (we have
|
|
203
|
+
// enough metadata for it from the index) plus a skeleton placeholder.
|
|
204
|
+
if (!test._stepsLoaded) return (
|
|
205
|
+
<div ref={scrollRef} style={{ flex:1, overflow:'auto', padding:24 }}>
|
|
206
|
+
<TestHeader test={headerTest}/>
|
|
207
|
+
<div style={{ padding:30, color:'var(--fg3)', fontFamily:'var(--font-mono)', fontSize:12, textAlign:'center' }}>Loading steps…</div>
|
|
208
|
+
</div>
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const steps = test.steps || [];
|
|
212
|
+
const enriched = enrichSteps(steps, test);
|
|
213
|
+
const failedCount = (function count(ss) { return ss.reduce((a,s) => a + (s.status==='failed'||s.status==='broken'?1:0) + (s.children?count(s.children):0), 0); })(steps);
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<div ref={scrollRef} style={{ flex:1, overflow:'auto', padding:24, minHeight:0 }}>
|
|
217
|
+
<TestHeader test={headerTest}/>
|
|
218
|
+
|
|
219
|
+
<div className="tabs" style={{ marginBottom:18 }}>
|
|
220
|
+
{/* Unified tab order — same set on Overview-click and tree-click. */}
|
|
221
|
+
{['steps','overview','log','retries','history','attachments','metadata'].map(t => {
|
|
222
|
+
// Hide retries tab when there were none — keeps the chrome tight.
|
|
223
|
+
if (t === 'retries' && !(test.retries > 0)) return null;
|
|
224
|
+
return (
|
|
225
|
+
<div key={t} className={`tab ${tab===t?'active':''}`} onClick={()=>setTab(t)}>{t[0].toUpperCase()+t.slice(1)}</div>
|
|
226
|
+
);
|
|
227
|
+
})}
|
|
228
|
+
{/* Host-injected extras (Kaizen platform: Triage, Cluster, Defects…). */}
|
|
229
|
+
{extraTabs.map(ex => (
|
|
230
|
+
<div key={ex.id} className={`tab ${tab===ex.id?'active':''}`} onClick={()=>setTab(ex.id)}>{ex.label}</div>
|
|
231
|
+
))}
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
{tab === 'overview' && <OverviewTab test={test}/>}
|
|
235
|
+
{tab === 'steps' && (
|
|
236
|
+
<div className="card" style={{ padding:0, marginTop:0 }}>
|
|
237
|
+
<div className="hd"><h3>Steps</h3><div className="meta">{steps.length} steps · {failedCount} failed</div></div>
|
|
238
|
+
<div style={{ padding:'0 14px 14px' }}>
|
|
239
|
+
<StepTreeV2 steps={enriched}/>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
)}
|
|
243
|
+
{tab === 'log' && <CaseLogTab test={test}/>}
|
|
244
|
+
{tab === 'retries' && <RetriesTab test={test}/>}
|
|
245
|
+
{tab === 'history' && <HistoryTab test={test}/>}
|
|
246
|
+
{tab === 'attachments' && <AttachmentsTab test={test}/>}
|
|
247
|
+
{tab === 'metadata' && <MetadataTab test={test}/>}
|
|
248
|
+
{/* Render host-injected tab body when the active tab matches one. */}
|
|
249
|
+
{(() => {
|
|
250
|
+
const ex = extraTabs.find(t => t.id === tab);
|
|
251
|
+
return ex ? ex.render(test) : null;
|
|
252
|
+
})()}
|
|
253
|
+
</div>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Aggregate every log line attached to a step (recursively) so the Log tab
|
|
258
|
+
// can present a unified case-level console even when the adapter only
|
|
259
|
+
// captured step-scoped logs. Each line is tagged with the step it came from
|
|
260
|
+
// so we can render a subtle step-context column next to it.
|
|
261
|
+
function collectStepLogs(steps, parentName) {
|
|
262
|
+
const out = [];
|
|
263
|
+
for (const s of steps || []) {
|
|
264
|
+
const ctx = parentName ? `${parentName} › ${s.name}` : s.name;
|
|
265
|
+
for (const l of s.logs || []) {
|
|
266
|
+
out.push({ ...l, _step: ctx });
|
|
267
|
+
}
|
|
268
|
+
if (s.children?.length) {
|
|
269
|
+
out.push(...collectStepLogs(s.children, ctx));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Case-level Log tab — renders the unified console for this test. Prefers
|
|
276
|
+
// case-level test.logs when the adapter shipped them; falls back to
|
|
277
|
+
// flattening every step's logs so the user sees something useful instead
|
|
278
|
+
// of an "empty" tab when only step-scoped logs were captured.
|
|
279
|
+
function CaseLogTab({ test }) {
|
|
280
|
+
const caseLogs = test.logs || [];
|
|
281
|
+
const stepLogs = caseLogs.length === 0 ? collectStepLogs(test.steps || []) : [];
|
|
282
|
+
const logs = caseLogs.length ? caseLogs : stepLogs;
|
|
283
|
+
const source = caseLogs.length ? 'case' : 'aggregated from steps';
|
|
284
|
+
|
|
285
|
+
if (!logs.length) return (
|
|
286
|
+
<div className="card" style={{ padding:30, textAlign:'center', color:'var(--fg3)', fontFamily:'var(--font-mono)', fontSize:12 }}>
|
|
287
|
+
No console output captured for this test.
|
|
288
|
+
<div style={{ marginTop:6, fontSize:11, color:'var(--fg4)', lineHeight:1.55 }}>
|
|
289
|
+
Adapters can ship logs at the case level (<code style={{ background:'var(--bg-sunken)', padding:'1px 6px', borderRadius:3 }}>case.logs</code>)
|
|
290
|
+
or per-step (<code style={{ background:'var(--bg-sunken)', padding:'1px 6px', borderRadius:3 }}>step.logs</code>).<br/>
|
|
291
|
+
When step logs exist, they're aggregated here automatically.
|
|
292
|
+
</div>
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
const LVL_COLOR = { info:'var(--fg2)', warn:'var(--status-broken-fg)', err:'var(--status-failed)', error:'var(--status-failed)', debug:'var(--fg3)' };
|
|
297
|
+
const LVL_BG = { info:'transparent', warn:'var(--status-broken-bg)', err:'var(--status-failed-bg)', error:'var(--status-failed-bg)', debug:'transparent' };
|
|
298
|
+
|
|
299
|
+
// Filter chips: All · Errors · Warnings · Info — let users zero in on
|
|
300
|
+
// what failed without scrolling through hundreds of lines.
|
|
301
|
+
const counts = { info:0, warn:0, err:0, debug:0 };
|
|
302
|
+
for (const l of logs) {
|
|
303
|
+
const lvl = l.lvl === 'error' ? 'err' : (l.lvl || 'info');
|
|
304
|
+
if (counts[lvl] != null) counts[lvl]++;
|
|
305
|
+
}
|
|
306
|
+
const [filter, setFilter] = React.useState('all');
|
|
307
|
+
const FILTERS = [
|
|
308
|
+
['all', 'All', logs.length],
|
|
309
|
+
['err', 'Errors', counts.err],
|
|
310
|
+
['warn', 'Warnings', counts.warn],
|
|
311
|
+
['info', 'Info', counts.info],
|
|
312
|
+
['debug', 'Debug', counts.debug],
|
|
313
|
+
].filter(([id, _, n]) => id === 'all' || n > 0);
|
|
314
|
+
const visible = filter === 'all' ? logs : logs.filter(l => {
|
|
315
|
+
const lvl = l.lvl === 'error' ? 'err' : l.lvl;
|
|
316
|
+
return lvl === filter;
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
return (
|
|
320
|
+
<div className="card" style={{ padding:0 }}>
|
|
321
|
+
<div className="hd">
|
|
322
|
+
<h3>Console</h3>
|
|
323
|
+
<div className="meta">{logs.length} entries · {source}</div>
|
|
324
|
+
</div>
|
|
325
|
+
{/* Filters */}
|
|
326
|
+
<div style={{ display:'flex', gap:6, padding:'0 14px 12px', flexWrap:'wrap' }}>
|
|
327
|
+
{FILTERS.map(([id, label, n]) => {
|
|
328
|
+
const active = filter === id;
|
|
329
|
+
const tone = id === 'err' ? 'var(--status-failed)' : id === 'warn' ? 'var(--status-broken)' : null;
|
|
330
|
+
return (
|
|
331
|
+
<button key={id} onClick={() => setFilter(id)} style={{
|
|
332
|
+
display:'inline-flex', alignItems:'center', gap:6, padding:'3px 10px', borderRadius:999,
|
|
333
|
+
border:'1px solid ' + (active ? (tone || 'var(--brand-blue-500)') : 'var(--line)'),
|
|
334
|
+
background: active ? (tone || 'var(--brand-blue-500)') : 'var(--bg-elev)',
|
|
335
|
+
color: active ? '#fff' : 'var(--fg2)',
|
|
336
|
+
fontFamily:'var(--font-body)', fontSize:11.5, fontWeight:600, cursor:'pointer',
|
|
337
|
+
transition:'all var(--dur-fast)',
|
|
338
|
+
}}>
|
|
339
|
+
{label}<span style={{ fontFamily:'var(--font-mono)', fontSize:10.5, opacity:0.9 }}>{n}</span>
|
|
340
|
+
</button>
|
|
341
|
+
);
|
|
342
|
+
})}
|
|
343
|
+
</div>
|
|
344
|
+
<div style={{ background:'var(--bg-sunken)', borderTop:'1px solid var(--line)', padding:'4px 0 12px', maxHeight:520, overflow:'auto' }}>
|
|
345
|
+
{visible.map((l, i) => {
|
|
346
|
+
const lvl = l.lvl === 'error' ? 'err' : (l.lvl || 'info');
|
|
347
|
+
return (
|
|
348
|
+
<div key={i} style={{
|
|
349
|
+
display:'grid', gridTemplateColumns:`80px 50px ${l._step ? '180px ' : ''}1fr`, gap:10, padding:'3px 14px',
|
|
350
|
+
background: LVL_BG[lvl] || 'transparent',
|
|
351
|
+
fontFamily:'var(--font-mono)', fontSize:11.5, lineHeight:1.55,
|
|
352
|
+
}}>
|
|
353
|
+
<span style={{ color:'var(--fg3)' }}>{l.ts}</span>
|
|
354
|
+
<span style={{ color: LVL_COLOR[lvl] || 'var(--fg2)', fontWeight:700, letterSpacing:0.5 }}>{lvl.toUpperCase()}</span>
|
|
355
|
+
{l._step && (
|
|
356
|
+
<span style={{ color:'var(--fg3)', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }} title={l._step}>{l._step}</span>
|
|
357
|
+
)}
|
|
358
|
+
<span style={{ color:'var(--fg1)', whiteSpace:'pre-wrap', wordBreak:'break-word' }}>{l.msg}</span>
|
|
359
|
+
</div>
|
|
360
|
+
);
|
|
361
|
+
})}
|
|
362
|
+
{visible.length === 0 && (
|
|
363
|
+
<div style={{ padding:'30px 14px', textAlign:'center', color:'var(--fg3)', fontFamily:'var(--font-mono)', fontSize:12 }}>
|
|
364
|
+
No {filter} entries.
|
|
365
|
+
</div>
|
|
366
|
+
)}
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Metadata tab — user-supplied data only (Allure-style). The header above
|
|
373
|
+
// already shows the canonical fields (severity, owner, suite, epic, etc.),
|
|
374
|
+
// so this tab focuses on what's actually customizable per-test:
|
|
375
|
+
// · Labels — free-form key/value pairs (case.labels) added by the adapter
|
|
376
|
+
// · Parameters — runtime parameters (case.parameters)
|
|
377
|
+
// · Tags — annotations (case.tags)
|
|
378
|
+
// · Links — external references (case.links) — also chip'd in header
|
|
379
|
+
// · Identity — Test ID + file path (always useful for grep + correlation)
|
|
380
|
+
// · Runtime — browser/platform/worker/started (debugging context)
|
|
381
|
+
function MetadataTab({ test }) {
|
|
382
|
+
const labels = test.labels || {};
|
|
383
|
+
const labelEntries = Object.entries(labels);
|
|
384
|
+
const params = test.parameters || [];
|
|
385
|
+
const tags = test.tags || [];
|
|
386
|
+
const links = test.links || [];
|
|
387
|
+
const startedAt = test._summary?.startedAt;
|
|
388
|
+
|
|
389
|
+
const Section = ({ title, hint, children }) => (
|
|
390
|
+
<section style={{ marginBottom:18 }}>
|
|
391
|
+
<div style={{ display:'flex', alignItems:'baseline', gap:8, marginBottom:8 }}>
|
|
392
|
+
<div className="k-overline">{title}</div>
|
|
393
|
+
{hint && <span style={{ fontFamily:'var(--font-mono)', fontSize:11, color:'var(--fg3)' }}>{hint}</span>}
|
|
394
|
+
</div>
|
|
395
|
+
{children}
|
|
396
|
+
</section>
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
const KVTable = ({ rows }) => (
|
|
400
|
+
<div style={{ border:'1px solid var(--line)', borderRadius:8, overflow:'hidden' }}>
|
|
401
|
+
{rows.map(([k, v], i) => (
|
|
402
|
+
<div key={k} style={{ display:'grid', gridTemplateColumns:'200px 1fr', borderTop: i ? '1px solid var(--line)' : 'none' }}>
|
|
403
|
+
<div style={{ padding:'10px 14px', background:'var(--bg-sunken)', fontFamily:'var(--font-mono)', fontSize:11.5, color:'var(--fg3)' }}>{k}</div>
|
|
404
|
+
<div style={{ padding:'10px 14px', fontFamily:'var(--font-mono)', fontSize:12.5, color:'var(--fg1)', wordBreak:'break-all' }}>{v}</div>
|
|
405
|
+
</div>
|
|
406
|
+
))}
|
|
407
|
+
</div>
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
const isEmpty = labelEntries.length === 0 && params.length === 0 && tags.length === 0 && links.length === 0;
|
|
411
|
+
|
|
412
|
+
return (
|
|
413
|
+
<div className="card" style={{ padding:0 }}>
|
|
414
|
+
<div className="hd">
|
|
415
|
+
<h3>Metadata</h3>
|
|
416
|
+
<div className="meta">user-supplied data · adapter-driven</div>
|
|
417
|
+
</div>
|
|
418
|
+
<div style={{ padding:'0 14px 14px' }}>
|
|
419
|
+
|
|
420
|
+
{labelEntries.length > 0 && (
|
|
421
|
+
<Section title={`Labels · ${labelEntries.length}`} hint="custom key/value pairs from your reporter">
|
|
422
|
+
<KVTable rows={labelEntries.map(([k, v]) => [k, String(v)])}/>
|
|
423
|
+
</Section>
|
|
424
|
+
)}
|
|
425
|
+
|
|
426
|
+
{params.length > 0 && (
|
|
427
|
+
<Section title={`Parameters · ${params.length}`} hint="runtime arguments / data-row values">
|
|
428
|
+
<KVTable rows={params}/>
|
|
429
|
+
</Section>
|
|
430
|
+
)}
|
|
431
|
+
|
|
432
|
+
{tags.length > 0 && (
|
|
433
|
+
<Section title={`Tags · ${tags.length}`}>
|
|
434
|
+
<div style={{ display:'flex', flexWrap:'wrap', gap:6 }}>
|
|
435
|
+
{tags.map(t => (
|
|
436
|
+
<span key={t} style={{
|
|
437
|
+
display:'inline-flex', alignItems:'center', padding:'3px 9px', borderRadius:4,
|
|
438
|
+
background:'var(--bg-sunken)', border:'1px solid var(--line)', color:'var(--fg2)',
|
|
439
|
+
fontFamily:'var(--font-mono)', fontSize:11.5, fontWeight:500,
|
|
440
|
+
}}>{t}</span>
|
|
441
|
+
))}
|
|
442
|
+
</div>
|
|
443
|
+
</Section>
|
|
444
|
+
)}
|
|
445
|
+
|
|
446
|
+
{links.length > 0 && (
|
|
447
|
+
<Section title={`External links · ${links.length}`} hint="referenced from the test header above too">
|
|
448
|
+
<div style={{ display:'flex', flexDirection:'column', gap:6 }}>
|
|
449
|
+
{links.map((l, i) => (
|
|
450
|
+
<a key={i} href={l.url} target="_blank" rel="noopener noreferrer" style={{
|
|
451
|
+
display:'flex', alignItems:'center', gap:10, padding:'10px 14px',
|
|
452
|
+
border:'1px solid var(--line)', borderRadius:6, background:'var(--bg-elev)',
|
|
453
|
+
textDecoration:'none', fontFamily:'var(--font-mono)', fontSize:12,
|
|
454
|
+
}}>
|
|
455
|
+
<span style={{
|
|
456
|
+
padding:'1px 7px', borderRadius:3, background:'var(--bg-sunken)',
|
|
457
|
+
color:'var(--fg2)', fontSize:10, fontWeight:700, letterSpacing:0.5, textTransform:'uppercase',
|
|
458
|
+
}}>{l.kind || 'link'}</span>
|
|
459
|
+
<span style={{ color:'var(--fg1)', fontWeight:600 }}>{l.label || l.url}</span>
|
|
460
|
+
<span style={{ flex:1, color:'var(--fg3)', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{l.url}</span>
|
|
461
|
+
</a>
|
|
462
|
+
))}
|
|
463
|
+
</div>
|
|
464
|
+
</Section>
|
|
465
|
+
)}
|
|
466
|
+
|
|
467
|
+
<Section title="Identity" hint="always-shown locator info">
|
|
468
|
+
<KVTable rows={[
|
|
469
|
+
['Test ID', test.id],
|
|
470
|
+
['Full name', test.fullName || test.name],
|
|
471
|
+
['File', test.file || '—'],
|
|
472
|
+
startedAt ? ['Started at', new Date(startedAt).toLocaleString()] : null,
|
|
473
|
+
].filter(Boolean)}/>
|
|
474
|
+
</Section>
|
|
475
|
+
|
|
476
|
+
<Section title="Runtime" hint="execution context for debugging">
|
|
477
|
+
<KVTable rows={[
|
|
478
|
+
test._summary?.browser ? ['Browser', test._summary.browser] : null,
|
|
479
|
+
test.platform ? ['Platform', test.platform] : null,
|
|
480
|
+
test._summary?.worker != null ? ['Worker', String(test._summary.worker)] : null,
|
|
481
|
+
test.retries > 0 ? ['Retries', String(test.retries)] : null,
|
|
482
|
+
].filter(Boolean)}/>
|
|
483
|
+
</Section>
|
|
484
|
+
|
|
485
|
+
{isEmpty && (
|
|
486
|
+
<div style={{ padding:'30px 14px', textAlign:'center', color:'var(--fg3)', fontFamily:'var(--font-mono)', fontSize:12, lineHeight:1.55 }}>
|
|
487
|
+
No labels / parameters / tags / links on this test.
|
|
488
|
+
<div style={{ marginTop:10, fontSize:11, color:'var(--fg4)' }}>
|
|
489
|
+
Adapters can attach <code style={{ background:'var(--bg-sunken)', padding:'1px 6px', borderRadius:3 }}>case.labels</code>,
|
|
490
|
+
<code style={{ background:'var(--bg-sunken)', padding:'1px 6px', borderRadius:3, marginLeft:4 }}>case.parameters</code>, and
|
|
491
|
+
<code style={{ background:'var(--bg-sunken)', padding:'1px 6px', borderRadius:3, marginLeft:4 }}>case.links</code> for richer metadata.
|
|
492
|
+
</div>
|
|
493
|
+
</div>
|
|
494
|
+
)}
|
|
495
|
+
</div>
|
|
496
|
+
</div>
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function OverviewTab({ test }) {
|
|
501
|
+
return (
|
|
502
|
+
<div style={{ display:'grid', gridTemplateColumns:'1fr', gap:18 }}>
|
|
503
|
+
<section>
|
|
504
|
+
<div className="k-overline" style={{ marginBottom:6 }}>Description</div>
|
|
505
|
+
<p className="k-body" style={{ margin:0 }}>{test.description}</p>
|
|
506
|
+
</section>
|
|
507
|
+
{test.bdd && (
|
|
508
|
+
<section>
|
|
509
|
+
<div className="k-overline" style={{ marginBottom:8 }}>Behavior · Given / When / Then</div>
|
|
510
|
+
<div style={{ border:'1px solid var(--line)', borderRadius:8, overflow:'hidden' }}>
|
|
511
|
+
{[['GIVEN', test.bdd.given, '#0E5BD9'],['WHEN', test.bdd.when, '#5B5BD6'],['THEN', test.bdd.then, '#10864E']].map(([k,v,c],i) => (
|
|
512
|
+
<div key={k} style={{ display:'grid', gridTemplateColumns:'80px 1fr', borderTop: i ? '1px solid var(--line)' : 'none' }}>
|
|
513
|
+
<div style={{ padding:'10px 12px', background:'var(--bg-sunken)', fontFamily:'var(--font-mono)', fontSize:11, fontWeight:700, color:c, letterSpacing:'.08em' }}>{k}</div>
|
|
514
|
+
<div style={{ padding:'10px 12px', fontFamily:'var(--font-body)', fontSize:13, color:'var(--fg1)' }}>{v}</div>
|
|
515
|
+
</div>
|
|
516
|
+
))}
|
|
517
|
+
</div>
|
|
518
|
+
</section>
|
|
519
|
+
)}
|
|
520
|
+
{test.parameters?.length > 0 && (
|
|
521
|
+
<section>
|
|
522
|
+
<div className="k-overline" style={{ marginBottom:8 }}>Parameters · {test.parameters.length}</div>
|
|
523
|
+
<div style={{ border:'1px solid var(--line)', borderRadius:8, overflow:'hidden' }}>
|
|
524
|
+
{test.parameters.map(([k,v],i) => (
|
|
525
|
+
<div key={k} style={{ display:'grid', gridTemplateColumns:'160px 1fr', borderTop: i ? '1px solid var(--line)' : 'none' }}>
|
|
526
|
+
<div style={{ padding:'8px 12px', background:'var(--bg-sunken)', fontFamily:'var(--font-mono)', fontSize:12, color:'var(--fg3)' }}>{k}</div>
|
|
527
|
+
<div style={{ padding:'8px 12px', fontFamily:'var(--font-mono)', fontSize:12.5, color:'var(--fg1)' }}>{v}</div>
|
|
528
|
+
</div>
|
|
529
|
+
))}
|
|
530
|
+
</div>
|
|
531
|
+
</section>
|
|
532
|
+
)}
|
|
533
|
+
{test.error && (
|
|
534
|
+
<section>
|
|
535
|
+
<div className="k-overline" style={{ marginBottom:8 }}>Failure</div>
|
|
536
|
+
<div style={{ background:'var(--status-failed-bg)', border:'1px solid var(--status-failed-border)', borderRadius:8, padding:'12px 14px' }}>
|
|
537
|
+
<div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:6, flexWrap:'wrap' }}>
|
|
538
|
+
<span style={{ fontFamily:'var(--font-mono)', fontWeight:700, color:'var(--status-failed)', fontSize:11, padding:'2px 7px', background:'var(--bg-elev)', border:'1px solid var(--status-failed-border)', borderRadius:3 }}>{test.error.kind}</span>
|
|
539
|
+
<span style={{ fontFamily:'var(--font-mono)', fontSize:12.5, color:'var(--status-failed-fg)' }}>{test.error.message}</span>
|
|
540
|
+
</div>
|
|
541
|
+
{test.error.stack && <pre style={{ margin:0, fontFamily:'var(--font-mono)', fontSize:11.5, color:'var(--status-failed-fg)', whiteSpace:'pre-wrap' }}>{test.error.stack}</pre>}
|
|
542
|
+
</div>
|
|
543
|
+
</section>
|
|
544
|
+
)}
|
|
545
|
+
<section>
|
|
546
|
+
<div className="k-overline" style={{ marginBottom:8 }}>Execution timeline</div>
|
|
547
|
+
<StepTreeRich steps={test.steps || []}/>
|
|
548
|
+
</section>
|
|
549
|
+
</div>
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function StepTreeRich({ steps }) {
|
|
554
|
+
return (
|
|
555
|
+
<div style={{ display:'flex', flexDirection:'column', border:'1px solid var(--line)', borderRadius:8, overflow:'hidden' }}>
|
|
556
|
+
{steps.map((s,i) => <StepRichNode key={i} step={s} depth={0} last={i === steps.length - 1}/>)}
|
|
557
|
+
</div>
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
function StepRichNode({ step, depth, last }) {
|
|
561
|
+
const [open, setOpen] = useStateT(true);
|
|
562
|
+
const has = step.children && step.children.length;
|
|
563
|
+
return (
|
|
564
|
+
<div>
|
|
565
|
+
<div onClick={() => has && setOpen(!open)} style={{
|
|
566
|
+
display:'grid', gridTemplateColumns:'14px 1fr auto', alignItems:'center', gap:10,
|
|
567
|
+
padding:'10px 14px', paddingLeft: 14 + depth * 18, cursor: has ? 'pointer' : 'default',
|
|
568
|
+
borderBottom: !last || has ? '1px solid var(--line)' : 'none',
|
|
569
|
+
background: depth > 0 ? 'var(--bg-sunken)' : 'transparent',
|
|
570
|
+
}}>
|
|
571
|
+
<span className={`s-icon ${step.status}`} style={{ width:14, height:14, fontSize:9 }}>{step.status==='passed'?'✓':step.status==='failed'?'✕':step.status==='broken'?'!':'⊘'}</span>
|
|
572
|
+
<div>
|
|
573
|
+
<div style={{ fontSize:13, color:'var(--fg1)', fontWeight:500, display:'flex', alignItems:'center', gap:6 }}>
|
|
574
|
+
{has && <span style={{ color:'var(--fg3)', display:'inline-block', fontSize:11, transform: open ? 'rotate(90deg)' : 'none', transition:'transform var(--dur-fast)' }}>›</span>}
|
|
575
|
+
{step.name}
|
|
576
|
+
</div>
|
|
577
|
+
{step.params && (
|
|
578
|
+
<div style={{ marginTop:3, fontFamily:'var(--font-mono)', fontSize:11, color:'var(--fg3)' }}>
|
|
579
|
+
{step.params.map(([k,v],i) => <span key={i} style={{ marginRight:10 }}>{k}=<span style={{ color:'var(--fg2)' }}>{v}</span></span>)}
|
|
580
|
+
</div>
|
|
581
|
+
)}
|
|
582
|
+
</div>
|
|
583
|
+
<span style={{ fontFamily:'var(--font-mono)', fontSize:11, color:'var(--fg3)', fontVariantNumeric:'tabular-nums' }}>{step.dur || step.duration}</span>
|
|
584
|
+
</div>
|
|
585
|
+
{has && open && step.children.map((c,i) => <StepRichNode key={i} step={c} depth={depth+1} last={i === step.children.length - 1 && last}/>)}
|
|
586
|
+
</div>
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function RetriesTab({ test }) {
|
|
591
|
+
const attempts = test.retries > 0 ? Array.from({ length: test.retries + 1 }, (_, i) => ({
|
|
592
|
+
status: i === test.retries ? test.status : 'failed',
|
|
593
|
+
dur: 1800 + i * 400,
|
|
594
|
+
label: i === test.retries
|
|
595
|
+
? `attempt ${i+1} — ${test.status === 'passed' ? 'recovered' : 'final ' + (test.error?.kind || 'error')}`
|
|
596
|
+
: `attempt ${i+1} — ${test.error?.kind || 'TimeoutError'}`,
|
|
597
|
+
})) : null;
|
|
598
|
+
if (!attempts) return <div style={{ color:'var(--fg3)', fontFamily:'var(--font-mono)', fontSize:12, padding:'30px 20px', textAlign:'center', border:'1px dashed var(--line)', borderRadius:8 }}>No retries on this run.</div>;
|
|
599
|
+
return <RetryWaterfall attempts={attempts}/>;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function HistoryTab({ test }) {
|
|
603
|
+
const STATUS_MAP = { pass:'passed', fail:'failed', broken:'broken', skip:'skipped' };
|
|
604
|
+
const runs = (test.history || []).map(h => ({
|
|
605
|
+
id: '#' + h.runId,
|
|
606
|
+
when: window._kenshoRelTime ? window._kenshoRelTime(h.startedAt) : h.startedAt,
|
|
607
|
+
status: STATUS_MAP[h.status] || h.status,
|
|
608
|
+
dur: window._kenshoFmtDuration ? window._kenshoFmtDuration(h.duration) : h.duration,
|
|
609
|
+
}));
|
|
610
|
+
|
|
611
|
+
if (runs.length === 0) {
|
|
612
|
+
return <div style={{padding:30,textAlign:'center',color:'var(--fg3)',fontFamily:'var(--font-mono)',fontSize:12}}>No prior run history available for this test.</div>;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return (
|
|
616
|
+
<div style={{ border:'1px solid var(--line)', borderRadius:8, overflow:'hidden' }}>
|
|
617
|
+
{runs.map((r,i) => (
|
|
618
|
+
<div key={i} style={{ display:'grid', gridTemplateColumns:'24px 1fr 90px 100px', alignItems:'center', gap:10, padding:'10px 14px', borderBottom: i < runs.length-1 ? '1px solid var(--line)' : 'none' }}>
|
|
619
|
+
<span className={`s-icon ${r.status}`}>{r.status==='passed'?'✓':r.status==='failed'?'✕':'!'}</span>
|
|
620
|
+
<span style={{ fontFamily:'var(--font-mono)', fontSize:12 }}>{r.id}</span>
|
|
621
|
+
<span style={{ fontFamily:'var(--font-mono)', fontSize:12, color:'var(--fg3)' }}>{r.dur}</span>
|
|
622
|
+
<span style={{ fontFamily:'var(--font-mono)', fontSize:11, color:'var(--fg3)', textAlign:'right' }}>{r.when}</span>
|
|
623
|
+
</div>
|
|
624
|
+
))}
|
|
625
|
+
</div>
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function AttachmentsTab({ test }) {
|
|
630
|
+
const ICON_MAP = {
|
|
631
|
+
screenshot: 'image',
|
|
632
|
+
image: 'image',
|
|
633
|
+
video: 'video',
|
|
634
|
+
trace: 'terminal',
|
|
635
|
+
log: 'terminal',
|
|
636
|
+
text: 'terminal',
|
|
637
|
+
har: 'globe',
|
|
638
|
+
json: 'code',
|
|
639
|
+
html: 'code',
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
const prettyBytes = (n) => {
|
|
643
|
+
if (n == null || isNaN(n)) return '';
|
|
644
|
+
if (n < 1024) return `${n} B`;
|
|
645
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
646
|
+
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
647
|
+
return `${(n / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
const basename = (p) => {
|
|
651
|
+
if (!p) return '';
|
|
652
|
+
const parts = String(p).split(/[\\/]/);
|
|
653
|
+
return parts[parts.length - 1] || p;
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
const items = (test.attachments || []).map(a => ({
|
|
657
|
+
name: basename(a.relativePath) || a.id,
|
|
658
|
+
size: prettyBytes(a.sizeBytes),
|
|
659
|
+
icon: ICON_MAP[a.kind] || 'file',
|
|
660
|
+
preview: a.kind === 'screenshot' || a.kind === 'video' || a.kind === 'image',
|
|
661
|
+
}));
|
|
662
|
+
|
|
663
|
+
if (items.length === 0) {
|
|
664
|
+
return <div style={{padding:30,textAlign:'center',color:'var(--fg3)',fontFamily:'var(--font-mono)',fontSize:12}}>No attachments captured for this test.</div>;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
return (
|
|
668
|
+
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:10 }}>
|
|
669
|
+
{items.map(a => (
|
|
670
|
+
<div key={a.name} style={{ border:'1px solid var(--line)', borderRadius:8, overflow:'hidden', background:'var(--bg-elev)' }}>
|
|
671
|
+
{a.preview && <div style={{ height: 110, background: 'repeating-linear-gradient(135deg, var(--bg-sunken) 0 12px, var(--bg-elev) 12px 24px)', borderBottom:'1px solid var(--line)' }}/>}
|
|
672
|
+
<div style={{ display:'flex', alignItems:'center', gap:8, padding:'10px 12px' }}>
|
|
673
|
+
<i data-lucide={a.icon} style={{ width:14, height:14 }}></i>
|
|
674
|
+
<span style={{ flex:1, fontFamily:'var(--font-mono)', fontSize:12, color:'var(--fg1)' }}>{a.name}</span>
|
|
675
|
+
<span style={{ fontFamily:'var(--font-mono)', fontSize:11, color:'var(--fg3)' }}>{a.size}</span>
|
|
676
|
+
</div>
|
|
677
|
+
</div>
|
|
678
|
+
))}
|
|
679
|
+
</div>
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// ============== Splitter constants ==============
|
|
684
|
+
const KV_SPLIT_KEY = 'kensho.tree.split';
|
|
685
|
+
const KV_SPLIT_MIN = 280;
|
|
686
|
+
const KV_SPLIT_DEFAULT = 480;
|
|
687
|
+
const KV_SPLIT_KEY_STEP = 16;
|
|
688
|
+
|
|
689
|
+
function readPersistedSplit() {
|
|
690
|
+
try {
|
|
691
|
+
const raw = window.localStorage?.getItem(KV_SPLIT_KEY);
|
|
692
|
+
if (!raw) return KV_SPLIT_DEFAULT;
|
|
693
|
+
const n = parseFloat(raw);
|
|
694
|
+
if (!Number.isFinite(n) || n < KV_SPLIT_MIN) return KV_SPLIT_DEFAULT;
|
|
695
|
+
return n;
|
|
696
|
+
} catch (_) {
|
|
697
|
+
return KV_SPLIT_DEFAULT;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// ============== Generic Tree+Detail page ==============
|
|
702
|
+
function TreeDetailPage({ title, subtitle, tree, leafLabel, headerExtra, defaultOpenAll = false }) {
|
|
703
|
+
const allIds = collectIds(tree);
|
|
704
|
+
// Default: every branch collapsed, no leaf selected. The detail pane shows
|
|
705
|
+
// a summary placeholder so the user explicitly picks what to inspect — at
|
|
706
|
+
// 800+ tests, auto-loading the first leaf wastes a fetch and renders a
|
|
707
|
+
// misleading "first thing alphabetically" view.
|
|
708
|
+
const [openIds, setOpenIds] = useStateT(new Set(defaultOpenAll ? allIds : []));
|
|
709
|
+
const [selectedId, setSelectedId] = useStateT(null);
|
|
710
|
+
const [filters, setFilters] = useStateT(new Set(['passed','failed','broken','skipped','unknown']));
|
|
711
|
+
const [query, setQuery] = useStateT('');
|
|
712
|
+
|
|
713
|
+
// ============== Splitter (resizable tree column) ==============
|
|
714
|
+
// Width of the left tree column. Restored from localStorage on mount;
|
|
715
|
+
// clamped to [MIN, 70% of parent] on every drag/key tick. We persist on
|
|
716
|
+
// pointerup / keyup, not on every mousemove, to avoid hammering storage.
|
|
717
|
+
const [splitWidth, setSplitWidth] = useStateT(() => readPersistedSplit());
|
|
718
|
+
const [dragging, setDragging] = useStateT(false);
|
|
719
|
+
const splitContainerRef = React.useRef(null);
|
|
720
|
+
const dragStateRef = React.useRef(null);
|
|
721
|
+
|
|
722
|
+
const persistSplit = React.useCallback((w) => {
|
|
723
|
+
try { window.localStorage?.setItem(KV_SPLIT_KEY, String(Math.round(w))); } catch (_) {}
|
|
724
|
+
}, []);
|
|
725
|
+
|
|
726
|
+
const clampWidth = React.useCallback((w) => {
|
|
727
|
+
const parentW = splitContainerRef.current?.getBoundingClientRect().width || 0;
|
|
728
|
+
const max = Math.max(KV_SPLIT_MIN, Math.floor(parentW * 0.7));
|
|
729
|
+
return Math.max(KV_SPLIT_MIN, Math.min(max, w));
|
|
730
|
+
}, []);
|
|
731
|
+
|
|
732
|
+
const onSplitPointerDown = React.useCallback((e) => {
|
|
733
|
+
if (e.button !== 0 && e.pointerType === 'mouse') return;
|
|
734
|
+
e.preventDefault();
|
|
735
|
+
const target = e.currentTarget;
|
|
736
|
+
try { target.setPointerCapture(e.pointerId); } catch (_) {}
|
|
737
|
+
dragStateRef.current = {
|
|
738
|
+
startX: e.clientX,
|
|
739
|
+
startWidth: splitWidth,
|
|
740
|
+
pointerId: e.pointerId,
|
|
741
|
+
target,
|
|
742
|
+
};
|
|
743
|
+
setDragging(true);
|
|
744
|
+
// Prevent text selection of the tree while dragging.
|
|
745
|
+
document.body.style.userSelect = 'none';
|
|
746
|
+
document.body.style.cursor = 'col-resize';
|
|
747
|
+
}, [splitWidth]);
|
|
748
|
+
|
|
749
|
+
const onSplitPointerMove = React.useCallback((e) => {
|
|
750
|
+
const ds = dragStateRef.current;
|
|
751
|
+
if (!ds) return;
|
|
752
|
+
const dx = e.clientX - ds.startX;
|
|
753
|
+
const next = clampWidth(ds.startWidth + dx);
|
|
754
|
+
setSplitWidth(next);
|
|
755
|
+
}, [clampWidth]);
|
|
756
|
+
|
|
757
|
+
const endDrag = React.useCallback(() => {
|
|
758
|
+
const ds = dragStateRef.current;
|
|
759
|
+
if (!ds) return;
|
|
760
|
+
try { ds.target?.releasePointerCapture?.(ds.pointerId); } catch (_) {}
|
|
761
|
+
dragStateRef.current = null;
|
|
762
|
+
setDragging(false);
|
|
763
|
+
document.body.style.userSelect = '';
|
|
764
|
+
document.body.style.cursor = '';
|
|
765
|
+
// Persist the latest committed width (functional setter so we read the
|
|
766
|
+
// freshest value, not a stale closure copy).
|
|
767
|
+
setSplitWidth(w => { persistSplit(w); return w; });
|
|
768
|
+
}, [persistSplit]);
|
|
769
|
+
|
|
770
|
+
const onSplitKeyDown = React.useCallback((e) => {
|
|
771
|
+
let next = null;
|
|
772
|
+
if (e.key === 'ArrowLeft') next = splitWidth - KV_SPLIT_KEY_STEP;
|
|
773
|
+
else if (e.key === 'ArrowRight') next = splitWidth + KV_SPLIT_KEY_STEP;
|
|
774
|
+
else if (e.key === 'Home') next = KV_SPLIT_MIN;
|
|
775
|
+
else if (e.key === 'End') {
|
|
776
|
+
const parentW = splitContainerRef.current?.getBoundingClientRect().width || 0;
|
|
777
|
+
next = Math.floor(parentW * 0.7);
|
|
778
|
+
}
|
|
779
|
+
if (next == null) return;
|
|
780
|
+
e.preventDefault();
|
|
781
|
+
const clamped = clampWidth(next);
|
|
782
|
+
setSplitWidth(clamped);
|
|
783
|
+
persistSplit(clamped);
|
|
784
|
+
}, [splitWidth, clampWidth, persistSplit]);
|
|
785
|
+
|
|
786
|
+
// Track parent width so we can publish a useful aria-valuemax to AT and
|
|
787
|
+
// re-clamp on viewport changes. Falls back to current splitWidth before
|
|
788
|
+
// the ref attaches (so aria-valuemax never trails aria-valuenow).
|
|
789
|
+
const [parentWidth, setParentWidth] = useStateT(0);
|
|
790
|
+
React.useEffect(() => {
|
|
791
|
+
const measure = () => {
|
|
792
|
+
const w = splitContainerRef.current?.getBoundingClientRect().width || 0;
|
|
793
|
+
if (w) setParentWidth(w);
|
|
794
|
+
};
|
|
795
|
+
measure();
|
|
796
|
+
const onResize = () => { measure(); setSplitWidth(w => clampWidth(w)); };
|
|
797
|
+
window.addEventListener('resize', onResize);
|
|
798
|
+
return () => window.removeEventListener('resize', onResize);
|
|
799
|
+
}, [clampWidth]);
|
|
800
|
+
|
|
801
|
+
const ariaMax = parentWidth > 0
|
|
802
|
+
? Math.max(KV_SPLIT_MIN, Math.floor(parentWidth * 0.7))
|
|
803
|
+
: Math.max(KV_SPLIT_MIN, splitWidth);
|
|
804
|
+
|
|
805
|
+
const totalCounts = countTree(tree);
|
|
806
|
+
const filteredTree = filterTree(tree, filters, query);
|
|
807
|
+
|
|
808
|
+
const toggle = id => { const n = new Set(openIds); n.has(id) ? n.delete(id) : n.add(id); setOpenIds(n); };
|
|
809
|
+
const toggleFilter = k => { const n = new Set(filters); n.has(k) ? n.delete(k) : n.add(k); setFilters(n); };
|
|
810
|
+
|
|
811
|
+
const test = selectedId ? window.RICH_TESTS[selectedId] : null;
|
|
812
|
+
const totalTests = Object.values(totalCounts).reduce((a,b)=>a+b,0);
|
|
813
|
+
|
|
814
|
+
// Visible-leaf order — recomputed on every change to filteredTree+openIds.
|
|
815
|
+
// Drives `j` / `k` / Enter shortcuts: navigate among VISIBLE leaves only.
|
|
816
|
+
const visibleLeafIds = React.useMemo(() => {
|
|
817
|
+
const out = [];
|
|
818
|
+
const walk = (nodes) => {
|
|
819
|
+
for (const n of nodes) {
|
|
820
|
+
if (!n.children) { out.push(n.testId); continue; }
|
|
821
|
+
if (openIds.has(n.id)) walk(n.children);
|
|
822
|
+
}
|
|
823
|
+
};
|
|
824
|
+
walk(filteredTree);
|
|
825
|
+
return out;
|
|
826
|
+
}, [filteredTree, openIds]);
|
|
827
|
+
|
|
828
|
+
// Wire up keyboard shortcuts dispatched from app.jsx.
|
|
829
|
+
React.useEffect(() => {
|
|
830
|
+
const onMove = (e) => {
|
|
831
|
+
const delta = e.detail?.delta || 0;
|
|
832
|
+
if (visibleLeafIds.length === 0) return;
|
|
833
|
+
const idx = selectedId ? visibleLeafIds.indexOf(selectedId) : -1;
|
|
834
|
+
const nextIdx = idx === -1
|
|
835
|
+
? (delta > 0 ? 0 : visibleLeafIds.length - 1)
|
|
836
|
+
: Math.max(0, Math.min(visibleLeafIds.length - 1, idx + delta));
|
|
837
|
+
setSelectedId(visibleLeafIds[nextIdx]);
|
|
838
|
+
};
|
|
839
|
+
const onOpen = () => {
|
|
840
|
+
if (selectedId) window.__openTest?.(selectedId);
|
|
841
|
+
};
|
|
842
|
+
const onClear = () => { setQuery(''); setSelectedId(null); };
|
|
843
|
+
window.addEventListener('kensho:move-selection', onMove);
|
|
844
|
+
window.addEventListener('kensho:open-selection', onOpen);
|
|
845
|
+
window.addEventListener('kensho:clear-search', onClear);
|
|
846
|
+
return () => {
|
|
847
|
+
window.removeEventListener('kensho:move-selection', onMove);
|
|
848
|
+
window.removeEventListener('kensho:open-selection', onOpen);
|
|
849
|
+
window.removeEventListener('kensho:clear-search', onClear);
|
|
850
|
+
};
|
|
851
|
+
}, [selectedId, visibleLeafIds]);
|
|
852
|
+
|
|
853
|
+
return (
|
|
854
|
+
<div>
|
|
855
|
+
<div style={{ display:'flex', alignItems:'baseline', justifyContent:'space-between', marginBottom: 14, gap:16, flexWrap:'wrap' }}>
|
|
856
|
+
<div>
|
|
857
|
+
<h1 className="k-h1" style={{ marginBottom:2 }}>{title}</h1>
|
|
858
|
+
<div className="k-meta">{subtitle} · {totalTests} tests</div>
|
|
859
|
+
</div>
|
|
860
|
+
{headerExtra}
|
|
861
|
+
</div>
|
|
862
|
+
|
|
863
|
+
{/* Toolbar */}
|
|
864
|
+
<div style={{ display:'flex', gap:12, alignItems:'center', marginBottom:14, flexWrap:'wrap' }}>
|
|
865
|
+
<div data-kv-search className="kv-tree-search" style={{ display:'flex', alignItems:'center', gap:8, height:32, padding:'0 10px', background:'var(--bg-elev)', border:'1px solid var(--line)', borderRadius:6, flex:'0 0 280px' }}>
|
|
866
|
+
<i data-lucide="search" style={{ width:14, height:14, color:'var(--fg3)' }}></i>
|
|
867
|
+
<input placeholder="Search tests… (press /)" value={query} onChange={e=>setQuery(e.target.value)} style={{ flex:1, border:0, outline:0, fontFamily:'var(--font-body)', fontSize:13, background:'transparent' }}/>
|
|
868
|
+
</div>
|
|
869
|
+
<StatusFilters counts={totalCounts} active={filters} onToggle={toggleFilter}/>
|
|
870
|
+
<div style={{ flex:1 }}></div>
|
|
871
|
+
<button className="btn btn-secondary" onClick={() => setOpenIds(new Set(allIds))} style={{ height:30 }}>Expand all</button>
|
|
872
|
+
<button className="btn btn-secondary" onClick={() => setOpenIds(new Set())} style={{ height:30 }}>Collapse all</button>
|
|
873
|
+
</div>
|
|
874
|
+
|
|
875
|
+
<div ref={splitContainerRef} style={{ display:'grid', gridTemplateColumns:`${splitWidth}px 8px 1fr`, gap:0, background:'var(--bg-elev)', border:'1px solid var(--line)', borderRadius:12, overflow:'hidden', height:'calc(100vh - 220px)', minHeight:560 }}>
|
|
876
|
+
<div style={{ overflow:'auto', minHeight:0 }}>
|
|
877
|
+
{filteredTree.length === 0 ? (
|
|
878
|
+
<div style={{ padding:30, textAlign:'center', color:'var(--fg3)', fontFamily:'var(--font-mono)', fontSize:12 }}>No tests match the current filter.</div>
|
|
879
|
+
) : filteredTree.map(n => (
|
|
880
|
+
<TreeNode key={n.id} node={n} depth={0} openIds={openIds} onToggle={toggle} selectedId={selectedId} onSelect={setSelectedId} leafLabel={leafLabel}/>
|
|
881
|
+
))}
|
|
882
|
+
</div>
|
|
883
|
+
<div
|
|
884
|
+
className={`kv-split-handle${dragging ? ' kv-split-handle--active' : ''}`}
|
|
885
|
+
role="separator"
|
|
886
|
+
aria-orientation="vertical"
|
|
887
|
+
aria-valuenow={Math.round(splitWidth)}
|
|
888
|
+
aria-valuemin={KV_SPLIT_MIN}
|
|
889
|
+
aria-valuemax={ariaMax}
|
|
890
|
+
aria-label="Resize test tree column"
|
|
891
|
+
tabIndex={0}
|
|
892
|
+
onPointerDown={onSplitPointerDown}
|
|
893
|
+
onPointerMove={onSplitPointerMove}
|
|
894
|
+
onPointerUp={endDrag}
|
|
895
|
+
onPointerCancel={endDrag}
|
|
896
|
+
onKeyDown={onSplitKeyDown}
|
|
897
|
+
/>
|
|
898
|
+
<DetailPane test={test}/>
|
|
899
|
+
</div>
|
|
900
|
+
</div>
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// helpers
|
|
905
|
+
function collectIds(tree) {
|
|
906
|
+
const out = [];
|
|
907
|
+
const walk = ns => ns.forEach(n => { if (n.children){ out.push(n.id); walk(n.children); } });
|
|
908
|
+
walk(tree); return out;
|
|
909
|
+
}
|
|
910
|
+
function firstLeaf(tree) {
|
|
911
|
+
for (const n of tree) {
|
|
912
|
+
if (!n.children) return n.testId;
|
|
913
|
+
const r = firstLeaf(n.children); if (r) return r;
|
|
914
|
+
}
|
|
915
|
+
return null;
|
|
916
|
+
}
|
|
917
|
+
function countTree(tree) {
|
|
918
|
+
const c = { passed:0, failed:0, broken:0, skipped:0, unknown:0 };
|
|
919
|
+
const walk = ns => ns.forEach(n => {
|
|
920
|
+
if (!n.children) { const t = window.RICH_TESTS[n.testId]; if (t) c[t.status]++; }
|
|
921
|
+
else walk(n.children);
|
|
922
|
+
});
|
|
923
|
+
walk(tree); return c;
|
|
924
|
+
}
|
|
925
|
+
function filterTree(tree, statusSet, query) {
|
|
926
|
+
const q = query.trim().toLowerCase();
|
|
927
|
+
const matchLeaf = n => {
|
|
928
|
+
const t = window.RICH_TESTS[n.testId]; if (!t) return false;
|
|
929
|
+
if (!statusSet.has(t.status)) return false;
|
|
930
|
+
if (q && !(t.name.toLowerCase().includes(q))) return false;
|
|
931
|
+
return true;
|
|
932
|
+
};
|
|
933
|
+
const recount = list => {
|
|
934
|
+
const c = { passed:0, failed:0, broken:0, skipped:0 };
|
|
935
|
+
const walk = ls => ls.forEach(k => { if (!k.children){ const t=window.RICH_TESTS[k.testId]; if(t && c[t.status] !== undefined) c[t.status]++; } else walk(k.children); });
|
|
936
|
+
walk(list); return c;
|
|
937
|
+
};
|
|
938
|
+
const walk = ns => ns.map(n => {
|
|
939
|
+
if (!n.children) return matchLeaf(n) ? n : null;
|
|
940
|
+
const kids = walk(n.children).filter(Boolean);
|
|
941
|
+
if (!kids.length) return null;
|
|
942
|
+
return { ...n, children: kids, counts: recount(kids) };
|
|
943
|
+
}).filter(Boolean);
|
|
944
|
+
return walk(tree);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
Object.assign(window, { TreeDetailPage, DetailPane, StepTreeRich, CaseLogTab, MetadataTab });
|