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