spec-up-t-healthcheck 1.0.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.
@@ -0,0 +1,626 @@
1
+ /**
2
+ * @fileoverview HTML report generator for health check results
3
+ *
4
+ * This module generates Bootstrap-styled HTML reports for health check results,
5
+ * providing an interactive and visually appealing presentation of the data.
6
+ * The generated reports include filtering capabilities, status indicators, and
7
+ * responsive design elements.
8
+ *
9
+ * @author spec-up-t-healthcheck
10
+
11
+ */
12
+
13
+ /**
14
+ * Generates a complete HTML report from health check results.
15
+ *
16
+ * Creates a comprehensive HTML document using Bootstrap framework for styling,
17
+ * including interactive features like filtering passing checks, status indicators,
18
+ * and responsive layout. The report follows the visual design patterns from
19
+ * the HealthCheck.vue reference.
20
+ *
21
+ * @param {import('./health-checker.js').HealthCheckReport} healthCheckOutput - The complete health check report
22
+ * @param {Object} [options={}] - Configuration options for HTML generation
23
+ * @param {string} [options.title='Spec-Up-T Health Check Report'] - Custom title for the report
24
+ * @param {boolean} [options.showPassingByDefault=true] - Whether to show passing checks by default
25
+ * @param {string} [options.repositoryUrl] - URL to the repository being checked
26
+ * @returns {string} Complete HTML document as string
27
+ *
28
+ * @example
29
+ * ```javascript
30
+ * const report = await runHealthChecks(provider);
31
+ * const htmlContent = generateHtmlReport(report, {
32
+ * title: 'My Custom Health Check Report',
33
+ * repositoryUrl: 'https://github.com/user/repo'
34
+ * });
35
+ * ```
36
+ */
37
+ export function generateHtmlReport(healthCheckOutput, options = {}) {
38
+ const {
39
+ title = 'Spec-Up-T Health Check Report',
40
+ showPassingByDefault = true,
41
+ repositoryUrl
42
+ } = options;
43
+
44
+ const { results, summary, timestamp, provider } = healthCheckOutput;
45
+
46
+ // Generate the main content sections
47
+ const headerSection = generateHeaderSection(title, timestamp, provider, repositoryUrl);
48
+ const summarySection = generateSummarySection(summary, showPassingByDefault);
49
+ const resultsSection = generateResultsSection(results);
50
+ const scriptsSection = generateScriptsSection();
51
+
52
+ return `<!DOCTYPE html>
53
+ <html lang="en">
54
+ <head>
55
+ <meta charset="UTF-8">
56
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
57
+ <title>${escapeHtml(title)}</title>
58
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
59
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
60
+ <style>
61
+ body {
62
+ padding-top: 2rem;
63
+ padding-bottom: 2rem;
64
+ background-color: #f8f9fa;
65
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
66
+ }
67
+ .report-header {
68
+ margin-bottom: 2rem;
69
+ padding-bottom: 1.5rem;
70
+ border-bottom: 2px solid #dee2e6;
71
+ }
72
+ .report-header h1 {
73
+ color: #212529;
74
+ font-weight: 600;
75
+ margin-bottom: 0.75rem;
76
+ }
77
+ .report-header h1 i {
78
+ margin-right: 0.5rem;
79
+ }
80
+ .timestamp {
81
+ color: #6c757d;
82
+ font-size: 0.95rem;
83
+ }
84
+ .timestamp i {
85
+ margin-right: 0.25rem;
86
+ }
87
+ .repo-info {
88
+ color: #6c757d;
89
+ margin-bottom: 0.5rem;
90
+ font-size: 0.95rem;
91
+ }
92
+ .repo-info i {
93
+ margin-right: 0.25rem;
94
+ }
95
+ .filter-toggle {
96
+ margin-bottom: 1.5rem;
97
+ }
98
+ .hidden-item {
99
+ display: none !important;
100
+ }
101
+ .status-icon {
102
+ font-size: 1.1em;
103
+ margin-right: 0.25rem;
104
+ }
105
+ .health-score {
106
+ font-size: 2.5rem;
107
+ font-weight: bold;
108
+ line-height: 1;
109
+ }
110
+ .summary-card {
111
+ border: none;
112
+ box-shadow: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.1);
113
+ border-radius: 0.5rem;
114
+ overflow: hidden;
115
+ }
116
+ .results-card {
117
+ border: none;
118
+ box-shadow: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.1);
119
+ border-radius: 0.5rem;
120
+ margin-bottom: 1.5rem;
121
+ overflow: hidden;
122
+ }
123
+ .card-header {
124
+ border-bottom: 1px solid #dee2e6;
125
+ background-color: #fff;
126
+ padding: 1rem 1.25rem;
127
+ }
128
+ .card-header h5 {
129
+ font-weight: 600;
130
+ color: #212529;
131
+ }
132
+ .card-header i {
133
+ margin-right: 0.5rem;
134
+ }
135
+ .card-body {
136
+ padding: 1.5rem;
137
+ }
138
+ .table th {
139
+ border-top: none;
140
+ font-weight: 600;
141
+ background-color: #f8f9fa;
142
+ color: #495057;
143
+ font-size: 0.875rem;
144
+ text-transform: uppercase;
145
+ letter-spacing: 0.5px;
146
+ padding: 0.75rem;
147
+ }
148
+ .table td {
149
+ padding: 0.75rem;
150
+ vertical-align: middle;
151
+ }
152
+ .table-striped tbody tr:nth-of-type(odd) {
153
+ background-color: rgba(0, 0, 0, 0.02);
154
+ }
155
+ .table-hover tbody tr:hover {
156
+ background-color: rgba(0, 0, 0, 0.04);
157
+ }
158
+ .status-badge {
159
+ white-space: nowrap;
160
+ font-weight: 600;
161
+ font-size: 0.95rem;
162
+ }
163
+ .overall-status-pass {
164
+ color: #198754;
165
+ font-size: 1.1rem;
166
+ }
167
+ .overall-status-warning {
168
+ color: #fd7e14;
169
+ font-size: 1.1rem;
170
+ }
171
+ .overall-status-fail {
172
+ color: #dc3545;
173
+ font-size: 1.1rem;
174
+ }
175
+ .text-success {
176
+ color: #198754 !important;
177
+ }
178
+ .text-danger {
179
+ color: #dc3545 !important;
180
+ }
181
+ .text-warning {
182
+ color: #fd7e14 !important;
183
+ }
184
+ .text-muted {
185
+ color: #6c757d !important;
186
+ }
187
+ .detail-errors ul,
188
+ .detail-warnings ul,
189
+ .detail-success ul {
190
+ padding-left: 1.25rem;
191
+ margin-top: 0.5rem;
192
+ }
193
+ .detail-errors li,
194
+ .detail-warnings li,
195
+ .detail-success li {
196
+ margin-bottom: 0.25rem;
197
+ }
198
+ /* Responsive adjustments */
199
+ @media (max-width: 768px) {
200
+ .health-score {
201
+ font-size: 2rem;
202
+ }
203
+ .card-body {
204
+ padding: 1rem;
205
+ }
206
+ .table td,
207
+ .table th {
208
+ padding: 0.5rem;
209
+ font-size: 0.875rem;
210
+ }
211
+ }
212
+ </style>
213
+ </head>
214
+ <body>
215
+ <div class="container">
216
+ ${headerSection}
217
+ ${summarySection}
218
+ ${resultsSection}
219
+ </div>
220
+
221
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
222
+ ${scriptsSection}
223
+ </body>
224
+ </html>`;
225
+ }
226
+
227
+ /**
228
+ * Generates the header section of the HTML report.
229
+ *
230
+ * @param {string} title - Report title
231
+ * @param {string} timestamp - Report generation timestamp
232
+ * @param {Object} provider - Provider information
233
+ * @param {string} [repositoryUrl] - Optional repository URL
234
+ * @returns {string} HTML string for the header section
235
+ */
236
+ function generateHeaderSection(title, timestamp, provider, repositoryUrl) {
237
+ const formattedTimestamp = new Date(timestamp).toLocaleString();
238
+
239
+ let repoInfo = '';
240
+ if (provider.repoPath) {
241
+ repoInfo = `<p class="repo-info">
242
+ <i class="bi bi-folder"></i>
243
+ Repository: ${escapeHtml(provider.repoPath)}
244
+ </p>`;
245
+ }
246
+
247
+ if (repositoryUrl) {
248
+ repoInfo += `<p class="repo-info">
249
+ <i class="bi bi-link-45deg"></i>
250
+ URL: <a href="${escapeHtml(repositoryUrl)}" target="_blank">${escapeHtml(repositoryUrl)}</a>
251
+ </p>`;
252
+ }
253
+
254
+ return `<div class="report-header">
255
+ <h1>
256
+ <i class="bi bi-heart-pulse text-primary"></i>
257
+ ${escapeHtml(title)}
258
+ </h1>
259
+ <p class="timestamp">
260
+ <i class="bi bi-clock"></i>
261
+ Generated: ${escapeHtml(formattedTimestamp)}
262
+ </p>
263
+ ${repoInfo}
264
+ </div>`;
265
+ }
266
+
267
+ /**
268
+ * Generates the summary section with overall statistics and status.
269
+ *
270
+ * @param {import('./health-checker.js').HealthCheckSummary} summary - Health check summary
271
+ * @param {boolean} showPassingByDefault - Whether to show passing checks by default
272
+ * @returns {string} HTML string for the summary section
273
+ */
274
+ function generateSummarySection(summary, showPassingByDefault) {
275
+ const overallStatusClass = summary.hasErrors ? 'overall-status-fail' :
276
+ summary.hasWarnings ? 'overall-status-warning' : 'overall-status-pass';
277
+
278
+ const overallStatusIcon = summary.hasErrors ? 'bi-x-circle-fill' :
279
+ summary.hasWarnings ? 'bi-exclamation-triangle-fill' : 'bi-check-circle-fill';
280
+
281
+ const overallStatusText = summary.hasErrors ? 'FAILED' :
282
+ summary.hasWarnings ? 'PASSED WITH WARNINGS' : 'PASSED';
283
+
284
+ const scoreColor = summary.score >= 80 ? 'text-success' :
285
+ summary.score >= 60 ? 'text-warning' : 'text-danger';
286
+
287
+ const headerClass = summary.hasErrors ? 'bg-danger text-white' :
288
+ summary.hasWarnings ? 'bg-warning text-dark' : 'bg-light';
289
+
290
+ return `<div class="card summary-card mb-4">
291
+ <div class="card-header ${headerClass}">
292
+ <div class="d-flex justify-content-between align-items-center">
293
+ <h5 class="mb-0">
294
+ <i class="bi bi-bar-chart"></i>
295
+ Health Check Summary
296
+ </h5>
297
+ <div class="filter-toggle form-check form-switch">
298
+ <input class="form-check-input" type="checkbox" id="togglePassingChecks" ${showPassingByDefault ? 'checked' : ''}>
299
+ <label class="form-check-label" for="togglePassingChecks">
300
+ Show passing checks
301
+ </label>
302
+ </div>
303
+ </div>
304
+ </div>
305
+ <div class="card-body">
306
+ <div class="row">
307
+ <div class="col-md-8">
308
+ <div class="row">
309
+ <div class="col-6 col-md-3 text-center mb-3">
310
+ <div class="h4 mb-1">${summary.total}</div>
311
+ <small class="text-muted">Total</small>
312
+ </div>
313
+ <div class="col-6 col-md-3 text-center mb-3">
314
+ <div class="h4 mb-1 text-success">
315
+ <i class="bi bi-check-circle-fill"></i> ${summary.passed}
316
+ </div>
317
+ <small class="text-muted">Passed</small>
318
+ </div>
319
+ <div class="col-6 col-md-3 text-center mb-3">
320
+ <div class="h4 mb-1 text-danger">
321
+ <i class="bi bi-x-circle-fill"></i> ${summary.failed}
322
+ </div>
323
+ <small class="text-muted">Failed</small>
324
+ </div>
325
+ ${summary.warnings > 0 ? `<div class="col-6 col-md-3 text-center mb-3">
326
+ <div class="h4 mb-1 text-warning">
327
+ <i class="bi bi-exclamation-triangle-fill"></i> ${summary.warnings}
328
+ </div>
329
+ <small class="text-muted">Warnings</small>
330
+ </div>` : ''}
331
+ </div>
332
+ <hr class="my-3">
333
+ <div class="${overallStatusClass}">
334
+ <i class="bi ${overallStatusIcon} status-icon"></i>
335
+ <strong>Overall Status: ${overallStatusText}</strong>
336
+ </div>
337
+ </div>
338
+ <div class="col-md-4 text-center d-flex flex-column justify-content-center align-items-center">
339
+ <div class="health-score ${scoreColor}">${Math.round(summary.score)}%</div>
340
+ <small class="text-muted mt-2">Health Score</small>
341
+ </div>
342
+ </div>
343
+ </div>
344
+ </div>`;
345
+ }
346
+
347
+ /**
348
+ * Generates the results section with individual check details.
349
+ *
350
+ * @param {import('./health-checker.js').HealthCheckResult[]} results - Array of check results
351
+ * @returns {string} HTML string for the results section
352
+ */
353
+ function generateResultsSection(results) {
354
+ if (results.length === 0) {
355
+ return `<div class="card results-card">
356
+ <div class="card-body text-center py-5">
357
+ <i class="bi bi-heart-pulse" style="font-size: 3rem; color: #6c757d;"></i>
358
+ <h5 class="mt-3">No Health Check Results</h5>
359
+ <p class="text-muted">No health checks have been performed yet.</p>
360
+ </div>
361
+ </div>`;
362
+ }
363
+
364
+ const resultsHtml = results.map((result, index) => {
365
+ const { statusClass, statusIcon, statusText } = getStatusDisplay(result);
366
+ const rowClass = getRowClass(result);
367
+
368
+ let detailsHtml = '';
369
+ if (result.details && Object.keys(result.details).length > 0) {
370
+ detailsHtml = formatResultDetails(result.details);
371
+ }
372
+
373
+ return `<tr data-status="${result.status}" class="check-row ${rowClass}">
374
+ <td class="${statusClass} status-badge">
375
+ <i class="bi ${statusIcon} status-icon"></i>
376
+ <span>${statusText}</span>
377
+ </td>
378
+ <td>${escapeHtml(result.check)}</td>
379
+ <td>
380
+ ${escapeHtml(result.message)}
381
+ ${detailsHtml}
382
+ </td>
383
+ </tr>`;
384
+ }).join('');
385
+
386
+ return `<div class="card results-card" data-section="health-check-results">
387
+ <div class="card-header">
388
+ <h5 class="mb-0">
389
+ <i class="bi bi-list-check"></i>
390
+ Detailed Results
391
+ </h5>
392
+ </div>
393
+ <div class="card-body">
394
+ <table class="table table-striped table-hover mb-0">
395
+ <thead>
396
+ <tr>
397
+ <th style="width: 120px;">Status</th>
398
+ <th style="width: 200px;">Check</th>
399
+ <th>Details</th>
400
+ </tr>
401
+ </thead>
402
+ <tbody>
403
+ ${resultsHtml}
404
+ </tbody>
405
+ </table>
406
+ </div>
407
+ </div>`;
408
+ }
409
+
410
+ /**
411
+ * Generates the JavaScript section for interactive functionality.
412
+ *
413
+ * @returns {string} HTML script section
414
+ */
415
+ function generateScriptsSection() {
416
+ return `<script>
417
+ // Toggle function for passing checks
418
+ document.getElementById('togglePassingChecks').addEventListener('change', function() {
419
+ const showPassing = this.checked;
420
+ // Select only fully passing checks (status="pass")
421
+ // Warnings (status="warn") should always remain visible as they indicate potential issues
422
+ const passingRows = document.querySelectorAll('tr[data-status="pass"]');
423
+
424
+ // Hide/show passing rows
425
+ passingRows.forEach(row => {
426
+ if (showPassing) {
427
+ row.classList.remove('hidden-item');
428
+ } else {
429
+ row.classList.add('hidden-item');
430
+ }
431
+ });
432
+
433
+ // Also hide/show success details within all checks
434
+ // When focusing on issues, we don't need to see successful validations
435
+ const successDetails = document.querySelectorAll('.detail-success');
436
+ successDetails.forEach(detail => {
437
+ if (showPassing) {
438
+ detail.classList.remove('hidden-item');
439
+ } else {
440
+ detail.classList.add('hidden-item');
441
+ }
442
+ });
443
+
444
+ // Check each results card to see if it should be hidden
445
+ document.querySelectorAll('.results-card').forEach(card => {
446
+ const visibleRows = card.querySelectorAll('tr.check-row:not(.hidden-item)');
447
+
448
+ if (visibleRows.length === 0) {
449
+ // If no visible rows, hide the entire card
450
+ card.classList.add('hidden-item');
451
+ } else {
452
+ // Otherwise show it
453
+ card.classList.remove('hidden-item');
454
+ }
455
+ });
456
+ });
457
+
458
+ // Initialize the filter state
459
+ document.getElementById('togglePassingChecks').dispatchEvent(new Event('change'));
460
+ </script>`;
461
+ }
462
+
463
+ /**
464
+ * Gets the appropriate display properties for a health check result status.
465
+ *
466
+ * @param {import('./health-checker.js').HealthCheckResult} result - The health check result
467
+ * @returns {Object} Object containing statusClass, statusIcon, and statusText
468
+ */
469
+ function getStatusDisplay(result) {
470
+ switch (result.status) {
471
+ case 'pass':
472
+ return {
473
+ statusClass: 'text-success',
474
+ statusIcon: 'bi-check-circle-fill',
475
+ statusText: 'Pass'
476
+ };
477
+ case 'fail':
478
+ return {
479
+ statusClass: 'text-danger',
480
+ statusIcon: 'bi-x-circle-fill',
481
+ statusText: 'Fail'
482
+ };
483
+ case 'warn':
484
+ return {
485
+ statusClass: 'text-warning',
486
+ statusIcon: 'bi-exclamation-triangle-fill',
487
+ statusText: 'Warning'
488
+ };
489
+ case 'skip':
490
+ return {
491
+ statusClass: 'text-muted',
492
+ statusIcon: 'bi-dash-circle',
493
+ statusText: 'Skipped'
494
+ };
495
+ default:
496
+ return {
497
+ statusClass: 'text-secondary',
498
+ statusIcon: 'bi-question-circle',
499
+ statusText: 'Unknown'
500
+ };
501
+ }
502
+ }
503
+
504
+ /**
505
+ * Gets the appropriate CSS class for a table row based on the result status.
506
+ *
507
+ * @param {import('./health-checker.js').HealthCheckResult} result - The health check result
508
+ * @returns {string} CSS class name
509
+ */
510
+ function getRowClass(result) {
511
+ switch (result.status) {
512
+ case 'fail':
513
+ return 'table-danger';
514
+ case 'warn':
515
+ return 'table-warning';
516
+ case 'pass':
517
+ return 'table-success';
518
+ default:
519
+ return '';
520
+ }
521
+ }
522
+
523
+ /**
524
+ * Formats result details into HTML.
525
+ *
526
+ * @param {Object} details - The details object from a health check result
527
+ * @returns {string} Formatted HTML string
528
+ */
529
+ function formatResultDetails(details) {
530
+ let html = '';
531
+
532
+ // Display errors array with clickable URLs
533
+ if (details.errors && details.errors.length > 0) {
534
+ html += `<div class="mt-2 detail-errors"><strong class="text-danger">Errors:</strong><ul class="mb-0 mt-1">`;
535
+ details.errors.forEach(error => {
536
+ html += `<li class="text-danger">${linkifyUrls(error)}</li>`;
537
+ });
538
+ html += `</ul></div>`;
539
+ }
540
+
541
+ // Display warnings array with clickable URLs
542
+ if (details.warnings && details.warnings.length > 0) {
543
+ html += `<div class="mt-2 detail-warnings"><strong class="text-warning">Warnings:</strong><ul class="mb-0 mt-1">`;
544
+ details.warnings.forEach(warning => {
545
+ html += `<li class="text-warning">${linkifyUrls(warning)}</li>`;
546
+ });
547
+ html += `</ul></div>`;
548
+ }
549
+
550
+ // Display success messages array with clickable URLs
551
+ // Add detail-success class so these can be hidden when "Show passing checks" is disabled
552
+ if (details.success && details.success.length > 0) {
553
+ html += `<div class="mt-2 detail-success"><strong class="text-success">Success:</strong><ul class="mb-0 mt-1">`;
554
+ details.success.forEach(success => {
555
+ html += `<li class="text-success">${linkifyUrls(success)}</li>`;
556
+ });
557
+ html += `</ul></div>`;
558
+ }
559
+
560
+ // Display missing fields (existing functionality)
561
+ if (details.missingFields && details.missingFields.length > 0) {
562
+ html += `<br><small class="text-muted">Missing fields: ${details.missingFields.map(escapeHtml).join(', ')}</small>`;
563
+ }
564
+
565
+ // Display count (existing functionality)
566
+ if (details.count !== undefined) {
567
+ html += `<br><small class="text-muted">Files found: ${details.count}</small>`;
568
+ }
569
+
570
+ // Display package data (existing functionality)
571
+ if (details.packageData) {
572
+ html += `<br><small class="text-muted">Package: ${escapeHtml(details.packageData.name)}@${escapeHtml(details.packageData.version)}</small>`;
573
+ }
574
+
575
+ return html;
576
+ }
577
+
578
+ /**
579
+ * Escapes HTML special characters to prevent XSS attacks.
580
+ *
581
+ * @param {string} text - Text to escape
582
+ * @returns {string} HTML-escaped text
583
+ */
584
+ function escapeHtml(text) {
585
+ if (typeof text !== 'string') {
586
+ return String(text);
587
+ }
588
+
589
+ const map = {
590
+ '&': '&amp;',
591
+ '<': '&lt;',
592
+ '>': '&gt;',
593
+ '"': '&quot;',
594
+ "'": '&#039;'
595
+ };
596
+
597
+ return text.replace(/[&<>"']/g, (m) => map[m]);
598
+ }
599
+
600
+ /**
601
+ * Converts URLs in text to clickable links that open in a new tab.
602
+ * The text is first escaped for HTML safety, then URLs are converted to links.
603
+ *
604
+ * @param {string} text - Text potentially containing URLs
605
+ * @returns {string} HTML string with clickable links
606
+ */
607
+ function linkifyUrls(text) {
608
+ if (typeof text !== 'string') {
609
+ return escapeHtml(String(text));
610
+ }
611
+
612
+ // First escape the text for HTML safety
613
+ const escaped = escapeHtml(text);
614
+
615
+ // URL regex pattern that matches http://, https://, and www. URLs
616
+ const urlPattern = /(https?:\/\/[^\s<>"]+|www\.[^\s<>"]+)/g;
617
+
618
+ // Replace URLs with clickable links
619
+ return escaped.replace(urlPattern, (url) => {
620
+ // Ensure the URL has a protocol for the href attribute
621
+ const href = url.startsWith('http') ? url : `https://${url}`;
622
+
623
+ // Create an anchor tag that opens in a new tab with security attributes
624
+ return `<a href="${href}" target="_blank" rel="noopener noreferrer" class="text-decoration-underline">${url}</a>`;
625
+ });
626
+ }