@nhonh/qabot 1.0.0 → 1.0.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nhonh/qabot",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "AI-powered universal QA automation tool. Import any project, AI analyzes and runs tests across all layers.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -1,4 +1,4 @@
1
- export const VERSION = "1.0.0";
1
+ export const VERSION = "1.0.1";
2
2
  export const TOOL_NAME = "qabot";
3
3
 
4
4
  export const PROJECT_TYPES = [
@@ -1,4 +1,4 @@
1
- import { formatMs } from "../core/logger.js";
1
+ import { VERSION } from "../core/constants.js";
2
2
 
3
3
  export class HtmlBuilder {
4
4
  render(results, meta) {
@@ -7,98 +7,186 @@ export class HtmlBuilder {
7
7
  const failedTests = allTests.filter((t) => t.status === "failed");
8
8
  const passedTests = allTests.filter((t) => t.status === "passed");
9
9
  const skippedTests = allTests.filter((t) => t.status === "skipped");
10
+ const screenshots = allTests.flatMap((t) =>
11
+ (t.screenshots || []).map((s) => ({ test: t.name, path: s })),
12
+ );
13
+ const maxDuration = Math.max(...allTests.map((t) => t.duration || 0), 1);
10
14
 
11
15
  return `<!DOCTYPE html>
12
- <html lang="en">
13
- <head>
14
- <meta charset="UTF-8">
15
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
16
- <title>QABot Report - ${esc(meta.feature || "all")} | ${esc(meta.projectName || "")}</title>
16
+ <html lang="en"><head>
17
+ <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
18
+ <title>QABot Report \u2014 ${esc(meta.feature || "all")} | ${esc(meta.projectName || "")}</title>
17
19
  <style>
20
+ :root{--bg:#0C0A1A;--bg2:#13102A;--card:#1A1635;--card2:#221E3D;--border:#2D2852;--text:#E8E4F0;--dim:#8B85A0;--v:#A78BFA;--v2:#7C3AED;--v3:#C4B5FD;--g:#34D399;--r:#F87171;--y:#FBBF24;--cyan:#22D3EE;--font:system-ui,-apple-system,BlinkMacSystemFont,sans-serif;--mono:'SF Mono',SFMono-Regular,'JetBrains Mono',Menlo,monospace}
18
21
  *{margin:0;padding:0;box-sizing:border-box}
19
- :root{--bg:#0f172a;--card:#1e293b;--card-hover:#273548;--border:#334155;--text:#e2e8f0;--text-dim:#94a3b8;--accent:#3b82f6;--green:#22c55e;--red:#ef4444;--yellow:#eab308;--font:system-ui,-apple-system,sans-serif;--mono:'SF Mono',SFMono-Regular,Menlo,monospace}
20
- body{background:var(--bg);color:var(--text);font-family:var(--font);line-height:1.6;padding:2rem}
21
- .container{max-width:1200px;margin:0 auto}
22
- h1{font-size:1.8rem;font-weight:700;margin-bottom:.5rem}
23
- h2{font-size:1.3rem;font-weight:600;margin:2rem 0 1rem;color:var(--accent)}
24
- .subtitle{color:var(--text-dim);font-size:.9rem;margin-bottom:2rem}
25
- .cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1rem;margin-bottom:2rem}
26
- .card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:1.5rem;text-align:center}
27
- .card .value{font-size:2rem;font-weight:700;margin:.5rem 0}
28
- .card .label{color:var(--text-dim);font-size:.85rem;text-transform:uppercase;letter-spacing:.05em}
29
- .card.pass .value{color:var(--green)}
30
- .card.fail .value{color:var(--red)}
31
- .card.skip .value{color:var(--yellow)}
32
- .card.rate .value{color:var(--accent)}
33
- .badge{display:inline-block;padding:2px 10px;border-radius:999px;font-size:.75rem;font-weight:600}
34
- .badge-pass{background:#22c55e22;color:var(--green)}
35
- .badge-fail{background:#ef444422;color:var(--red)}
36
- .badge-skip{background:#eab30822;color:var(--yellow)}
37
- .bar{height:8px;background:var(--border);border-radius:4px;overflow:hidden;margin:1rem 0}
38
- .bar-fill{height:100%;border-radius:4px;transition:width .5s}
39
- .bar-green{background:var(--green)}
40
- .bar-red{background:var(--red)}
41
- table{width:100%;border-collapse:collapse;margin:1rem 0}
42
- th{text-align:left;padding:.75rem 1rem;border-bottom:2px solid var(--border);color:var(--text-dim);font-size:.8rem;text-transform:uppercase;letter-spacing:.05em}
43
- td{padding:.75rem 1rem;border-bottom:1px solid var(--border)}
44
- tr:hover td{background:var(--card-hover)}
45
- .error-box{background:#1a0000;border:1px solid #ef444444;border-radius:8px;padding:1rem;margin:.5rem 0;font-family:var(--mono);font-size:.8rem;color:#fca5a5;white-space:pre-wrap;overflow-x:auto;max-height:200px;overflow-y:auto}
46
- .filters{display:flex;gap:.5rem;margin:1rem 0}
47
- .filter-btn{background:var(--card);border:1px solid var(--border);color:var(--text);padding:.4rem 1rem;border-radius:8px;cursor:pointer;font-size:.85rem;transition:all .2s}
48
- .filter-btn:hover,.filter-btn.active{background:var(--accent);border-color:var(--accent);color:#fff}
49
- .layer-section{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:1.5rem;margin:1rem 0}
50
- .collapsible{cursor:pointer;user-select:none}
51
- .collapsible::after{content:' \\25BC';font-size:.7rem;color:var(--text-dim)}
52
- .hidden{display:none}
53
- footer{margin-top:3rem;padding-top:1rem;border-top:1px solid var(--border);color:var(--text-dim);font-size:.8rem;text-align:center}
54
- </style>
55
- </head>
22
+ body{background:var(--bg);color:var(--text);font-family:var(--font);line-height:1.6}
23
+ .wrap{max-width:1200px;margin:0 auto;padding:2.5rem 2rem}
24
+ a{color:var(--v);text-decoration:none}
25
+ a:hover{text-decoration:underline}
26
+
27
+ .hero{text-align:center;padding:3rem 0 2rem;border-bottom:1px solid var(--border)}
28
+ .hero h1{font-size:1.5rem;font-weight:600;color:var(--v3);letter-spacing:.02em}
29
+ .hero .meta{color:var(--dim);font-size:.85rem;margin-top:.5rem}
30
+ .hero .meta span{margin:0 .5rem}
31
+ .rate-ring{width:120px;height:120px;margin:1.5rem auto;position:relative}
32
+ .rate-ring svg{transform:rotate(-90deg)}
33
+ .rate-ring .rate-text{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:2rem;font-weight:700}
34
+
35
+ .cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:.75rem;margin:2rem 0}
36
+ .card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:1.25rem;text-align:center;transition:border-color .2s}
37
+ .card:hover{border-color:var(--v)}
38
+ .card .val{font-size:1.75rem;font-weight:700;margin:.25rem 0}
39
+ .card .lbl{font-size:.75rem;color:var(--dim);text-transform:uppercase;letter-spacing:.08em}
40
+ .card.pass .val{color:var(--g)}.card.fail .val{color:var(--r)}.card.skip .val{color:var(--y)}.card.dur .val{color:var(--cyan)}
41
+
42
+ h2{font-size:1.1rem;font-weight:600;color:var(--v3);margin:2.5rem 0 1rem;display:flex;align-items:center;gap:.5rem}
43
+ h2::before{content:'';width:3px;height:1.1rem;background:var(--v2);border-radius:2px}
44
+
45
+ .filters{display:flex;gap:.5rem;margin:1rem 0;flex-wrap:wrap}
46
+ .fbtn{background:var(--card);border:1px solid var(--border);color:var(--dim);padding:.35rem .85rem;border-radius:20px;cursor:pointer;font-size:.8rem;transition:all .2s}
47
+ .fbtn:hover,.fbtn.on{background:var(--v2);border-color:var(--v2);color:#fff}
48
+ .fbtn .count{background:var(--bg);padding:1px 6px;border-radius:10px;font-size:.7rem;margin-left:.3rem}
49
+ .fbtn.on .count{background:rgba(255,255,255,.15)}
50
+
51
+ .tbl{width:100%;border-collapse:separate;border-spacing:0;margin:1rem 0}
52
+ .tbl th{text-align:left;padding:.6rem 1rem;color:var(--dim);font-size:.75rem;text-transform:uppercase;letter-spacing:.06em;border-bottom:1px solid var(--border);position:sticky;top:0;background:var(--bg)}
53
+ .tbl td{padding:.6rem 1rem;border-bottom:1px solid var(--border);vertical-align:top}
54
+ .tbl tr:hover td{background:var(--card)}
55
+ .tbl .idx{color:var(--dim);font-size:.8rem;width:3rem}
56
+ .badge{display:inline-flex;align-items:center;gap:.3rem;padding:2px 10px;border-radius:20px;font-size:.72rem;font-weight:600}
57
+ .badge-passed{background:#34D39918;color:var(--g)}.badge-failed{background:#F8717118;color:var(--r)}.badge-skipped{background:#FBBF2418;color:var(--y)}
58
+ .badge::before{content:'';width:6px;height:6px;border-radius:50%}
59
+ .badge-passed::before{background:var(--g)}.badge-failed::before{background:var(--r)}.badge-skipped::before{background:var(--y)}
60
+
61
+ .dur-bar{display:flex;align-items:center;gap:.5rem;font-size:.8rem;color:var(--dim)}
62
+ .dur-fill{height:4px;border-radius:2px;background:var(--v);min-width:2px;transition:width .3s}
63
+ .test-name{font-weight:500;font-size:.9rem}
64
+ .test-suite{font-size:.8rem;color:var(--dim);margin-top:2px}
65
+ .test-file{font-family:var(--mono);font-size:.75rem;color:var(--dim);margin-top:2px}
66
+
67
+ .detail{background:var(--card);border:1px solid var(--border);border-radius:10px;margin:.75rem 0;overflow:hidden}
68
+ .detail-head{padding:.75rem 1rem;cursor:pointer;display:flex;align-items:center;justify-content:space-between;user-select:none}
69
+ .detail-head:hover{background:var(--card2)}
70
+ .detail-head .arrow{color:var(--dim);font-size:.7rem;transition:transform .2s}
71
+ .detail-head.open .arrow{transform:rotate(180deg)}
72
+ .detail-body{display:none;padding:1rem;border-top:1px solid var(--border)}
73
+ .detail-body.show{display:block}
74
+
75
+ .err-box{background:#1A0A0A;border:1px solid #F8717130;border-radius:8px;padding:1rem;font-family:var(--mono);font-size:.78rem;color:#FCA5A5;white-space:pre-wrap;overflow-x:auto;max-height:250px;overflow-y:auto;line-height:1.5}
76
+ .skip-reason{background:#1A170A;border:1px solid #FBBF2430;border-radius:8px;padding:.75rem 1rem;font-size:.85rem;color:var(--y)}
77
+ .io-section{margin-top:.75rem}
78
+ .io-label{font-size:.75rem;color:var(--dim);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.3rem}
79
+ .io-box{background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:.75rem;font-family:var(--mono);font-size:.78rem;max-height:150px;overflow-y:auto;white-space:pre-wrap}
80
+
81
+ .ss-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1rem;margin:1rem 0}
82
+ .ss-card{background:var(--card);border:1px solid var(--border);border-radius:10px;overflow:hidden;transition:border-color .2s}
83
+ .ss-card:hover{border-color:var(--v)}
84
+ .ss-card img{width:100%;display:block;cursor:pointer}
85
+ .ss-card .ss-info{padding:.6rem .8rem;font-size:.8rem;color:var(--dim)}
86
+
87
+ .timeline{margin:1.5rem 0}
88
+ .tl-item{display:flex;align-items:center;gap:.5rem;padding:.4rem 0}
89
+ .tl-bar{flex:1;height:20px;background:var(--bg);border-radius:4px;overflow:hidden;position:relative}
90
+ .tl-fill{height:100%;border-radius:4px;display:flex;align-items:center;padding:0 .5rem;font-size:.7rem;color:#fff;font-weight:500;min-width:fit-content}
91
+ .tl-fill.pass{background:var(--g)}.tl-fill.fail{background:var(--r)}.tl-fill.skip{background:var(--y)}
92
+ .tl-name{width:200px;font-size:.8rem;text-align:right;color:var(--dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
93
+ .tl-dur{width:60px;font-size:.75rem;color:var(--dim);text-align:right}
94
+
95
+ .modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:999;align-items:center;justify-content:center}
96
+ .modal.show{display:flex}
97
+ .modal img{max-width:95vw;max-height:95vh;border-radius:8px}
98
+ .modal-close{position:fixed;top:1rem;right:1.5rem;color:#fff;font-size:2rem;cursor:pointer;z-index:1000}
99
+
100
+ footer{text-align:center;padding:2rem 0 1rem;color:var(--dim);font-size:.8rem;border-top:1px solid var(--border);margin-top:3rem}
101
+ </style></head>
56
102
  <body>
57
- <div class="container">
58
- <h1>QABot Test Report</h1>
59
- <div class="subtitle">${esc(meta.projectName || "")} &bull; Feature: ${esc(meta.feature || "all")} &bull; Env: ${esc(meta.environment || "local")} &bull; ${esc(meta.timestamp?.slice(0, 19) || "")}</div>
103
+ <div class="wrap">
60
104
 
61
- <div class="cards">
62
- <div class="card"><div class="label">Total Tests</div><div class="value">${summary.totalTests || 0}</div></div>
63
- <div class="card pass"><div class="label">Passed</div><div class="value">${summary.totalPassed || 0}</div></div>
64
- <div class="card fail"><div class="label">Failed</div><div class="value">${summary.totalFailed || 0}</div></div>
65
- <div class="card skip"><div class="label">Skipped</div><div class="value">${summary.totalSkipped || 0}</div></div>
66
- <div class="card rate"><div class="label">Pass Rate</div><div class="value">${summary.overallPassRate || 0}%</div></div>
67
- <div class="card"><div class="label">Duration</div><div class="value">${formatMs(meta.duration || summary.totalDuration || 0)}</div></div>
105
+ <div class="hero">
106
+ <h1>QABot Test Report</h1>
107
+ <div class="meta">
108
+ <span>${esc(meta.projectName || "")}</span> &middot;
109
+ <span>Feature: <strong>${esc(meta.feature || "all")}</strong></span> &middot;
110
+ <span>Env: ${esc(meta.environment || "local")}</span> &middot;
111
+ <span>${esc(meta.timestamp?.slice(0, 16).replace("T", " ") || "")}</span>
112
+ </div>
113
+ <div class="rate-ring">
114
+ <svg width="120" height="120" viewBox="0 0 120 120">
115
+ <circle cx="60" cy="60" r="52" fill="none" stroke="var(--border)" stroke-width="8"/>
116
+ <circle cx="60" cy="60" r="52" fill="none" stroke="${(summary.overallPassRate || 0) >= 80 ? "var(--g)" : (summary.overallPassRate || 0) >= 50 ? "var(--y)" : "var(--r)"}" stroke-width="8" stroke-dasharray="${Math.round((summary.overallPassRate || 0) * 3.267)} 327" stroke-linecap="round"/>
117
+ </svg>
118
+ <div class="rate-text" style="color:${(summary.overallPassRate || 0) >= 80 ? "var(--g)" : (summary.overallPassRate || 0) >= 50 ? "var(--y)" : "var(--r)"}">${summary.overallPassRate || 0}%</div>
119
+ </div>
68
120
  </div>
69
121
 
70
- <div class="bar"><div class="bar-fill ${(summary.overallPassRate || 0) >= 80 ? "bar-green" : "bar-red"}" style="width:${summary.overallPassRate || 0}%"></div></div>
122
+ <div class="cards">
123
+ <div class="card"><div class="lbl">Total</div><div class="val">${summary.totalTests || 0}</div></div>
124
+ <div class="card pass"><div class="lbl">Passed</div><div class="val">${summary.totalPassed || 0}</div></div>
125
+ <div class="card fail"><div class="lbl">Failed</div><div class="val">${summary.totalFailed || 0}</div></div>
126
+ <div class="card skip"><div class="lbl">Skipped</div><div class="val">${summary.totalSkipped || 0}</div></div>
127
+ <div class="card dur"><div class="lbl">Duration</div><div class="val">${fmtMs(meta.duration || summary.totalDuration || 0)}</div></div>
128
+ </div>
71
129
 
72
- ${Object.entries(summary.byLayer || {})
73
- .map(
74
- ([layer, s]) => `
75
- <h2>${layer.charAt(0).toUpperCase() + layer.slice(1)} Tests</h2>
76
- <div class="layer-section">
77
- <div>${s.passed} passed, ${s.failed} failed, ${s.skipped} skipped of ${s.total}</div>
130
+ <h2>Duration Waterfall</h2>
131
+ <div class="timeline">
132
+ ${allTests
133
+ .map((t) => {
134
+ const pct =
135
+ maxDuration > 0
136
+ ? Math.max(2, Math.round(((t.duration || 0) / maxDuration) * 100))
137
+ : 2;
138
+ return `<div class="tl-item">
139
+ <div class="tl-name">${esc(t.name)}</div>
140
+ <div class="tl-bar"><div class="tl-fill ${t.status}" style="width:${pct}%">${fmtMs(t.duration || 0)}</div></div>
141
+ <div class="tl-dur">${fmtMs(t.duration || 0)}</div>
142
+ </div>`;
143
+ })
144
+ .join("\n")}
78
145
  </div>
79
- `,
80
- )
81
- .join("")}
82
146
 
83
- <h2>All Test Results</h2>
147
+ <h2>Test Results</h2>
84
148
  <div class="filters">
85
- <button class="filter-btn active" onclick="filterTests('all')">All (${allTests.length})</button>
86
- <button class="filter-btn" onclick="filterTests('passed')">Passed (${passedTests.length})</button>
87
- <button class="filter-btn" onclick="filterTests('failed')">Failed (${failedTests.length})</button>
88
- <button class="filter-btn" onclick="filterTests('skipped')">Skipped (${skippedTests.length})</button>
149
+ <button class="fbtn on" onclick="filt('all',this)">All <span class="count">${allTests.length}</span></button>
150
+ <button class="fbtn" onclick="filt('passed',this)">Passed <span class="count">${passedTests.length}</span></button>
151
+ <button class="fbtn" onclick="filt('failed',this)">Failed <span class="count">${failedTests.length}</span></button>
152
+ <button class="fbtn" onclick="filt('skipped',this)">Skipped <span class="count">${skippedTests.length}</span></button>
89
153
  </div>
90
154
 
91
- <table>
92
- <thead><tr><th>Status</th><th>Test Name</th><th>Suite</th><th>Duration</th></tr></thead>
155
+ <table class="tbl">
156
+ <thead><tr><th class="idx">#</th><th>Status</th><th>Test</th><th>Duration</th><th>Details</th></tr></thead>
93
157
  <tbody>
94
158
  ${allTests
95
159
  .map(
96
- (t) => `<tr class="test-row" data-status="${t.status}">
160
+ (t, i) => `<tr class="trow" data-s="${t.status}">
161
+ <td class="idx">${i + 1}</td>
97
162
  <td><span class="badge badge-${t.status}">${t.status}</span></td>
98
- <td>${esc(t.name)}${t.error ? `<div class="error-box">${esc(t.error.message || "")}</div>` : ""}</td>
99
- <td style="color:var(--text-dim);font-size:.85rem">${esc(t.suite || t.file || "")}</td>
100
- <td style="color:var(--text-dim)">${formatMs(t.duration || 0)}</td>
101
- </tr>`,
163
+ <td>
164
+ <div class="test-name">${esc(t.name)}</div>
165
+ ${t.suite ? `<div class="test-suite">${esc(t.suite)}</div>` : ""}
166
+ ${t.file ? `<div class="test-file">${esc(t.file)}</div>` : ""}
167
+ </td>
168
+ <td>
169
+ <div class="dur-bar">
170
+ <div class="dur-fill" style="width:${maxDuration > 0 ? Math.max(2, Math.round(((t.duration || 0) / maxDuration) * 100)) : 2}%"></div>
171
+ ${fmtMs(t.duration || 0)}
172
+ </div>
173
+ </td>
174
+ <td>${t.error || t.skipReason || (t.screenshots || []).length > 0 ? `<button class="fbtn" onclick="toggle(this)" style="font-size:.75rem">View</button>` : "&mdash;"}</td>
175
+ </tr>
176
+ ${
177
+ t.error || t.skipReason || (t.screenshots || []).length > 0
178
+ ? `<tr class="trow-detail" data-s="${t.status}" style="display:none">
179
+ <td colspan="5" style="padding:0 1rem 1rem">
180
+ ${t.status === "skipped" ? `<div class="skip-reason">\u25B2 Skip Reason: ${esc(t.skipReason || t.error?.message || "No reason provided \u2014 test was marked skip or conditional skip")}</div>` : ""}
181
+ ${t.error ? `<div class="io-section"><div class="io-label">Error Message</div><div class="err-box">${esc(t.error.message || "")}</div></div>` : ""}
182
+ ${t.error?.stack ? `<div class="io-section"><div class="io-label">Stack Trace</div><div class="io-box">${esc(t.error.stack)}</div></div>` : ""}
183
+ ${t.error?.expected ? `<div class="io-section"><div class="io-label">Expected</div><div class="io-box">${esc(String(t.error.expected))}</div></div>` : ""}
184
+ ${t.error?.actual ? `<div class="io-section"><div class="io-label">Actual</div><div class="io-box">${esc(String(t.error.actual))}</div></div>` : ""}
185
+ ${(t.screenshots || []).length > 0 ? `<div class="io-section"><div class="io-label">Screenshots</div><div class="ss-grid">${t.screenshots.map((s) => `<div class="ss-card"><img src="${esc(s)}" onclick="openImg(this.src)" loading="lazy"/><div class="ss-info">${esc(s.split("/").pop())}</div></div>`).join("")}</div></div>` : ""}
186
+ </td>
187
+ </tr>`
188
+ : ""
189
+ }`,
102
190
  )
103
191
  .join("\n")}
104
192
  </tbody>
@@ -107,36 +195,101 @@ ${allTests
107
195
  ${
108
196
  failedTests.length > 0
109
197
  ? `
110
- <h2>Failed Test Details</h2>
198
+ <h2>Failed Tests \u2014 Detail</h2>
111
199
  ${failedTests
112
200
  .map(
113
- (t) => `
114
- <div class="layer-section">
115
- <strong style="color:var(--red)">${esc(t.name)}</strong>
116
- <div style="color:var(--text-dim);font-size:.85rem;margin:.5rem 0">${esc(t.file || "")} &bull; ${esc(t.suite || "")}</div>
117
- ${t.error ? `<div class="error-box">${esc(t.error.message || "")}\n${esc(t.error.stack || "")}</div>` : ""}
118
- </div>
119
- `,
201
+ (t, i) => `
202
+ <div class="detail">
203
+ <div class="detail-head" onclick="toggleDetail(this)">
204
+ <div><span class="badge badge-failed">FAILED</span> <strong style="margin-left:.5rem">${esc(t.name)}</strong> <span style="color:var(--dim);font-size:.85rem;margin-left:.5rem">${fmtMs(t.duration || 0)}</span></div>
205
+ <span class="arrow">\u25BC</span>
206
+ </div>
207
+ <div class="detail-body">
208
+ ${t.file ? `<div class="test-file" style="margin-bottom:.75rem">${esc(t.file)} &bull; ${esc(t.suite || "")}</div>` : ""}
209
+ ${t.error ? `<div class="io-section"><div class="io-label">Error</div><div class="err-box">${esc(t.error.message || "")}</div></div>` : ""}
210
+ ${t.error?.stack ? `<div class="io-section"><div class="io-label">Stack Trace</div><div class="io-box">${esc(t.error.stack)}</div></div>` : ""}
211
+ ${
212
+ t.error?.expected !== undefined
213
+ ? `<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-top:.75rem">
214
+ <div><div class="io-label">Expected</div><div class="io-box" style="border-color:var(--g)">${esc(String(t.error.expected))}</div></div>
215
+ <div><div class="io-label">Actual</div><div class="io-box" style="border-color:var(--r)">${esc(String(t.error.actual))}</div></div>
216
+ </div>`
217
+ : ""
218
+ }
219
+ ${(t.screenshots || []).length > 0 ? `<div class="io-section"><div class="io-label">Screenshots</div><div class="ss-grid">${t.screenshots.map((s) => `<div class="ss-card"><img src="${esc(s)}" onclick="openImg(this.src)" loading="lazy"/></div>`).join("")}</div></div>` : ""}
220
+ </div>
221
+ </div>`,
120
222
  )
121
223
  .join("")}
122
224
  `
123
225
  : ""
124
226
  }
125
227
 
126
- <footer>Generated by QABot v0.1.0 &bull; ${new Date().toISOString()}</footer>
228
+ ${
229
+ skippedTests.length > 0
230
+ ? `
231
+ <h2>Skipped Tests \u2014 Reasons</h2>
232
+ <table class="tbl"><thead><tr><th>#</th><th>Test</th><th>Reason</th></tr></thead><tbody>
233
+ ${skippedTests
234
+ .map(
235
+ (t, i) => `<tr>
236
+ <td class="idx">${i + 1}</td>
237
+ <td><div class="test-name">${esc(t.name)}</div></td>
238
+ <td><div class="skip-reason" style="display:inline-block">${esc(t.skipReason || t.error?.message || "Marked as skip / conditional skip / prerequisite failed")}</div></td>
239
+ </tr>`,
240
+ )
241
+ .join("\n")}
242
+ </tbody></table>
243
+ `
244
+ : ""
245
+ }
246
+
247
+ ${
248
+ screenshots.length > 0
249
+ ? `
250
+ <h2>Screenshots Gallery</h2>
251
+ <div class="ss-grid">
252
+ ${screenshots.map((s) => `<div class="ss-card"><img src="${esc(s.path)}" onclick="openImg(this.src)" loading="lazy"/><div class="ss-info">${esc(s.test)}</div></div>`).join("")}
253
+ </div>
254
+ `
255
+ : ""
256
+ }
257
+
258
+ <footer>Generated by QABot v${VERSION} &middot; ${new Date().toISOString().slice(0, 19).replace("T", " ")}</footer>
259
+ </div>
260
+
261
+ <div class="modal" id="modal" onclick="this.classList.remove('show')">
262
+ <span class="modal-close" onclick="document.getElementById('modal').classList.remove('show')">&times;</span>
263
+ <img id="modal-img" src="" />
127
264
  </div>
128
265
 
129
266
  <script>
130
- function filterTests(status){
131
- document.querySelectorAll('.filter-btn').forEach(b=>b.classList.remove('active'));
132
- event.target.classList.add('active');
133
- document.querySelectorAll('.test-row').forEach(r=>{
134
- r.style.display=status==='all'||r.dataset.status===status?'':'none';
267
+ function filt(s,btn){
268
+ document.querySelectorAll('.fbtn').forEach(b=>b.classList.remove('on'));
269
+ btn.classList.add('on');
270
+ document.querySelectorAll('.trow,.trow-detail').forEach(r=>{
271
+ r.style.display=s==='all'||r.dataset.s===s?'':'none';
135
272
  });
136
273
  }
274
+ function toggle(btn){
275
+ const tr=btn.closest('tr');
276
+ const next=tr.nextElementSibling;
277
+ if(next&&next.classList.contains('trow-detail')){
278
+ next.style.display=next.style.display==='none'?'':'none';
279
+ btn.textContent=next.style.display==='none'?'View':'Hide';
280
+ }
281
+ }
282
+ function toggleDetail(el){
283
+ el.classList.toggle('open');
284
+ const body=el.nextElementSibling;
285
+ body.classList.toggle('show');
286
+ }
287
+ function openImg(src){
288
+ document.getElementById('modal-img').src=src;
289
+ document.getElementById('modal').classList.add('show');
290
+ }
137
291
  </script>
138
- </body>
139
- </html>`;
292
+ </body></html>`;
140
293
  }
141
294
  }
142
295
 
@@ -147,3 +300,9 @@ function esc(str) {
147
300
  .replace(/>/g, "&gt;")
148
301
  .replace(/"/g, "&quot;");
149
302
  }
303
+
304
+ function fmtMs(ms) {
305
+ if (ms < 1000) return `${ms}ms`;
306
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
307
+ return `${(ms / 60000).toFixed(1)}m`;
308
+ }