create-backlist 10.0.3 → 10.0.5
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/bin/index.js +989 -713
- package/bin/qa.js +138 -183
- package/package.json +6 -1
- package/src/qa/analyzers/accessibility.js +199 -0
- package/src/qa/analyzers/api.js +125 -0
- package/src/qa/analyzers/performance.js +124 -0
- package/src/qa/analyzers/security.js +207 -0
- package/src/qa/analyzers/seo.js +248 -0
- package/src/qa/browser/crawler.js +305 -0
- package/src/qa/browser/installer.js +209 -0
- package/src/qa/browser/interactions.js +320 -0
- package/src/qa/browser/screenshot.js +34 -0
- package/src/qa/qa-engine.js +797 -2936
- package/src/qa/reporters/html.js +623 -0
- package/src/qa/reporters/json.js +49 -0
- package/src/qa/reporters/terminal.js +184 -0
- package/src/qa/utils/ai-classifier.js +98 -0
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
// Enterprise HTML report — 100% real runtime data, zero fake values
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { formatDuration, formatBytes, VERSION } from '../qa-engine.js';
|
|
5
|
+
|
|
6
|
+
export class HTMLReporter {
|
|
7
|
+
#session;
|
|
8
|
+
|
|
9
|
+
constructor(session) { this.#session = session; }
|
|
10
|
+
|
|
11
|
+
async generate(reportDir) {
|
|
12
|
+
const summary = this.#session.getSummary();
|
|
13
|
+
const filename = `${this.#session.id.toLowerCase()}.html`;
|
|
14
|
+
const filepath = path.join(reportDir, filename);
|
|
15
|
+
const html = this.#buildHTML(summary);
|
|
16
|
+
await fs.writeFile(filepath, html, 'utf8');
|
|
17
|
+
return filepath;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
#buildHTML(summary) {
|
|
21
|
+
const s = this.#session;
|
|
22
|
+
const passRate = Number(summary.passRate);
|
|
23
|
+
const rateColor = passRate >= 90 ? '#22c55e' : passRate >= 70 ? '#f59e0b' : '#ef4444';
|
|
24
|
+
|
|
25
|
+
// Coverage by type — from REAL results
|
|
26
|
+
const coverage = {};
|
|
27
|
+
for (const r of s.results) {
|
|
28
|
+
if (!coverage[r.type]) coverage[r.type] = { pass: 0, fail: 0, skip: 0 };
|
|
29
|
+
if (r.status === 'PASS' || r.status === 'FLAKY') coverage[r.type].pass++;
|
|
30
|
+
else if (r.status === 'FAIL') coverage[r.type].fail++;
|
|
31
|
+
else coverage[r.type].skip++;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Severity counts — from REAL bugs
|
|
35
|
+
const sevCounts = { P0: 0, P1: 0, P2: 0, P3: 0 };
|
|
36
|
+
s.bugs.forEach(b => { if (sevCounts[b.severity] !== undefined) sevCounts[b.severity]++; });
|
|
37
|
+
|
|
38
|
+
const urlsStr = Object.entries(s.urls)
|
|
39
|
+
.filter(([, v]) => v)
|
|
40
|
+
.map(([k, v]) => `<div class="url-card"><span class="url-label">${k}</span><a href="${v}" target="_blank">${v}</a></div>`)
|
|
41
|
+
.join('');
|
|
42
|
+
|
|
43
|
+
const routeRows = s.routeMap.slice(0, 100).map(r => `
|
|
44
|
+
<tr>
|
|
45
|
+
<td><code class="url">${r.url}</code></td>
|
|
46
|
+
<td><span class="badge badge-${r.type}">${r.type}</span></td>
|
|
47
|
+
<td class="${r.status >= 400 ? 'fail' : 'pass'}">${r.status || '–'}</td>
|
|
48
|
+
<td>${r.forms?.length || 0} form(s)</td>
|
|
49
|
+
<td>${r.error ? `<span class="err-text">${r.error}</span>` : '✓'}</td>
|
|
50
|
+
</tr>`).join('');
|
|
51
|
+
|
|
52
|
+
const apiRows = s.apiLog.slice(0, 50).map(r => `
|
|
53
|
+
<tr>
|
|
54
|
+
<td><span class="method method-${(r.method||'GET').toLowerCase()}">${r.method || 'GET'}</span></td>
|
|
55
|
+
<td><code class="url">${r.url}</code></td>
|
|
56
|
+
<td class="${r.statusCode >= 400 ? 'fail' : 'pass'}">${r.statusCode || '–'}</td>
|
|
57
|
+
<td>${r.responseTime ? r.responseTime + 'ms' : '–'}</td>
|
|
58
|
+
<td>${r.contentType?.split(';')[0] || '–'}</td>
|
|
59
|
+
<td class="${r.pass ? 'pass' : 'fail'}">${r.pass ? '✓' : '✗ ' + (r.message || '')}</td>
|
|
60
|
+
</tr>`).join('');
|
|
61
|
+
|
|
62
|
+
const testRows = s.results.map(r => `
|
|
63
|
+
<tr class="result-row ${r.status?.toLowerCase()}" data-type="${r.type}" data-status="${r.status}">
|
|
64
|
+
<td>${r.name}</td>
|
|
65
|
+
<td><span class="badge badge-type">${r.type}</span></td>
|
|
66
|
+
<td><span class="status status-${r.status?.toLowerCase()}">${r.status}</span></td>
|
|
67
|
+
<td>${r.severity ? `<span class="sev sev-${r.severity.toLowerCase()}">${r.severity}</span>` : '–'}</td>
|
|
68
|
+
<td>${r.duration ? formatDuration(r.duration) : '–'}</td>
|
|
69
|
+
<td class="err-cell">${r.message ? `<details><summary>Details</summary><pre>${this.#esc(r.message)}</pre></details>` : '✓'}</td>
|
|
70
|
+
${r.screenshotPath ? `<td><a href="${r.screenshotPath}" target="_blank">📸 Screenshot</a></td>` : '<td>–</td>'}
|
|
71
|
+
</tr>`).join('');
|
|
72
|
+
|
|
73
|
+
const bugCards = s.bugs.map(b => `
|
|
74
|
+
<div class="bug-card sev-border-${(b.aiSeverity || b.severity || 'p3').toLowerCase()}" data-severity="${b.aiSeverity || b.severity}">
|
|
75
|
+
<div class="bug-header">
|
|
76
|
+
<span class="bug-id">${b.id}</span>
|
|
77
|
+
<span class="sev sev-${(b.aiSeverity || b.severity || 'p3').toLowerCase()}">${b.aiSeverity || b.severity}</span>
|
|
78
|
+
<span class="badge badge-type">${b.type || 'general'}</span>
|
|
79
|
+
${b.aiConfidence ? `<span class="ai-badge">🤖 AI ${Math.round((b.aiConfidence || 0.7) * 100)}%</span>` : ''}
|
|
80
|
+
<span class="bug-status">${b.status || 'OPEN'}</span>
|
|
81
|
+
</div>
|
|
82
|
+
<div class="bug-title">${this.#esc(b.title)}</div>
|
|
83
|
+
${b.url ? `<div class="bug-url"><a href="${b.url}" target="_blank">${b.url}</a></div>` : ''}
|
|
84
|
+
${b.description ? `<details class="bug-detail"><summary>Details</summary><pre>${this.#esc(b.description)}</pre></details>` : ''}
|
|
85
|
+
${b.aiRecommendation ? `<div class="bug-rec">💡 ${this.#esc(b.aiRecommendation)}</div>` : ''}
|
|
86
|
+
${b.evidence && typeof b.evidence === 'object' ? `<details class="bug-evidence"><summary>Evidence</summary><pre>${this.#esc(JSON.stringify(b.evidence, null, 2).slice(0, 1000))}</pre></details>` : ''}
|
|
87
|
+
</div>`).join('') || '<p class="no-bugs">No bugs detected 🎉</p>';
|
|
88
|
+
|
|
89
|
+
const consoleErrorRows = s.consoleErrors.slice(0, 100).map(e => `
|
|
90
|
+
<tr>
|
|
91
|
+
<td><span class="badge badge-${e.type}">${e.type}</span></td>
|
|
92
|
+
<td><a href="${e.url}" target="_blank" class="url">${e.url?.slice(0, 60)}</a></td>
|
|
93
|
+
<td class="err-cell"><code>${this.#esc((e.text || '').slice(0, 200))}</code></td>
|
|
94
|
+
<td>${e.timestamp ? new Date(e.timestamp).toLocaleTimeString() : '–'}</td>
|
|
95
|
+
</tr>`).join('') || '<tr><td colspan="4" class="no-data">No console errors detected</td></tr>';
|
|
96
|
+
|
|
97
|
+
const networkErrorRows = s.networkLog.filter(e => e.type === 'failed').slice(0, 50).map(e => `
|
|
98
|
+
<tr>
|
|
99
|
+
<td><code>${e.method || 'GET'}</code></td>
|
|
100
|
+
<td><a href="${e.url}" target="_blank" class="url">${(e.url || '').slice(0, 80)}</a></td>
|
|
101
|
+
<td class="fail">${this.#esc(e.failure || 'unknown')}</td>
|
|
102
|
+
<td>${e.url?.split('/')[2] || '–'}</td>
|
|
103
|
+
</tr>`).join('') || '<tr><td colspan="4" class="no-data">No network failures detected</td></tr>';
|
|
104
|
+
|
|
105
|
+
const securityRows = s.secFindings.map(f => `
|
|
106
|
+
<tr class="${f.pass ? 'pass' : 'fail'}">
|
|
107
|
+
<td>${f.check}</td>
|
|
108
|
+
<td><span class="badge badge-${f.category}">${f.category}</span></td>
|
|
109
|
+
<td><span class="status ${f.pass ? 'status-pass' : 'status-fail'}">${f.pass ? 'PASS' : 'FAIL'}</span></td>
|
|
110
|
+
<td>${f.severity !== 'INFO' ? `<span class="sev sev-${(f.severity||'p3').toLowerCase()}">${f.severity}</span>` : '–'}</td>
|
|
111
|
+
<td class="err-cell">${f.detail ? this.#esc(f.detail.slice(0, 120)) : '–'}</td>
|
|
112
|
+
<td>${f.recommendation ? `<span class="rec">${this.#esc(f.recommendation)}</span>` : '–'}</td>
|
|
113
|
+
</tr>`).join('');
|
|
114
|
+
|
|
115
|
+
// Perf metrics from REAL measurement
|
|
116
|
+
const perfSections = Object.entries(s.perfMetrics).map(([label, m]) => `
|
|
117
|
+
<div class="perf-card">
|
|
118
|
+
<h3>${label}</h3>
|
|
119
|
+
<div class="vitals-grid">
|
|
120
|
+
${this.#vitalCard('LCP', m.lcp, 2500, 'ms', 'Largest Contentful Paint')}
|
|
121
|
+
${this.#vitalCard('FCP', m.fcp, 1800, 'ms', 'First Contentful Paint')}
|
|
122
|
+
${this.#vitalCard('CLS', m.cls, 0.1, '', 'Cumulative Layout Shift')}
|
|
123
|
+
${this.#vitalCard('TTFB', m.ttfb, 800, 'ms', 'Time to First Byte')}
|
|
124
|
+
${this.#vitalCard('TBT', m.tbt, 200, 'ms', 'Total Blocking Time')}
|
|
125
|
+
</div>
|
|
126
|
+
${m.slowResources?.length ? `
|
|
127
|
+
<h4 style="margin-top:1rem;color:#f87171">Slow Resources (${m.slowResources.length})</h4>
|
|
128
|
+
<table style="font-size:.78rem;width:100%">
|
|
129
|
+
<tr><th>Resource</th><th>Type</th><th>Time</th><th>Size</th></tr>
|
|
130
|
+
${m.slowResources.map(r => `<tr>
|
|
131
|
+
<td class="url">${r.url.split('/').pop() || r.url}</td>
|
|
132
|
+
<td>${r.type}</td>
|
|
133
|
+
<td class="fail">${r.duration}ms</td>
|
|
134
|
+
<td>${formatBytes(r.size)}</td>
|
|
135
|
+
</tr>`).join('')}
|
|
136
|
+
</table>` : ''}
|
|
137
|
+
</div>`).join('') || '<p class="no-data">Performance data not collected — no URLs configured</p>';
|
|
138
|
+
|
|
139
|
+
const a11ySection = s.a11yResults.length
|
|
140
|
+
? s.a11yResults.map(r => `
|
|
141
|
+
<div class="a11y-page">
|
|
142
|
+
<div class="a11y-header">
|
|
143
|
+
<a href="${r.url}" target="_blank">${r.url}</a>
|
|
144
|
+
<span>Score: <strong>${r.score ?? '–'}%</strong></span>
|
|
145
|
+
<span class="${r.pass ? 'pass' : 'fail'}">${r.violations?.length || 0} violations</span>
|
|
146
|
+
</div>
|
|
147
|
+
${r.violations?.length ? r.violations.map(v => `
|
|
148
|
+
<div class="violation impact-${v.impact}">
|
|
149
|
+
<div class="violation-header">
|
|
150
|
+
<span class="impact-badge">${v.impact}</span>
|
|
151
|
+
<strong>${this.#esc(v.description)}</strong>
|
|
152
|
+
<a href="${v.helpUrl}" target="_blank">docs</a>
|
|
153
|
+
</div>
|
|
154
|
+
<p>${this.#esc(v.help)}</p>
|
|
155
|
+
<p class="wcag-tags">${(v.tags || []).join(' · ')}</p>
|
|
156
|
+
${v.affectedNodes?.slice(0, 2).map(n => `<code class="node-html">${this.#esc(n.html || '')}</code>`).join('') || ''}
|
|
157
|
+
</div>`).join('') : '<p class="no-bugs">No violations on this page ✓</p>'}
|
|
158
|
+
</div>`).join('')
|
|
159
|
+
: '<p class="no-data">No accessibility scans completed</p>';
|
|
160
|
+
|
|
161
|
+
const seoSection = s.seoResults.length
|
|
162
|
+
? s.seoResults.map(r => `
|
|
163
|
+
<div class="seo-page">
|
|
164
|
+
<div class="seo-header">
|
|
165
|
+
<a href="${r.url}" target="_blank">${r.url}</a>
|
|
166
|
+
<span>${r.checks.filter(c => c.pass).length}/${r.checks.length} checks passed</span>
|
|
167
|
+
</div>
|
|
168
|
+
<table>
|
|
169
|
+
<tr><th>Check</th><th>Category</th><th>Status</th><th>Detail</th></tr>
|
|
170
|
+
${r.checks.map(c => `<tr class="${c.pass ? '' : 'fail'}">
|
|
171
|
+
<td>${c.name}</td>
|
|
172
|
+
<td>${c.category || '–'}</td>
|
|
173
|
+
<td><span class="status ${c.pass ? 'status-pass' : 'status-fail'}">${c.pass ? 'PASS' : 'FAIL'}</span></td>
|
|
174
|
+
<td>${this.#esc((c.detail || '').slice(0, 100))}</td>
|
|
175
|
+
</tr>`).join('')}
|
|
176
|
+
</table>
|
|
177
|
+
</div>`).join('')
|
|
178
|
+
: '<p class="no-data">No SEO scans completed</p>';
|
|
179
|
+
|
|
180
|
+
const screenshotSection = s.screenshots.length
|
|
181
|
+
? `<div class="screenshots-grid">${s.screenshots.map(sc => `
|
|
182
|
+
<div class="screenshot-card">
|
|
183
|
+
<a href="${sc.path}" target="_blank">
|
|
184
|
+
<img src="${sc.path}" alt="Screenshot: ${sc.url}" onerror="this.style.display='none'">
|
|
185
|
+
</a>
|
|
186
|
+
<div class="screenshot-url">${sc.url}</div>
|
|
187
|
+
<div class="screenshot-reason">${sc.reason || 'Captured on failure'}</div>
|
|
188
|
+
</div>`).join('')}
|
|
189
|
+
</div>`
|
|
190
|
+
: '<p class="no-data">No screenshots captured (all pages passed)</p>';
|
|
191
|
+
|
|
192
|
+
// Chart data — real values
|
|
193
|
+
const chartTypes = JSON.stringify(Object.keys(coverage));
|
|
194
|
+
const chartPass = JSON.stringify(Object.values(coverage).map(c => c.pass));
|
|
195
|
+
const chartFail = JSON.stringify(Object.values(coverage).map(c => c.fail));
|
|
196
|
+
const bugSevLabels = JSON.stringify(['P0 Critical','P1 High','P2 Medium','P3 Low']);
|
|
197
|
+
const bugSevData = JSON.stringify([sevCounts.P0, sevCounts.P1, sevCounts.P2, sevCounts.P3]);
|
|
198
|
+
|
|
199
|
+
return `<!DOCTYPE html>
|
|
200
|
+
<html lang="en" data-theme="dark">
|
|
201
|
+
<head>
|
|
202
|
+
<meta charset="UTF-8">
|
|
203
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
204
|
+
<title>Backlist Enterprise QA — ${s.id}</title>
|
|
205
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js"></script>
|
|
206
|
+
<style>
|
|
207
|
+
:root {
|
|
208
|
+
--bg: #0a0a12;
|
|
209
|
+
--surface: #1a1a2e;
|
|
210
|
+
--border: #2d2d4e;
|
|
211
|
+
--text: #e2e8f0;
|
|
212
|
+
--dim: #64748b;
|
|
213
|
+
--cyan: #00f5ff;
|
|
214
|
+
--purple: #bf40ff;
|
|
215
|
+
--green: #22c55e;
|
|
216
|
+
--red: #ef4444;
|
|
217
|
+
--yellow: #f59e0b;
|
|
218
|
+
--blue: #3b82f6;
|
|
219
|
+
}
|
|
220
|
+
[data-theme="light"] {
|
|
221
|
+
--bg: #f8fafc;
|
|
222
|
+
--surface: #ffffff;
|
|
223
|
+
--border: #e2e8f0;
|
|
224
|
+
--text: #1e293b;
|
|
225
|
+
--dim: #94a3b8;
|
|
226
|
+
}
|
|
227
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
228
|
+
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);font-size:14px;line-height:1.6}
|
|
229
|
+
a{color:var(--cyan);text-decoration:none}a:hover{text-decoration:underline}
|
|
230
|
+
header{background:var(--surface);border-bottom:2px solid #00f5ff22;padding:1.5rem 2rem;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:100}
|
|
231
|
+
.logo{font-size:1.2rem;font-weight:700;color:var(--cyan)}
|
|
232
|
+
.header-meta{font-size:.8rem;color:var(--dim);margin-top:2px}
|
|
233
|
+
.header-actions{display:flex;gap:.75rem;align-items:center}
|
|
234
|
+
.theme-btn{background:var(--border);border:none;color:var(--text);padding:6px 12px;border-radius:6px;cursor:pointer;font-size:.8rem}
|
|
235
|
+
.version-badge{font-size:.7rem;color:var(--purple);border:1px solid var(--purple);padding:3px 10px;border-radius:20px}
|
|
236
|
+
nav{background:var(--surface);border-bottom:1px solid var(--border);padding:0 2rem;display:flex;gap:0;overflow-x:auto}
|
|
237
|
+
.nav-tab{padding:.75rem 1.25rem;border:none;background:none;color:var(--dim);cursor:pointer;font-size:.83rem;border-bottom:2px solid transparent;white-space:nowrap;transition:.2s}
|
|
238
|
+
.nav-tab.active,.nav-tab:hover{color:var(--cyan);border-bottom-color:var(--cyan)}
|
|
239
|
+
.container{max-width:1400px;margin:0 auto;padding:2rem}
|
|
240
|
+
.tab-panel{display:none}.tab-panel.active{display:block}
|
|
241
|
+
.search-bar{display:flex;gap:.75rem;margin-bottom:1.5rem}
|
|
242
|
+
.search-bar input,.search-bar select{background:var(--surface);border:1px solid var(--border);color:var(--text);padding:.5rem .75rem;border-radius:6px;font-size:.83rem;flex:1}
|
|
243
|
+
.metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:.75rem;margin-bottom:1.5rem}
|
|
244
|
+
.mc{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1rem;transition:border .2s}
|
|
245
|
+
.mc:hover{border-color:var(--cyan)}.ml{font-size:.65rem;color:var(--dim);text-transform:uppercase;letter-spacing:.1em}
|
|
246
|
+
.mv{font-size:1.8rem;font-weight:700;margin-top:4px}
|
|
247
|
+
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1.25rem}
|
|
248
|
+
.card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1.5rem;margin-bottom:1rem}
|
|
249
|
+
.card-title{font-size:.9rem;font-weight:600;color:#cbd5e1;border-bottom:1px solid var(--border);padding-bottom:.75rem;margin-bottom:1rem;display:flex;justify-content:space-between;align-items:center}
|
|
250
|
+
.chart-wrap{position:relative;height:260px}
|
|
251
|
+
table{width:100%;border-collapse:collapse;font-size:.8rem}
|
|
252
|
+
th{text-align:left;color:var(--dim);font-weight:500;padding:.5rem .75rem;border-bottom:1px solid var(--border);white-space:nowrap}
|
|
253
|
+
td{padding:.45rem .75rem;border-bottom:1px solid #1a1a2e;vertical-align:top;word-break:break-word}
|
|
254
|
+
tr.fail td{background:rgba(239,68,68,.04)}
|
|
255
|
+
.pass{color:var(--green)}.fail{color:var(--red)}.err-text{color:var(--red);font-size:.75rem}
|
|
256
|
+
.status{display:inline-block;padding:2px 8px;border-radius:4px;font-size:.7rem;font-weight:600}
|
|
257
|
+
.status-pass{background:#064e3b;color:#34d399}.status-fail{background:#450a0a;color:#f87171}
|
|
258
|
+
.status-flaky{background:#422006;color:#fbbf24}.status-skip{background:#1e293b;color:#94a3b8}
|
|
259
|
+
.sev{padding:2px 7px;border-radius:3px;font-size:.7rem;font-weight:700}
|
|
260
|
+
.sev-p0{background:#450a0a;color:#f87171}.sev-p1{background:#422006;color:#fbbf24}
|
|
261
|
+
.sev-p2{background:#1e3a5f;color:#60a5fa}.sev-p3{background:#1e293b;color:#94a3b8}
|
|
262
|
+
.badge{display:inline-block;padding:1px 6px;border-radius:3px;font-size:.7rem;background:#1e293b;color:#94a3b8}
|
|
263
|
+
.badge-security{background:#450a0a;color:#f87171}.badge-api{background:#1a2a3b;color:#38bdf8}
|
|
264
|
+
.badge-auth{background:#3b1f5e;color:#c084fc}.badge-performance{background:#1a2a3b;color:#38bdf8}
|
|
265
|
+
.badge-page,.badge-error-page{background:#1e293b;color:#94a3b8}
|
|
266
|
+
.method{padding:2px 8px;border-radius:3px;font-size:.75rem;font-weight:700}
|
|
267
|
+
.method-get{background:#064e3b;color:#34d399}.method-post{background:#1e3a5f;color:#60a5fa}
|
|
268
|
+
.method-put{background:#422006;color:#fbbf24}.method-delete{background:#450a0a;color:#f87171}
|
|
269
|
+
.url{font-family:monospace;font-size:.75rem;word-break:break-all;color:var(--cyan)}
|
|
270
|
+
code{font-family:monospace;font-size:.75rem;background:#0f1a2e;padding:2px 6px;border-radius:3px;color:#93c5fd}
|
|
271
|
+
pre{white-space:pre-wrap;word-break:break-all;font-size:.73rem;padding:.75rem;background:#0a0a18;border-radius:6px;overflow-x:auto}
|
|
272
|
+
details summary{cursor:pointer;color:var(--cyan);font-size:.78rem}
|
|
273
|
+
.bug-card{border-radius:8px;padding:1rem;margin-bottom:.75rem;border-left:3px solid var(--border);background:var(--surface)}
|
|
274
|
+
.sev-border-p0{border-left-color:#ef4444;background:rgba(239,68,68,.05)}
|
|
275
|
+
.sev-border-p1{border-left-color:#f59e0b;background:rgba(245,158,11,.04)}
|
|
276
|
+
.sev-border-p2{border-left-color:#3b82f6;background:rgba(59,130,246,.04)}
|
|
277
|
+
.sev-border-p3{border-left-color:#64748b}
|
|
278
|
+
.bug-header{display:flex;flex-wrap:wrap;gap:.5rem;align-items:center;margin-bottom:.5rem}
|
|
279
|
+
.bug-id{font-family:monospace;font-size:.72rem;color:var(--dim)}
|
|
280
|
+
.bug-status{font-size:.68rem;padding:2px 6px;border-radius:4px;background:var(--border);color:var(--dim)}
|
|
281
|
+
.bug-title{font-weight:600;font-size:.9rem;margin-bottom:.3rem}
|
|
282
|
+
.bug-url{font-size:.75rem;margin-bottom:.3rem}
|
|
283
|
+
.bug-detail,.bug-evidence{margin-top:.5rem}
|
|
284
|
+
.bug-rec{font-size:.78rem;color:#86efac;margin-top:.5rem;padding:.5rem;background:rgba(134,239,172,.06);border-radius:4px}
|
|
285
|
+
.ai-badge{font-size:.68rem;padding:2px 6px;border-radius:10px;background:#1a1a3b;color:#c084fc;border:1px solid #bf40ff44}
|
|
286
|
+
.rec{font-size:.75rem;color:#86efac}
|
|
287
|
+
.no-data,.no-bugs{color:var(--dim);font-style:italic;padding:1rem 0;text-align:center}
|
|
288
|
+
.perf-card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1.5rem;margin-bottom:1rem}
|
|
289
|
+
.vitals-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:.75rem;margin:.75rem 0}
|
|
290
|
+
.vital-card{border-radius:8px;padding:1rem;text-align:center;border:1px solid var(--border)}
|
|
291
|
+
.vital-value{font-size:1.6rem;font-weight:700;margin:.25rem 0}
|
|
292
|
+
.vital-label{font-size:.68rem;color:var(--dim);text-transform:uppercase}
|
|
293
|
+
.vital-threshold{font-size:.7rem;color:var(--dim)}
|
|
294
|
+
.vital-pass{background:rgba(34,197,94,.08);border-color:#22c55e}
|
|
295
|
+
.vital-fail{background:rgba(239,68,68,.08);border-color:#ef4444}
|
|
296
|
+
.vital-na{background:var(--surface);border-color:var(--border)}
|
|
297
|
+
.url-card{display:flex;justify-content:space-between;align-items:center;padding:.75rem 1rem;background:#1a1a2e;border-radius:6px;margin-bottom:.5rem;font-size:.85rem}
|
|
298
|
+
.url-label{text-transform:uppercase;font-size:.7rem;color:var(--dim);min-width:80px}
|
|
299
|
+
.a11y-page{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:1rem;margin-bottom:1rem}
|
|
300
|
+
.a11y-header{display:flex;justify-content:space-between;flex-wrap:wrap;gap:.5rem;margin-bottom:1rem;font-size:.85rem}
|
|
301
|
+
.violation{border-radius:6px;padding:.75rem;margin-bottom:.5rem;border-left:3px solid var(--border)}
|
|
302
|
+
.impact-critical{border-left-color:#ef4444;background:rgba(239,68,68,.06)}
|
|
303
|
+
.impact-serious{border-left-color:#f59e0b;background:rgba(245,158,11,.05)}
|
|
304
|
+
.impact-moderate{border-left-color:#3b82f6;background:rgba(59,130,246,.05)}
|
|
305
|
+
.impact-minor{border-left-color:#64748b;background:rgba(100,116,139,.04)}
|
|
306
|
+
.violation-header{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;margin-bottom:.3rem}
|
|
307
|
+
.impact-badge{font-size:.7rem;padding:2px 6px;border-radius:4px;font-weight:600;background:#1e293b;color:#94a3b8}
|
|
308
|
+
.wcag-tags{font-size:.7rem;color:var(--dim);margin-top:.25rem}
|
|
309
|
+
.node-html{display:block;margin-top:.35rem;font-size:.7rem;padding:.35rem .5rem;background:#0a0a18;border-radius:4px}
|
|
310
|
+
.seo-page{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:1rem;margin-bottom:1rem}
|
|
311
|
+
.seo-header{display:flex;justify-content:space-between;font-size:.85rem;margin-bottom:.75rem;flex-wrap:wrap;gap:.5rem}
|
|
312
|
+
.screenshots-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1rem}
|
|
313
|
+
.screenshot-card{background:var(--surface);border:1px solid var(--border);border-radius:8px;overflow:hidden}
|
|
314
|
+
.screenshot-card img{width:100%;height:180px;object-fit:cover;display:block}
|
|
315
|
+
.screenshot-url{padding:.5rem .75rem;font-size:.75rem;color:var(--cyan);font-family:monospace}
|
|
316
|
+
.screenshot-reason{padding:0 .75rem .5rem;font-size:.72rem;color:var(--dim)}
|
|
317
|
+
.err-cell details{font-size:.78rem}
|
|
318
|
+
.timeline{position:relative;padding:.5rem 0}
|
|
319
|
+
.timeline-item{display:flex;gap:1rem;margin-bottom:.75rem;align-items:flex-start}
|
|
320
|
+
.timeline-dot{width:10px;height:10px;border-radius:50%;background:var(--cyan);margin-top:5px;flex-shrink:0}
|
|
321
|
+
.timeline-dot.fail{background:var(--red)}.timeline-dot.pass{background:var(--green)}
|
|
322
|
+
.timeline-content{flex:1}
|
|
323
|
+
.timeline-time{font-size:.72rem;color:var(--dim)}
|
|
324
|
+
.real-data-notice{background:rgba(0,245,255,.06);border:1px solid #00f5ff22;border-radius:8px;padding:.75rem 1rem;margin-bottom:1.5rem;font-size:.83rem;color:var(--cyan)}
|
|
325
|
+
footer{text-align:center;color:var(--dim);font-size:.7rem;padding:2rem;border-top:1px solid var(--border);margin-top:2rem}
|
|
326
|
+
@media(max-width:768px){.grid2{grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}}
|
|
327
|
+
</style>
|
|
328
|
+
</head>
|
|
329
|
+
<body>
|
|
330
|
+
|
|
331
|
+
<header>
|
|
332
|
+
<div>
|
|
333
|
+
<div class="logo">🧪 Backlist Enterprise QA Platform</div>
|
|
334
|
+
<div class="header-meta">
|
|
335
|
+
Run ID: ${s.id} · ${new Date(s.startedAt).toLocaleString()}
|
|
336
|
+
· ${formatDuration(summary.duration)}
|
|
337
|
+
· ${summary.total} tests · ${s.bugs.length} bugs
|
|
338
|
+
· ${s.routeMap.length} routes discovered
|
|
339
|
+
</div>
|
|
340
|
+
</div>
|
|
341
|
+
<div class="header-actions">
|
|
342
|
+
<button class="theme-btn" onclick="toggleTheme()">🌙 Dark/Light</button>
|
|
343
|
+
<span class="version-badge">v${VERSION}</span>
|
|
344
|
+
</div>
|
|
345
|
+
</header>
|
|
346
|
+
|
|
347
|
+
<nav>
|
|
348
|
+
<button class="nav-tab active" onclick="showTab('overview')">📊 Overview</button>
|
|
349
|
+
<button class="nav-tab" onclick="showTab('tests')">🧪 Tests (${summary.total})</button>
|
|
350
|
+
<button class="nav-tab" onclick="showTab('bugs')">🐛 Bugs (${s.bugs.length})</button>
|
|
351
|
+
<button class="nav-tab" onclick="showTab('routes')">🗺️ Routes (${s.routeMap.length})</button>
|
|
352
|
+
<button class="nav-tab" onclick="showTab('api')">📡 API (${s.apiLog.length})</button>
|
|
353
|
+
<button class="nav-tab" onclick="showTab('security')">🛡️ Security (${s.secFindings.length})</button>
|
|
354
|
+
<button class="nav-tab" onclick="showTab('performance')">⚡ Performance</button>
|
|
355
|
+
<button class="nav-tab" onclick="showTab('a11y')">♿ Accessibility</button>
|
|
356
|
+
<button class="nav-tab" onclick="showTab('seo')">🔎 SEO</button>
|
|
357
|
+
<button class="nav-tab" onclick="showTab('console')">🖥️ Console</button>
|
|
358
|
+
<button class="nav-tab" onclick="showTab('network')">🌐 Network</button>
|
|
359
|
+
<button class="nav-tab" onclick="showTab('screenshots')">📸 Screenshots (${s.screenshots.length})</button>
|
|
360
|
+
</nav>
|
|
361
|
+
|
|
362
|
+
<div class="container">
|
|
363
|
+
|
|
364
|
+
<div class="real-data-notice">
|
|
365
|
+
✅ <strong>100% Real Runtime Data</strong> — All results collected from actual browser execution, real HTTP requests, and live application testing. No simulated or mocked values.
|
|
366
|
+
</div>
|
|
367
|
+
|
|
368
|
+
<!-- ── OVERVIEW ─────────────────────────────────────────────────────── -->
|
|
369
|
+
<div id="tab-overview" class="tab-panel active">
|
|
370
|
+
${urlsStr ? `<div class="card"><div class="card-title">Target URLs</div>${urlsStr}</div>` : ''}
|
|
371
|
+
<div class="metrics">
|
|
372
|
+
<div class="mc"><div class="ml">Pass Rate</div><div class="mv" style="color:${rateColor}">${summary.passRate}%</div></div>
|
|
373
|
+
<div class="mc"><div class="ml">Tests</div><div class="mv">${summary.total}</div></div>
|
|
374
|
+
<div class="mc"><div class="ml">Passed</div><div class="mv" style="color:var(--green)">${summary.passed}</div></div>
|
|
375
|
+
<div class="mc"><div class="ml">Failed</div><div class="mv" style="color:var(--red)">${summary.failed}</div></div>
|
|
376
|
+
<div class="mc"><div class="ml">Flaky</div><div class="mv" style="color:var(--yellow)">${summary.flaky}</div></div>
|
|
377
|
+
<div class="mc"><div class="ml">Bugs</div><div class="mv" style="color:#c084fc">${s.bugs.length}</div></div>
|
|
378
|
+
<div class="mc"><div class="ml">P0 Critical</div><div class="mv" style="color:var(--red)">${sevCounts.P0}</div></div>
|
|
379
|
+
<div class="mc"><div class="ml">P1 High</div><div class="mv" style="color:var(--yellow)">${sevCounts.P1}</div></div>
|
|
380
|
+
<div class="mc"><div class="ml">Routes Found</div><div class="mv">${s.routeMap.length}</div></div>
|
|
381
|
+
<div class="mc"><div class="ml">APIs Tested</div><div class="mv">${s.apiLog.length}</div></div>
|
|
382
|
+
<div class="mc"><div class="ml">Screenshots</div><div class="mv">${s.screenshots.length}</div></div>
|
|
383
|
+
<div class="mc"><div class="ml">JS Errors</div><div class="mv" style="color:${s.consoleErrors.length>0?'var(--red)':'var(--green)'}"> ${s.consoleErrors.length}</div></div>
|
|
384
|
+
</div>
|
|
385
|
+
<div class="grid2">
|
|
386
|
+
<div class="card">
|
|
387
|
+
<div class="card-title">Tests by Category</div>
|
|
388
|
+
<div class="chart-wrap"><canvas id="coverageChart"></canvas></div>
|
|
389
|
+
</div>
|
|
390
|
+
<div class="card">
|
|
391
|
+
<div class="card-title">Bug Severity Distribution</div>
|
|
392
|
+
<div class="chart-wrap"><canvas id="bugChart"></canvas></div>
|
|
393
|
+
</div>
|
|
394
|
+
</div>
|
|
395
|
+
</div>
|
|
396
|
+
|
|
397
|
+
<!-- ── TESTS ──────────────────────────────────────────────────────── -->
|
|
398
|
+
<div id="tab-tests" class="tab-panel">
|
|
399
|
+
<div class="search-bar">
|
|
400
|
+
<input type="text" id="testSearch" placeholder="Search tests..." onkeyup="filterTests()">
|
|
401
|
+
<select id="testStatus" onchange="filterTests()">
|
|
402
|
+
<option value="">All statuses</option>
|
|
403
|
+
<option value="FAIL">Failed only</option>
|
|
404
|
+
<option value="PASS">Passed only</option>
|
|
405
|
+
<option value="FLAKY">Flaky only</option>
|
|
406
|
+
</select>
|
|
407
|
+
<select id="testType" onchange="filterTests()">
|
|
408
|
+
<option value="">All types</option>
|
|
409
|
+
${[...new Set(s.results.map(r => r.type))].map(t => `<option value="${t}">${t}</option>`).join('')}
|
|
410
|
+
</select>
|
|
411
|
+
</div>
|
|
412
|
+
<div class="card">
|
|
413
|
+
<div class="card-title">All Real Test Results <span>${summary.total} tests</span></div>
|
|
414
|
+
<table id="testTable">
|
|
415
|
+
<thead><tr><th>Test Name</th><th>Type</th><th>Status</th><th>Sev</th><th>Duration</th><th>Details</th><th>Screenshot</th></tr></thead>
|
|
416
|
+
<tbody>${testRows}</tbody>
|
|
417
|
+
</table>
|
|
418
|
+
</div>
|
|
419
|
+
</div>
|
|
420
|
+
|
|
421
|
+
<!-- ── BUGS ───────────────────────────────────────────────────────── -->
|
|
422
|
+
<div id="tab-bugs" class="tab-panel">
|
|
423
|
+
<div class="search-bar">
|
|
424
|
+
<input type="text" id="bugSearch" placeholder="Search bugs..." onkeyup="filterBugs()">
|
|
425
|
+
<select id="bugSev" onchange="filterBugs()">
|
|
426
|
+
<option value="">All severities</option>
|
|
427
|
+
<option value="P0">P0 Critical</option>
|
|
428
|
+
<option value="P1">P1 High</option>
|
|
429
|
+
<option value="P2">P2 Medium</option>
|
|
430
|
+
<option value="P3">P3 Low</option>
|
|
431
|
+
</select>
|
|
432
|
+
</div>
|
|
433
|
+
<div id="bugList">${bugCards}</div>
|
|
434
|
+
</div>
|
|
435
|
+
|
|
436
|
+
<!-- ── ROUTES ─────────────────────────────────────────────────────── -->
|
|
437
|
+
<div id="tab-routes" class="tab-panel">
|
|
438
|
+
<div class="card">
|
|
439
|
+
<div class="card-title">Discovered Routes <span style="font-size:.8rem;color:var(--dim)">${s.routeMap.length} real pages/APIs found via crawler</span></div>
|
|
440
|
+
<table>
|
|
441
|
+
<thead><tr><th>URL</th><th>Type</th><th>HTTP Status</th><th>Forms</th><th>Status</th></tr></thead>
|
|
442
|
+
<tbody>${routeRows || '<tr><td colspan="5" class="no-data">No routes discovered</td></tr>'}</tbody>
|
|
443
|
+
</table>
|
|
444
|
+
</div>
|
|
445
|
+
</div>
|
|
446
|
+
|
|
447
|
+
<!-- ── API ────────────────────────────────────────────────────────── -->
|
|
448
|
+
<div id="tab-api" class="tab-panel">
|
|
449
|
+
<div class="card">
|
|
450
|
+
<div class="card-title">Real API Responses <span>${s.apiLog.length} endpoints tested</span></div>
|
|
451
|
+
<table>
|
|
452
|
+
<thead><tr><th>Method</th><th>URL</th><th>Status</th><th>Time</th><th>Content-Type</th><th>Result</th></tr></thead>
|
|
453
|
+
<tbody>${apiRows || '<tr><td colspan="6" class="no-data">No API endpoints tested</td></tr>'}</tbody>
|
|
454
|
+
</table>
|
|
455
|
+
</div>
|
|
456
|
+
</div>
|
|
457
|
+
|
|
458
|
+
<!-- ── SECURITY ───────────────────────────────────────────────────── -->
|
|
459
|
+
<div id="tab-security" class="tab-panel">
|
|
460
|
+
<div class="card">
|
|
461
|
+
<div class="card-title">Real Security Scan Findings <span>${s.secFindings.length} checks</span></div>
|
|
462
|
+
<table>
|
|
463
|
+
<thead><tr><th>Check</th><th>Category</th><th>Result</th><th>Severity</th><th>Detail</th><th>Recommendation</th></tr></thead>
|
|
464
|
+
<tbody>${securityRows || '<tr><td colspan="6" class="no-data">No security scans</td></tr>'}</tbody>
|
|
465
|
+
</table>
|
|
466
|
+
</div>
|
|
467
|
+
</div>
|
|
468
|
+
|
|
469
|
+
<!-- ── PERFORMANCE ────────────────────────────────────────────────── -->
|
|
470
|
+
<div id="tab-performance" class="tab-panel">
|
|
471
|
+
<div class="card-title" style="padding:.75rem 0">Real Core Web Vitals — measured via Playwright + Performance API</div>
|
|
472
|
+
${perfSections}
|
|
473
|
+
</div>
|
|
474
|
+
|
|
475
|
+
<!-- ── ACCESSIBILITY ──────────────────────────────────────────────── -->
|
|
476
|
+
<div id="tab-a11y" class="tab-panel">
|
|
477
|
+
<div class="card-title" style="padding:.75rem 0">Real Accessibility Results — powered by axe-core WCAG 2.1 AA</div>
|
|
478
|
+
${a11ySection}
|
|
479
|
+
</div>
|
|
480
|
+
|
|
481
|
+
<!-- ── SEO ────────────────────────────────────────────────────────── -->
|
|
482
|
+
<div id="tab-seo" class="tab-panel">
|
|
483
|
+
<div class="card-title" style="padding:.75rem 0">Real SEO Scan — fetched with Googlebot user-agent</div>
|
|
484
|
+
${seoSection}
|
|
485
|
+
</div>
|
|
486
|
+
|
|
487
|
+
<!-- ── CONSOLE ────────────────────────────────────────────────────── -->
|
|
488
|
+
<div id="tab-console" class="tab-panel">
|
|
489
|
+
<div class="card">
|
|
490
|
+
<div class="card-title">Real Console Errors & Warnings <span>${s.consoleErrors.length} entries</span></div>
|
|
491
|
+
<table>
|
|
492
|
+
<thead><tr><th>Type</th><th>Page URL</th><th>Message</th><th>Time</th></tr></thead>
|
|
493
|
+
<tbody>${consoleErrorRows}</tbody>
|
|
494
|
+
</table>
|
|
495
|
+
</div>
|
|
496
|
+
</div>
|
|
497
|
+
|
|
498
|
+
<!-- ── NETWORK ────────────────────────────────────────────────────── -->
|
|
499
|
+
<div id="tab-network" class="tab-panel">
|
|
500
|
+
<div class="card">
|
|
501
|
+
<div class="card-title">Real Network Failures <span>${s.networkLog.filter(e => e.type === 'failed').length} failures</span></div>
|
|
502
|
+
<table>
|
|
503
|
+
<thead><tr><th>Method</th><th>URL</th><th>Failure</th><th>Domain</th></tr></thead>
|
|
504
|
+
<tbody>${networkErrorRows}</tbody>
|
|
505
|
+
</table>
|
|
506
|
+
</div>
|
|
507
|
+
</div>
|
|
508
|
+
|
|
509
|
+
<!-- ── SCREENSHOTS ────────────────────────────────────────────────── -->
|
|
510
|
+
<div id="tab-screenshots" class="tab-panel">
|
|
511
|
+
<div class="card-title" style="padding:.75rem 0">Real Failure Screenshots — captured automatically</div>
|
|
512
|
+
${screenshotSection}
|
|
513
|
+
</div>
|
|
514
|
+
|
|
515
|
+
</div><!-- /container -->
|
|
516
|
+
|
|
517
|
+
<footer>
|
|
518
|
+
Backlist Enterprise QA Platform v${VERSION} ·
|
|
519
|
+
${summary.total} real tests · ${s.bugs.length} bugs detected · ${s.routeMap.length} routes crawled
|
|
520
|
+
· Generated ${new Date().toLocaleString()}
|
|
521
|
+
· 100% real runtime data
|
|
522
|
+
</footer>
|
|
523
|
+
|
|
524
|
+
<script>
|
|
525
|
+
// Tab navigation
|
|
526
|
+
function showTab(name) {
|
|
527
|
+
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
|
528
|
+
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
|
|
529
|
+
document.getElementById('tab-' + name)?.classList.add('active');
|
|
530
|
+
event.target.classList.add('active');
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Theme toggle
|
|
534
|
+
function toggleTheme() {
|
|
535
|
+
const html = document.documentElement;
|
|
536
|
+
html.dataset.theme = html.dataset.theme === 'dark' ? 'light' : 'dark';
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Test filter
|
|
540
|
+
function filterTests() {
|
|
541
|
+
const search = document.getElementById('testSearch')?.value?.toLowerCase() || '';
|
|
542
|
+
const status = document.getElementById('testStatus')?.value || '';
|
|
543
|
+
const type = document.getElementById('testType')?.value || '';
|
|
544
|
+
document.querySelectorAll('#testTable tbody .result-row').forEach(row => {
|
|
545
|
+
const text = row.textContent.toLowerCase();
|
|
546
|
+
const rowStat = row.dataset.status;
|
|
547
|
+
const rowType = row.dataset.type;
|
|
548
|
+
const show = text.includes(search) &&
|
|
549
|
+
(!status || rowStat === status) &&
|
|
550
|
+
(!type || rowType === type);
|
|
551
|
+
row.style.display = show ? '' : 'none';
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Bug filter
|
|
556
|
+
function filterBugs() {
|
|
557
|
+
const search = document.getElementById('bugSearch')?.value?.toLowerCase() || '';
|
|
558
|
+
const sev = document.getElementById('bugSev')?.value || '';
|
|
559
|
+
document.querySelectorAll('#bugList .bug-card').forEach(card => {
|
|
560
|
+
const text = card.textContent.toLowerCase();
|
|
561
|
+
const cardSev = card.dataset.severity;
|
|
562
|
+
const show = text.includes(search) && (!sev || cardSev === sev);
|
|
563
|
+
card.style.display = show ? '' : 'none';
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Charts — real data
|
|
568
|
+
const chartDefaults = {
|
|
569
|
+
plugins: { legend: { labels: { color: '#94a3b8', font: { size: 11 } } } },
|
|
570
|
+
scales : {
|
|
571
|
+
x: { ticks: { color: '#64748b', font: { size: 10 } }, grid: { color: '#1e293b' } },
|
|
572
|
+
y: { ticks: { color: '#64748b', stepSize: 1, font: { size: 10 } }, grid: { color: '#1e293b' }, beginAtZero: true },
|
|
573
|
+
},
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
new Chart(document.getElementById('coverageChart'), {
|
|
577
|
+
type: 'bar',
|
|
578
|
+
data: {
|
|
579
|
+
labels: ${chartTypes},
|
|
580
|
+
datasets: [
|
|
581
|
+
{ label: 'Passed', data: ${chartPass}, backgroundColor: '#34d399', borderRadius: 3 },
|
|
582
|
+
{ label: 'Failed', data: ${chartFail}, backgroundColor: '#f87171', borderRadius: 3 },
|
|
583
|
+
],
|
|
584
|
+
},
|
|
585
|
+
options: { responsive: true, maintainAspectRatio: false, ...chartDefaults, scales: { ...chartDefaults.scales, x: { ...chartDefaults.scales.x, stacked: true }, y: { ...chartDefaults.scales.y, stacked: true } } },
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
new Chart(document.getElementById('bugChart'), {
|
|
589
|
+
type: 'doughnut',
|
|
590
|
+
data: {
|
|
591
|
+
labels: ${bugSevLabels},
|
|
592
|
+
datasets: [{ data: ${bugSevData}, backgroundColor: ['#ef4444','#f59e0b','#3b82f6','#64748b'], borderWidth: 0 }],
|
|
593
|
+
},
|
|
594
|
+
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { labels: { color: '#94a3b8', font: { size: 12 } } } } },
|
|
595
|
+
});
|
|
596
|
+
</script>
|
|
597
|
+
</body>
|
|
598
|
+
</html>`;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
#vitalCard(name, value, threshold, unit, label) {
|
|
602
|
+
const na = value === null || value === undefined;
|
|
603
|
+
const pass = !na && value <= threshold;
|
|
604
|
+
const cls = na ? 'vital-na' : (pass ? 'vital-pass' : 'vital-fail');
|
|
605
|
+
const color = na ? '#64748b' : (pass ? '#22c55e' : '#ef4444');
|
|
606
|
+
const display = na ? 'N/A' : `${Number(value).toFixed(name === 'CLS' ? 3 : 0)}${unit}`;
|
|
607
|
+
|
|
608
|
+
return `<div class="vital-card ${cls}">
|
|
609
|
+
<div class="vital-label">${name}</div>
|
|
610
|
+
<div class="vital-value" style="color:${color}">${display}</div>
|
|
611
|
+
<div class="vital-threshold">≤ ${threshold}${unit}</div>
|
|
612
|
+
<div class="vital-label" style="margin-top:2px">${label}</div>
|
|
613
|
+
</div>`;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
#esc(str) {
|
|
617
|
+
return String(str || '')
|
|
618
|
+
.replace(/&/g, '&')
|
|
619
|
+
.replace(/</g, '<')
|
|
620
|
+
.replace(/>/g, '>')
|
|
621
|
+
.replace(/"/g, '"');
|
|
622
|
+
}
|
|
623
|
+
}
|