@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,543 @@
|
|
|
1
|
+
/* Auto-generated. Wraps src/data.js for the static-report bootstrap. */
|
|
2
|
+
(function () {
|
|
3
|
+
// =============================================================
|
|
4
|
+
// loadKenshoData — pure data loader + normalizer.
|
|
5
|
+
//
|
|
6
|
+
// Fetches `${dataUrl}/index.json` and (lazily) `${dataUrl}/cases/<id>.json`,
|
|
7
|
+
// then maps the Kensho v1 schema into the shape the viewer's components
|
|
8
|
+
// expect (RUN, ENV, SUITES, RICH_TESTS, …).
|
|
9
|
+
//
|
|
10
|
+
// Used by:
|
|
11
|
+
// * `assets/data-bridge.jsx` — writes the normalized result onto window
|
|
12
|
+
// globals so the static-report bootstrap path keeps working unchanged.
|
|
13
|
+
// * `src/component.jsx` — feeds the same result into a React Context
|
|
14
|
+
// so the embeddable `<KenshoViewer>` doesn't depend on globals.
|
|
15
|
+
//
|
|
16
|
+
// Important: this module must stay framework-free. Don't import React.
|
|
17
|
+
// =============================================================
|
|
18
|
+
|
|
19
|
+
const STATUS = { pass: 'passed', fail: 'failed', broken: 'broken', skip: 'skipped' };
|
|
20
|
+
|
|
21
|
+
function fmtDuration(ms) {
|
|
22
|
+
if (ms == null) return '—';
|
|
23
|
+
if (ms === 0) return '—';
|
|
24
|
+
if (ms < 1000) return ms + 'ms';
|
|
25
|
+
const totalSec = ms / 1000;
|
|
26
|
+
if (totalSec < 60) {
|
|
27
|
+
const whole = Math.floor(totalSec);
|
|
28
|
+
const remMs = ms - whole * 1000;
|
|
29
|
+
return remMs ? whole + 's ' + remMs + 'ms' : whole + 's';
|
|
30
|
+
}
|
|
31
|
+
const m = Math.floor(totalSec / 60);
|
|
32
|
+
const s = Math.floor(totalSec % 60);
|
|
33
|
+
return m + 'm ' + s + 's';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function relTime(iso) {
|
|
37
|
+
if (!iso) return '';
|
|
38
|
+
const ms = Date.now() - new Date(iso).getTime();
|
|
39
|
+
if (ms < 0) return new Date(iso).toLocaleString();
|
|
40
|
+
if (ms < 60_000) return Math.max(1, Math.floor(ms / 1000)) + 's ago';
|
|
41
|
+
if (ms < 3_600_000) return Math.floor(ms / 60_000) + 'm ago';
|
|
42
|
+
if (ms < 86_400_000) return Math.floor(ms / 3_600_000) + 'h ago';
|
|
43
|
+
return Math.floor(ms / 86_400_000) + 'd ago';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function inferStepType(s) {
|
|
47
|
+
if (s.phase === 'setup') return 'setup';
|
|
48
|
+
if (s.phase === 'teardown') return 'teardown';
|
|
49
|
+
if (s.children?.length) return 'group';
|
|
50
|
+
if (s.assertion) return 'assertion';
|
|
51
|
+
if (s.network?.length) return 'http';
|
|
52
|
+
const t = (s.title || '').toLowerCase();
|
|
53
|
+
if (t.includes('navigate') || t.includes('goto') || t.startsWith('open ')) return 'navigation';
|
|
54
|
+
if (t.includes('verify') || t.includes('expect') || t.includes('assert')) return 'assertion';
|
|
55
|
+
if (t.startsWith('post ') || t.startsWith('get ') || t.includes('http')) return 'http';
|
|
56
|
+
if (t.includes('select ') || t.includes('insert ') || t.includes('query')) return 'db';
|
|
57
|
+
if (t.startsWith('screenshot')) return 'screenshot';
|
|
58
|
+
return 'action';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function mapLog(l) {
|
|
62
|
+
return {
|
|
63
|
+
ts: typeof l.t === 'number'
|
|
64
|
+
? new Date(l.t).toISOString().slice(11, 23)
|
|
65
|
+
: (typeof l.t === 'string' ? l.t.slice(11, 23) : ''),
|
|
66
|
+
lvl: l.level === 'error' ? 'err' : (l.level || 'info'),
|
|
67
|
+
msg: l.msg || '',
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// `attachmentBase` is "data/" for the static report (matches existing legacy
|
|
72
|
+
// behavior) and `${dataUrl}/` for the embedded component.
|
|
73
|
+
function makeMapStep(attachmentBase) {
|
|
74
|
+
function mapStep(s) {
|
|
75
|
+
const out = {
|
|
76
|
+
name: s.title || '(unnamed step)',
|
|
77
|
+
status: STATUS[s.status] || s.status,
|
|
78
|
+
duration: fmtDuration(s.duration),
|
|
79
|
+
type: inferStepType(s),
|
|
80
|
+
};
|
|
81
|
+
if (s.logs?.length) out.logs = s.logs.map(mapLog);
|
|
82
|
+
if (s.children?.length) out.children = s.children.map(mapStep);
|
|
83
|
+
if (s.parameters?.length) {
|
|
84
|
+
out.payload = s.parameters.map(p => `${p.name} = ${p.value}`).join('\n');
|
|
85
|
+
}
|
|
86
|
+
if (s.assertion) {
|
|
87
|
+
out.assertion = {
|
|
88
|
+
passed: s.status === 'pass',
|
|
89
|
+
matcher: s.action || 'expect',
|
|
90
|
+
expected: typeof s.assertion.expected === 'string'
|
|
91
|
+
? s.assertion.expected
|
|
92
|
+
: JSON.stringify(s.assertion.expected),
|
|
93
|
+
actual: typeof s.assertion.received === 'string'
|
|
94
|
+
? s.assertion.received
|
|
95
|
+
: JSON.stringify(s.assertion.received),
|
|
96
|
+
};
|
|
97
|
+
if (s.assertion.diff) out.body = s.assertion.diff;
|
|
98
|
+
}
|
|
99
|
+
if (s.network?.length) {
|
|
100
|
+
const n = s.network[0];
|
|
101
|
+
out.request = {
|
|
102
|
+
method: n.method, url: n.url, duration: fmtDuration(n.durationMs),
|
|
103
|
+
...(n.requestBody ? { body: n.requestBody } : {}),
|
|
104
|
+
};
|
|
105
|
+
out.response = {
|
|
106
|
+
status: n.status, statusText: n.status >= 400 ? 'ERR' : 'OK',
|
|
107
|
+
size: n.sizeBytes ? n.sizeBytes + 'B' : '',
|
|
108
|
+
...(n.responseBody ? { body: n.responseBody } : {}),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (s.attachments?.length) {
|
|
112
|
+
const visual = s.attachments.find(a =>
|
|
113
|
+
a.kind === 'screenshot' || a.kind === 'image' || a.kind === 'video' ||
|
|
114
|
+
(a.mimeType || '').startsWith('image/') || (a.mimeType || '').startsWith('video/')
|
|
115
|
+
);
|
|
116
|
+
if (visual) {
|
|
117
|
+
const url = attachmentBase + (visual.relativePath || '').replace(/^\/+/, '');
|
|
118
|
+
const name = (visual.relativePath || '').split(/[\\/]/).pop() || visual.id || 'attachment';
|
|
119
|
+
const sizeKB = visual.sizeBytes ? (visual.sizeBytes / 1024).toFixed(1) + ' KB' : '';
|
|
120
|
+
out.screenshot = { url, name, size: sizeKB, dimensions: '' };
|
|
121
|
+
}
|
|
122
|
+
out._attachments = s.attachments;
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
return mapStep;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function buildRichTest(c, idx, runStartMs) {
|
|
130
|
+
const startMs = c.startedAt ? Math.max(0, new Date(c.startedAt).getTime() - runStartMs) : 0;
|
|
131
|
+
return {
|
|
132
|
+
id: c.id,
|
|
133
|
+
order: idx + 1,
|
|
134
|
+
name: c.name,
|
|
135
|
+
fullName: c.fullName,
|
|
136
|
+
status: STATUS[c.status] || c.status,
|
|
137
|
+
dur: fmtDuration(c.duration),
|
|
138
|
+
durMs: c.duration || 0,
|
|
139
|
+
start: startMs,
|
|
140
|
+
suite: (c.suite || []).join(' › '),
|
|
141
|
+
suiteChain: c.suite || [],
|
|
142
|
+
severity: c.severity || 'normal',
|
|
143
|
+
retries: c.retries || 0,
|
|
144
|
+
flakeRate: 0,
|
|
145
|
+
avgDurMs: c.duration || 0,
|
|
146
|
+
platform: [c.browser, c.platform].filter(Boolean).join(' · '),
|
|
147
|
+
description: '',
|
|
148
|
+
parameters: [],
|
|
149
|
+
tags: c.tags || [],
|
|
150
|
+
owner: c.owner || '',
|
|
151
|
+
file: c.filePath ? c.filePath + (c.line ? ':' + c.line : '') : '',
|
|
152
|
+
epic: c.behavior?.epic,
|
|
153
|
+
feature: c.behavior?.feature,
|
|
154
|
+
story: c.behavior?.scenario,
|
|
155
|
+
lastRun: c.startedAt ? relTime(c.startedAt) : '',
|
|
156
|
+
bdd: null,
|
|
157
|
+
labels: c.labels || {},
|
|
158
|
+
links: c.links || [],
|
|
159
|
+
error: (c.hasErrors || c.errorPreview) ? {
|
|
160
|
+
kind: c.errorType || 'Error',
|
|
161
|
+
message: c.errorPreview || '',
|
|
162
|
+
stack: '',
|
|
163
|
+
} : null,
|
|
164
|
+
steps: [],
|
|
165
|
+
_summary: c,
|
|
166
|
+
_full: null,
|
|
167
|
+
_stepsLoaded: false,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function deriveSuiteTree(cases) {
|
|
172
|
+
const root = { _children: new Map() };
|
|
173
|
+
for (const c of cases) {
|
|
174
|
+
const chain = (c.suite && c.suite.length) ? c.suite : ['Default'];
|
|
175
|
+
let node = root;
|
|
176
|
+
for (let i = 0; i < chain.length; i++) {
|
|
177
|
+
const part = chain[i];
|
|
178
|
+
if (!node._children.has(part)) node._children.set(part, { _name: part, _children: new Map() });
|
|
179
|
+
node = node._children.get(part);
|
|
180
|
+
}
|
|
181
|
+
if (!node._tests) node._tests = [];
|
|
182
|
+
node._tests.push(c.id);
|
|
183
|
+
}
|
|
184
|
+
let auto = 0;
|
|
185
|
+
function toTree(node, parentId) {
|
|
186
|
+
const out = [];
|
|
187
|
+
for (const [name, child] of node._children) {
|
|
188
|
+
const id = parentId + '/' + name;
|
|
189
|
+
const childNodes = toTree(child, id);
|
|
190
|
+
const leaves = (child._tests || []).map(tid => ({ id: id + '/leaf-' + (++auto), testId: tid }));
|
|
191
|
+
const all = [...childNodes, ...leaves];
|
|
192
|
+
if (all.length) out.push({ id, name, children: all });
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
return toTree(root, 'suite');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function _kvBehaviorPickFromLabels(c) {
|
|
200
|
+
// Some adapters populate `case.behavior.{epic,feature,scenario}`; others
|
|
201
|
+
// emit them as labels (`epic`/`feature`/`story`). Read both.
|
|
202
|
+
const labels = c.labels || {};
|
|
203
|
+
return {
|
|
204
|
+
epic: (c.behavior && c.behavior.epic) || labels.epic || labels.Epic,
|
|
205
|
+
feature: (c.behavior && c.behavior.feature) || labels.feature || labels.Feature,
|
|
206
|
+
story: (c.behavior && c.behavior.scenario) || labels.story || labels.Story,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
function deriveBehaviorTree(cases) {
|
|
210
|
+
const tree = new Map();
|
|
211
|
+
for (const c of cases) {
|
|
212
|
+
const { epic, feature, story } = _kvBehaviorPickFromLabels(c);
|
|
213
|
+
if (!epic && !feature) continue;
|
|
214
|
+
const e = epic || '(unspecified epic)';
|
|
215
|
+
const f = feature || '(unspecified feature)';
|
|
216
|
+
if (!tree.has(e)) tree.set(e, new Map());
|
|
217
|
+
const features = tree.get(e);
|
|
218
|
+
if (!features.has(f)) features.set(f, []);
|
|
219
|
+
features.get(f).push({ caseId: c.id, story: story || c.name });
|
|
220
|
+
}
|
|
221
|
+
let auto = 0;
|
|
222
|
+
const out = [];
|
|
223
|
+
for (const [epic, features] of tree) {
|
|
224
|
+
const eId = 'epic/' + epic;
|
|
225
|
+
const fNodes = [];
|
|
226
|
+
for (const [feature, stories] of features) {
|
|
227
|
+
const fId = eId + '/' + feature;
|
|
228
|
+
const sNodes = stories.map(s => ({ id: fId + '/leaf-' + (++auto), testId: s.caseId }));
|
|
229
|
+
fNodes.push({ id: fId, name: 'Feature · ' + feature, children: sNodes });
|
|
230
|
+
}
|
|
231
|
+
out.push({ id: eId, name: 'Epic · ' + epic, children: fNodes });
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function deriveCategories(cases) {
|
|
237
|
+
const map = new Map();
|
|
238
|
+
for (const c of cases) {
|
|
239
|
+
const isFail = c.status === 'fail' || c.status === 'broken';
|
|
240
|
+
// Always bucket failures, even when the adapter didn't populate
|
|
241
|
+
// errorType/errorPreview — the user expects every failed case to be
|
|
242
|
+
// visible somewhere on Categories.
|
|
243
|
+
if (!isFail && !c.errorType && !c.errorPreview) continue;
|
|
244
|
+
const kind = c.errorType || 'Error';
|
|
245
|
+
const family = c.status === 'broken' ? 'broken' : c.status === 'skip' ? 'skipped' : 'failed';
|
|
246
|
+
const color = family === 'failed' ? 'var(--status-failed)'
|
|
247
|
+
: family === 'broken' ? 'var(--status-broken)'
|
|
248
|
+
: 'var(--status-skipped)';
|
|
249
|
+
if (!map.has(kind)) map.set(kind, { kind, family, color, count: 0, tests: [], description: describeKind(kind) });
|
|
250
|
+
const e = map.get(kind);
|
|
251
|
+
e.count += 1;
|
|
252
|
+
e.tests.push(c.id);
|
|
253
|
+
}
|
|
254
|
+
return [...map.values()].sort((a, b) => b.count - a.count);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function describeKind(kind) {
|
|
258
|
+
const map = {
|
|
259
|
+
AssertionError: 'Expected vs. actual mismatch in a test assertion. Most often a real product defect.',
|
|
260
|
+
TimeoutError: 'A wait condition exceeded its budget. Could be a slow service or a missing element.',
|
|
261
|
+
NetworkError: 'Non-2xx response from a backend dependency during the test.',
|
|
262
|
+
Error: 'Generic error — inspect the failing test for details.',
|
|
263
|
+
};
|
|
264
|
+
return map[kind] || ('Failures classified as ' + kind + '.');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function deriveTimelineRows(cases, runStartMs) {
|
|
268
|
+
return cases
|
|
269
|
+
.filter(c => c.duration && c.duration > 0)
|
|
270
|
+
.map(c => ({
|
|
271
|
+
id: c.id,
|
|
272
|
+
suite: (c.suite && c.suite[0]) || 'Default',
|
|
273
|
+
name: c.name,
|
|
274
|
+
start: c.startedAt ? Math.max(0, new Date(c.startedAt).getTime() - runStartMs) : 0,
|
|
275
|
+
durMs: c.duration,
|
|
276
|
+
dur: fmtDuration(c.duration),
|
|
277
|
+
status: STATUS[c.status] || c.status,
|
|
278
|
+
platform: [c.browser, c.platform].filter(Boolean).join(' · '),
|
|
279
|
+
severity: c.severity || 'normal',
|
|
280
|
+
retries: c.retries || 0,
|
|
281
|
+
file: c.filePath ? c.filePath + (c.line ? ':' + c.line : '') : '',
|
|
282
|
+
}));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function deriveTrendRuns(history, current) {
|
|
286
|
+
const runs = [];
|
|
287
|
+
if (history?.length) {
|
|
288
|
+
for (const h of history) {
|
|
289
|
+
const t = h.totals || {};
|
|
290
|
+
runs.push({
|
|
291
|
+
short: (h.id || '').slice(-4) || h.id,
|
|
292
|
+
passed: t.pass || 0,
|
|
293
|
+
failed: t.fail || 0,
|
|
294
|
+
broken: t.broken || 0,
|
|
295
|
+
skipped: t.skip || 0,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (current) runs.push(current);
|
|
300
|
+
if (runs.length === 0 && current) runs.push(current);
|
|
301
|
+
return runs;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function deriveDurationHistogram(cases) {
|
|
305
|
+
const buckets = [
|
|
306
|
+
{ label: '<100ms', max: 100 },
|
|
307
|
+
{ label: '<500ms', max: 500 },
|
|
308
|
+
{ label: '<1s', max: 1000 },
|
|
309
|
+
{ label: '<2s', max: 2000 },
|
|
310
|
+
{ label: '<5s', max: 5000 },
|
|
311
|
+
{ label: '<10s', max: 10000 },
|
|
312
|
+
{ label: '≥10s', max: Infinity },
|
|
313
|
+
];
|
|
314
|
+
return buckets.map(b => ({
|
|
315
|
+
label: b.label,
|
|
316
|
+
n: cases.filter(c => c.duration && c.duration > 0 && c.duration < b.max).length,
|
|
317
|
+
})).map((b, i, arr) => ({
|
|
318
|
+
label: b.label,
|
|
319
|
+
n: i === 0 ? b.n : (b.n - (arr[i - 1]?.n || 0)),
|
|
320
|
+
}));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function deriveHistoryRuns(history, current) {
|
|
324
|
+
const out = [];
|
|
325
|
+
if (current) {
|
|
326
|
+
out.push({
|
|
327
|
+
id: current.id,
|
|
328
|
+
when: '2m ago',
|
|
329
|
+
branch: current.branch,
|
|
330
|
+
actor: current.actor,
|
|
331
|
+
passed: current.counts.passed,
|
|
332
|
+
failed: current.counts.failed,
|
|
333
|
+
broken: current.counts.broken,
|
|
334
|
+
skipped: current.counts.skipped,
|
|
335
|
+
dur: current.duration,
|
|
336
|
+
status: current.counts.failed + current.counts.broken > 0 ? 'failed' : 'passed',
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
if (history?.length) {
|
|
340
|
+
for (const h of history) {
|
|
341
|
+
const t = h.totals || {};
|
|
342
|
+
const failed = (t.fail || 0) + (t.broken || 0);
|
|
343
|
+
out.push({
|
|
344
|
+
id: '#' + (h.id || '?'),
|
|
345
|
+
when: h.startedAt ? relTime(h.startedAt) : '',
|
|
346
|
+
branch: h.branch || 'main',
|
|
347
|
+
actor: 'kensho',
|
|
348
|
+
passed: t.pass || 0,
|
|
349
|
+
failed: t.fail || 0,
|
|
350
|
+
broken: t.broken || 0,
|
|
351
|
+
skipped: t.skip || 0,
|
|
352
|
+
dur: fmtDuration(h.durationMs),
|
|
353
|
+
status: failed > 0 ? 'failed' : 'passed',
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const ENV_LABEL = {
|
|
361
|
+
ci: 'CI', branch: 'Branch', commit: 'Commit', commitMsg: 'Commit msg',
|
|
362
|
+
author: 'Author', runUrl: 'Run URL', repoUrl: 'Repo URL', os: 'OS', osVersion: 'OS version',
|
|
363
|
+
arch: 'Arch', nodeVersion: 'Node', pythonVersion: 'Python',
|
|
364
|
+
browsers: 'Browsers', workers: 'Workers', stage: 'Stage', baseUrl: 'Base URL',
|
|
365
|
+
appVersion: 'App version', buildNumber: 'Build', release: 'Release',
|
|
366
|
+
device: 'Device', viewport: 'Viewport', region: 'Region', locale: 'Locale',
|
|
367
|
+
timezone: 'Timezone', tunnel: 'Tunnel', trigger: 'Trigger', feature: 'Feature',
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Load and normalize a Kensho run.
|
|
372
|
+
*
|
|
373
|
+
* @param {string} dataUrl
|
|
374
|
+
* URL (relative or absolute) to a Kensho `data/` directory. The loader
|
|
375
|
+
* appends `/index.json` and `/cases/<id>.json` to it.
|
|
376
|
+
* @param {object} [opts]
|
|
377
|
+
* @param {(caseId: string) => string} [opts.caseUrl]
|
|
378
|
+
* Override the per-case URL builder. Default:
|
|
379
|
+
* `${dataUrl}/cases/${caseId}.json`.
|
|
380
|
+
* @param {typeof fetch} [opts.fetch]
|
|
381
|
+
* Override the global `fetch` implementation (useful for tests / SSR).
|
|
382
|
+
* @returns {Promise<KenshoState>}
|
|
383
|
+
*/
|
|
384
|
+
async function loadKenshoData(dataUrl, opts = {}) {
|
|
385
|
+
if (!dataUrl) throw new Error('loadKenshoData: dataUrl is required');
|
|
386
|
+
const baseUrl = String(dataUrl).replace(/\/+$/, '');
|
|
387
|
+
const fetchImpl = opts.fetch || (typeof fetch !== 'undefined' ? fetch : null);
|
|
388
|
+
if (!fetchImpl) throw new Error('loadKenshoData: no `fetch` available — pass opts.fetch.');
|
|
389
|
+
const caseUrl = opts.caseUrl || ((id) => `${baseUrl}/cases/${id}.json`);
|
|
390
|
+
// Attachments declared on `step.attachments[*].relativePath` are joined to
|
|
391
|
+
// `${baseUrl}/`. For the static report, baseUrl === "data" so the URL ends
|
|
392
|
+
// up "data/attachments/…", matching the legacy behavior. For embedded, it
|
|
393
|
+
// ends up something like "/v1/runs/<id>/data/attachments/…".
|
|
394
|
+
const attachmentBase = baseUrl + '/';
|
|
395
|
+
const mapStep = makeMapStep(attachmentBase);
|
|
396
|
+
|
|
397
|
+
const idx = await fetchImpl(`${baseUrl}/index.json`, { cache: 'no-cache' }).then(r => r.json());
|
|
398
|
+
const runStartMs = idx.startedAt ? new Date(idx.startedAt).getTime() : Date.now();
|
|
399
|
+
const totals = idx.totals || {};
|
|
400
|
+
const counts = {
|
|
401
|
+
passed: totals.pass || 0,
|
|
402
|
+
failed: totals.fail || 0,
|
|
403
|
+
broken: totals.broken || 0,
|
|
404
|
+
skipped: totals.skip || 0,
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
const run = {
|
|
408
|
+
id: '#' + (idx.runId || 'unknown'),
|
|
409
|
+
branch: idx.env?.branch || (idx.env?.ci === 'local' ? 'local' : 'main'),
|
|
410
|
+
commit: (idx.env?.commit || '').slice(0, 7),
|
|
411
|
+
commitFull: idx.env?.commit || '',
|
|
412
|
+
actor: idx.env?.author || idx.project?.slug || 'kensho',
|
|
413
|
+
startedAt: idx.startedAt ? new Date(idx.startedAt).toLocaleString() : '',
|
|
414
|
+
duration: fmtDuration(idx.durationMs),
|
|
415
|
+
counts,
|
|
416
|
+
repoUrl: idx.env?.repoUrl || '',
|
|
417
|
+
runUrl: idx.env?.runUrl || '',
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
const ENV_FIELDS = Object.keys(ENV_LABEL);
|
|
421
|
+
const env = ENV_FIELDS
|
|
422
|
+
.filter(k => idx.env?.[k] != null && idx.env[k] !== '' && (!Array.isArray(idx.env[k]) || idx.env[k].length > 0))
|
|
423
|
+
.map(k => [
|
|
424
|
+
ENV_LABEL[k] || k,
|
|
425
|
+
Array.isArray(idx.env[k]) ? idx.env[k].join(', ') : String(idx.env[k]),
|
|
426
|
+
]);
|
|
427
|
+
if (idx.env?.vars && typeof idx.env.vars === 'object') {
|
|
428
|
+
for (const [k, v] of Object.entries(idx.env.vars)) {
|
|
429
|
+
if (v != null && v !== '') env.push([k, String(v)]);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const cases = idx.cases || [];
|
|
434
|
+
const richTests = {};
|
|
435
|
+
cases.forEach((c, i) => { richTests[c.id] = buildRichTest(c, i, runStartMs); });
|
|
436
|
+
|
|
437
|
+
const bySuite = new Map();
|
|
438
|
+
for (const c of cases) {
|
|
439
|
+
const key = (c.suite && c.suite[0]) || 'Default';
|
|
440
|
+
const arr = bySuite.get(key) || [];
|
|
441
|
+
arr.push(c);
|
|
442
|
+
bySuite.set(key, arr);
|
|
443
|
+
}
|
|
444
|
+
const suites = [...bySuite.entries()].map(([name, cs]) => {
|
|
445
|
+
const segs = ['pass', 'fail', 'broken', 'skip']
|
|
446
|
+
.map(k => ({ k: STATUS[k], n: cs.filter(c => c.status === k).length }))
|
|
447
|
+
.filter(s => s.n > 0);
|
|
448
|
+
return { name, segs, total: cs.length };
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
const tests = cases.map(c => ({
|
|
452
|
+
ns: '',
|
|
453
|
+
name: c.name,
|
|
454
|
+
status: STATUS[c.status] || c.status,
|
|
455
|
+
duration: fmtDuration(c.duration),
|
|
456
|
+
last: c.startedAt ? relTime(c.startedAt) : '',
|
|
457
|
+
retries: c.retries,
|
|
458
|
+
richId: c.id,
|
|
459
|
+
}));
|
|
460
|
+
|
|
461
|
+
const suiteTree = deriveSuiteTree(cases);
|
|
462
|
+
const behaviorTree = deriveBehaviorTree(cases);
|
|
463
|
+
const categories = deriveCategories(cases);
|
|
464
|
+
const timelineTests = deriveTimelineRows(cases, runStartMs);
|
|
465
|
+
const trendRuns = deriveTrendRuns(idx.history, {
|
|
466
|
+
short: (idx.runId || '').slice(-4) || idx.runId,
|
|
467
|
+
passed: counts.passed, failed: counts.failed,
|
|
468
|
+
broken: counts.broken, skipped: counts.skipped,
|
|
469
|
+
});
|
|
470
|
+
const histogram = deriveDurationHistogram(cases);
|
|
471
|
+
const historyRuns = deriveHistoryRuns(idx.history, run);
|
|
472
|
+
|
|
473
|
+
const reportType = idx.reportType
|
|
474
|
+
|| (idx.framework?.name === 'playwright' ? 'e2e'
|
|
475
|
+
: (idx.framework?.name === 'jest' || idx.framework?.name === 'vitest' || idx.framework?.name === 'pytest' ? 'unit' : 'mixed'));
|
|
476
|
+
|
|
477
|
+
// Per-case JSON cache local to this state instance — keeps two embedded
|
|
478
|
+
// viewers from clobbering each other's case caches.
|
|
479
|
+
const caseCache = {};
|
|
480
|
+
async function loadCase(id) {
|
|
481
|
+
if (!id) return null;
|
|
482
|
+
if (caseCache[id]) return caseCache[id];
|
|
483
|
+
try {
|
|
484
|
+
const r = await fetchImpl(caseUrl(id), { cache: 'no-cache' });
|
|
485
|
+
caseCache[id] = await r.json();
|
|
486
|
+
return caseCache[id];
|
|
487
|
+
} catch (e) {
|
|
488
|
+
console.error('[kensho] failed to load case', id, e);
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async function ensureCaseLoaded(richTest) {
|
|
494
|
+
if (!richTest || richTest._stepsLoaded) return richTest;
|
|
495
|
+
const full = await loadCase(richTest.id);
|
|
496
|
+
if (!full) { richTest._stepsLoaded = true; return richTest; }
|
|
497
|
+
richTest._full = full;
|
|
498
|
+
richTest.description = full.description || richTest.description;
|
|
499
|
+
richTest.parameters = (full.parameters || []).map(p => [p.name, p.value]);
|
|
500
|
+
if (full.behavior?.gherkin?.length) {
|
|
501
|
+
const text = full.behavior.gherkin.join(' ');
|
|
502
|
+
const m = text.match(/given\s+(.+?)\s+when\s+(.+?)\s+then\s+(.+)/i);
|
|
503
|
+
if (m) richTest.bdd = { given: m[1].trim(), when: m[2].trim(), then: m[3].trim() };
|
|
504
|
+
}
|
|
505
|
+
if (full.errors?.length) {
|
|
506
|
+
const e = full.errors[0];
|
|
507
|
+
richTest.error = { kind: e.type || 'Error', message: e.message || '', stack: e.stack || '' };
|
|
508
|
+
}
|
|
509
|
+
richTest.steps = (full.steps || []).map(mapStep);
|
|
510
|
+
richTest.attachments = full.attachments || [];
|
|
511
|
+
richTest.logs = (full.logs || []).map(mapLog);
|
|
512
|
+
richTest.history = full.history || [];
|
|
513
|
+
richTest._stepsLoaded = true;
|
|
514
|
+
return richTest;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return {
|
|
518
|
+
kenshoIndex: idx,
|
|
519
|
+
reportType,
|
|
520
|
+
run,
|
|
521
|
+
env,
|
|
522
|
+
suites,
|
|
523
|
+
tests,
|
|
524
|
+
richTests,
|
|
525
|
+
suiteTree,
|
|
526
|
+
behaviorTree,
|
|
527
|
+
categories,
|
|
528
|
+
timelineTests,
|
|
529
|
+
trendRuns,
|
|
530
|
+
histogram,
|
|
531
|
+
historyRuns,
|
|
532
|
+
ensureCaseLoaded,
|
|
533
|
+
loadCase,
|
|
534
|
+
fmtDuration,
|
|
535
|
+
relTime,
|
|
536
|
+
// Helpers for the data-bridge adapter (mostly unused outside it).
|
|
537
|
+
_baseUrl: baseUrl,
|
|
538
|
+
_attachmentBase: attachmentBase,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
window.__KENSHO_LOAD_DATA = loadKenshoData;
|
|
543
|
+
})();
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg width="300" height="300" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" fill="none">
|
|
2
|
+
<circle cx="256" cy="256" r="220" stroke="#0072FF" stroke-width="20"></circle>
|
|
3
|
+
<path d="M180 120 L180 392 M180 256 L340 120 M180 256 L340 392" stroke="#00FF87" stroke-width="24" stroke-linecap="round"></path>
|
|
4
|
+
<path d="M200 260 L240 300 L320 200" stroke="#00FF87" stroke-width="20" stroke-linecap="round" stroke-linejoin="round"></path>
|
|
5
|
+
</svg>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 64" fill="none">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="wg" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
|
|
4
|
+
<stop offset="0" stop-color="#1E6BFF"></stop>
|
|
5
|
+
<stop offset="1" stop-color="#0B3FB5"></stop>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
<linearGradient id="wg2" x1="32" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
|
|
8
|
+
<stop offset="0" stop-color="#3DDC84"></stop>
|
|
9
|
+
<stop offset="1" stop-color="#1FA864"></stop>
|
|
10
|
+
</linearGradient>
|
|
11
|
+
</defs>
|
|
12
|
+
<path d="M14 12h8v40h-8z" fill="url(#wg)"></path>
|
|
13
|
+
<path d="M22 32 L34 14 h10 L31 32 Z" fill="url(#wg)"></path>
|
|
14
|
+
<path d="M22 32 L34 50 h10 L31 32 Z" fill="url(#wg2)"></path>
|
|
15
|
+
<path d="M44 24 l4 4 l8-8" stroke="#1FA864" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"></path>
|
|
16
|
+
<text x="74" y="42" font-family="Manrope, Inter, system-ui, sans-serif" font-size="28" font-weight="700" fill="#0B1220" letter-spacing="-0.5">kensho</text>
|
|
17
|
+
<text x="74" y="56" font-family="JetBrains Mono, ui-monospace, monospace" font-size="9" font-weight="500" fill="#5B6478" letter-spacing="2">TEST · REPORTS</text>
|
|
18
|
+
</svg>
|