@nhonh/qabot 1.0.0 → 1.0.2

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,15 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
2
+ <defs>
3
+ <linearGradient id="sg" x1="0" y1="0" x2="0" y2="1">
4
+ <stop offset="0%" stop-color="#A78BFA"/>
5
+ <stop offset="100%" stop-color="#7C3AED"/>
6
+ </linearGradient>
7
+ <linearGradient id="cg" x1="0" y1="0" x2="1" y2="1">
8
+ <stop offset="0%" stop-color="#34D399"/>
9
+ <stop offset="100%" stop-color="#10B981"/>
10
+ </linearGradient>
11
+ </defs>
12
+ <path d="M16 2 L28 7.5 C28 7.5 29 18 16 29.5 C3 18 4 7.5 4 7.5 Z" fill="url(#sg)"/>
13
+ <path d="M16 5 L25.5 9.5 C25.5 9.5 26.2 17.5 16 27 C5.8 17.5 6.5 9.5 6.5 9.5 Z" fill="#0C0A1A"/>
14
+ <path d="M11.5 15.5 L14.5 18.5 L20.5 12.5" stroke="url(#cg)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
15
+ </svg>
@@ -0,0 +1,56 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
2
+ <defs>
3
+ <linearGradient id="shieldGrad" x1="0" y1="0" x2="0" y2="1">
4
+ <stop offset="0%" stop-color="#A78BFA"/>
5
+ <stop offset="100%" stop-color="#7C3AED"/>
6
+ </linearGradient>
7
+ <linearGradient id="innerGrad" x1="0" y1="0" x2="0" y2="1">
8
+ <stop offset="0%" stop-color="#1A1635"/>
9
+ <stop offset="100%" stop-color="#0C0A1A"/>
10
+ </linearGradient>
11
+ <linearGradient id="checkGrad" x1="0" y1="0" x2="1" y2="1">
12
+ <stop offset="0%" stop-color="#34D399"/>
13
+ <stop offset="100%" stop-color="#10B981"/>
14
+ </linearGradient>
15
+ <filter id="glow">
16
+ <feGaussianBlur stdDeviation="8" result="blur"/>
17
+ <feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
18
+ </filter>
19
+ <filter id="innerShadow">
20
+ <feGaussianBlur stdDeviation="4" result="blur"/>
21
+ <feOffset dx="0" dy="2" result="offset"/>
22
+ <feComposite in="SourceGraphic" in2="offset" operator="over"/>
23
+ </filter>
24
+ </defs>
25
+
26
+ <!-- Outer glow -->
27
+ <path d="M256 28 L462 120 C462 120 474 300 256 484 C38 300 50 120 50 120 Z" fill="#A78BFA" opacity="0.15" filter="url(#glow)"/>
28
+
29
+ <!-- Shield body -->
30
+ <path d="M256 42 L448 128 C448 128 458 290 256 468 C54 290 64 128 64 128 Z" fill="url(#shieldGrad)" stroke="#C4B5FD" stroke-width="3"/>
31
+
32
+ <!-- Inner shield -->
33
+ <path d="M256 72 L420 146 C420 146 428 280 256 438 C84 280 92 146 92 146 Z" fill="url(#innerGrad)" stroke="#2D2852" stroke-width="2"/>
34
+
35
+ <!-- Checkmark circle background -->
36
+ <circle cx="256" cy="245" r="105" fill="#7C3AED" opacity="0.3"/>
37
+ <circle cx="256" cy="245" r="90" fill="none" stroke="#A78BFA" stroke-width="3" stroke-dasharray="6 4" opacity="0.5"/>
38
+
39
+ <!-- Main checkmark -->
40
+ <path d="M196 245 L236 285 L316 205" fill="none" stroke="url(#checkGrad)" stroke-width="24" stroke-linecap="round" stroke-linejoin="round" filter="url(#innerShadow)"/>
41
+
42
+ <!-- Small decorative checkmarks (verified layers) -->
43
+ <path d="M152 175 L162 185 L182 165" fill="none" stroke="#34D399" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" opacity="0.6"/>
44
+ <path d="M340 175 L350 185 L370 165" fill="none" stroke="#34D399" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" opacity="0.6"/>
45
+ <path d="M152 315 L162 325 L182 305" fill="none" stroke="#34D399" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" opacity="0.6"/>
46
+ <path d="M340 315 L350 325 L370 305" fill="none" stroke="#34D399" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" opacity="0.6"/>
47
+
48
+ <!-- QA text -->
49
+ <text x="256" y="400" text-anchor="middle" font-family="system-ui,-apple-system,sans-serif" font-size="52" font-weight="800" fill="#C4B5FD" letter-spacing="8">QA</text>
50
+
51
+ <!-- Horizontal scan lines (inspection feel) -->
52
+ <line x1="130" y1="210" x2="170" y2="210" stroke="#A78BFA" stroke-width="2" opacity="0.3"/>
53
+ <line x1="342" y1="210" x2="382" y2="210" stroke="#A78BFA" stroke-width="2" opacity="0.3"/>
54
+ <line x1="130" y1="280" x2="170" y2="280" stroke="#A78BFA" stroke-width="2" opacity="0.3"/>
55
+ <line x1="342" y1="280" x2="382" y2="280" stroke="#A78BFA" stroke-width="2" opacity="0.3"/>
56
+ </svg>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nhonh/qabot",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
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": {
@@ -59,6 +59,7 @@
59
59
  "files": [
60
60
  "bin/",
61
61
  "src/",
62
- "templates/"
62
+ "templates/",
63
+ "assets/"
63
64
  ]
64
65
  }
@@ -1,4 +1,4 @@
1
- export const VERSION = "1.0.0";
1
+ export const VERSION = "1.0.2";
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,188 @@ 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
+ <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='none'%3E%3Cdefs%3E%3ClinearGradient id='sg' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0%25' stop-color='%23A78BFA'/%3E%3Cstop offset='100%25' stop-color='%237C3AED'/%3E%3C/linearGradient%3E%3ClinearGradient id='cg' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0%25' stop-color='%2334D399'/%3E%3Cstop offset='100%25' stop-color='%2310B981'/%3E%3C/linearGradient%3E%3C/defs%3E%3Cpath d='M16 2L28 7.5C28 7.5 29 18 16 29.5 3 18 4 7.5 4 7.5Z' fill='url(%23sg)'/%3E%3Cpath d='M16 5L25.5 9.5C25.5 9.5 26.2 17.5 16 27 5.8 17.5 6.5 9.5 6.5 9.5Z' fill='%230C0A1A'/%3E%3Cpath d='M11.5 15.5L14.5 18.5 20.5 12.5' stroke='url(%23cg)' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E">
19
+ <title>QABot Report \u2014 ${esc(meta.feature || "all")} | ${esc(meta.projectName || "")}</title>
17
20
  <style>
21
+ :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
22
  *{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>
23
+ body{background:var(--bg);color:var(--text);font-family:var(--font);line-height:1.6}
24
+ .wrap{max-width:1200px;margin:0 auto;padding:2.5rem 2rem}
25
+ a{color:var(--v);text-decoration:none}
26
+ a:hover{text-decoration:underline}
27
+
28
+ .hero{text-align:center;padding:3rem 0 2rem;border-bottom:1px solid var(--border)}
29
+ .hero h1{font-size:1.5rem;font-weight:600;color:var(--v3);letter-spacing:.02em}
30
+ .hero .meta{color:var(--dim);font-size:.85rem;margin-top:.5rem}
31
+ .hero .meta span{margin:0 .5rem}
32
+ .rate-ring{width:120px;height:120px;margin:1.5rem auto;position:relative}
33
+ .rate-ring svg{transform:rotate(-90deg)}
34
+ .rate-ring .rate-text{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:2rem;font-weight:700}
35
+
36
+ .cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:.75rem;margin:2rem 0}
37
+ .card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:1.25rem;text-align:center;transition:border-color .2s}
38
+ .card:hover{border-color:var(--v)}
39
+ .card .val{font-size:1.75rem;font-weight:700;margin:.25rem 0}
40
+ .card .lbl{font-size:.75rem;color:var(--dim);text-transform:uppercase;letter-spacing:.08em}
41
+ .card.pass .val{color:var(--g)}.card.fail .val{color:var(--r)}.card.skip .val{color:var(--y)}.card.dur .val{color:var(--cyan)}
42
+
43
+ h2{font-size:1.1rem;font-weight:600;color:var(--v3);margin:2.5rem 0 1rem;display:flex;align-items:center;gap:.5rem}
44
+ h2::before{content:'';width:3px;height:1.1rem;background:var(--v2);border-radius:2px}
45
+
46
+ .filters{display:flex;gap:.5rem;margin:1rem 0;flex-wrap:wrap}
47
+ .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}
48
+ .fbtn:hover,.fbtn.on{background:var(--v2);border-color:var(--v2);color:#fff}
49
+ .fbtn .count{background:var(--bg);padding:1px 6px;border-radius:10px;font-size:.7rem;margin-left:.3rem}
50
+ .fbtn.on .count{background:rgba(255,255,255,.15)}
51
+
52
+ .tbl{width:100%;border-collapse:separate;border-spacing:0;margin:1rem 0}
53
+ .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)}
54
+ .tbl td{padding:.6rem 1rem;border-bottom:1px solid var(--border);vertical-align:top}
55
+ .tbl tr:hover td{background:var(--card)}
56
+ .tbl .idx{color:var(--dim);font-size:.8rem;width:3rem}
57
+ .badge{display:inline-flex;align-items:center;gap:.3rem;padding:2px 10px;border-radius:20px;font-size:.72rem;font-weight:600}
58
+ .badge-passed{background:#34D39918;color:var(--g)}.badge-failed{background:#F8717118;color:var(--r)}.badge-skipped{background:#FBBF2418;color:var(--y)}
59
+ .badge::before{content:'';width:6px;height:6px;border-radius:50%}
60
+ .badge-passed::before{background:var(--g)}.badge-failed::before{background:var(--r)}.badge-skipped::before{background:var(--y)}
61
+
62
+ .dur-bar{display:flex;align-items:center;gap:.5rem;font-size:.8rem;color:var(--dim)}
63
+ .dur-fill{height:4px;border-radius:2px;background:var(--v);min-width:2px;transition:width .3s}
64
+ .test-name{font-weight:500;font-size:.9rem}
65
+ .test-suite{font-size:.8rem;color:var(--dim);margin-top:2px}
66
+ .test-file{font-family:var(--mono);font-size:.75rem;color:var(--dim);margin-top:2px}
67
+
68
+ .detail{background:var(--card);border:1px solid var(--border);border-radius:10px;margin:.75rem 0;overflow:hidden}
69
+ .detail-head{padding:.75rem 1rem;cursor:pointer;display:flex;align-items:center;justify-content:space-between;user-select:none}
70
+ .detail-head:hover{background:var(--card2)}
71
+ .detail-head .arrow{color:var(--dim);font-size:.7rem;transition:transform .2s}
72
+ .detail-head.open .arrow{transform:rotate(180deg)}
73
+ .detail-body{display:none;padding:1rem;border-top:1px solid var(--border)}
74
+ .detail-body.show{display:block}
75
+
76
+ .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}
77
+ .skip-reason{background:#1A170A;border:1px solid #FBBF2430;border-radius:8px;padding:.75rem 1rem;font-size:.85rem;color:var(--y)}
78
+ .io-section{margin-top:.75rem}
79
+ .io-label{font-size:.75rem;color:var(--dim);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.3rem}
80
+ .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}
81
+
82
+ .ss-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1rem;margin:1rem 0}
83
+ .ss-card{background:var(--card);border:1px solid var(--border);border-radius:10px;overflow:hidden;transition:border-color .2s}
84
+ .ss-card:hover{border-color:var(--v)}
85
+ .ss-card img{width:100%;display:block;cursor:pointer}
86
+ .ss-card .ss-info{padding:.6rem .8rem;font-size:.8rem;color:var(--dim)}
87
+
88
+ .timeline{margin:1.5rem 0}
89
+ .tl-item{display:flex;align-items:center;gap:.5rem;padding:.4rem 0}
90
+ .tl-bar{flex:1;height:20px;background:var(--bg);border-radius:4px;overflow:hidden;position:relative}
91
+ .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}
92
+ .tl-fill.pass{background:var(--g)}.tl-fill.fail{background:var(--r)}.tl-fill.skip{background:var(--y)}
93
+ .tl-name{width:200px;font-size:.8rem;text-align:right;color:var(--dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
94
+ .tl-dur{width:60px;font-size:.75rem;color:var(--dim);text-align:right}
95
+
96
+ .modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:999;align-items:center;justify-content:center}
97
+ .modal.show{display:flex}
98
+ .modal img{max-width:95vw;max-height:95vh;border-radius:8px}
99
+ .modal-close{position:fixed;top:1rem;right:1.5rem;color:#fff;font-size:2rem;cursor:pointer;z-index:1000}
100
+
101
+ footer{text-align:center;padding:2rem 0 1rem;color:var(--dim);font-size:.8rem;border-top:1px solid var(--border);margin-top:3rem}
102
+ </style></head>
56
103
  <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>
104
+ <div class="wrap">
60
105
 
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>
106
+ <div class="hero">
107
+ <div style="margin-bottom:1rem"><svg width="48" height="48" viewBox="0 0 32 32" fill="none"><defs><linearGradient id="hsg" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#A78BFA"/><stop offset="100%" stop-color="#7C3AED"/></linearGradient><linearGradient id="hcg" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#34D399"/><stop offset="100%" stop-color="#10B981"/></linearGradient></defs><path d="M16 2L28 7.5C28 7.5 29 18 16 29.5 3 18 4 7.5 4 7.5Z" fill="url(#hsg)"/><path d="M16 5L25.5 9.5C25.5 9.5 26.2 17.5 16 27 5.8 17.5 6.5 9.5 6.5 9.5Z" fill="#0C0A1A"/><path d="M11.5 15.5L14.5 18.5 20.5 12.5" stroke="url(#hcg)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg></div>
108
+ <h1>QABot Test Report</h1>
109
+ <div class="meta">
110
+ <span>${esc(meta.projectName || "")}</span> &middot;
111
+ <span>Feature: <strong>${esc(meta.feature || "all")}</strong></span> &middot;
112
+ <span>Env: ${esc(meta.environment || "local")}</span> &middot;
113
+ <span>${esc(meta.timestamp?.slice(0, 16).replace("T", " ") || "")}</span>
114
+ </div>
115
+ <div class="rate-ring">
116
+ <svg width="120" height="120" viewBox="0 0 120 120">
117
+ <circle cx="60" cy="60" r="52" fill="none" stroke="var(--border)" stroke-width="8"/>
118
+ <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"/>
119
+ </svg>
120
+ <div class="rate-text" style="color:${(summary.overallPassRate || 0) >= 80 ? "var(--g)" : (summary.overallPassRate || 0) >= 50 ? "var(--y)" : "var(--r)"}">${summary.overallPassRate || 0}%</div>
121
+ </div>
68
122
  </div>
69
123
 
70
- <div class="bar"><div class="bar-fill ${(summary.overallPassRate || 0) >= 80 ? "bar-green" : "bar-red"}" style="width:${summary.overallPassRate || 0}%"></div></div>
124
+ <div class="cards">
125
+ <div class="card"><div class="lbl">Total</div><div class="val">${summary.totalTests || 0}</div></div>
126
+ <div class="card pass"><div class="lbl">Passed</div><div class="val">${summary.totalPassed || 0}</div></div>
127
+ <div class="card fail"><div class="lbl">Failed</div><div class="val">${summary.totalFailed || 0}</div></div>
128
+ <div class="card skip"><div class="lbl">Skipped</div><div class="val">${summary.totalSkipped || 0}</div></div>
129
+ <div class="card dur"><div class="lbl">Duration</div><div class="val">${fmtMs(meta.duration || summary.totalDuration || 0)}</div></div>
130
+ </div>
71
131
 
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>
132
+ <h2>Duration Waterfall</h2>
133
+ <div class="timeline">
134
+ ${allTests
135
+ .map((t) => {
136
+ const pct =
137
+ maxDuration > 0
138
+ ? Math.max(2, Math.round(((t.duration || 0) / maxDuration) * 100))
139
+ : 2;
140
+ return `<div class="tl-item">
141
+ <div class="tl-name">${esc(t.name)}</div>
142
+ <div class="tl-bar"><div class="tl-fill ${t.status}" style="width:${pct}%">${fmtMs(t.duration || 0)}</div></div>
143
+ <div class="tl-dur">${fmtMs(t.duration || 0)}</div>
144
+ </div>`;
145
+ })
146
+ .join("\n")}
78
147
  </div>
79
- `,
80
- )
81
- .join("")}
82
148
 
83
- <h2>All Test Results</h2>
149
+ <h2>Test Results</h2>
84
150
  <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>
151
+ <button class="fbtn on" onclick="filt('all',this)">All <span class="count">${allTests.length}</span></button>
152
+ <button class="fbtn" onclick="filt('passed',this)">Passed <span class="count">${passedTests.length}</span></button>
153
+ <button class="fbtn" onclick="filt('failed',this)">Failed <span class="count">${failedTests.length}</span></button>
154
+ <button class="fbtn" onclick="filt('skipped',this)">Skipped <span class="count">${skippedTests.length}</span></button>
89
155
  </div>
90
156
 
91
- <table>
92
- <thead><tr><th>Status</th><th>Test Name</th><th>Suite</th><th>Duration</th></tr></thead>
157
+ <table class="tbl">
158
+ <thead><tr><th class="idx">#</th><th>Status</th><th>Test</th><th>Duration</th><th>Details</th></tr></thead>
93
159
  <tbody>
94
160
  ${allTests
95
161
  .map(
96
- (t) => `<tr class="test-row" data-status="${t.status}">
162
+ (t, i) => `<tr class="trow" data-s="${t.status}">
163
+ <td class="idx">${i + 1}</td>
97
164
  <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>`,
165
+ <td>
166
+ <div class="test-name">${esc(t.name)}</div>
167
+ ${t.suite ? `<div class="test-suite">${esc(t.suite)}</div>` : ""}
168
+ ${t.file ? `<div class="test-file">${esc(t.file)}</div>` : ""}
169
+ </td>
170
+ <td>
171
+ <div class="dur-bar">
172
+ <div class="dur-fill" style="width:${maxDuration > 0 ? Math.max(2, Math.round(((t.duration || 0) / maxDuration) * 100)) : 2}%"></div>
173
+ ${fmtMs(t.duration || 0)}
174
+ </div>
175
+ </td>
176
+ <td>${t.error || t.skipReason || (t.screenshots || []).length > 0 ? `<button class="fbtn" onclick="toggle(this)" style="font-size:.75rem">View</button>` : "&mdash;"}</td>
177
+ </tr>
178
+ ${
179
+ t.error || t.skipReason || (t.screenshots || []).length > 0
180
+ ? `<tr class="trow-detail" data-s="${t.status}" style="display:none">
181
+ <td colspan="5" style="padding:0 1rem 1rem">
182
+ ${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>` : ""}
183
+ ${t.error ? `<div class="io-section"><div class="io-label">Error Message</div><div class="err-box">${esc(t.error.message || "")}</div></div>` : ""}
184
+ ${t.error?.stack ? `<div class="io-section"><div class="io-label">Stack Trace</div><div class="io-box">${esc(t.error.stack)}</div></div>` : ""}
185
+ ${t.error?.expected ? `<div class="io-section"><div class="io-label">Expected</div><div class="io-box">${esc(String(t.error.expected))}</div></div>` : ""}
186
+ ${t.error?.actual ? `<div class="io-section"><div class="io-label">Actual</div><div class="io-box">${esc(String(t.error.actual))}</div></div>` : ""}
187
+ ${(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>` : ""}
188
+ </td>
189
+ </tr>`
190
+ : ""
191
+ }`,
102
192
  )
103
193
  .join("\n")}
104
194
  </tbody>
@@ -107,36 +197,101 @@ ${allTests
107
197
  ${
108
198
  failedTests.length > 0
109
199
  ? `
110
- <h2>Failed Test Details</h2>
200
+ <h2>Failed Tests \u2014 Detail</h2>
111
201
  ${failedTests
112
202
  .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
- `,
203
+ (t, i) => `
204
+ <div class="detail">
205
+ <div class="detail-head" onclick="toggleDetail(this)">
206
+ <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>
207
+ <span class="arrow">\u25BC</span>
208
+ </div>
209
+ <div class="detail-body">
210
+ ${t.file ? `<div class="test-file" style="margin-bottom:.75rem">${esc(t.file)} &bull; ${esc(t.suite || "")}</div>` : ""}
211
+ ${t.error ? `<div class="io-section"><div class="io-label">Error</div><div class="err-box">${esc(t.error.message || "")}</div></div>` : ""}
212
+ ${t.error?.stack ? `<div class="io-section"><div class="io-label">Stack Trace</div><div class="io-box">${esc(t.error.stack)}</div></div>` : ""}
213
+ ${
214
+ t.error?.expected !== undefined
215
+ ? `<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-top:.75rem">
216
+ <div><div class="io-label">Expected</div><div class="io-box" style="border-color:var(--g)">${esc(String(t.error.expected))}</div></div>
217
+ <div><div class="io-label">Actual</div><div class="io-box" style="border-color:var(--r)">${esc(String(t.error.actual))}</div></div>
218
+ </div>`
219
+ : ""
220
+ }
221
+ ${(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>` : ""}
222
+ </div>
223
+ </div>`,
120
224
  )
121
225
  .join("")}
122
226
  `
123
227
  : ""
124
228
  }
125
229
 
126
- <footer>Generated by QABot v0.1.0 &bull; ${new Date().toISOString()}</footer>
230
+ ${
231
+ skippedTests.length > 0
232
+ ? `
233
+ <h2>Skipped Tests \u2014 Reasons</h2>
234
+ <table class="tbl"><thead><tr><th>#</th><th>Test</th><th>Reason</th></tr></thead><tbody>
235
+ ${skippedTests
236
+ .map(
237
+ (t, i) => `<tr>
238
+ <td class="idx">${i + 1}</td>
239
+ <td><div class="test-name">${esc(t.name)}</div></td>
240
+ <td><div class="skip-reason" style="display:inline-block">${esc(t.skipReason || t.error?.message || "Marked as skip / conditional skip / prerequisite failed")}</div></td>
241
+ </tr>`,
242
+ )
243
+ .join("\n")}
244
+ </tbody></table>
245
+ `
246
+ : ""
247
+ }
248
+
249
+ ${
250
+ screenshots.length > 0
251
+ ? `
252
+ <h2>Screenshots Gallery</h2>
253
+ <div class="ss-grid">
254
+ ${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("")}
255
+ </div>
256
+ `
257
+ : ""
258
+ }
259
+
260
+ <footer>Generated by QABot v${VERSION} &middot; ${new Date().toISOString().slice(0, 19).replace("T", " ")}</footer>
261
+ </div>
262
+
263
+ <div class="modal" id="modal" onclick="this.classList.remove('show')">
264
+ <span class="modal-close" onclick="document.getElementById('modal').classList.remove('show')">&times;</span>
265
+ <img id="modal-img" src="" />
127
266
  </div>
128
267
 
129
268
  <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';
269
+ function filt(s,btn){
270
+ document.querySelectorAll('.fbtn').forEach(b=>b.classList.remove('on'));
271
+ btn.classList.add('on');
272
+ document.querySelectorAll('.trow,.trow-detail').forEach(r=>{
273
+ r.style.display=s==='all'||r.dataset.s===s?'':'none';
135
274
  });
136
275
  }
276
+ function toggle(btn){
277
+ const tr=btn.closest('tr');
278
+ const next=tr.nextElementSibling;
279
+ if(next&&next.classList.contains('trow-detail')){
280
+ next.style.display=next.style.display==='none'?'':'none';
281
+ btn.textContent=next.style.display==='none'?'View':'Hide';
282
+ }
283
+ }
284
+ function toggleDetail(el){
285
+ el.classList.toggle('open');
286
+ const body=el.nextElementSibling;
287
+ body.classList.toggle('show');
288
+ }
289
+ function openImg(src){
290
+ document.getElementById('modal-img').src=src;
291
+ document.getElementById('modal').classList.add('show');
292
+ }
137
293
  </script>
138
- </body>
139
- </html>`;
294
+ </body></html>`;
140
295
  }
141
296
  }
142
297
 
@@ -147,3 +302,9 @@ function esc(str) {
147
302
  .replace(/>/g, "&gt;")
148
303
  .replace(/"/g, "&quot;");
149
304
  }
305
+
306
+ function fmtMs(ms) {
307
+ if (ms < 1000) return `${ms}ms`;
308
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
309
+ return `${(ms / 60000).toFixed(1)}m`;
310
+ }