@test-station/render-html 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/package.json +15 -0
- package/src/index.js +1413 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,1413 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export function renderHtmlReport(report, options = {}) {
|
|
5
|
+
const title = options.title || report?.meta?.projectName || 'Test Station';
|
|
6
|
+
const rootDir = resolveRootDir(report, options);
|
|
7
|
+
const defaultView = normalizeView(options.defaultView || report?.meta?.render?.defaultView || 'module');
|
|
8
|
+
const includeDetailedAnalysisToggle = options.includeDetailedAnalysisToggle ?? report?.meta?.render?.includeDetailedAnalysisToggle ?? true;
|
|
9
|
+
const summary = report?.summary || {};
|
|
10
|
+
const packages = Array.isArray(report?.packages) ? report.packages : [];
|
|
11
|
+
const modules = Array.isArray(report?.modules) ? report.modules : [];
|
|
12
|
+
const generatedAt = typeof report?.generatedAt === 'string' ? report.generatedAt : 'unknown';
|
|
13
|
+
const schemaVersion = report?.schemaVersion || '1';
|
|
14
|
+
const projectName = report?.meta?.projectName || title;
|
|
15
|
+
const moduleFilterOptions = Array.isArray(summary?.filterOptions?.modules) ? summary.filterOptions.modules : dedupe(modules.map((entry) => entry.module));
|
|
16
|
+
const packageFilterOptions = Array.isArray(summary?.filterOptions?.packages) ? summary.filterOptions.packages : dedupe(packages.map((entry) => entry.name));
|
|
17
|
+
const frameworkFilterOptions = Array.isArray(summary?.filterOptions?.frameworks)
|
|
18
|
+
? summary.filterOptions.frameworks
|
|
19
|
+
: dedupe([
|
|
20
|
+
...packages.flatMap((entry) => entry.frameworks || []),
|
|
21
|
+
...modules.flatMap((entry) => entry.frameworks || []),
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const moduleCards = modules.map((entry) => renderModuleCard(entry)).join('');
|
|
25
|
+
const moduleSections = modules.map((entry) => renderModuleSection(entry, rootDir)).join('');
|
|
26
|
+
const packageCards = packages.map((entry) => renderPackageCard(entry)).join('');
|
|
27
|
+
const packageSections = packages.map((entry) => renderPackageSection(entry, rootDir)).join('');
|
|
28
|
+
const coverageAttribution = summary.coverageAttribution || null;
|
|
29
|
+
const coverageAttributionCards = coverageAttribution?.totalFiles > 0
|
|
30
|
+
? [
|
|
31
|
+
renderSummaryCard('Attributed Files', coverageAttribution.attributedFiles),
|
|
32
|
+
renderSummaryCard('Shared Files', coverageAttribution.sharedFiles),
|
|
33
|
+
renderSummaryCard('Unattributed Files', coverageAttribution.unattributedFiles),
|
|
34
|
+
].join('')
|
|
35
|
+
: '';
|
|
36
|
+
|
|
37
|
+
return `<!DOCTYPE html>
|
|
38
|
+
<html lang="en" data-view="${escapeHtml(defaultView)}" data-show-detail="false">
|
|
39
|
+
<head>
|
|
40
|
+
<meta charset="utf-8" />
|
|
41
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
42
|
+
<title>${escapeHtml(title)}</title>
|
|
43
|
+
<style>
|
|
44
|
+
:root {
|
|
45
|
+
color-scheme: dark;
|
|
46
|
+
--bg: #07111f;
|
|
47
|
+
--bg-soft: #0d1c31;
|
|
48
|
+
--panel: rgba(16, 28, 49, 0.82);
|
|
49
|
+
--panel-strong: rgba(22, 36, 61, 0.94);
|
|
50
|
+
--border: rgba(124, 160, 224, 0.16);
|
|
51
|
+
--border-strong: rgba(124, 160, 224, 0.3);
|
|
52
|
+
--text: #eef4ff;
|
|
53
|
+
--muted: #99a9c4;
|
|
54
|
+
--pass: #4ee38b;
|
|
55
|
+
--fail: #ff6f8f;
|
|
56
|
+
--skip: #f7c55a;
|
|
57
|
+
--accent: #6bb2ff;
|
|
58
|
+
--shadow: 0 22px 80px rgba(2, 8, 20, 0.45);
|
|
59
|
+
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
60
|
+
}
|
|
61
|
+
* { box-sizing: border-box; }
|
|
62
|
+
[hidden] { display: none !important; }
|
|
63
|
+
body {
|
|
64
|
+
margin: 0;
|
|
65
|
+
min-height: 100vh;
|
|
66
|
+
color: var(--text);
|
|
67
|
+
background:
|
|
68
|
+
radial-gradient(circle at top left, rgba(107, 178, 255, 0.22), transparent 32%),
|
|
69
|
+
radial-gradient(circle at top right, rgba(78, 227, 139, 0.12), transparent 26%),
|
|
70
|
+
linear-gradient(180deg, #08101b 0%, #07111f 55%, #050c16 100%);
|
|
71
|
+
}
|
|
72
|
+
main {
|
|
73
|
+
width: min(1400px, calc(100vw - 48px));
|
|
74
|
+
margin: 0 auto;
|
|
75
|
+
padding: 32px 0 56px;
|
|
76
|
+
}
|
|
77
|
+
.hero,
|
|
78
|
+
.panel,
|
|
79
|
+
.module-section,
|
|
80
|
+
.theme-section,
|
|
81
|
+
.package-group,
|
|
82
|
+
.suite,
|
|
83
|
+
.test-row {
|
|
84
|
+
border: 1px solid var(--border);
|
|
85
|
+
background: var(--panel);
|
|
86
|
+
box-shadow: var(--shadow);
|
|
87
|
+
backdrop-filter: blur(18px);
|
|
88
|
+
}
|
|
89
|
+
.hero {
|
|
90
|
+
padding: 28px;
|
|
91
|
+
border-radius: 28px;
|
|
92
|
+
margin-bottom: 22px;
|
|
93
|
+
background:
|
|
94
|
+
radial-gradient(circle at top left, rgba(107, 178, 255, 0.24), transparent 35%),
|
|
95
|
+
linear-gradient(135deg, rgba(29, 45, 72, 0.96), rgba(10, 18, 34, 0.92));
|
|
96
|
+
}
|
|
97
|
+
h1 {
|
|
98
|
+
margin: 0 0 10px;
|
|
99
|
+
font-size: clamp(2.1rem, 5vw, 3.4rem);
|
|
100
|
+
line-height: 0.95;
|
|
101
|
+
letter-spacing: -0.05em;
|
|
102
|
+
}
|
|
103
|
+
.hero p {
|
|
104
|
+
margin: 0;
|
|
105
|
+
max-width: 72ch;
|
|
106
|
+
color: var(--muted);
|
|
107
|
+
font-size: 1rem;
|
|
108
|
+
line-height: 1.6;
|
|
109
|
+
}
|
|
110
|
+
.summary-grid {
|
|
111
|
+
display: grid;
|
|
112
|
+
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
|
113
|
+
gap: 14px;
|
|
114
|
+
margin-top: 22px;
|
|
115
|
+
}
|
|
116
|
+
.summary-card {
|
|
117
|
+
padding: 16px 18px;
|
|
118
|
+
border-radius: 18px;
|
|
119
|
+
border: 1px solid var(--border);
|
|
120
|
+
background: rgba(11, 20, 36, 0.7);
|
|
121
|
+
}
|
|
122
|
+
.summary-card__label {
|
|
123
|
+
display: block;
|
|
124
|
+
font-size: 0.78rem;
|
|
125
|
+
text-transform: uppercase;
|
|
126
|
+
letter-spacing: 0.08em;
|
|
127
|
+
color: var(--muted);
|
|
128
|
+
margin-bottom: 8px;
|
|
129
|
+
}
|
|
130
|
+
.summary-card__value {
|
|
131
|
+
font-size: 1.7rem;
|
|
132
|
+
letter-spacing: -0.04em;
|
|
133
|
+
}
|
|
134
|
+
.toolbar {
|
|
135
|
+
display: flex;
|
|
136
|
+
justify-content: space-between;
|
|
137
|
+
gap: 16px;
|
|
138
|
+
align-items: flex-start;
|
|
139
|
+
margin-bottom: 18px;
|
|
140
|
+
padding: 16px 20px;
|
|
141
|
+
border-radius: 20px;
|
|
142
|
+
flex-wrap: wrap;
|
|
143
|
+
}
|
|
144
|
+
.toolbar__meta {
|
|
145
|
+
color: var(--muted);
|
|
146
|
+
font-size: 0.95rem;
|
|
147
|
+
}
|
|
148
|
+
.toolbar__controls {
|
|
149
|
+
display: flex;
|
|
150
|
+
align-items: center;
|
|
151
|
+
gap: 12px;
|
|
152
|
+
flex-wrap: wrap;
|
|
153
|
+
justify-content: flex-end;
|
|
154
|
+
}
|
|
155
|
+
.toolbar__filters {
|
|
156
|
+
display: flex;
|
|
157
|
+
gap: 10px;
|
|
158
|
+
flex-wrap: wrap;
|
|
159
|
+
align-items: center;
|
|
160
|
+
justify-content: flex-end;
|
|
161
|
+
}
|
|
162
|
+
.view-toggle {
|
|
163
|
+
display: inline-flex;
|
|
164
|
+
gap: 4px;
|
|
165
|
+
padding: 4px;
|
|
166
|
+
border-radius: 999px;
|
|
167
|
+
background: rgba(11, 20, 36, 0.7);
|
|
168
|
+
border: 1px solid var(--border);
|
|
169
|
+
}
|
|
170
|
+
.view-toggle__button {
|
|
171
|
+
appearance: none;
|
|
172
|
+
border: 0;
|
|
173
|
+
background: transparent;
|
|
174
|
+
color: var(--muted);
|
|
175
|
+
cursor: pointer;
|
|
176
|
+
font: inherit;
|
|
177
|
+
padding: 8px 12px;
|
|
178
|
+
border-radius: 999px;
|
|
179
|
+
}
|
|
180
|
+
html[data-view="module"] .view-toggle__button[data-view-button="module"],
|
|
181
|
+
html[data-view="package"] .view-toggle__button[data-view-button="package"] {
|
|
182
|
+
background: color-mix(in srgb, var(--accent) 18%, rgba(11, 20, 36, 0.92));
|
|
183
|
+
color: var(--text);
|
|
184
|
+
}
|
|
185
|
+
.toolbar__toggle {
|
|
186
|
+
display: inline-flex;
|
|
187
|
+
gap: 10px;
|
|
188
|
+
align-items: center;
|
|
189
|
+
padding: 10px 14px;
|
|
190
|
+
border-radius: 999px;
|
|
191
|
+
background: rgba(11, 20, 36, 0.7);
|
|
192
|
+
border: 1px solid var(--border);
|
|
193
|
+
cursor: pointer;
|
|
194
|
+
user-select: none;
|
|
195
|
+
}
|
|
196
|
+
.toolbar__toggle input { accent-color: var(--accent); }
|
|
197
|
+
.filter-control {
|
|
198
|
+
display: grid;
|
|
199
|
+
gap: 6px;
|
|
200
|
+
min-width: 150px;
|
|
201
|
+
}
|
|
202
|
+
.filter-control__label {
|
|
203
|
+
font-size: 0.72rem;
|
|
204
|
+
text-transform: uppercase;
|
|
205
|
+
letter-spacing: 0.08em;
|
|
206
|
+
color: var(--muted);
|
|
207
|
+
}
|
|
208
|
+
.filter-control__input,
|
|
209
|
+
.toolbar__button {
|
|
210
|
+
appearance: none;
|
|
211
|
+
border: 1px solid var(--border);
|
|
212
|
+
border-radius: 12px;
|
|
213
|
+
background: rgba(11, 20, 36, 0.8);
|
|
214
|
+
color: var(--text);
|
|
215
|
+
padding: 10px 12px;
|
|
216
|
+
font: inherit;
|
|
217
|
+
}
|
|
218
|
+
.toolbar__button { cursor: pointer; }
|
|
219
|
+
.toolbar__filtersMeta {
|
|
220
|
+
color: var(--muted);
|
|
221
|
+
font-size: 0.85rem;
|
|
222
|
+
min-height: 20px;
|
|
223
|
+
width: 100%;
|
|
224
|
+
}
|
|
225
|
+
html[data-view="module"] .view-pane--package,
|
|
226
|
+
html[data-view="package"] .view-pane--module {
|
|
227
|
+
display: none;
|
|
228
|
+
}
|
|
229
|
+
.module-grid {
|
|
230
|
+
display: grid;
|
|
231
|
+
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
|
232
|
+
gap: 14px;
|
|
233
|
+
margin-bottom: 26px;
|
|
234
|
+
}
|
|
235
|
+
.module-card {
|
|
236
|
+
appearance: none;
|
|
237
|
+
width: 100%;
|
|
238
|
+
text-align: left;
|
|
239
|
+
cursor: pointer;
|
|
240
|
+
display: flex;
|
|
241
|
+
flex-direction: column;
|
|
242
|
+
gap: 14px;
|
|
243
|
+
padding: 16px 18px;
|
|
244
|
+
border-radius: 22px;
|
|
245
|
+
color: inherit;
|
|
246
|
+
border: 1px solid var(--border);
|
|
247
|
+
background: linear-gradient(160deg, rgba(17, 29, 49, 0.92), rgba(9, 17, 31, 0.82));
|
|
248
|
+
box-shadow: var(--shadow);
|
|
249
|
+
}
|
|
250
|
+
.module-card.status-failed { border-color: color-mix(in srgb, var(--fail) 30%, transparent); }
|
|
251
|
+
.module-card.status-passed { border-color: color-mix(in srgb, var(--pass) 20%, transparent); }
|
|
252
|
+
.module-card.status-skipped { border-color: color-mix(in srgb, var(--skip) 20%, transparent); }
|
|
253
|
+
.module-card__header {
|
|
254
|
+
display: flex;
|
|
255
|
+
justify-content: space-between;
|
|
256
|
+
gap: 12px;
|
|
257
|
+
align-items: flex-start;
|
|
258
|
+
}
|
|
259
|
+
.module-card__name {
|
|
260
|
+
font-size: 1rem;
|
|
261
|
+
font-weight: 600;
|
|
262
|
+
}
|
|
263
|
+
.module-card__meta {
|
|
264
|
+
font-size: 0.85rem;
|
|
265
|
+
color: var(--muted);
|
|
266
|
+
}
|
|
267
|
+
.module-card__summary {
|
|
268
|
+
display: grid;
|
|
269
|
+
gap: 4px;
|
|
270
|
+
}
|
|
271
|
+
.owner-pill {
|
|
272
|
+
display: inline-flex;
|
|
273
|
+
align-items: center;
|
|
274
|
+
gap: 6px;
|
|
275
|
+
width: fit-content;
|
|
276
|
+
padding: 6px 10px;
|
|
277
|
+
border-radius: 999px;
|
|
278
|
+
border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent);
|
|
279
|
+
background: color-mix(in srgb, var(--accent) 10%, rgba(11, 20, 36, 0.82));
|
|
280
|
+
color: var(--text);
|
|
281
|
+
font-size: 0.74rem;
|
|
282
|
+
letter-spacing: 0.04em;
|
|
283
|
+
text-transform: uppercase;
|
|
284
|
+
}
|
|
285
|
+
.module-card__coverage { display: grid; gap: 10px; }
|
|
286
|
+
.coverage-mini { display: grid; gap: 6px; }
|
|
287
|
+
.coverage-mini__row {
|
|
288
|
+
display: flex;
|
|
289
|
+
justify-content: space-between;
|
|
290
|
+
gap: 12px;
|
|
291
|
+
font-size: 0.78rem;
|
|
292
|
+
color: var(--muted);
|
|
293
|
+
text-transform: uppercase;
|
|
294
|
+
letter-spacing: 0.08em;
|
|
295
|
+
}
|
|
296
|
+
.coverage-mini__value {
|
|
297
|
+
color: var(--text);
|
|
298
|
+
letter-spacing: 0;
|
|
299
|
+
text-transform: none;
|
|
300
|
+
}
|
|
301
|
+
.coverage-mini__bar {
|
|
302
|
+
height: 8px;
|
|
303
|
+
border-radius: 999px;
|
|
304
|
+
overflow: hidden;
|
|
305
|
+
background: rgba(255, 111, 143, 0.14);
|
|
306
|
+
border: 1px solid rgba(124, 160, 224, 0.12);
|
|
307
|
+
}
|
|
308
|
+
.coverage-mini__fill { height: 100%; border-radius: inherit; }
|
|
309
|
+
.module-stack { display: grid; gap: 18px; }
|
|
310
|
+
.module-section,
|
|
311
|
+
.theme-section,
|
|
312
|
+
.package-group,
|
|
313
|
+
.suite {
|
|
314
|
+
border-radius: 24px;
|
|
315
|
+
background: linear-gradient(180deg, rgba(14, 24, 42, 0.94), rgba(8, 16, 29, 0.92));
|
|
316
|
+
}
|
|
317
|
+
.module-section > summary,
|
|
318
|
+
.theme-section > summary,
|
|
319
|
+
.package-group > summary,
|
|
320
|
+
.suite > summary {
|
|
321
|
+
list-style: none;
|
|
322
|
+
cursor: pointer;
|
|
323
|
+
}
|
|
324
|
+
.module-section > summary::-webkit-details-marker,
|
|
325
|
+
.theme-section > summary::-webkit-details-marker,
|
|
326
|
+
.package-group > summary::-webkit-details-marker,
|
|
327
|
+
.suite > summary::-webkit-details-marker {
|
|
328
|
+
display: none;
|
|
329
|
+
}
|
|
330
|
+
.module-section__summary,
|
|
331
|
+
.theme-section__summary,
|
|
332
|
+
.package-group__summary,
|
|
333
|
+
.suite__summaryRow {
|
|
334
|
+
display: flex;
|
|
335
|
+
justify-content: space-between;
|
|
336
|
+
gap: 16px;
|
|
337
|
+
align-items: baseline;
|
|
338
|
+
flex-wrap: wrap;
|
|
339
|
+
padding: 22px;
|
|
340
|
+
}
|
|
341
|
+
.module-section[open] > summary,
|
|
342
|
+
.theme-section[open] > summary,
|
|
343
|
+
.package-group[open] > summary,
|
|
344
|
+
.suite[open] > summary {
|
|
345
|
+
border-bottom: 1px solid rgba(124, 160, 224, 0.12);
|
|
346
|
+
}
|
|
347
|
+
.module-section__title,
|
|
348
|
+
.theme-section__title,
|
|
349
|
+
.package-group__title {
|
|
350
|
+
margin: 0;
|
|
351
|
+
font-size: 1.5rem;
|
|
352
|
+
letter-spacing: -0.04em;
|
|
353
|
+
}
|
|
354
|
+
.theme-section__title { font-size: 1.18rem; }
|
|
355
|
+
.package-group__title { font-size: 1rem; }
|
|
356
|
+
.module-section__meta,
|
|
357
|
+
.theme-section__meta,
|
|
358
|
+
.package-group__meta {
|
|
359
|
+
color: var(--muted);
|
|
360
|
+
font-size: 0.92rem;
|
|
361
|
+
}
|
|
362
|
+
.module-section__body,
|
|
363
|
+
.theme-section__body,
|
|
364
|
+
.package-group__body,
|
|
365
|
+
.suite__body {
|
|
366
|
+
display: grid;
|
|
367
|
+
gap: 16px;
|
|
368
|
+
padding: 18px 22px 22px;
|
|
369
|
+
}
|
|
370
|
+
.module-section__headline,
|
|
371
|
+
.theme-section__headline,
|
|
372
|
+
.package-group__headline {
|
|
373
|
+
display: flex;
|
|
374
|
+
align-items: center;
|
|
375
|
+
gap: 12px;
|
|
376
|
+
flex-wrap: wrap;
|
|
377
|
+
}
|
|
378
|
+
.module-section__packages { color: var(--muted); font-size: 0.9rem; }
|
|
379
|
+
.theme-list,
|
|
380
|
+
.package-list,
|
|
381
|
+
.suite-list {
|
|
382
|
+
display: grid;
|
|
383
|
+
gap: 16px;
|
|
384
|
+
}
|
|
385
|
+
.theme-section {
|
|
386
|
+
border-radius: 20px;
|
|
387
|
+
background: linear-gradient(180deg, rgba(15, 25, 44, 0.95), rgba(9, 18, 33, 0.88));
|
|
388
|
+
}
|
|
389
|
+
.package-group {
|
|
390
|
+
border-radius: 18px;
|
|
391
|
+
background: rgba(10, 18, 33, 0.72);
|
|
392
|
+
border: 1px solid rgba(124, 160, 224, 0.12);
|
|
393
|
+
box-shadow: none;
|
|
394
|
+
}
|
|
395
|
+
.suite {
|
|
396
|
+
border-radius: 18px;
|
|
397
|
+
padding: 0;
|
|
398
|
+
background: rgba(6, 13, 24, 0.58);
|
|
399
|
+
border: 1px solid rgba(124, 160, 224, 0.1);
|
|
400
|
+
box-shadow: none;
|
|
401
|
+
}
|
|
402
|
+
.suite__label { font-size: 1rem; font-weight: 600; }
|
|
403
|
+
.suite__runtime {
|
|
404
|
+
display: inline-flex;
|
|
405
|
+
align-items: center;
|
|
406
|
+
gap: 8px;
|
|
407
|
+
font-size: 0.78rem;
|
|
408
|
+
text-transform: uppercase;
|
|
409
|
+
letter-spacing: 0.08em;
|
|
410
|
+
color: var(--muted);
|
|
411
|
+
}
|
|
412
|
+
.suite__summary { color: var(--muted); font-size: 0.88rem; }
|
|
413
|
+
.suite__warnings {
|
|
414
|
+
margin: 0;
|
|
415
|
+
padding-left: 20px;
|
|
416
|
+
color: var(--skip);
|
|
417
|
+
}
|
|
418
|
+
.suite__artifacts {
|
|
419
|
+
display: grid;
|
|
420
|
+
gap: 10px;
|
|
421
|
+
padding: 14px;
|
|
422
|
+
border-radius: 16px;
|
|
423
|
+
border: 1px solid rgba(124, 160, 224, 0.12);
|
|
424
|
+
background: rgba(11, 20, 36, 0.66);
|
|
425
|
+
}
|
|
426
|
+
.suite__artifactsTitle {
|
|
427
|
+
margin: 0;
|
|
428
|
+
font-size: 0.82rem;
|
|
429
|
+
text-transform: uppercase;
|
|
430
|
+
letter-spacing: 0.08em;
|
|
431
|
+
color: var(--muted);
|
|
432
|
+
}
|
|
433
|
+
.suite__artifactList {
|
|
434
|
+
margin: 0;
|
|
435
|
+
padding-left: 18px;
|
|
436
|
+
display: grid;
|
|
437
|
+
gap: 8px;
|
|
438
|
+
}
|
|
439
|
+
.suite__artifactLink {
|
|
440
|
+
color: var(--accent);
|
|
441
|
+
text-decoration: none;
|
|
442
|
+
}
|
|
443
|
+
.suite__artifactLink:hover {
|
|
444
|
+
text-decoration: underline;
|
|
445
|
+
}
|
|
446
|
+
.suite__artifactMeta {
|
|
447
|
+
color: var(--muted);
|
|
448
|
+
font-size: 0.84rem;
|
|
449
|
+
margin-left: 6px;
|
|
450
|
+
}
|
|
451
|
+
.test-list { display: grid; gap: 12px; }
|
|
452
|
+
.test-row {
|
|
453
|
+
border-radius: 16px;
|
|
454
|
+
padding: 14px 16px;
|
|
455
|
+
background: rgba(6, 13, 24, 0.78);
|
|
456
|
+
border-color: rgba(124, 160, 224, 0.12);
|
|
457
|
+
}
|
|
458
|
+
.test-row[open] { border-color: var(--border-strong); }
|
|
459
|
+
.test-row__summary {
|
|
460
|
+
display: grid;
|
|
461
|
+
grid-template-columns: auto 1fr auto auto;
|
|
462
|
+
gap: 12px;
|
|
463
|
+
align-items: start;
|
|
464
|
+
list-style: none;
|
|
465
|
+
cursor: pointer;
|
|
466
|
+
}
|
|
467
|
+
.status-pill {
|
|
468
|
+
display: inline-flex;
|
|
469
|
+
align-items: center;
|
|
470
|
+
justify-content: center;
|
|
471
|
+
min-width: 66px;
|
|
472
|
+
padding: 6px 10px;
|
|
473
|
+
border-radius: 999px;
|
|
474
|
+
font-size: 0.72rem;
|
|
475
|
+
font-weight: 700;
|
|
476
|
+
text-transform: uppercase;
|
|
477
|
+
letter-spacing: 0.08em;
|
|
478
|
+
}
|
|
479
|
+
.status-pill.pass { background: color-mix(in srgb, var(--pass) 18%, transparent); color: var(--pass); }
|
|
480
|
+
.status-pill.fail { background: color-mix(in srgb, var(--fail) 18%, transparent); color: var(--fail); }
|
|
481
|
+
.status-pill.skip { background: color-mix(in srgb, var(--skip) 18%, transparent); color: var(--skip); }
|
|
482
|
+
.test-row__name { min-width: 0; }
|
|
483
|
+
.test-row__title {
|
|
484
|
+
display: block;
|
|
485
|
+
font-weight: 600;
|
|
486
|
+
margin-bottom: 4px;
|
|
487
|
+
overflow: hidden;
|
|
488
|
+
text-overflow: ellipsis;
|
|
489
|
+
white-space: nowrap;
|
|
490
|
+
}
|
|
491
|
+
.test-row__file,
|
|
492
|
+
.test-row__duration { font-size: 0.83rem; color: var(--muted); }
|
|
493
|
+
.test-row__details {
|
|
494
|
+
display: none;
|
|
495
|
+
margin-top: 16px;
|
|
496
|
+
padding-top: 16px;
|
|
497
|
+
border-top: 1px solid rgba(124, 160, 224, 0.12);
|
|
498
|
+
}
|
|
499
|
+
html[data-show-detail="true"] .test-row__details,
|
|
500
|
+
.test-row[open] .test-row__details {
|
|
501
|
+
display: grid;
|
|
502
|
+
gap: 14px;
|
|
503
|
+
}
|
|
504
|
+
.detail-grid {
|
|
505
|
+
display: grid;
|
|
506
|
+
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
507
|
+
gap: 14px;
|
|
508
|
+
}
|
|
509
|
+
.detail-card {
|
|
510
|
+
padding: 14px;
|
|
511
|
+
border-radius: 14px;
|
|
512
|
+
border: 1px solid rgba(124, 160, 224, 0.12);
|
|
513
|
+
background: rgba(11, 20, 36, 0.66);
|
|
514
|
+
}
|
|
515
|
+
.detail-card h4 {
|
|
516
|
+
margin: 0 0 10px;
|
|
517
|
+
font-size: 0.82rem;
|
|
518
|
+
text-transform: uppercase;
|
|
519
|
+
letter-spacing: 0.08em;
|
|
520
|
+
color: var(--muted);
|
|
521
|
+
}
|
|
522
|
+
.detail-card ul {
|
|
523
|
+
margin: 0;
|
|
524
|
+
padding-left: 18px;
|
|
525
|
+
display: grid;
|
|
526
|
+
gap: 8px;
|
|
527
|
+
}
|
|
528
|
+
.detail-card pre {
|
|
529
|
+
margin: 0;
|
|
530
|
+
white-space: pre-wrap;
|
|
531
|
+
word-break: break-word;
|
|
532
|
+
font-size: 0.82rem;
|
|
533
|
+
color: #d7e5ff;
|
|
534
|
+
}
|
|
535
|
+
.failure-list {
|
|
536
|
+
margin: 0;
|
|
537
|
+
padding-left: 18px;
|
|
538
|
+
color: #ffd8e1;
|
|
539
|
+
display: grid;
|
|
540
|
+
gap: 8px;
|
|
541
|
+
}
|
|
542
|
+
.coverage-block {
|
|
543
|
+
margin: 0 0 16px;
|
|
544
|
+
padding: 14px;
|
|
545
|
+
border-radius: 16px;
|
|
546
|
+
border: 1px solid rgba(124, 160, 224, 0.14);
|
|
547
|
+
background: rgba(8, 16, 29, 0.68);
|
|
548
|
+
}
|
|
549
|
+
.coverage-block__grid {
|
|
550
|
+
display: grid;
|
|
551
|
+
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
552
|
+
gap: 10px;
|
|
553
|
+
margin-bottom: 12px;
|
|
554
|
+
}
|
|
555
|
+
.coverage-metric {
|
|
556
|
+
padding: 10px 12px;
|
|
557
|
+
border-radius: 12px;
|
|
558
|
+
background: rgba(11, 20, 36, 0.72);
|
|
559
|
+
border: 1px solid rgba(124, 160, 224, 0.1);
|
|
560
|
+
}
|
|
561
|
+
.coverage-metric__label {
|
|
562
|
+
display: block;
|
|
563
|
+
font-size: 0.75rem;
|
|
564
|
+
color: var(--muted);
|
|
565
|
+
text-transform: uppercase;
|
|
566
|
+
letter-spacing: 0.08em;
|
|
567
|
+
margin-bottom: 6px;
|
|
568
|
+
}
|
|
569
|
+
.coverage-metric__value {
|
|
570
|
+
display: flex;
|
|
571
|
+
justify-content: space-between;
|
|
572
|
+
gap: 12px;
|
|
573
|
+
align-items: baseline;
|
|
574
|
+
font-size: 1.1rem;
|
|
575
|
+
letter-spacing: -0.03em;
|
|
576
|
+
}
|
|
577
|
+
.coverage-metric__counts {
|
|
578
|
+
font-size: 0.78rem;
|
|
579
|
+
color: var(--muted);
|
|
580
|
+
letter-spacing: 0;
|
|
581
|
+
}
|
|
582
|
+
.coverage-metric__bar {
|
|
583
|
+
margin-top: 10px;
|
|
584
|
+
height: 12px;
|
|
585
|
+
border-radius: 999px;
|
|
586
|
+
overflow: hidden;
|
|
587
|
+
background: rgba(255, 111, 143, 0.14);
|
|
588
|
+
border: 1px solid rgba(124, 160, 224, 0.12);
|
|
589
|
+
}
|
|
590
|
+
.coverage-metric__fill {
|
|
591
|
+
height: 100%;
|
|
592
|
+
border-radius: inherit;
|
|
593
|
+
transition: width 180ms ease-out;
|
|
594
|
+
}
|
|
595
|
+
.coverage-block details {
|
|
596
|
+
border-top: 1px solid rgba(124, 160, 224, 0.12);
|
|
597
|
+
padding-top: 12px;
|
|
598
|
+
}
|
|
599
|
+
.coverage-block summary {
|
|
600
|
+
cursor: pointer;
|
|
601
|
+
color: var(--muted);
|
|
602
|
+
}
|
|
603
|
+
.coverage-table {
|
|
604
|
+
width: 100%;
|
|
605
|
+
border-collapse: collapse;
|
|
606
|
+
margin-top: 12px;
|
|
607
|
+
font-size: 0.84rem;
|
|
608
|
+
}
|
|
609
|
+
.coverage-table th,
|
|
610
|
+
.coverage-table td {
|
|
611
|
+
text-align: left;
|
|
612
|
+
padding: 8px 10px;
|
|
613
|
+
border-bottom: 1px solid rgba(124, 160, 224, 0.08);
|
|
614
|
+
vertical-align: top;
|
|
615
|
+
}
|
|
616
|
+
.coverage-table th {
|
|
617
|
+
color: var(--muted);
|
|
618
|
+
font-size: 0.74rem;
|
|
619
|
+
text-transform: uppercase;
|
|
620
|
+
letter-spacing: 0.08em;
|
|
621
|
+
}
|
|
622
|
+
.coverage-table code { font-size: 0.8rem; color: #d7e5ff; }
|
|
623
|
+
@media (max-width: 920px) {
|
|
624
|
+
main { width: min(100vw - 24px, 1400px); }
|
|
625
|
+
.toolbar { align-items: flex-start; }
|
|
626
|
+
.toolbar__controls { justify-content: flex-start; }
|
|
627
|
+
.toolbar__filters { justify-content: flex-start; }
|
|
628
|
+
.test-row__summary { grid-template-columns: auto 1fr; }
|
|
629
|
+
}
|
|
630
|
+
</style>
|
|
631
|
+
</head>
|
|
632
|
+
<body>
|
|
633
|
+
<main>
|
|
634
|
+
<section class="hero">
|
|
635
|
+
<h1>${escapeHtml(title)}</h1>
|
|
636
|
+
<p>Structured test results grouped by logical module first, with package drilldowns, suite-level coverage, and test detail views that can be expanded globally or per test. The renderer consumes only the normalized report data and explicit render options.</p>
|
|
637
|
+
<div class="summary-grid">
|
|
638
|
+
${renderSummaryCard('Modules', summary.totalModules || 0)}
|
|
639
|
+
${renderSummaryCard('Packages', summary.totalPackages || 0)}
|
|
640
|
+
${renderSummaryCard('Suites', summary.totalSuites || 0)}
|
|
641
|
+
${renderSummaryCard('Tests', summary.totalTests || 0)}
|
|
642
|
+
${renderSummaryCard('Passed', summary.passedTests || 0)}
|
|
643
|
+
${renderSummaryCard('Failed', summary.failedTests || 0)}
|
|
644
|
+
${renderSummaryCard('Skipped', summary.skippedTests || 0)}
|
|
645
|
+
${renderSummaryCard('Line Coverage', summary.coverage?.lines ? `${summary.coverage.lines.pct.toFixed(2)}%` : 'n/a')}
|
|
646
|
+
${renderSummaryCard('Branch Coverage', summary.coverage?.branches ? `${summary.coverage.branches.pct.toFixed(2)}%` : 'n/a')}
|
|
647
|
+
${renderSummaryCard('Function Coverage', summary.coverage?.functions ? `${summary.coverage.functions.pct.toFixed(2)}%` : 'n/a')}
|
|
648
|
+
${coverageAttributionCards}
|
|
649
|
+
${renderSummaryCard('Duration', formatDuration(report?.durationMs || 0))}
|
|
650
|
+
</div>
|
|
651
|
+
</section>
|
|
652
|
+
|
|
653
|
+
<section class="panel toolbar">
|
|
654
|
+
<div class="toolbar__meta">Project ${escapeHtml(projectName)} • Schema v${escapeHtml(schemaVersion)} • Generated ${escapeHtml(generatedAt)}</div>
|
|
655
|
+
<div class="toolbar__controls">
|
|
656
|
+
<div class="view-toggle" role="tablist" aria-label="Report view">
|
|
657
|
+
<button type="button" class="view-toggle__button" data-view-button="module">Group by Module</button>
|
|
658
|
+
<button type="button" class="view-toggle__button" data-view-button="package">Group by Package</button>
|
|
659
|
+
</div>
|
|
660
|
+
<div class="toolbar__filters">
|
|
661
|
+
${renderFilterSelect('module-filter', 'Module', moduleFilterOptions)}
|
|
662
|
+
${renderFilterSelect('package-filter', 'Package', packageFilterOptions)}
|
|
663
|
+
${renderFilterSelect('framework-filter', 'Framework', frameworkFilterOptions)}
|
|
664
|
+
<label class="toolbar__toggle">
|
|
665
|
+
<input id="failed-only-toggle" type="checkbox" />
|
|
666
|
+
Failed only
|
|
667
|
+
</label>
|
|
668
|
+
<label class="toolbar__toggle">
|
|
669
|
+
<input id="low-coverage-toggle" type="checkbox" />
|
|
670
|
+
Low coverage only
|
|
671
|
+
</label>
|
|
672
|
+
<label class="filter-control" for="coverage-threshold">
|
|
673
|
+
<span class="filter-control__label">Coverage Threshold</span>
|
|
674
|
+
<select id="coverage-threshold" class="filter-control__input">
|
|
675
|
+
<option value="50">50%</option>
|
|
676
|
+
<option value="60">60%</option>
|
|
677
|
+
<option value="70">70%</option>
|
|
678
|
+
<option value="80" selected>80%</option>
|
|
679
|
+
<option value="90">90%</option>
|
|
680
|
+
</select>
|
|
681
|
+
</label>
|
|
682
|
+
<button type="button" id="clear-filters" class="toolbar__button">Clear filters</button>
|
|
683
|
+
</div>
|
|
684
|
+
${includeDetailedAnalysisToggle ? `
|
|
685
|
+
<label class="toolbar__toggle">
|
|
686
|
+
<input id="detail-toggle" type="checkbox" />
|
|
687
|
+
Show detailed analysis
|
|
688
|
+
</label>` : ''}
|
|
689
|
+
</div>
|
|
690
|
+
<div id="filter-results" class="toolbar__filtersMeta">No filters applied.</div>
|
|
691
|
+
</section>
|
|
692
|
+
|
|
693
|
+
<section class="view-pane view-pane--module">
|
|
694
|
+
<section class="module-grid">
|
|
695
|
+
${moduleCards || '<div class="module-card status-skipped"><div class="module-card__summary"><span class="module-card__name">No modules</span><span class="module-card__meta">The report did not contain module-grouped results.</span></div></div>'}
|
|
696
|
+
</section>
|
|
697
|
+
<section class="module-stack">
|
|
698
|
+
${moduleSections || ''}
|
|
699
|
+
</section>
|
|
700
|
+
</section>
|
|
701
|
+
|
|
702
|
+
<section class="view-pane view-pane--package">
|
|
703
|
+
<section class="module-grid">
|
|
704
|
+
${packageCards || '<div class="module-card status-skipped"><div class="module-card__summary"><span class="module-card__name">No packages</span><span class="module-card__meta">The report did not contain package-grouped results.</span></div></div>'}
|
|
705
|
+
</section>
|
|
706
|
+
<section class="module-stack">
|
|
707
|
+
${packageSections || ''}
|
|
708
|
+
</section>
|
|
709
|
+
</section>
|
|
710
|
+
</main>
|
|
711
|
+
<script>
|
|
712
|
+
const root = document.documentElement;
|
|
713
|
+
const toggle = document.getElementById('detail-toggle');
|
|
714
|
+
const failedOnlyToggle = document.getElementById('failed-only-toggle');
|
|
715
|
+
const lowCoverageToggle = document.getElementById('low-coverage-toggle');
|
|
716
|
+
const coverageThreshold = document.getElementById('coverage-threshold');
|
|
717
|
+
const moduleFilter = document.getElementById('module-filter');
|
|
718
|
+
const packageFilter = document.getElementById('package-filter');
|
|
719
|
+
const frameworkFilter = document.getElementById('framework-filter');
|
|
720
|
+
const clearFiltersButton = document.getElementById('clear-filters');
|
|
721
|
+
const filterResults = document.getElementById('filter-results');
|
|
722
|
+
if (toggle) {
|
|
723
|
+
toggle.addEventListener('change', () => {
|
|
724
|
+
root.dataset.showDetail = toggle.checked ? 'true' : 'false';
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
document.querySelectorAll('[data-view-button]').forEach((button) => {
|
|
728
|
+
button.addEventListener('click', () => {
|
|
729
|
+
root.dataset.view = button.dataset.viewButton || 'module';
|
|
730
|
+
applyFilters();
|
|
731
|
+
});
|
|
732
|
+
});
|
|
733
|
+
document.querySelectorAll('[data-open-target]').forEach((button) => {
|
|
734
|
+
button.addEventListener('click', () => {
|
|
735
|
+
if (button.dataset.viewTarget) {
|
|
736
|
+
root.dataset.view = button.dataset.viewTarget;
|
|
737
|
+
}
|
|
738
|
+
const target = document.getElementById(button.dataset.openTarget || '');
|
|
739
|
+
if (!target) {
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
if (typeof target.open === 'boolean') {
|
|
743
|
+
target.open = true;
|
|
744
|
+
}
|
|
745
|
+
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
746
|
+
});
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
const filterNodes = Array.from(document.querySelectorAll('[data-filter-node]'));
|
|
750
|
+
|
|
751
|
+
function parseFilterTokens(value) {
|
|
752
|
+
return new Set(String(value || '').split('|').map((item) => item.trim().toLowerCase()).filter(Boolean));
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function matchesTokenFilter(datasetValue, selectedValue) {
|
|
756
|
+
if (!selectedValue) {
|
|
757
|
+
return true;
|
|
758
|
+
}
|
|
759
|
+
return parseFilterTokens(datasetValue).has(String(selectedValue).trim().toLowerCase());
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function matchesFilters(node, state) {
|
|
763
|
+
const dataset = node.dataset;
|
|
764
|
+
if (state.failedOnly && dataset.filterHasFailures !== 'true') {
|
|
765
|
+
return false;
|
|
766
|
+
}
|
|
767
|
+
if (state.lowCoverageOnly) {
|
|
768
|
+
const pct = Number(dataset.filterLineCoverage);
|
|
769
|
+
if (Number.isFinite(pct) && pct >= state.coverageThreshold) {
|
|
770
|
+
return false;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
if (!matchesTokenFilter(dataset.filterModules, state.module)) {
|
|
774
|
+
return false;
|
|
775
|
+
}
|
|
776
|
+
if (!matchesTokenFilter(dataset.filterPackages, state.packageName)) {
|
|
777
|
+
return false;
|
|
778
|
+
}
|
|
779
|
+
if (!matchesTokenFilter(dataset.filterFrameworks, state.framework)) {
|
|
780
|
+
return false;
|
|
781
|
+
}
|
|
782
|
+
return true;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function currentFilterState() {
|
|
786
|
+
return {
|
|
787
|
+
failedOnly: Boolean(failedOnlyToggle?.checked),
|
|
788
|
+
lowCoverageOnly: Boolean(lowCoverageToggle?.checked),
|
|
789
|
+
coverageThreshold: Number(coverageThreshold?.value || '80'),
|
|
790
|
+
module: moduleFilter?.value || '',
|
|
791
|
+
packageName: packageFilter?.value || '',
|
|
792
|
+
framework: frameworkFilter?.value || '',
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function updateFilterSummary(state) {
|
|
797
|
+
const topLevelSelector = root.dataset.view === 'package'
|
|
798
|
+
? '.view-pane--package .module-grid [data-filter-node="package-card"]'
|
|
799
|
+
: '.view-pane--module .module-grid [data-filter-node="module-card"]';
|
|
800
|
+
const visibleTopLevel = Array.from(document.querySelectorAll(topLevelSelector))
|
|
801
|
+
.filter((node) => !node.hidden)
|
|
802
|
+
.length;
|
|
803
|
+
|
|
804
|
+
const active = [];
|
|
805
|
+
if (state.failedOnly) active.push('failed');
|
|
806
|
+
if (state.lowCoverageOnly) active.push('coverage < ' + state.coverageThreshold + '%');
|
|
807
|
+
if (state.module) active.push('module=' + state.module);
|
|
808
|
+
if (state.packageName) active.push('package=' + state.packageName);
|
|
809
|
+
if (state.framework) active.push('framework=' + state.framework);
|
|
810
|
+
|
|
811
|
+
filterResults.textContent = active.length === 0
|
|
812
|
+
? 'No filters applied.'
|
|
813
|
+
: visibleTopLevel + ' top-level result' + (visibleTopLevel === 1 ? '' : 's') + ' visible • ' + active.join(' • ');
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function applyFilters() {
|
|
817
|
+
const state = currentFilterState();
|
|
818
|
+
filterNodes.forEach((node) => {
|
|
819
|
+
node.hidden = !matchesFilters(node, state);
|
|
820
|
+
});
|
|
821
|
+
updateFilterSummary(state);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
[failedOnlyToggle, lowCoverageToggle, coverageThreshold, moduleFilter, packageFilter, frameworkFilter]
|
|
825
|
+
.filter(Boolean)
|
|
826
|
+
.forEach((control) => control.addEventListener('change', applyFilters));
|
|
827
|
+
|
|
828
|
+
clearFiltersButton?.addEventListener('click', () => {
|
|
829
|
+
if (failedOnlyToggle) failedOnlyToggle.checked = false;
|
|
830
|
+
if (lowCoverageToggle) lowCoverageToggle.checked = false;
|
|
831
|
+
if (coverageThreshold) coverageThreshold.value = '80';
|
|
832
|
+
if (moduleFilter) moduleFilter.value = '';
|
|
833
|
+
if (packageFilter) packageFilter.value = '';
|
|
834
|
+
if (frameworkFilter) frameworkFilter.value = '';
|
|
835
|
+
applyFilters();
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
applyFilters();
|
|
839
|
+
</script>
|
|
840
|
+
</body>
|
|
841
|
+
</html>`;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
export function writeHtmlReport(report, outputDir, options = {}) {
|
|
845
|
+
const resolvedOutputDir = path.resolve(process.cwd(), outputDir);
|
|
846
|
+
fs.mkdirSync(resolvedOutputDir, { recursive: true });
|
|
847
|
+
const html = renderHtmlReport(report, options);
|
|
848
|
+
const reportPath = path.join(resolvedOutputDir, 'index.html');
|
|
849
|
+
fs.writeFileSync(reportPath, html);
|
|
850
|
+
return reportPath;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function resolveRootDir(report, options) {
|
|
854
|
+
const candidate = options.projectRootDir || report?.meta?.projectRootDir || null;
|
|
855
|
+
if (!candidate || typeof candidate !== 'string') {
|
|
856
|
+
return null;
|
|
857
|
+
}
|
|
858
|
+
return path.resolve(candidate);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function normalizeView(value) {
|
|
862
|
+
return value === 'package' ? 'package' : 'module';
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function renderSummaryCard(label, value) {
|
|
866
|
+
return `
|
|
867
|
+
<div class="summary-card">
|
|
868
|
+
<span class="summary-card__label">${escapeHtml(label)}</span>
|
|
869
|
+
<span class="summary-card__value">${escapeHtml(String(value))}</span>
|
|
870
|
+
</div>
|
|
871
|
+
`;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function renderStatusPill(status) {
|
|
875
|
+
const normalized = status === 'failed' ? 'fail' : status === 'skipped' ? 'skip' : 'pass';
|
|
876
|
+
return `<span class="status-pill ${normalized}">${escapeHtml(status)}</span>`;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function renderCoverageMiniMetric(label, metric) {
|
|
880
|
+
const pct = metric ? metric.pct.toFixed(2) : 'n/a';
|
|
881
|
+
const fillStyle = metric
|
|
882
|
+
? `width:${metric.pct.toFixed(2)}%;background:hsl(${coverageHue(metric.pct)} 68% 48%);`
|
|
883
|
+
: 'width:0%;background:hsl(0deg 68% 48%);';
|
|
884
|
+
return `
|
|
885
|
+
<div class="coverage-mini">
|
|
886
|
+
<div class="coverage-mini__row">
|
|
887
|
+
<span>${escapeHtml(label)}</span>
|
|
888
|
+
<span class="coverage-mini__value">${escapeHtml(metric ? `${pct}%` : 'n/a')}</span>
|
|
889
|
+
</div>
|
|
890
|
+
<div class="coverage-mini__bar" aria-hidden="true">
|
|
891
|
+
<div class="coverage-mini__fill" style="${fillStyle}"></div>
|
|
892
|
+
</div>
|
|
893
|
+
</div>
|
|
894
|
+
`;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function renderFilterSelect(id, label, options) {
|
|
898
|
+
const optionMarkup = (options || [])
|
|
899
|
+
.map((option) => `<option value="${escapeHtml(option)}">${escapeHtml(option)}</option>`)
|
|
900
|
+
.join('');
|
|
901
|
+
return `
|
|
902
|
+
<label class="filter-control" for="${escapeHtml(id)}">
|
|
903
|
+
<span class="filter-control__label">${escapeHtml(label)}</span>
|
|
904
|
+
<select id="${escapeHtml(id)}" class="filter-control__input">
|
|
905
|
+
<option value="">All</option>
|
|
906
|
+
${optionMarkup}
|
|
907
|
+
</select>
|
|
908
|
+
</label>
|
|
909
|
+
`;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function renderOwnerPill(owner) {
|
|
913
|
+
if (!owner) {
|
|
914
|
+
return '';
|
|
915
|
+
}
|
|
916
|
+
return `<span class="owner-pill">Owner: ${escapeHtml(owner)}</span>`;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function renderFilterAttributes({ nodeType, moduleNames = [], packageNames = [], frameworks = [], hasFailures = false, lineCoverage = null }) {
|
|
920
|
+
const attributes = [
|
|
921
|
+
['data-filter-node', nodeType || 'node'],
|
|
922
|
+
['data-filter-modules', serializeFilterValues(moduleNames)],
|
|
923
|
+
['data-filter-packages', serializeFilterValues(packageNames)],
|
|
924
|
+
['data-filter-frameworks', serializeFilterValues(frameworks)],
|
|
925
|
+
['data-filter-has-failures', hasFailures ? 'true' : 'false'],
|
|
926
|
+
];
|
|
927
|
+
if (Number.isFinite(lineCoverage)) {
|
|
928
|
+
attributes.push(['data-filter-line-coverage', Number(lineCoverage).toFixed(2)]);
|
|
929
|
+
}
|
|
930
|
+
return attributes.map(([key, value]) => `${key}="${escapeHtml(value)}"`).join(' ');
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function renderModuleCard(moduleEntry) {
|
|
934
|
+
const status = deriveStatusFromSummary(moduleEntry.summary);
|
|
935
|
+
const targetId = `module-${slugify(moduleEntry.module)}`;
|
|
936
|
+
const dominantPackages = Array.isArray(moduleEntry.dominantPackages) && moduleEntry.dominantPackages.length > 0
|
|
937
|
+
? moduleEntry.dominantPackages.join(', ')
|
|
938
|
+
: 'No packages';
|
|
939
|
+
const filterAttrs = renderFilterAttributes({
|
|
940
|
+
nodeType: 'module-card',
|
|
941
|
+
moduleNames: [moduleEntry.module],
|
|
942
|
+
packageNames: moduleEntry.packages,
|
|
943
|
+
frameworks: moduleEntry.frameworks,
|
|
944
|
+
hasFailures: (moduleEntry.summary?.failed || 0) > 0,
|
|
945
|
+
lineCoverage: moduleEntry.coverage?.lines?.pct,
|
|
946
|
+
});
|
|
947
|
+
return `
|
|
948
|
+
<button type="button" class="module-card status-${status}" data-open-target="${escapeHtml(targetId)}" data-view-target="module" ${filterAttrs}>
|
|
949
|
+
<div class="module-card__header">
|
|
950
|
+
<div class="module-card__summary">
|
|
951
|
+
<span class="module-card__name">${escapeHtml(moduleEntry.module)}</span>
|
|
952
|
+
<span class="module-card__meta">${escapeHtml(formatSummary(moduleEntry.summary))}</span>
|
|
953
|
+
<span class="module-card__meta">${escapeHtml(formatDuration(moduleEntry.durationMs || 0))} • ${escapeHtml(`${moduleEntry.packageCount || 0} package${moduleEntry.packageCount === 1 ? '' : 's'}`)}</span>
|
|
954
|
+
</div>
|
|
955
|
+
${renderStatusPill(status)}
|
|
956
|
+
</div>
|
|
957
|
+
${renderOwnerPill(moduleEntry.owner)}
|
|
958
|
+
<div class="module-card__coverage">
|
|
959
|
+
${renderCoverageMiniMetric('Lines', moduleEntry.coverage?.lines)}
|
|
960
|
+
${renderCoverageMiniMetric('Branches', moduleEntry.coverage?.branches)}
|
|
961
|
+
${renderCoverageMiniMetric('Functions', moduleEntry.coverage?.functions)}
|
|
962
|
+
</div>
|
|
963
|
+
<span class="module-card__meta">Packages: ${escapeHtml(dominantPackages)}</span>
|
|
964
|
+
</button>
|
|
965
|
+
`;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function renderPackageCard(pkg) {
|
|
969
|
+
const status = pkg.status || deriveStatusFromSummary(pkg.summary);
|
|
970
|
+
const targetId = `package-${slugify(pkg.name)}`;
|
|
971
|
+
const filterAttrs = renderFilterAttributes({
|
|
972
|
+
nodeType: 'package-card',
|
|
973
|
+
moduleNames: pkg.modules,
|
|
974
|
+
packageNames: [pkg.name],
|
|
975
|
+
frameworks: pkg.frameworks,
|
|
976
|
+
hasFailures: (pkg.summary?.failed || 0) > 0,
|
|
977
|
+
lineCoverage: pkg.coverage?.lines?.pct,
|
|
978
|
+
});
|
|
979
|
+
return `
|
|
980
|
+
<button type="button" class="module-card status-${status}" data-open-target="${escapeHtml(targetId)}" data-view-target="package" ${filterAttrs}>
|
|
981
|
+
<div class="module-card__header">
|
|
982
|
+
<div class="module-card__summary">
|
|
983
|
+
<span class="module-card__name">${escapeHtml(pkg.name)}</span>
|
|
984
|
+
<span class="module-card__meta">${escapeHtml(formatSummary(pkg.summary))}</span>
|
|
985
|
+
<span class="module-card__meta">${escapeHtml(formatDuration(pkg.durationMs || 0))} • ${escapeHtml(`${Array.isArray(pkg.suites) ? pkg.suites.length : 0} suite${Array.isArray(pkg.suites) && pkg.suites.length === 1 ? '' : 's'}`)}</span>
|
|
986
|
+
</div>
|
|
987
|
+
${renderStatusPill(status)}
|
|
988
|
+
</div>
|
|
989
|
+
<div class="module-card__coverage">
|
|
990
|
+
${renderCoverageMiniMetric('Lines', pkg.coverage?.lines)}
|
|
991
|
+
${renderCoverageMiniMetric('Branches', pkg.coverage?.branches)}
|
|
992
|
+
${renderCoverageMiniMetric('Functions', pkg.coverage?.functions)}
|
|
993
|
+
</div>
|
|
994
|
+
<span class="module-card__meta">Location: ${escapeHtml(pkg.location || pkg.name)}</span>
|
|
995
|
+
</button>
|
|
996
|
+
`;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function renderModuleSection(moduleEntry, rootDir) {
|
|
1000
|
+
const status = deriveStatusFromSummary(moduleEntry.summary);
|
|
1001
|
+
const themes = Array.isArray(moduleEntry.themes) ? moduleEntry.themes : [];
|
|
1002
|
+
const themeMarkup = themes.length === 0
|
|
1003
|
+
? '<div class="theme-section"><div class="theme-section__summary"><div class="theme-section__headline"><h3 class="theme-section__title">No themes</h3></div><div class="theme-section__meta">No grouped test data was available.</div></div></div>'
|
|
1004
|
+
: themes.map((themeEntry) => renderThemeSection(moduleEntry, themeEntry, rootDir)).join('');
|
|
1005
|
+
const filterAttrs = renderFilterAttributes({
|
|
1006
|
+
nodeType: 'module-section',
|
|
1007
|
+
moduleNames: [moduleEntry.module],
|
|
1008
|
+
packageNames: moduleEntry.packages,
|
|
1009
|
+
frameworks: moduleEntry.frameworks,
|
|
1010
|
+
hasFailures: (moduleEntry.summary?.failed || 0) > 0,
|
|
1011
|
+
lineCoverage: moduleEntry.coverage?.lines?.pct,
|
|
1012
|
+
});
|
|
1013
|
+
return `
|
|
1014
|
+
<details class="module-section" id="module-${slugify(moduleEntry.module)}" ${filterAttrs}>
|
|
1015
|
+
<summary class="module-section__summary">
|
|
1016
|
+
<div class="module-section__headline">
|
|
1017
|
+
<h2 class="module-section__title">${escapeHtml(moduleEntry.module)}</h2>
|
|
1018
|
+
${renderStatusPill(status)}
|
|
1019
|
+
</div>
|
|
1020
|
+
<div class="module-section__meta">${escapeHtml(formatSummary(moduleEntry.summary))} • ${escapeHtml(formatDuration(moduleEntry.durationMs || 0))} • ${escapeHtml(`${moduleEntry.packageCount || 0} package${moduleEntry.packageCount === 1 ? '' : 's'}`)}</div>
|
|
1021
|
+
</summary>
|
|
1022
|
+
<div class="module-section__body">
|
|
1023
|
+
${renderOwnerPill(moduleEntry.owner)}
|
|
1024
|
+
<div class="module-section__packages">Dominant packages: ${escapeHtml((moduleEntry.dominantPackages || []).join(', ') || 'n/a')}</div>
|
|
1025
|
+
${renderCoverageBlock(moduleEntry.coverage, rootDir)}
|
|
1026
|
+
<div class="theme-list">${themeMarkup}</div>
|
|
1027
|
+
</div>
|
|
1028
|
+
</details>
|
|
1029
|
+
`;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function renderPackageSection(pkg, rootDir) {
|
|
1033
|
+
const status = pkg.status || deriveStatusFromSummary(pkg.summary);
|
|
1034
|
+
const suites = Array.isArray(pkg.suites) ? pkg.suites : [];
|
|
1035
|
+
const suiteMarkup = suites.length === 0
|
|
1036
|
+
? '<div class="suite"><div class="suite__summaryRow"><div><span class="suite__label">No test suites</span></div><div class="suite__summary">No package test script was found.</div></div></div>'
|
|
1037
|
+
: suites.map((suite) => renderSuite(suite, rootDir, { packageNames: [pkg.name] })).join('');
|
|
1038
|
+
const filterAttrs = renderFilterAttributes({
|
|
1039
|
+
nodeType: 'package-section',
|
|
1040
|
+
moduleNames: pkg.modules,
|
|
1041
|
+
packageNames: [pkg.name],
|
|
1042
|
+
frameworks: pkg.frameworks,
|
|
1043
|
+
hasFailures: (pkg.summary?.failed || 0) > 0,
|
|
1044
|
+
lineCoverage: pkg.coverage?.lines?.pct,
|
|
1045
|
+
});
|
|
1046
|
+
return `
|
|
1047
|
+
<details class="module-section" id="package-${slugify(pkg.name)}" ${filterAttrs}>
|
|
1048
|
+
<summary class="module-section__summary">
|
|
1049
|
+
<div class="module-section__headline">
|
|
1050
|
+
<h2 class="module-section__title">${escapeHtml(pkg.name)}</h2>
|
|
1051
|
+
${renderStatusPill(status)}
|
|
1052
|
+
</div>
|
|
1053
|
+
<div class="module-section__meta">${escapeHtml(formatSummary(pkg.summary))} • ${escapeHtml(formatDuration(pkg.durationMs || 0))} • ${escapeHtml(`${suites.length} suite${suites.length === 1 ? '' : 's'}`)}</div>
|
|
1054
|
+
</summary>
|
|
1055
|
+
<div class="module-section__body">
|
|
1056
|
+
<div class="module-section__packages">Package path: ${escapeHtml(pkg.location || pkg.name)}</div>
|
|
1057
|
+
${renderCoverageBlock(pkg.coverage, rootDir)}
|
|
1058
|
+
<div class="suite-list">${suiteMarkup}</div>
|
|
1059
|
+
</div>
|
|
1060
|
+
</details>
|
|
1061
|
+
`;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function renderThemeSection(moduleEntry, themeEntry, rootDir) {
|
|
1065
|
+
const status = deriveStatusFromSummary(themeEntry.summary);
|
|
1066
|
+
const packages = Array.isArray(themeEntry.packages) ? themeEntry.packages : [];
|
|
1067
|
+
const packageMarkup = packages.length === 0
|
|
1068
|
+
? '<div class="package-group"><div class="package-group__summary"><div class="package-group__headline"><h4 class="package-group__title">No package groups</h4></div><div class="package-group__meta">No package-level data was available for this theme.</div></div></div>'
|
|
1069
|
+
: packages.map((packageEntry) => renderThemePackageSection(moduleEntry, themeEntry, packageEntry, rootDir)).join('');
|
|
1070
|
+
const filterAttrs = renderFilterAttributes({
|
|
1071
|
+
nodeType: 'theme-section',
|
|
1072
|
+
moduleNames: [moduleEntry.module],
|
|
1073
|
+
packageNames: themeEntry.packageNames,
|
|
1074
|
+
frameworks: themeEntry.frameworks,
|
|
1075
|
+
hasFailures: (themeEntry.summary?.failed || 0) > 0,
|
|
1076
|
+
lineCoverage: themeEntry.coverage?.lines?.pct,
|
|
1077
|
+
});
|
|
1078
|
+
return `
|
|
1079
|
+
<details class="theme-section" ${filterAttrs}>
|
|
1080
|
+
<summary class="theme-section__summary">
|
|
1081
|
+
<div class="theme-section__headline">
|
|
1082
|
+
<h3 class="theme-section__title">${escapeHtml(`${moduleEntry.module} / ${themeEntry.theme}`)}</h3>
|
|
1083
|
+
${renderStatusPill(status)}
|
|
1084
|
+
</div>
|
|
1085
|
+
<div class="theme-section__meta">${escapeHtml(formatSummary(themeEntry.summary))} • ${escapeHtml(formatDuration(themeEntry.durationMs || 0))} • ${escapeHtml(`${themeEntry.packageCount || 0} package${themeEntry.packageCount === 1 ? '' : 's'}`)}</div>
|
|
1086
|
+
</summary>
|
|
1087
|
+
<div class="theme-section__body">
|
|
1088
|
+
${renderOwnerPill(themeEntry.owner)}
|
|
1089
|
+
${renderCoverageBlock(themeEntry.coverage, rootDir)}
|
|
1090
|
+
<div class="package-list">${packageMarkup}</div>
|
|
1091
|
+
</div>
|
|
1092
|
+
</details>
|
|
1093
|
+
`;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function renderThemePackageSection(moduleEntry, themeEntry, packageEntry, rootDir) {
|
|
1097
|
+
const status = deriveStatusFromSummary(packageEntry.summary);
|
|
1098
|
+
const suites = Array.isArray(packageEntry.suites) ? packageEntry.suites : [];
|
|
1099
|
+
const suiteMarkup = suites.length === 0
|
|
1100
|
+
? '<div class="suite"><div class="suite__summaryRow"><div><span class="suite__label">No test suites</span></div><div class="suite__summary">No suite results were grouped under this package.</div></div></div>'
|
|
1101
|
+
: suites.map((suite) => renderSuite(suite, rootDir, { packageNames: [packageEntry.name], moduleNames: [moduleEntry.module] })).join('');
|
|
1102
|
+
const filterAttrs = renderFilterAttributes({
|
|
1103
|
+
nodeType: 'theme-package',
|
|
1104
|
+
moduleNames: [moduleEntry.module],
|
|
1105
|
+
packageNames: [packageEntry.name],
|
|
1106
|
+
frameworks: packageEntry.frameworks,
|
|
1107
|
+
hasFailures: (packageEntry.summary?.failed || 0) > 0,
|
|
1108
|
+
});
|
|
1109
|
+
return `
|
|
1110
|
+
<details class="package-group" ${filterAttrs}>
|
|
1111
|
+
<summary class="package-group__summary">
|
|
1112
|
+
<div class="package-group__headline">
|
|
1113
|
+
<h4 class="package-group__title">${escapeHtml(packageEntry.name)}</h4>
|
|
1114
|
+
${renderStatusPill(status)}
|
|
1115
|
+
</div>
|
|
1116
|
+
<div class="package-group__meta">${escapeHtml(`${moduleEntry.module} / ${themeEntry.theme}`)} • ${escapeHtml(formatSummary(packageEntry.summary))} • ${escapeHtml(formatDuration(packageEntry.durationMs || 0))}</div>
|
|
1117
|
+
</summary>
|
|
1118
|
+
<div class="package-group__body">
|
|
1119
|
+
<div class="suite-list">${suiteMarkup}</div>
|
|
1120
|
+
</div>
|
|
1121
|
+
</details>
|
|
1122
|
+
`;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
function renderSuite(suite, rootDir, filterContext = {}) {
|
|
1126
|
+
const warnings = Array.isArray(suite.warnings) ? suite.warnings : [];
|
|
1127
|
+
const tests = Array.isArray(suite.tests) ? suite.tests : [];
|
|
1128
|
+
const rawArtifacts = Array.isArray(suite.rawArtifacts) ? suite.rawArtifacts : [];
|
|
1129
|
+
const warningMarkup = warnings.length > 0
|
|
1130
|
+
? `<ul class="suite__warnings">${warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join('')}</ul>`
|
|
1131
|
+
: '';
|
|
1132
|
+
const coverageMarkup = renderCoverageBlock(suite.coverage, rootDir);
|
|
1133
|
+
const artifactMarkup = rawArtifacts.length > 0 ? renderRawArtifactBlock(rawArtifacts) : '';
|
|
1134
|
+
const testsMarkup = tests.length === 0
|
|
1135
|
+
? '<div class="test-row"><div class="test-row__summary"><span class="status-pill skip">skip</span><div class="test-row__name"><span class="test-row__title">No test results emitted</span></div></div></div>'
|
|
1136
|
+
: tests.map((test) => renderTest(suite, test, rootDir, filterContext)).join('');
|
|
1137
|
+
const filterAttrs = renderFilterAttributes({
|
|
1138
|
+
nodeType: 'suite',
|
|
1139
|
+
moduleNames: filterContext.moduleNames || dedupe(tests.map((test) => test.module || 'uncategorized')),
|
|
1140
|
+
packageNames: filterContext.packageNames || [],
|
|
1141
|
+
frameworks: [suite.runtime],
|
|
1142
|
+
hasFailures: (suite.summary?.failed || 0) > 0,
|
|
1143
|
+
lineCoverage: suite.coverage?.lines?.pct,
|
|
1144
|
+
});
|
|
1145
|
+
return `
|
|
1146
|
+
<details class="suite" ${filterAttrs}>
|
|
1147
|
+
<summary class="suite__summaryRow">
|
|
1148
|
+
<div>
|
|
1149
|
+
<div class="suite__label">${escapeHtml(suite.label)}</div>
|
|
1150
|
+
<div class="suite__runtime">${escapeHtml(suite.runtime || 'custom')} • ${escapeHtml(suite.command || '')}</div>
|
|
1151
|
+
</div>
|
|
1152
|
+
<div class="suite__summary">${escapeHtml(formatSummary(suite.summary))} • ${escapeHtml(formatDuration(suite.durationMs || 0))}</div>
|
|
1153
|
+
</summary>
|
|
1154
|
+
<div class="suite__body">
|
|
1155
|
+
${warningMarkup}
|
|
1156
|
+
${coverageMarkup}
|
|
1157
|
+
${artifactMarkup}
|
|
1158
|
+
<div class="test-list">${testsMarkup}</div>
|
|
1159
|
+
</div>
|
|
1160
|
+
</details>
|
|
1161
|
+
`;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function renderRawArtifactBlock(rawArtifacts) {
|
|
1165
|
+
return `
|
|
1166
|
+
<section class="suite__artifacts">
|
|
1167
|
+
<h4 class="suite__artifactsTitle">Raw Artifacts</h4>
|
|
1168
|
+
<ul class="suite__artifactList">
|
|
1169
|
+
${rawArtifacts.map((artifact) => renderRawArtifactItem(artifact)).join('')}
|
|
1170
|
+
</ul>
|
|
1171
|
+
</section>
|
|
1172
|
+
`;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function renderRawArtifactItem(artifact) {
|
|
1176
|
+
const label = artifact?.label || artifact?.relativePath || 'artifact';
|
|
1177
|
+
const kind = artifact?.kind === 'directory' ? 'directory' : 'file';
|
|
1178
|
+
const meta = [kind, artifact?.mediaType].filter(Boolean).join(' • ');
|
|
1179
|
+
const href = typeof artifact?.href === 'string' && artifact.href.length > 0
|
|
1180
|
+
? artifact.href
|
|
1181
|
+
: `raw/${artifact?.relativePath || ''}`;
|
|
1182
|
+
return `
|
|
1183
|
+
<li>
|
|
1184
|
+
<a class="suite__artifactLink" href="${escapeHtml(href)}">${escapeHtml(label)}</a>
|
|
1185
|
+
<span class="suite__artifactMeta">${escapeHtml(meta || artifact?.relativePath || '')}</span>
|
|
1186
|
+
</li>
|
|
1187
|
+
`;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
function renderTest(suite, test, rootDir, filterContext = {}) {
|
|
1191
|
+
const statusClass = test.status === 'failed' ? 'fail' : test.status === 'skipped' ? 'skip' : 'pass';
|
|
1192
|
+
const assertions = Array.isArray(test.assertions) && test.assertions.length > 0
|
|
1193
|
+
? `<div class="detail-card"><h4>Assertions</h4><ul>${test.assertions.map((item) => `<li><code>${escapeHtml(item)}</code></li>`).join('')}</ul></div>`
|
|
1194
|
+
: '';
|
|
1195
|
+
const setup = Array.isArray(test.setup) && test.setup.length > 0
|
|
1196
|
+
? `<div class="detail-card"><h4>Setup</h4><ul>${test.setup.map((item) => `<li>${escapeHtml(item)}</li>`).join('')}</ul></div>`
|
|
1197
|
+
: '';
|
|
1198
|
+
const mocks = Array.isArray(test.mocks) && test.mocks.length > 0
|
|
1199
|
+
? `<div class="detail-card"><h4>Mocks</h4><ul>${test.mocks.map((item) => `<li>${escapeHtml(item)}</li>`).join('')}</ul></div>`
|
|
1200
|
+
: '';
|
|
1201
|
+
const failures = Array.isArray(test.failureMessages) && test.failureMessages.length > 0
|
|
1202
|
+
? `<div class="detail-card"><h4>Failures</h4><ul class="failure-list">${test.failureMessages.map((item) => `<li>${escapeHtml(item)}</li>`).join('')}</ul></div>`
|
|
1203
|
+
: '';
|
|
1204
|
+
const rawDetails = test.rawDetails && Object.keys(test.rawDetails).length > 0
|
|
1205
|
+
? `<div class="detail-card"><h4>Detail</h4><pre>${escapeHtml(JSON.stringify(test.rawDetails, null, 2))}</pre></div>`
|
|
1206
|
+
: '';
|
|
1207
|
+
const snippet = test.sourceSnippet
|
|
1208
|
+
? `<div class="detail-card"><h4>Source</h4><pre>${escapeHtml(test.sourceSnippet)}</pre></div>`
|
|
1209
|
+
: '';
|
|
1210
|
+
const filterAttrs = renderFilterAttributes({
|
|
1211
|
+
nodeType: 'test',
|
|
1212
|
+
moduleNames: [test.module || 'uncategorized'],
|
|
1213
|
+
packageNames: filterContext.packageNames || [],
|
|
1214
|
+
frameworks: [suite.runtime],
|
|
1215
|
+
hasFailures: test.status === 'failed',
|
|
1216
|
+
});
|
|
1217
|
+
return `
|
|
1218
|
+
<details class="test-row" ${filterAttrs}>
|
|
1219
|
+
<summary class="test-row__summary">
|
|
1220
|
+
<span class="status-pill ${statusClass}">${escapeHtml(test.status || 'passed')}</span>
|
|
1221
|
+
<div class="test-row__name">
|
|
1222
|
+
<span class="test-row__title">${escapeHtml(test.fullName || test.name || 'Unnamed test')}</span>
|
|
1223
|
+
<span class="test-row__file">${escapeHtml(formatTestLocation(test, rootDir))}</span>
|
|
1224
|
+
</div>
|
|
1225
|
+
<span class="test-row__duration">${escapeHtml(formatDuration(test.durationMs || 0))}</span>
|
|
1226
|
+
<span class="test-row__file">${escapeHtml(suite.label || '')}</span>
|
|
1227
|
+
</summary>
|
|
1228
|
+
<div class="test-row__details">
|
|
1229
|
+
<div class="detail-grid">
|
|
1230
|
+
${assertions}
|
|
1231
|
+
${setup}
|
|
1232
|
+
${mocks}
|
|
1233
|
+
${failures}
|
|
1234
|
+
${snippet}
|
|
1235
|
+
${rawDetails}
|
|
1236
|
+
</div>
|
|
1237
|
+
</div>
|
|
1238
|
+
</details>
|
|
1239
|
+
`;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
function formatTestLocation(test, rootDir) {
|
|
1243
|
+
if (!test?.file) {
|
|
1244
|
+
return 'No source file';
|
|
1245
|
+
}
|
|
1246
|
+
const file = rootDir ? path.relative(rootDir, test.file) : test.file;
|
|
1247
|
+
if (Number.isFinite(test.line)) {
|
|
1248
|
+
return `${file}:${test.line}${Number.isFinite(test.column) ? `:${test.column}` : ''}`;
|
|
1249
|
+
}
|
|
1250
|
+
return file;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
function renderCoverageBlock(coverage, rootDir) {
|
|
1254
|
+
if (!coverage) {
|
|
1255
|
+
return '';
|
|
1256
|
+
}
|
|
1257
|
+
const files = Array.isArray(coverage.files) ? coverage.files : [];
|
|
1258
|
+
const showAttribution = files.some((file) => file.module || file.shared || file.attributionReason);
|
|
1259
|
+
const fileRows = files
|
|
1260
|
+
.slice(0, 20)
|
|
1261
|
+
.map((file) => {
|
|
1262
|
+
const displayPath = rootDir ? path.relative(rootDir, file.path) : file.path;
|
|
1263
|
+
return `
|
|
1264
|
+
<tr>
|
|
1265
|
+
<td><code>${escapeHtml(displayPath)}</code></td>
|
|
1266
|
+
<td>${escapeHtml(file.lines ? `${file.lines.pct.toFixed(2)}%` : 'n/a')}</td>
|
|
1267
|
+
<td>${escapeHtml(file.branches ? `${file.branches.pct.toFixed(2)}%` : 'n/a')}</td>
|
|
1268
|
+
<td>${escapeHtml(file.functions ? `${file.functions.pct.toFixed(2)}%` : 'n/a')}</td>
|
|
1269
|
+
<td>${escapeHtml(file.statements ? `${file.statements.pct.toFixed(2)}%` : 'n/a')}</td>
|
|
1270
|
+
${showAttribution ? `<td>${escapeHtml(formatCoverageAttribution(file))}</td>` : ''}
|
|
1271
|
+
</tr>`;
|
|
1272
|
+
})
|
|
1273
|
+
.join('');
|
|
1274
|
+
return `
|
|
1275
|
+
<section class="coverage-block">
|
|
1276
|
+
<div class="coverage-block__grid">
|
|
1277
|
+
${renderCoverageMetric('Lines', coverage.lines)}
|
|
1278
|
+
${renderCoverageMetric('Branches', coverage.branches)}
|
|
1279
|
+
${renderCoverageMetric('Functions', coverage.functions)}
|
|
1280
|
+
${renderCoverageMetric('Statements', coverage.statements)}
|
|
1281
|
+
</div>
|
|
1282
|
+
<details>
|
|
1283
|
+
<summary>Coverage by file (${files.length} files, lowest line coverage first)</summary>
|
|
1284
|
+
<table class="coverage-table">
|
|
1285
|
+
<thead>
|
|
1286
|
+
<tr>
|
|
1287
|
+
<th>File</th>
|
|
1288
|
+
<th>Lines</th>
|
|
1289
|
+
<th>Branches</th>
|
|
1290
|
+
<th>Functions</th>
|
|
1291
|
+
<th>Statements</th>
|
|
1292
|
+
${showAttribution ? '<th>Attribution</th>' : ''}
|
|
1293
|
+
</tr>
|
|
1294
|
+
</thead>
|
|
1295
|
+
<tbody>${fileRows || '<tr><td colspan="6">No coverage files</td></tr>'}</tbody>
|
|
1296
|
+
</table>
|
|
1297
|
+
</details>
|
|
1298
|
+
</section>
|
|
1299
|
+
`;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
function renderCoverageMetric(label, metric) {
|
|
1303
|
+
const pct = metric ? metric.pct.toFixed(2) : '0.00';
|
|
1304
|
+
const counts = metric ? `${formatCoverageCount(metric.covered)}/${formatCoverageCount(metric.total)}` : 'n/a';
|
|
1305
|
+
const fillStyle = metric
|
|
1306
|
+
? `width:${metric.pct.toFixed(2)}%;background:hsl(${coverageHue(metric.pct)} 68% 48%);`
|
|
1307
|
+
: 'width:0%;background:hsl(0 68% 48%);';
|
|
1308
|
+
return `
|
|
1309
|
+
<div class="coverage-metric">
|
|
1310
|
+
<span class="coverage-metric__label">${escapeHtml(label)}</span>
|
|
1311
|
+
<span class="coverage-metric__value">
|
|
1312
|
+
<strong>${escapeHtml(metric ? `${pct}%` : 'n/a')}</strong>
|
|
1313
|
+
<span class="coverage-metric__counts">${escapeHtml(counts)}</span>
|
|
1314
|
+
</span>
|
|
1315
|
+
<div class="coverage-metric__bar" aria-hidden="true">
|
|
1316
|
+
<div class="coverage-metric__fill" style="${fillStyle}"></div>
|
|
1317
|
+
</div>
|
|
1318
|
+
</div>
|
|
1319
|
+
`;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
function formatCoverageAttribution(file) {
|
|
1323
|
+
const parts = [];
|
|
1324
|
+
if (file.module) {
|
|
1325
|
+
parts.push(file.theme ? `${file.module}/${file.theme}` : `${file.module} (module-wide)`);
|
|
1326
|
+
}
|
|
1327
|
+
if (file.shared) {
|
|
1328
|
+
parts.push(file.attributionWeight < 1 ? `shared ${formatCoverageCount(file.attributionWeight)}` : 'shared');
|
|
1329
|
+
}
|
|
1330
|
+
if (file.attributionSource === 'heuristic') {
|
|
1331
|
+
parts.push('heuristic');
|
|
1332
|
+
}
|
|
1333
|
+
if (file.attributionReason) {
|
|
1334
|
+
parts.push(trimForReport(file.attributionReason, 80));
|
|
1335
|
+
}
|
|
1336
|
+
return parts.join(' • ') || 'n/a';
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
function deriveStatusFromSummary(summary) {
|
|
1340
|
+
if (!summary || summary.total === 0) return 'skipped';
|
|
1341
|
+
if (summary.failed > 0) return 'failed';
|
|
1342
|
+
if (summary.skipped === summary.total) return 'skipped';
|
|
1343
|
+
return 'passed';
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function formatSummary(summary = {}) {
|
|
1347
|
+
const total = Number.isFinite(summary.total) ? summary.total : 0;
|
|
1348
|
+
const passed = Number.isFinite(summary.passed) ? summary.passed : 0;
|
|
1349
|
+
const failed = Number.isFinite(summary.failed) ? summary.failed : 0;
|
|
1350
|
+
const skipped = Number.isFinite(summary.skipped) ? summary.skipped : 0;
|
|
1351
|
+
return `${total} total • ${passed} passed • ${failed} failed • ${skipped} skipped`;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
function formatDuration(value) {
|
|
1355
|
+
const durationMs = Number.isFinite(value) ? Math.max(0, value) : 0;
|
|
1356
|
+
if (durationMs < 1000) {
|
|
1357
|
+
return `${durationMs}ms`;
|
|
1358
|
+
}
|
|
1359
|
+
const seconds = durationMs / 1000;
|
|
1360
|
+
if (seconds < 60) {
|
|
1361
|
+
return `${seconds.toFixed(2)}s`;
|
|
1362
|
+
}
|
|
1363
|
+
const minutes = Math.floor(seconds / 60);
|
|
1364
|
+
const remainingSeconds = seconds % 60;
|
|
1365
|
+
return `${minutes}m ${remainingSeconds.toFixed(1)}s`;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
function coverageHue(pct) {
|
|
1369
|
+
const normalized = Math.max(0, Math.min(100, pct));
|
|
1370
|
+
return `${Math.round((normalized / 100) * 120)}deg`;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
function formatCoverageCount(value) {
|
|
1374
|
+
if (!Number.isFinite(value)) {
|
|
1375
|
+
return '0';
|
|
1376
|
+
}
|
|
1377
|
+
return Number.isInteger(value) ? String(value) : value.toFixed(2);
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
function serializeFilterValues(values) {
|
|
1381
|
+
return dedupe((values || []).map((value) => normalizeFilterValue(value))).join('|');
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
function normalizeFilterValue(value) {
|
|
1385
|
+
return String(value || '').trim().toLowerCase();
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
function dedupe(values) {
|
|
1389
|
+
return Array.from(new Set((values || []).filter(Boolean)));
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
function slugify(value) {
|
|
1393
|
+
return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
function trimForReport(value, maxLength) {
|
|
1397
|
+
if (typeof value !== 'string') {
|
|
1398
|
+
return value;
|
|
1399
|
+
}
|
|
1400
|
+
if (value.length <= maxLength) {
|
|
1401
|
+
return value;
|
|
1402
|
+
}
|
|
1403
|
+
return `${value.slice(0, maxLength)}…`;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
function escapeHtml(value) {
|
|
1407
|
+
return String(value)
|
|
1408
|
+
.replace(/&/g, '&')
|
|
1409
|
+
.replace(/</g, '<')
|
|
1410
|
+
.replace(/>/g, '>')
|
|
1411
|
+
.replace(/"/g, '"')
|
|
1412
|
+
.replace(/'/g, ''');
|
|
1413
|
+
}
|