@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,502 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// TEST DETAIL — header + step tree with logs/payloads/screenshots
|
|
3
|
+
// Multi-platform: Web · Mobile · API · DB
|
|
4
|
+
// Multi-language: TS · JS · Python · Java · Kotlin · Swift · Go · Ruby · C#
|
|
5
|
+
// ============================================================
|
|
6
|
+
|
|
7
|
+
// ----------- helpers -----------
|
|
8
|
+
|
|
9
|
+
const SEVERITY_COLORS = {
|
|
10
|
+
blocker: { bg: 'var(--status-failed-bg)', fg: 'var(--status-failed-fg)', border: 'var(--status-failed-border)' },
|
|
11
|
+
critical: { bg: 'var(--status-failed-bg)', fg: 'var(--status-failed-fg)', border: 'var(--status-failed-border)' },
|
|
12
|
+
normal: { bg: 'var(--status-broken-bg)', fg: 'var(--status-broken-fg)', border: 'var(--status-broken-border)' },
|
|
13
|
+
minor: { bg: 'var(--status-skipped-bg)', fg: 'var(--status-skipped-fg)', border: 'var(--status-skipped-border)' },
|
|
14
|
+
trivial: { bg: 'var(--status-skipped-bg)', fg: 'var(--status-skipped-fg)', border: 'var(--status-skipped-border)' },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function CopyPath({ path }) {
|
|
18
|
+
const [copied, setCopied] = React.useState(false);
|
|
19
|
+
const handle = () => {
|
|
20
|
+
navigator.clipboard?.writeText(path);
|
|
21
|
+
setCopied(true);
|
|
22
|
+
setTimeout(() => setCopied(false), 1500);
|
|
23
|
+
};
|
|
24
|
+
return (
|
|
25
|
+
<button onClick={handle} style={{
|
|
26
|
+
display:'inline-flex', alignItems:'center', gap:6, fontFamily:'var(--font-mono)', fontSize:12,
|
|
27
|
+
color:'var(--fg2)', background:'transparent', border:'none', padding:'2px 6px', borderRadius:4,
|
|
28
|
+
cursor:'pointer'
|
|
29
|
+
}}
|
|
30
|
+
onMouseEnter={e => e.currentTarget.style.background='var(--bg-hover)'}
|
|
31
|
+
onMouseLeave={e => e.currentTarget.style.background='transparent'}>
|
|
32
|
+
<span style={{ color:'var(--fg2)' }}>{path}</span>
|
|
33
|
+
<Icon name={copied ? 'check' : 'copy'} size={11} />
|
|
34
|
+
{copied && <span style={{ fontSize:10, color:'var(--status-passed)' }}>copied</span>}
|
|
35
|
+
</button>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// CopyPermalink — round-trip the current URL with a `#/case/<id>` fragment so
|
|
40
|
+
// users can paste a deep-link to a specific case into Slack/Jira/etc.
|
|
41
|
+
function CopyPermalink({ testId }) {
|
|
42
|
+
const [copied, setCopied] = React.useState(false);
|
|
43
|
+
const handle = (e) => {
|
|
44
|
+
e.stopPropagation();
|
|
45
|
+
const base = window.location.href.split('#')[0];
|
|
46
|
+
const url = base + '#/case/' + encodeURIComponent(testId);
|
|
47
|
+
navigator.clipboard?.writeText(url);
|
|
48
|
+
setCopied(true);
|
|
49
|
+
window.__kenshoToast?.('Link copied to clipboard');
|
|
50
|
+
setTimeout(() => setCopied(false), 1500);
|
|
51
|
+
};
|
|
52
|
+
return (
|
|
53
|
+
<button onClick={handle} title="Copy permalink to this test"
|
|
54
|
+
style={{
|
|
55
|
+
display:'inline-flex', alignItems:'center', gap:5,
|
|
56
|
+
fontFamily:'var(--font-mono)', fontSize:11, fontWeight:600,
|
|
57
|
+
color: copied ? 'var(--status-passed)' : 'var(--fg3)',
|
|
58
|
+
background:'transparent', border:'1px solid var(--line)',
|
|
59
|
+
padding:'2px 8px', borderRadius:4, cursor:'pointer',
|
|
60
|
+
transition:'background var(--dur-fast), color var(--dur-fast)',
|
|
61
|
+
}}
|
|
62
|
+
onMouseEnter={e => e.currentTarget.style.background='var(--bg-hover)'}
|
|
63
|
+
onMouseLeave={e => e.currentTarget.style.background='transparent'}>
|
|
64
|
+
<Icon name={copied ? 'check' : 'link'} size={11} />
|
|
65
|
+
{copied ? 'copied' : 'copy link'}
|
|
66
|
+
</button>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function StatusPill({ status }) {
|
|
71
|
+
const map = {
|
|
72
|
+
passed: { label:'PASSED', fg:'var(--status-passed)', bg:'var(--status-passed-bg)' },
|
|
73
|
+
failed: { label:'FAILED', fg:'var(--status-failed)', bg:'var(--status-failed-bg)' },
|
|
74
|
+
broken: { label:'BROKEN', fg:'var(--status-broken)', bg:'var(--status-broken-bg)' },
|
|
75
|
+
skipped: { label:'SKIPPED', fg:'var(--status-skipped)', bg:'var(--status-skipped-bg)' },
|
|
76
|
+
};
|
|
77
|
+
const m = map[status] || map.passed;
|
|
78
|
+
return (
|
|
79
|
+
<span style={{
|
|
80
|
+
display:'inline-flex', alignItems:'center', gap:6, padding:'3px 8px', borderRadius:4,
|
|
81
|
+
background:m.bg, color:m.fg, fontFamily:'var(--font-mono)', fontSize:11, fontWeight:700,
|
|
82
|
+
letterSpacing:0.5
|
|
83
|
+
}}>
|
|
84
|
+
<span style={{ width:6, height:6, borderRadius:999, background:m.fg }} />
|
|
85
|
+
{m.label}
|
|
86
|
+
</span>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function MetaField({ label, children }) {
|
|
91
|
+
return (
|
|
92
|
+
<div style={{ display:'inline-flex', alignItems:'center', gap:6 }}>
|
|
93
|
+
<span style={{ fontFamily:'var(--font-mono)', fontSize:10.5, color:'var(--fg3)', letterSpacing:1, textTransform:'uppercase' }}>{label}</span>
|
|
94
|
+
<span style={{ fontFamily:'var(--font-mono)', fontSize:12, color:'var(--fg1)' }}>{children}</span>
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function Tag({ children }) {
|
|
100
|
+
return (
|
|
101
|
+
<span style={{
|
|
102
|
+
display:'inline-flex', alignItems:'center', padding:'2px 8px', borderRadius:4,
|
|
103
|
+
background:'var(--bg-2)', border:'1px solid var(--line)', color:'var(--fg2)',
|
|
104
|
+
fontFamily:'var(--font-mono)', fontSize:11, fontWeight:500
|
|
105
|
+
}}>{children}</span>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// LinkChip — renders external test links (Jira ticket, GitHub PR, runbook,
|
|
110
|
+
// design doc, defect, etc.) as clickable, color-coded chips next to tags.
|
|
111
|
+
// Each kind gets a distinct tint so users can scan visually. Falls back to
|
|
112
|
+
// the URL host when no label is provided.
|
|
113
|
+
const LINK_KIND_STYLE = {
|
|
114
|
+
jira: { bg:'#E7F0FB', fg:'#1D4ED8', border:'#BCD3F0', icon:'square-pen' },
|
|
115
|
+
github: { bg:'#F4EFFF', fg:'#5B21B6', border:'#D4C4F4', icon:'github' },
|
|
116
|
+
gitlab: { bg:'#FFF4EC', fg:'#B7430C', border:'#F4D2BC', icon:'git-branch' },
|
|
117
|
+
linear: { bg:'#EEEDFB', fg:'#3F3DB7', border:'#C8C5F0', icon:'square-arrow-out-up-right' },
|
|
118
|
+
pr: { bg:'#F4EFFF', fg:'#5B21B6', border:'#D4C4F4', icon:'git-pull-request' },
|
|
119
|
+
bug: { bg:'#FCEBEC', fg:'#B91C1C', border:'#F2C8CB', icon:'bug' },
|
|
120
|
+
defect: { bg:'#FCEBEC', fg:'#B91C1C', border:'#F2C8CB', icon:'bug' },
|
|
121
|
+
doc: { bg:'#FFF4DD', fg:'#92400E', border:'#F4DDA8', icon:'book-open' },
|
|
122
|
+
runbook: { bg:'#FFF4DD', fg:'#92400E', border:'#F4DDA8', icon:'play-square' },
|
|
123
|
+
slack: { bg:'#EAFBF1', fg:'#0E5C39', border:'#C5E5D2', icon:'message-square' },
|
|
124
|
+
other: { bg:'var(--bg-sunken)', fg:'var(--fg2)', border:'var(--line)', icon:'link-2' },
|
|
125
|
+
};
|
|
126
|
+
function LinkChip({ link }) {
|
|
127
|
+
const kind = (link.kind || 'other').toLowerCase();
|
|
128
|
+
const s = LINK_KIND_STYLE[kind] || LINK_KIND_STYLE.other;
|
|
129
|
+
const label = link.label || (() => {
|
|
130
|
+
try { return new URL(link.url).hostname.replace(/^www\./, ''); }
|
|
131
|
+
catch { return link.url; }
|
|
132
|
+
})();
|
|
133
|
+
return (
|
|
134
|
+
<a
|
|
135
|
+
href={link.url}
|
|
136
|
+
target="_blank"
|
|
137
|
+
rel="noopener noreferrer"
|
|
138
|
+
title={`${(link.kind || 'link').toUpperCase()} · ${link.url}`}
|
|
139
|
+
style={{
|
|
140
|
+
display:'inline-flex', alignItems:'center', gap:6, padding:'2px 8px',
|
|
141
|
+
borderRadius:4, background:s.bg, color:s.fg, border:`1px solid ${s.border}`,
|
|
142
|
+
fontFamily:'var(--font-mono)', fontSize:11, fontWeight:600, textDecoration:'none',
|
|
143
|
+
transition:'background var(--dur-fast)',
|
|
144
|
+
}}
|
|
145
|
+
>
|
|
146
|
+
<Icon name={s.icon} size={11} />
|
|
147
|
+
{label}
|
|
148
|
+
</a>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function SeverityBadge({ level }) {
|
|
153
|
+
const c = SEVERITY_COLORS[level] || SEVERITY_COLORS.normal;
|
|
154
|
+
return (
|
|
155
|
+
<span style={{
|
|
156
|
+
display:'inline-flex', alignItems:'center', padding:'1px 7px', borderRadius:3,
|
|
157
|
+
background:c.bg, color:c.fg, border:`1px solid ${c.border}`,
|
|
158
|
+
fontFamily:'var(--font-mono)', fontSize:10.5, fontWeight:700, textTransform:'uppercase', letterSpacing:0.5
|
|
159
|
+
}}>{level}</span>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ----------- header -----------
|
|
164
|
+
|
|
165
|
+
function TestHeader({ test, onCopyId }) {
|
|
166
|
+
// Visual hierarchy:
|
|
167
|
+
// 1) file path (copyable, monospace, deemphasized)
|
|
168
|
+
// 2) title row: status pill · title · tags right-aligned
|
|
169
|
+
// 3) facts grid — vertical key-value stack, 2 cols on wide, 1 col on narrow
|
|
170
|
+
//
|
|
171
|
+
// Why: the previous horizontal strip wrapped clumsily. A definition-list
|
|
172
|
+
// style grid stays readable at any width, and a vertical cascade is what
|
|
173
|
+
// real test-report tools (Allure, Cypress dashboard, BrowserStack) use.
|
|
174
|
+
|
|
175
|
+
// Conditional metadata — only render fields the user actually provided.
|
|
176
|
+
// Empty/null/'unassigned'/'—' all suppress the row so the grid stays clean.
|
|
177
|
+
// Severity, Duration and Test ID are always shown (core identity).
|
|
178
|
+
const isBlank = (v) => v == null || v === '' || v === '—' || v === 'unassigned';
|
|
179
|
+
const facts = [
|
|
180
|
+
{ k:'Severity', v:<SeverityBadge level={test.severity}/>, always:true },
|
|
181
|
+
{ k:'Duration', v:<b style={{ fontWeight:600 }}>{test.duration}</b>, always:true },
|
|
182
|
+
!isBlank(test.owner) && { k:'Owner', v:<span style={{ color:'var(--accent)' }}>@{test.owner}</span> },
|
|
183
|
+
!isBlank(test.suite) && { k:'Suite', v:test.suite },
|
|
184
|
+
!isBlank(test.lastRun) && { k:'Last run', v:test.lastRun },
|
|
185
|
+
!isBlank(test.platform) && { k:'Platform', v:test.platform },
|
|
186
|
+
!isBlank(test.epic) && { k:'Epic', v:test.epic },
|
|
187
|
+
!isBlank(test.feature) && { k:'Feature', v:test.feature },
|
|
188
|
+
!isBlank(test.story) && { k:'Story', v:test.story },
|
|
189
|
+
!isBlank(test.language) && { k:'Language', v:test.language },
|
|
190
|
+
!isBlank(test.framework)&& { k:'Framework',v:test.framework },
|
|
191
|
+
{ k:'Test ID', v:<span style={{ fontFamily:'var(--font-mono)', fontSize:11.5 }}>{test.id}</span>, always:true },
|
|
192
|
+
].filter(Boolean);
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<div style={{ marginBottom: 22 }}>
|
|
196
|
+
{/* file path + copy-link permalink */}
|
|
197
|
+
<div style={{ marginBottom: 8, marginLeft: -6, display:'flex', alignItems:'center', gap:8, flexWrap:'wrap' }}>
|
|
198
|
+
<CopyPath path={test.file} />
|
|
199
|
+
<CopyPermalink testId={test.id} />
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
{/* title row */}
|
|
203
|
+
<div style={{ display:'flex', alignItems:'flex-start', gap:14, flexWrap:'wrap', marginBottom: 14 }}>
|
|
204
|
+
<div style={{ display:'flex', alignItems:'center', gap:10, flexWrap:'wrap', flex:1, minWidth:0 }}>
|
|
205
|
+
<StatusPill status={test.status} />
|
|
206
|
+
{test.retries > 0 && (
|
|
207
|
+
<span style={{ display:'inline-flex', alignItems:'center', gap:5, fontFamily:'var(--font-mono)', fontSize:12, color:'var(--fg2)' }}>
|
|
208
|
+
<Icon name="rotate-ccw" size={12} />
|
|
209
|
+
{test.retries} {test.retries === 1 ? 'retry' : 'retries'}
|
|
210
|
+
</span>
|
|
211
|
+
)}
|
|
212
|
+
<h1 style={{ fontSize:26, fontWeight:600, color:'var(--fg1)', margin:0, letterSpacing:-0.3, lineHeight:1.2 }}>{test.title}</h1>
|
|
213
|
+
</div>
|
|
214
|
+
{(test.tags?.length > 0 || test.links?.length > 0) && (
|
|
215
|
+
<div style={{ display:'flex', gap:6, flexWrap:'wrap', alignItems:'center' }}>
|
|
216
|
+
{(test.links || []).map((l, i) => <LinkChip key={'L'+i} link={l}/>)}
|
|
217
|
+
{test.tags?.map(t => <Tag key={t}>{t}</Tag>)}
|
|
218
|
+
</div>
|
|
219
|
+
)}
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
{/* facts grid — vertical cascade; columns auto-collapse. Long values
|
|
223
|
+
(URL paths, long file paths, etc.) wrap onto a second line instead
|
|
224
|
+
of being clipped with an ellipsis — readers should never have to
|
|
225
|
+
guess what a Story or Suite was. align-items:start so the label
|
|
226
|
+
sticks to the top of the row when the value wraps. */}
|
|
227
|
+
<div style={{
|
|
228
|
+
borderTop:'1px solid var(--line)',
|
|
229
|
+
paddingTop:14,
|
|
230
|
+
display:'grid',
|
|
231
|
+
gridTemplateColumns:'repeat(auto-fit, minmax(220px, 1fr))',
|
|
232
|
+
gap:'10px 28px',
|
|
233
|
+
}}>
|
|
234
|
+
{facts.map(f => (
|
|
235
|
+
<div key={f.k} style={{ display:'grid', gridTemplateColumns:'88px 1fr', alignItems:'start', gap:10, minWidth:0 }}>
|
|
236
|
+
<span style={{ fontFamily:'var(--font-mono)', fontSize:10.5, color:'var(--fg3)', letterSpacing:1, textTransform:'uppercase', paddingTop:2 }}>{f.k}</span>
|
|
237
|
+
<span style={{ fontFamily:'var(--font-mono)', fontSize:12.5, color:'var(--fg1)', wordBreak:'break-word', overflowWrap:'anywhere', minWidth:0 }}>{f.v}</span>
|
|
238
|
+
</div>
|
|
239
|
+
))}
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ----------- step tree v2 -----------
|
|
246
|
+
//
|
|
247
|
+
// A step has: { name, status, duration, type?, kind?, body?, logs?, payload?, screenshot?, request?, response?, assertion?, children? }
|
|
248
|
+
// type: 'action' (default) | 'assertion' | 'http' | 'screenshot' | 'device' | 'db' | 'group'
|
|
249
|
+
// logs: [{ ts, lvl: info|warn|err|debug, msg }]
|
|
250
|
+
|
|
251
|
+
const STEP_TYPE_ICON = {
|
|
252
|
+
action: 'mouse-pointer-2',
|
|
253
|
+
assertion: 'check-check',
|
|
254
|
+
http: 'globe',
|
|
255
|
+
screenshot: 'image',
|
|
256
|
+
device: 'smartphone',
|
|
257
|
+
db: 'database',
|
|
258
|
+
group: 'folder',
|
|
259
|
+
navigation: 'navigation',
|
|
260
|
+
api: 'globe',
|
|
261
|
+
setup: 'wrench',
|
|
262
|
+
teardown: 'eraser',
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
function StepIcon({ type }) {
|
|
266
|
+
const name = STEP_TYPE_ICON[type] || 'chevron-right';
|
|
267
|
+
return <Icon name={name} size={12} />;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function LogLine({ log }) {
|
|
271
|
+
const lvlColor = { info:'var(--fg2)', warn:'var(--status-broken-fg)', err:'var(--status-failed)', debug:'var(--fg3)' }[log.lvl] || 'var(--fg2)';
|
|
272
|
+
const lvlBg = { info:'transparent', warn:'var(--status-broken-bg)', err:'var(--status-failed-bg)', debug:'transparent' }[log.lvl] || 'transparent';
|
|
273
|
+
return (
|
|
274
|
+
<div style={{
|
|
275
|
+
display:'grid', gridTemplateColumns:'90px 50px 1fr', gap:10, padding:'3px 8px', borderRadius:3,
|
|
276
|
+
background:lvlBg, fontFamily:'var(--font-mono)', fontSize:11.5, lineHeight:1.55
|
|
277
|
+
}}>
|
|
278
|
+
<span style={{ color:'var(--fg3)' }}>{log.ts}</span>
|
|
279
|
+
<span style={{ color:lvlColor, fontWeight:700, letterSpacing:0.5 }}>{log.lvl.toUpperCase()}</span>
|
|
280
|
+
<span style={{ color:'var(--fg1)', whiteSpace:'pre-wrap', wordBreak:'break-word' }}>{log.msg}</span>
|
|
281
|
+
</div>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function HttpBlock({ request, response }) {
|
|
286
|
+
const statusColor = response.status >= 500 ? 'var(--status-failed)' : response.status >= 400 ? 'var(--status-broken)' : 'var(--status-passed)';
|
|
287
|
+
return (
|
|
288
|
+
<div style={{ border:'1px solid var(--line)', borderRadius:6, overflow:'hidden', fontFamily:'var(--font-mono)', fontSize:11.5 }}>
|
|
289
|
+
{/* request */}
|
|
290
|
+
<div style={{ padding:'8px 12px', borderBottom:'1px solid var(--line)', background:'var(--bg-2)' }}>
|
|
291
|
+
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
|
|
292
|
+
<span style={{ padding:'1px 6px', borderRadius:3, background:'var(--dark-bg)', color:'#fff', fontSize:10, fontWeight:700 }}>{request.method}</span>
|
|
293
|
+
<span style={{ color:'var(--fg1)' }}>{request.url}</span>
|
|
294
|
+
<span style={{ marginLeft:'auto', color:'var(--fg3)' }}>{request.duration}</span>
|
|
295
|
+
</div>
|
|
296
|
+
{request.headers && (
|
|
297
|
+
<details style={{ marginTop:6 }}>
|
|
298
|
+
<summary style={{ cursor:'pointer', color:'var(--fg3)', fontSize:10.5, letterSpacing:0.5, textTransform:'uppercase' }}>Request headers ({Object.keys(request.headers).length})</summary>
|
|
299
|
+
<pre style={{ margin:'6px 0 0', padding:8, background:'var(--bg-elev)', border:'1px solid var(--line)', borderRadius:4, fontSize:11, color:'var(--fg2)', overflow:'auto' }}>{Object.entries(request.headers).map(([k,v]) => `${k}: ${v}`).join('\n')}</pre>
|
|
300
|
+
</details>
|
|
301
|
+
)}
|
|
302
|
+
{request.body && (
|
|
303
|
+
<details style={{ marginTop:6 }} open>
|
|
304
|
+
<summary style={{ cursor:'pointer', color:'var(--fg3)', fontSize:10.5, letterSpacing:0.5, textTransform:'uppercase' }}>Request body</summary>
|
|
305
|
+
<pre style={{ margin:'6px 0 0', padding:8, background:'var(--bg-elev)', border:'1px solid var(--line)', borderRadius:4, fontSize:11, color:'var(--fg2)', overflow:'auto' }}>{typeof request.body === 'string' ? request.body : JSON.stringify(request.body, null, 2)}</pre>
|
|
306
|
+
</details>
|
|
307
|
+
)}
|
|
308
|
+
</div>
|
|
309
|
+
{/* response */}
|
|
310
|
+
<div style={{ padding:'8px 12px' }}>
|
|
311
|
+
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
|
|
312
|
+
<span style={{ padding:'1px 6px', borderRadius:3, background:statusColor, color:'#fff', fontSize:10, fontWeight:700 }}>{response.status} {response.statusText}</span>
|
|
313
|
+
<span style={{ color:'var(--fg3)' }}>{response.size}</span>
|
|
314
|
+
</div>
|
|
315
|
+
{response.body && (
|
|
316
|
+
<details style={{ marginTop:6 }} open>
|
|
317
|
+
<summary style={{ cursor:'pointer', color:'var(--fg3)', fontSize:10.5, letterSpacing:0.5, textTransform:'uppercase' }}>Response body</summary>
|
|
318
|
+
<pre style={{ margin:'6px 0 0', padding:8, background:'var(--bg-2)', border:'1px solid var(--line)', borderRadius:4, fontSize:11, color:'var(--fg2)', overflow:'auto', maxHeight:200 }}>{typeof response.body === 'string' ? response.body : JSON.stringify(response.body, null, 2)}</pre>
|
|
319
|
+
</details>
|
|
320
|
+
)}
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function AssertionBlock({ assertion }) {
|
|
327
|
+
const ok = assertion.passed;
|
|
328
|
+
const c = ok ? { fg:'var(--status-passed)', bg:'var(--status-passed-bg)', border:'var(--status-passed-border)' } : { fg:'var(--status-failed)', bg:'var(--status-failed-bg)', border:'var(--status-failed-border)' };
|
|
329
|
+
return (
|
|
330
|
+
<div style={{ border:`1px solid ${c.border}`, background:c.bg, borderRadius:6, padding:10, fontFamily:'var(--font-mono)', fontSize:11.5, color:'var(--fg1)' }}>
|
|
331
|
+
<div style={{ display:'flex', gap:8, alignItems:'center', marginBottom:6 }}>
|
|
332
|
+
<span style={{ color:c.fg, fontWeight:700, letterSpacing:0.5 }}>{ok ? '✓ ASSERTION PASSED' : '✕ ASSERTION FAILED'}</span>
|
|
333
|
+
<span style={{ color:'var(--fg3)' }}>{assertion.matcher}</span>
|
|
334
|
+
</div>
|
|
335
|
+
<div style={{ display:'grid', gridTemplateColumns:'70px 1fr', gap:'4px 10px' }}>
|
|
336
|
+
<span style={{ color:'var(--fg3)' }}>expected</span>
|
|
337
|
+
<span style={{ color:'var(--status-passed)' }}>{assertion.expected}</span>
|
|
338
|
+
<span style={{ color:'var(--fg3)' }}>actual</span>
|
|
339
|
+
<span style={{ color: ok ? 'var(--status-passed)' : 'var(--status-failed)' }}>{assertion.actual}</span>
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function ScreenshotBlock({ screenshot }) {
|
|
346
|
+
// `screenshot.url` (if present) is the direct path to the image file. When
|
|
347
|
+
// the bridge maps `step.attachments[]` to this shape it sets `.url` to
|
|
348
|
+
// `data/<relativePath>`. Falls back to the placeholder hatching when the
|
|
349
|
+
// adapter only described the screenshot without copying it through.
|
|
350
|
+
const url = screenshot.url;
|
|
351
|
+
return (
|
|
352
|
+
<div style={{ display:'flex', gap:10, alignItems:'flex-start' }}>
|
|
353
|
+
<a href={url || '#'} target="_blank" rel="noopener noreferrer" onClick={url ? undefined : (e => e.preventDefault())}
|
|
354
|
+
style={{ flexShrink:0, display:'block' }} title={url ? 'Open full-size in new tab' : screenshot.name}>
|
|
355
|
+
{url ? (
|
|
356
|
+
<img src={url} alt={screenshot.name || 'screenshot'}
|
|
357
|
+
style={{ width:200, height:120, objectFit:'cover', borderRadius:6, border:'1px solid var(--line)', display:'block', cursor:'zoom-in' }}
|
|
358
|
+
onError={e => { e.currentTarget.style.display = 'none'; }} />
|
|
359
|
+
) : (
|
|
360
|
+
<div style={{
|
|
361
|
+
width:160, height:100, borderRadius:6, border:'1px solid var(--line)',
|
|
362
|
+
background:`repeating-linear-gradient(135deg, var(--bg-sunken) 0 12px, var(--bg-hover) 12px 24px)`,
|
|
363
|
+
display:'flex', alignItems:'center', justifyContent:'center',
|
|
364
|
+
}}>
|
|
365
|
+
<Icon name="image" size={20} />
|
|
366
|
+
</div>
|
|
367
|
+
)}
|
|
368
|
+
</a>
|
|
369
|
+
<div style={{ flex:1, fontFamily:'var(--font-mono)', fontSize:11.5, color:'var(--fg2)', minWidth:0 }}>
|
|
370
|
+
<div style={{ color:'var(--fg1)', fontWeight:600, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{screenshot.name}</div>
|
|
371
|
+
<div style={{ color:'var(--fg3)', marginTop:2 }}>{[screenshot.size, screenshot.dimensions].filter(Boolean).join(' · ')}</div>
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function StepNode({ step, depth = 0, defaultOpen }) {
|
|
378
|
+
const hasContent = step.logs?.length || step.body || step.children?.length || step.request || step.response || step.assertion || step.screenshot || step.payload;
|
|
379
|
+
const isProblem = step.status === 'failed' || step.status === 'broken';
|
|
380
|
+
const [open, setOpen] = React.useState(defaultOpen ?? isProblem);
|
|
381
|
+
const [hover, setHover] = React.useState(false);
|
|
382
|
+
const stepIcon = step.status === 'passed' ? '✓' : step.status === 'failed' ? '✕' : step.status === 'broken' ? '!' : '⊘';
|
|
383
|
+
|
|
384
|
+
// What's behind this row? Build a hint string so users know it's clickable.
|
|
385
|
+
const hints = [];
|
|
386
|
+
if (step.children?.length) hints.push(`${step.children.length} sub-step${step.children.length===1?'':'s'}`);
|
|
387
|
+
if (step.request || step.response) hints.push('request');
|
|
388
|
+
if (step.assertion) hints.push('assertion');
|
|
389
|
+
if (step.screenshot) hints.push('screenshot');
|
|
390
|
+
if (step.logs?.length) hints.push(`${step.logs.length} log${step.logs.length===1?'':'s'}`);
|
|
391
|
+
if (step.body) hints.push('details');
|
|
392
|
+
if (step.payload && hints.length === 0) hints.push('params');
|
|
393
|
+
|
|
394
|
+
return (
|
|
395
|
+
<div className={`step ${step.status}`}>
|
|
396
|
+
<div
|
|
397
|
+
className="head"
|
|
398
|
+
onClick={() => hasContent && setOpen(o => !o)}
|
|
399
|
+
onMouseEnter={() => setHover(true)}
|
|
400
|
+
onMouseLeave={() => setHover(false)}
|
|
401
|
+
style={{
|
|
402
|
+
cursor: hasContent ? 'pointer' : 'default',
|
|
403
|
+
userSelect:'none',
|
|
404
|
+
background: hasContent && hover ? 'var(--bg-hover)' : 'transparent',
|
|
405
|
+
borderRadius: 4,
|
|
406
|
+
padding: '4px 6px',
|
|
407
|
+
margin: '-4px -6px',
|
|
408
|
+
transition: 'background 120ms',
|
|
409
|
+
}}
|
|
410
|
+
>
|
|
411
|
+
{/* Chevron — CSS triangle so it never depends on icon font hydration.
|
|
412
|
+
Always present (12px slot) so step rows align; rendered only when expandable. */}
|
|
413
|
+
<span style={{
|
|
414
|
+
width:12, height:12, display:'inline-flex', alignItems:'center', justifyContent:'center',
|
|
415
|
+
color: hasContent ? (open ? 'var(--fg1)' : 'var(--fg2)') : 'transparent',
|
|
416
|
+
transition: 'transform 140ms, color 120ms',
|
|
417
|
+
transform: open ? 'rotate(90deg)' : 'rotate(0deg)',
|
|
418
|
+
fontSize: 10, lineHeight: 1, fontFamily: 'var(--font-mono)',
|
|
419
|
+
}}>
|
|
420
|
+
{hasContent ? '▶' : ''}
|
|
421
|
+
</span>
|
|
422
|
+
<span className={`s-icon ${step.status}`} style={{ width:14, height:14, fontSize:9 }}>{stepIcon}</span>
|
|
423
|
+
{step.type && step.type !== 'action' && (
|
|
424
|
+
<span style={{ color:'var(--fg3)', display:'inline-flex', alignItems:'center' }}><StepIcon type={step.type}/></span>
|
|
425
|
+
)}
|
|
426
|
+
<span className="name" style={{ flex:1, minWidth:0, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{step.name}</span>
|
|
427
|
+
|
|
428
|
+
{/* Content hint — tells the user what's hidden behind the click.
|
|
429
|
+
Only rendered when there's something useful to expand. */}
|
|
430
|
+
{hasContent && hints.length > 0 && (
|
|
431
|
+
<span style={{
|
|
432
|
+
fontFamily:'var(--font-mono)', fontSize:10, color:'var(--fg3)',
|
|
433
|
+
padding:'1px 6px', background:'var(--bg-2)', borderRadius:3,
|
|
434
|
+
border:'1px solid var(--line)',
|
|
435
|
+
}}>
|
|
436
|
+
{hints.join(' · ')}
|
|
437
|
+
</span>
|
|
438
|
+
)}
|
|
439
|
+
<span className="dur">{step.duration}</span>
|
|
440
|
+
</div>
|
|
441
|
+
|
|
442
|
+
{open && (
|
|
443
|
+
<div style={{ marginTop:6, marginLeft:14, display:'flex', flexDirection:'column', gap:8 }}>
|
|
444
|
+
{/* body / failure trace */}
|
|
445
|
+
{step.body && (
|
|
446
|
+
<pre style={{
|
|
447
|
+
margin:0, padding:'10px 12px', background:isProblem ? 'var(--status-failed-bg)' : 'var(--bg-2)',
|
|
448
|
+
border:`1px solid ${isProblem ? 'var(--status-failed-border)' : 'var(--line)'}`, borderRadius:6,
|
|
449
|
+
fontFamily:'var(--font-mono)', fontSize:11.5, color: isProblem ? 'var(--status-failed-fg)' : 'var(--fg2)',
|
|
450
|
+
whiteSpace:'pre-wrap', wordBreak:'break-word', lineHeight:1.55
|
|
451
|
+
}}>{step.body}</pre>
|
|
452
|
+
)}
|
|
453
|
+
|
|
454
|
+
{/* http */}
|
|
455
|
+
{step.request && step.response && <HttpBlock request={step.request} response={step.response} />}
|
|
456
|
+
|
|
457
|
+
{/* assertion */}
|
|
458
|
+
{step.assertion && <AssertionBlock assertion={step.assertion} />}
|
|
459
|
+
|
|
460
|
+
{/* screenshot */}
|
|
461
|
+
{step.screenshot && <ScreenshotBlock screenshot={step.screenshot} />}
|
|
462
|
+
|
|
463
|
+
{/* payload (DB, device, generic) */}
|
|
464
|
+
{step.payload && (
|
|
465
|
+
<pre style={{ margin:0, padding:'8px 12px', background:'var(--bg-2)', border:'1px solid var(--line)', borderRadius:6, fontFamily:'var(--font-mono)', fontSize:11.5, color:'var(--fg2)', overflow:'auto' }}>{step.payload}</pre>
|
|
466
|
+
)}
|
|
467
|
+
|
|
468
|
+
{/* logs — wrapper uses --bg-sunken so it adapts to light/dark theme
|
|
469
|
+
(light: pale gray strip; dark: near-black strip) and never clashes
|
|
470
|
+
against the surrounding card background. */}
|
|
471
|
+
{step.logs?.length > 0 && (
|
|
472
|
+
<div style={{ background:'var(--bg-sunken)', border:'1px solid var(--line)', borderRadius:6, padding:'6px 4px' }}>
|
|
473
|
+
<div style={{ padding:'4px 10px', fontFamily:'var(--font-mono)', fontSize:10, color:'var(--fg3)', letterSpacing:1, textTransform:'uppercase' }}>Logs · {step.logs.length}</div>
|
|
474
|
+
<div style={{ background:'var(--bg-elev)', border:'1px solid var(--line)', borderRadius:4, margin:'4px', padding:'4px 0' }}>
|
|
475
|
+
{step.logs.map((l,i) => <LogLine key={i} log={l}/>)}
|
|
476
|
+
</div>
|
|
477
|
+
</div>
|
|
478
|
+
)}
|
|
479
|
+
|
|
480
|
+
{/* children */}
|
|
481
|
+
{step.children?.length > 0 && (
|
|
482
|
+
<div className="children">
|
|
483
|
+
<StepTreeV2 steps={step.children} depth={depth+1} />
|
|
484
|
+
</div>
|
|
485
|
+
)}
|
|
486
|
+
</div>
|
|
487
|
+
)}
|
|
488
|
+
</div>
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function StepTreeV2({ steps, depth = 0 }) {
|
|
493
|
+
return (
|
|
494
|
+
<div>
|
|
495
|
+
{steps.map((s, i) => <StepNode key={i} step={s} depth={depth} />)}
|
|
496
|
+
</div>
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
Object.assign(window, {
|
|
501
|
+
TestHeader, StepTreeV2, StatusPill, Tag, SeverityBadge, MetaField, CopyPath, CopyPermalink, LinkChip
|
|
502
|
+
});
|