@matware/e2e-runner 1.0.3 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1044 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>E2E Runner</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
9
+ <style>
10
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
11
+ :root{
12
+ --bg:#090a10;--surface:#11131b;--surface2:#181b26;--surface3:#1f2333;
13
+ --border:#232738;--border-hi:#2d3248;
14
+ --text:#dfe1e8;--text2:#7c8198;--text3:#464b62;
15
+ --accent:#3b82f6;--accent-dim:rgba(59,130,246,.12);
16
+ --green:#10b981;--green-dim:rgba(16,185,129,.10);
17
+ --red:#ef4444;--red-dim:rgba(239,68,68,.10);
18
+ --amber:#f59e0b;--amber-dim:rgba(245,158,11,.10);
19
+ --purple:#8b5cf6;--purple-dim:rgba(139,92,246,.12);
20
+ --mono:'JetBrains Mono','SF Mono','Fira Code',monospace;
21
+ --sans:'Outfit','Inter',-apple-system,sans-serif;
22
+ --r:6px;
23
+ }
24
+ html{font-size:13px}
25
+ body{font-family:var(--mono);background:var(--bg);color:var(--text);min-height:100vh;display:flex}
26
+ a{color:var(--accent);text-decoration:none}
27
+ ::selection{background:var(--accent);color:#fff}
28
+
29
+ /* ── Sidebar ── */
30
+ .sidebar{width:232px;background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;position:fixed;top:0;left:0;bottom:0;z-index:50;overflow-y:auto}
31
+ .sidebar-logo{padding:20px 16px 16px;border-bottom:1px solid var(--border)}
32
+ .sidebar-logo h1{font-family:var(--sans);font-size:15px;font-weight:700;letter-spacing:-.02em}
33
+ .sidebar-logo h1 span{color:var(--accent)}
34
+ .sidebar-logo .ver{font-size:10px;color:var(--text3);margin-top:2px}
35
+
36
+ .sidebar-section{padding:12px 16px 8px}
37
+ .sidebar-section-label{font-size:9px;font-weight:600;color:var(--text3);letter-spacing:.12em;text-transform:uppercase;margin-bottom:8px}
38
+ .sidebar select{width:100%;padding:8px 10px;border-radius:var(--r);border:1px solid var(--border);background:var(--surface2);color:var(--text);font-family:var(--mono);font-size:12px;appearance:auto;cursor:pointer}
39
+ .sidebar select:focus{outline:none;border-color:var(--accent)}
40
+
41
+ .nav-item{display:flex;align-items:center;gap:10px;padding:8px 16px;font-size:12px;color:var(--text2);cursor:pointer;border-left:2px solid transparent;transition:all .15s}
42
+ .nav-item:hover{color:var(--text);background:var(--surface2)}
43
+ .nav-item.active{color:var(--accent);border-left-color:var(--accent);background:var(--accent-dim)}
44
+ .nav-item .icon{width:16px;text-align:center;font-style:normal}
45
+ .nav-item .badge{margin-left:auto;background:var(--surface3);color:var(--text2);font-size:10px;padding:1px 6px;border-radius:10px}
46
+
47
+ .pool-status{margin-top:auto;padding:16px;border-top:1px solid var(--border)}
48
+ .pool-dot{width:7px;height:7px;border-radius:50%;display:inline-block;margin-right:6px}
49
+ .pool-dot.on{background:var(--green);box-shadow:0 0 8px var(--green)}
50
+ .pool-dot.off{background:var(--red);box-shadow:0 0 8px var(--red)}
51
+ .pool-info{font-size:11px;color:var(--text2);line-height:1.7}
52
+ .pool-info strong{color:var(--text)}
53
+ .ws-dot{width:6px;height:6px;border-radius:50%;display:inline-block;margin-right:4px}
54
+
55
+ /* ── Main ── */
56
+ .main{margin-left:232px;flex:1;min-height:100vh}
57
+ .main-header{padding:16px 24px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px;background:var(--surface);position:sticky;top:0;z-index:40}
58
+ .main-header .title{font-family:var(--sans);font-size:16px;font-weight:600}
59
+ .main-header .actions{margin-left:auto;display:flex;gap:8px}
60
+ .view{display:none;padding:24px}
61
+ .view.active{display:block}
62
+
63
+ /* ── Buttons ── */
64
+ .btn{display:inline-flex;align-items:center;gap:6px;padding:7px 14px;border-radius:var(--r);font-family:var(--mono);font-size:11px;font-weight:500;cursor:pointer;border:1px solid var(--border);background:var(--surface2);color:var(--text);transition:all .15s;white-space:nowrap}
65
+ .btn:hover{background:var(--surface3);border-color:var(--border-hi)}
66
+ .btn.primary{background:var(--accent);border-color:var(--accent);color:#fff}
67
+ .btn.primary:hover{background:#2563eb}
68
+ .btn.danger{background:var(--red-dim);border-color:rgba(239,68,68,.3);color:var(--red)}
69
+ .btn:disabled{opacity:.4;cursor:not-allowed}
70
+ .btn.sm{padding:4px 10px;font-size:10px}
71
+
72
+ /* ── Cards ── */
73
+ .card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:16px;margin-bottom:16px}
74
+ .card-label{font-size:9px;font-weight:600;color:var(--text3);letter-spacing:.1em;text-transform:uppercase;margin-bottom:10px}
75
+
76
+ /* ── Stats Row ── */
77
+ .stats{display:flex;gap:24px;margin-bottom:20px}
78
+ .stat-block{text-align:center;min-width:80px}
79
+ .stat-val{font-size:26px;font-weight:700}
80
+ .stat-lbl{font-size:9px;color:var(--text3);text-transform:uppercase;letter-spacing:.1em;margin-top:2px}
81
+ .stat-val.green{color:var(--green)}
82
+ .stat-val.red{color:var(--red)}
83
+ .stat-val.accent{color:var(--accent)}
84
+ .stat-val.purple{color:var(--purple)}
85
+
86
+ /* ── Tables ── */
87
+ .tbl-wrap{overflow-x:auto}
88
+ table{width:100%;border-collapse:collapse;font-size:12px}
89
+ th{text-align:left;font-size:9px;font-weight:600;color:var(--text3);letter-spacing:.1em;text-transform:uppercase;padding:8px 12px;border-bottom:1px solid var(--border)}
90
+ td{padding:8px 12px;border-bottom:1px solid var(--border)}
91
+ tbody tr{cursor:pointer;transition:background .1s}
92
+ tbody tr:hover td{background:var(--surface2)}
93
+ tbody tr.selected td{background:var(--accent-dim)}
94
+ .badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:600}
95
+ .badge.pass{background:var(--green-dim);color:var(--green)}
96
+ .badge.fail{background:var(--red-dim);color:var(--red)}
97
+ .badge.flaky{background:var(--amber-dim);color:var(--amber)}
98
+ .badge.run{background:var(--purple-dim);color:var(--purple)}
99
+
100
+ /* ── Trend Chart ── */
101
+ .chart{display:flex;align-items:flex-end;gap:3px;height:60px}
102
+ .chart-bar{flex:1;min-width:3px;max-width:16px;border-radius:2px 2px 0 0;cursor:pointer;position:relative;transition:opacity .15s}
103
+ .chart-bar:hover{opacity:.75}
104
+ .chart-bar .tip{display:none;position:absolute;bottom:calc(100% + 4px);left:50%;transform:translateX(-50%);background:var(--surface3);border:1px solid var(--border);padding:4px 8px;border-radius:4px;font-size:10px;white-space:nowrap;z-index:10;pointer-events:none}
105
+ .chart-bar:hover .tip{display:block}
106
+
107
+ /* ── Suite Cards ── */
108
+ .suite-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px}
109
+ .suite-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:16px;transition:border-color .15s}
110
+ .suite-card:hover{border-color:var(--border-hi)}
111
+ .suite-card-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}
112
+ .suite-card-name{font-weight:600;font-size:13px}
113
+ .suite-card-count{font-size:10px;color:var(--text3)}
114
+ .suite-card-tests{list-style:none;margin-bottom:12px}
115
+ .suite-card-tests li{font-size:11px;color:var(--text2);padding:2px 0;padding-left:14px;position:relative}
116
+ .suite-card-tests li::before{content:">";position:absolute;left:0;color:var(--text3)}
117
+
118
+ /* ── Live Execution ── */
119
+ .live-panel{display:none;background:var(--surface);border:1px solid var(--purple);border-radius:var(--r);overflow:hidden;animation:fadeSlide .3s ease}
120
+ .live-panel.active{display:block}
121
+ @keyframes fadeSlide{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:translateY(0)}}
122
+ .live-header{padding:14px 16px;display:flex;align-items:center;gap:16px;border-bottom:1px solid var(--border);background:var(--purple-dim)}
123
+ .live-header .label{font-weight:600;color:var(--purple);font-size:12px;display:flex;align-items:center;gap:8px}
124
+ .live-header .label .dot{width:8px;height:8px;border-radius:50%;background:var(--purple);animation:pulse 1.5s infinite}
125
+ @keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
126
+ .live-project{display:flex;align-items:center;padding:2px 10px;background:rgba(255,255,255,.05);border-radius:4px;border:1px solid var(--border)}
127
+ .live-stats{display:flex;gap:16px;font-size:11px;color:var(--text2);margin-left:auto}
128
+ .live-stats span strong{color:var(--text)}
129
+ .live-progress{height:3px;background:var(--surface3)}
130
+ .live-progress-fill{height:100%;background:var(--purple);transition:width .4s;border-radius:0 2px 2px 0}
131
+ .live-tests{padding:12px 16px;display:flex;flex-direction:column;gap:2px;max-height:calc(100vh - 200px);overflow-y:auto}
132
+ .live-test{padding:10px 12px;border-radius:var(--r);border-left:3px solid var(--text3);background:var(--surface2);font-size:11px;transition:border-color .2s,padding .25s,max-height .35s cubic-bezier(.4,0,.2,1)}
133
+ .live-test.running{border-left-color:var(--purple)}
134
+ .live-test.passed{border-left-color:var(--green)}
135
+ .live-test.failed{border-left-color:var(--red)}
136
+ .live-test.collapsed{cursor:pointer;padding:6px 12px}
137
+ .live-test.collapsed:hover{background:var(--surface3)}
138
+ .live-test.collapsed .lt-meta,.live-test.collapsed .lt-actions,.live-test.collapsed .lt-screenshots{display:none}
139
+ .live-test.collapsed .lt-name{margin-bottom:0}
140
+ .live-test.collapsed .lt-summary{display:flex}
141
+ .live-test .lt-name{font-weight:600;margin-bottom:4px;display:flex;align-items:center;gap:6px}
142
+ .live-test .lt-summary{display:none;align-items:center;gap:8px;margin-left:auto;font-size:10px;color:var(--text3);font-family:var(--mono)}
143
+ .live-test .lt-summary .lt-dur{color:var(--text2)}
144
+ .live-test .lt-summary .lt-expand{color:var(--purple);font-size:9px;opacity:.6}
145
+ .live-test .lt-meta{color:var(--text2);font-size:10px;margin-bottom:6px}
146
+ .live-test .lt-icon{font-size:12px}
147
+ .lt-actions{max-height:180px;overflow-y:auto;border-top:1px solid var(--border);padding-top:6px;margin-top:4px}
148
+ .lt-step{display:flex;align-items:flex-start;gap:6px;padding:2px 0;font-size:10px;font-family:var(--mono);line-height:1.4}
149
+ .lt-step .step-icon{flex-shrink:0;width:14px;text-align:center}
150
+ .lt-step .step-icon.ok{color:var(--green)}
151
+ .lt-step .step-icon.fail{color:var(--red)}
152
+ .lt-step .step-icon.run{color:var(--purple)}
153
+ .lt-step .step-type{color:var(--purple);font-weight:600;flex-shrink:0}
154
+ .lt-step .step-detail{color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}
155
+ .lt-step .step-dur{color:var(--text3);flex-shrink:0;margin-left:auto}
156
+ .lt-screenshots{border-top:1px solid var(--border);margin-top:6px;padding-top:6px}
157
+ .lt-screenshots-toggle{display:flex;align-items:center;gap:6px;cursor:pointer;font-size:10px;color:var(--text3);font-family:var(--mono);padding:2px 0;user-select:none}
158
+ .lt-screenshots-toggle:hover{color:var(--text)}
159
+ .lt-screenshots-toggle .ss-arrow{transition:transform .2s;font-size:8px}
160
+ .lt-screenshots-toggle.open .ss-arrow{transform:rotate(90deg)}
161
+ .lt-screenshots-grid{display:none;grid-template-columns:repeat(auto-fill,minmax(80px,1fr));gap:6px;padding-top:6px}
162
+ .lt-screenshots-toggle.open+.lt-screenshots-grid{display:grid}
163
+ .lt-ss-thumb{position:relative;border-radius:4px;overflow:hidden;border:1px solid var(--border);cursor:pointer;aspect-ratio:16/10;background:var(--surface2)}
164
+ .lt-ss-thumb img{width:100%;height:100%;object-fit:cover;display:block}
165
+ .lt-ss-thumb:hover{border-color:var(--purple);box-shadow:0 0 0 1px var(--purple)}
166
+ .lt-ss-label{font-size:8px;color:var(--text3);font-family:var(--mono);text-align:center;padding:2px 2px 0;display:flex;align-items:center;justify-content:center;gap:3px;flex-wrap:wrap}
167
+ .lr-section-header{display:flex;align-items:center;justify-content:space-between;padding:8px 14px;margin:6px 0 2px;border-radius:6px;font-family:var(--mono);font-size:12px;font-weight:700;border-left:3px solid var(--purple)}
168
+ .lr-section-header.running{border-color:var(--purple);background:rgba(139,92,246,.08);color:var(--purple)}
169
+ .lr-section-header.pass{border-color:var(--green);background:rgba(52,211,153,.08);color:var(--green)}
170
+ .lr-section-header.fail{border-color:var(--red);background:rgba(248,113,113,.08);color:var(--red)}
171
+ .lr-section-stats{display:flex;align-items:center;gap:4px;font-size:10px;color:var(--text2);font-weight:400}
172
+ .lr-project-name{letter-spacing:.5px}
173
+ .lr-test-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:8px;padding:4px 0 8px}
174
+ .live-done{background:var(--green-dim);color:var(--green);text-align:center;padding:10px;font-weight:600;font-size:12px}
175
+ .live-done.has-failures{background:var(--red-dim);color:var(--red)}
176
+ .live-close{padding:4px 10px;font-size:10px;background:transparent;border:1px solid var(--border);border-radius:var(--r);color:var(--text2);cursor:pointer}
177
+ .live-close:hover{color:var(--text);border-color:var(--border-hi)}
178
+
179
+ .live-nav-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--purple);animation:pulse 1.5s infinite}
180
+ .spinner{display:inline-block;width:12px;height:12px;border:2px solid var(--border);border-top-color:var(--purple);border-radius:50%;animation:spin .6s linear infinite;vertical-align:middle}
181
+ .spinner-small{display:inline-block;width:8px;height:8px;border:1.5px solid var(--border);border-top-color:var(--purple);border-radius:50%;animation:spin .6s linear infinite;vertical-align:middle}
182
+ @keyframes spin{to{transform:rotate(360deg)}}
183
+
184
+ /* ── Screenshots ── */
185
+ .gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}
186
+ .gallery-item{background:var(--surface2);border:1px solid var(--border);border-radius:var(--r);overflow:hidden;cursor:pointer;transition:border-color .15s}
187
+ .gallery-item:hover{border-color:var(--accent)}
188
+ .gallery-item img{width:100%;height:150px;object-fit:cover;display:block}
189
+ .gallery-item .cap{padding:6px 10px;font-size:10px;color:var(--text2);display:flex;align-items:center;gap:4px}
190
+ .gallery-item .cap .cap-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}
191
+ .gallery-item .cap .ss-hash{flex-shrink:0}
192
+
193
+ /* ── Inline Run Detail ── */
194
+ .run-detail-row td{padding:0!important;border-bottom:2px solid var(--border-hi)}
195
+ .run-detail-row:hover td{background:transparent!important}
196
+ .rd-wrap{overflow:hidden;transition:max-height .35s cubic-bezier(.4,0,.2,1);max-height:0}
197
+ .rd-wrap.open{max-height:2000px}
198
+ .rd-inner{padding:16px 20px;background:var(--bg);border-left:3px solid var(--purple);margin:0 8px 8px}
199
+ .rd-test{margin-bottom:14px;background:var(--surface);border:1px solid var(--border);border-radius:var(--r);overflow:hidden}
200
+ .rd-test:last-child{margin-bottom:0}
201
+ .rd-test-head{display:flex;align-items:center;gap:10px;padding:10px 14px;border-bottom:1px solid var(--border);background:var(--surface2)}
202
+ .rd-test-name{font-weight:600;font-size:12px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
203
+ .rd-test-dur{font-size:11px;color:var(--text2);flex-shrink:0}
204
+ .rd-test-body{padding:10px 14px}
205
+ .rd-error-msg{font-size:11px;color:var(--red);padding:8px 12px;background:var(--red-dim);border-radius:var(--r);margin-bottom:10px;word-break:break-all;border-left:3px solid var(--red)}
206
+ .rd-shots{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}
207
+ .rd-shot{width:120px;border-radius:4px;overflow:hidden;border:1px solid var(--border);cursor:pointer;transition:border-color .15s,transform .15s}
208
+ .rd-shot:hover{border-color:var(--accent);transform:scale(1.03)}
209
+ .rd-shot img{width:100%;height:72px;object-fit:cover;display:block}
210
+ .rd-shot .rd-shot-cap{font-size:9px;color:var(--text3);padding:4px 6px;background:var(--surface2)}
211
+ .rd-shot .rd-shot-cap .cap-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
212
+ .rd-shot .rd-shot-cap .ss-hash{margin-top:3px}
213
+ .rd-shot.err-shot{border-color:rgba(239,68,68,.4)}
214
+ .rd-shot.err-shot .rd-shot-cap{color:var(--red)}
215
+ .rd-logs{margin-top:10px}
216
+ .rd-log-label{font-size:9px;font-weight:600;color:var(--text3);letter-spacing:.08em;text-transform:uppercase;margin-bottom:4px}
217
+ .rd-log-item{font-size:10px;padding:3px 8px;border-left:2px solid var(--border);margin-bottom:2px;color:var(--text2);word-break:break-all}
218
+ .rd-log-item.error{border-left-color:var(--red);color:var(--red)}
219
+ .rd-log-item.warning,.rd-log-item.warn{border-left-color:var(--amber);color:var(--amber)}
220
+ .rd-retries{font-size:10px;color:var(--amber);margin-bottom:6px}
221
+ .rd-summary{display:flex;gap:16px;margin-bottom:14px;padding:10px 14px;background:var(--surface);border:1px solid var(--border);border-radius:var(--r);font-size:11px;align-items:center}
222
+ .rd-summary .rd-s-label{color:var(--text3);font-size:9px;text-transform:uppercase;letter-spacing:.08em}
223
+ .rd-summary .rd-s-val{font-weight:700;font-size:16px;margin-top:2px}
224
+ tr.expanded td{background:var(--surface2)!important}
225
+ tr.expanded td:first-child{position:relative}
226
+ tr.expanded td:first-child::before{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;background:var(--purple)}
227
+
228
+ /* ── Empty ── */
229
+ .empty{text-align:center;padding:48px 24px;color:var(--text3)}
230
+ .empty-icon{font-size:36px;margin-bottom:8px;opacity:.5}
231
+
232
+ /* ── Modal ── */
233
+ .modal{position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:200;display:none;align-items:center;justify-content:center;padding:24px;cursor:pointer}
234
+ .modal.open{display:flex}
235
+ .modal img{max-width:100%;max-height:90vh;border-radius:var(--r);cursor:default}
236
+
237
+ /* ── Screenshot Hash Badge ── */
238
+ .ss-hash{display:inline-flex;align-items:center;gap:4px;padding:2px 7px;border-radius:10px;font-family:var(--mono);font-size:9px;font-weight:500;background:var(--surface3);border:1px solid var(--border);color:var(--text2);cursor:pointer;transition:all .15s;user-select:none;white-space:nowrap;vertical-align:middle}
239
+ .ss-hash:hover{border-color:var(--accent);color:var(--accent);background:var(--accent-dim)}
240
+ .ss-hash.copied{border-color:var(--green);color:var(--green);background:var(--green-dim)}
241
+ .ss-hash .ss-icon{font-size:10px;line-height:1}
242
+
243
+ /* ── Responsive ── */
244
+ @media(max-width:768px){
245
+ .sidebar{width:60px}.sidebar-logo h1,.sidebar-section-label,.nav-item span:not(.icon),.pool-info,.sidebar select,.sidebar-logo .ver{display:none}
246
+ .nav-item{justify-content:center;padding:12px}.nav-item .icon{width:auto}
247
+ .main{margin-left:60px}
248
+ .suite-grid,.gallery{grid-template-columns:1fr}
249
+ .lr-test-grid{grid-template-columns:1fr}
250
+ }
251
+ </style>
252
+ </head>
253
+ <body>
254
+
255
+ <aside class="sidebar">
256
+ <div class="sidebar-logo">
257
+ <h1><span>E2E</span> Runner</h1>
258
+ <div class="ver">Dashboard</div>
259
+ </div>
260
+
261
+ <div class="sidebar-section">
262
+ <div class="sidebar-section-label">Project</div>
263
+ <select id="projectSelect">
264
+ <option value="">All Projects</option>
265
+ </select>
266
+ </div>
267
+
268
+ <div class="sidebar-section">
269
+ <div class="sidebar-section-label">Navigation</div>
270
+ </div>
271
+ <div class="nav-item" data-view="live" id="navLive" style="display:none">
272
+ <i class="icon"><span class="live-nav-dot"></span></i><span>Live</span><span class="badge" id="liveBadge" style="background:var(--purple-dim);color:var(--purple)">0</span>
273
+ </div>
274
+ <div class="nav-item active" data-view="suites">
275
+ <i class="icon">&#9655;</i><span>Suites</span>
276
+ </div>
277
+ <div class="nav-item" data-view="runs">
278
+ <i class="icon">&#9776;</i><span>Runs</span>
279
+ </div>
280
+ <div class="nav-item" data-view="screenshots">
281
+ <i class="icon">&#9635;</i><span>Screenshots</span>
282
+ </div>
283
+
284
+ <div class="pool-status" id="poolStatus">
285
+ <div class="pool-info">
286
+ <span class="pool-dot off" id="poolDot"></span>
287
+ <strong>Pool</strong> <span id="poolLabel">--</span>
288
+ </div>
289
+ <div class="pool-info">Sessions: <strong id="poolSessions">-/-</strong></div>
290
+ <div class="pool-info" style="margin-top:6px">
291
+ <span class="ws-dot" id="wsDot" style="background:var(--red)"></span>
292
+ <span id="wsLabel" style="font-size:10px;color:var(--text3)">ws: connecting</span>
293
+ </div>
294
+ </div>
295
+ </aside>
296
+
297
+ <div class="main">
298
+
299
+ <!-- Live View -->
300
+ <div class="view" id="view-live">
301
+ <div class="live-panel active" id="livePanel">
302
+ <div class="live-header">
303
+ <div class="label"><span class="dot"></span> RUNNING</div>
304
+ <div class="live-project" id="liveProject" style="display:none">
305
+ <span style="color:var(--text3);font-size:11px">Project:</span>
306
+ <strong id="liveProjectName" style="font-size:12px;color:var(--text);margin-left:4px"></strong>
307
+ <span id="liveProjectCwd" style="font-size:10px;color:var(--text3);margin-left:8px;font-family:var(--mono);opacity:.7"></span>
308
+ </div>
309
+ <div class="live-stats">
310
+ <span>Total: <strong id="liveTotal">0</strong></span>
311
+ <span style="color:var(--green)">Pass: <strong id="livePass">0</strong></span>
312
+ <span style="color:var(--red)">Fail: <strong id="liveFail">0</strong></span>
313
+ <span style="color:var(--purple)">Active: <strong id="liveActive">0</strong></span>
314
+ </div>
315
+ </div>
316
+ <div class="live-progress"><div class="live-progress-fill" id="liveProgressFill" style="width:0"></div></div>
317
+ <div class="live-tests" id="liveTests"></div>
318
+ </div>
319
+ <div class="empty" id="liveEmpty">
320
+ <div class="empty-icon" style="font-size:48px;opacity:.3">&#9679;</div>
321
+ <p>No tests running. Start a test from the Suites view or another console.</p>
322
+ <p style="margin-top:8px;font-size:11px;color:var(--text3)">This view activates automatically when tests are detected.</p>
323
+ </div>
324
+ </div>
325
+
326
+ <!-- Suites View -->
327
+ <div class="view active" id="view-suites">
328
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px">
329
+ <div style="font-family:var(--sans);font-size:16px;font-weight:600">Test Suites</div>
330
+ <button class="btn primary" id="btnRunAll">Run All Tests</button>
331
+ </div>
332
+ <div class="suite-grid" id="suiteGrid"></div>
333
+ <div class="empty" id="suitesEmpty" style="display:none">
334
+ <div class="empty-icon">&#9655;</div>
335
+ <p>No test suites found.</p>
336
+ </div>
337
+ </div>
338
+
339
+ <!-- Runs View -->
340
+ <div class="view" id="view-runs">
341
+ <div style="font-family:var(--sans);font-size:16px;font-weight:600;margin-bottom:20px">Run History</div>
342
+ <div class="card">
343
+ <div class="card-label">Pass Rate Trend</div>
344
+ <div class="chart" id="trendChart"></div>
345
+ </div>
346
+ <div class="card" style="padding:0">
347
+ <div class="tbl-wrap">
348
+ <table>
349
+ <thead id="runsHead"><tr></tr></thead>
350
+ <tbody id="runsBody"></tbody>
351
+ </table>
352
+ </div>
353
+ </div>
354
+ <div class="empty" id="runsEmpty" style="display:none">
355
+ <div class="empty-icon">&#9776;</div>
356
+ <p>No runs recorded yet.</p>
357
+ </div>
358
+ </div>
359
+
360
+ <!-- Screenshots View -->
361
+ <div class="view" id="view-screenshots">
362
+ <div style="font-family:var(--sans);font-size:16px;font-weight:600;margin-bottom:20px">Screenshots</div>
363
+ <div class="gallery" id="screenshotGallery"></div>
364
+ <div class="empty" id="screenshotsEmpty" style="display:none">
365
+ <div class="empty-icon">&#9635;</div>
366
+ <p>Select a project to view screenshots.</p>
367
+ </div>
368
+ </div>
369
+
370
+ </div>
371
+
372
+ <div class="modal" id="modal"><img id="modalImg" src="" alt=""></div>
373
+
374
+ <script>
375
+ (function(){
376
+ 'use strict';
377
+ var $=function(s){return document.querySelector(s)};
378
+ var $$=function(s){return document.querySelectorAll(s)};
379
+
380
+ function el(tag,a,ch){
381
+ var e=document.createElement(tag);
382
+ if(a)Object.keys(a).forEach(function(k){
383
+ if(k==='className')e.className=a[k];
384
+ else if(k==='style')e.style.cssText=a[k];
385
+ else if(k.indexOf('on')===0)e.addEventListener(k.slice(2),a[k]);
386
+ else e.setAttribute(k,a[k]);
387
+ });
388
+ if(typeof ch==='string')e.textContent=ch;
389
+ else if(Array.isArray(ch))ch.forEach(function(c){if(c)e.appendChild(c)});
390
+ return e;
391
+ }
392
+ function css(n){return n.replace(/[^a-zA-Z0-9\-_]/g,'_')}
393
+ function dur(ms){return ms>=1000?(ms/1000).toFixed(1)+'s':ms+'ms'}
394
+ function fdate(iso){return iso?new Date(iso).toLocaleString():'--'}
395
+
396
+ /* ── Screenshot hash helpers ── */
397
+ var ssHashCache={};
398
+ async function ssHash(filePath){
399
+ if(ssHashCache[filePath])return ssHashCache[filePath];
400
+ var data=new TextEncoder().encode(filePath);
401
+ var buf=await crypto.subtle.digest('SHA-256',data);
402
+ var hex=Array.from(new Uint8Array(buf)).map(function(b){return b.toString(16).padStart(2,'0')}).join('');
403
+ var h=hex.slice(0,8);
404
+ ssHashCache[filePath]=h;
405
+ return h;
406
+ }
407
+ function ssHashSync(filePath){return ssHashCache[filePath]||null}
408
+ function copyHash(hash,badge){
409
+ navigator.clipboard.writeText('ss:'+hash).then(function(){
410
+ badge.classList.add('copied');
411
+ setTimeout(function(){badge.classList.remove('copied')},1200);
412
+ });
413
+ }
414
+ function createHashBadge(hash){
415
+ var badge=el('span',{className:'ss-hash',onclick:function(e){e.stopPropagation();copyHash(hash,badge)}},[
416
+ el('span',{className:'ss-icon'},'\u2318'),
417
+ document.createTextNode('ss:'+hash)
418
+ ]);
419
+ return badge;
420
+ }
421
+
422
+ /* ── State ── */
423
+ var S={
424
+ ws:null,project:null,view:'suites',selectedRun:null,
425
+ liveRuns:{},liveExpanded:new Set(),liveSSOpen:new Set()
426
+ };
427
+
428
+ /* ── Navigation ── */
429
+ $$('.nav-item').forEach(function(n){
430
+ n.addEventListener('click',function(){
431
+ $$('.nav-item').forEach(function(x){x.classList.remove('active')});
432
+ n.classList.add('active');
433
+ S.view=n.dataset.view;
434
+ $$('.view').forEach(function(v){v.classList.remove('active')});
435
+ $('#view-'+S.view).classList.add('active');
436
+ });
437
+ });
438
+ function showView(v){
439
+ S.view=v;
440
+ $$('.nav-item').forEach(function(n){n.classList.toggle('active',n.dataset.view===v)});
441
+ $$('.view').forEach(function(x){x.classList.remove('active')});
442
+ $('#view-'+v).classList.add('active');
443
+ }
444
+
445
+ /* ── WebSocket ── */
446
+ function connectWS(){
447
+ var proto=location.protocol==='https:'?'wss:':'ws:';
448
+ S.ws=new WebSocket(proto+'//'+location.host);
449
+ S.ws.onopen=function(){$('#wsDot').style.background='var(--green)';$('#wsLabel').textContent='ws: connected';$('#wsLabel').style.color='var(--green)'};
450
+ S.ws.onclose=function(){$('#wsDot').style.background='var(--red)';$('#wsLabel').textContent='ws: disconnected';$('#wsLabel').style.color='var(--text3)';setTimeout(connectWS,3000)};
451
+ S.ws.onerror=function(){};
452
+ S.ws.onmessage=function(e){try{handleWS(JSON.parse(e.data))}catch(x){}};
453
+ }
454
+
455
+ function getLiveRun(m){
456
+ var rid=m.runId;if(!rid)return null;
457
+ if(!S.liveRuns[rid])S.liveRuns[rid]={on:true,done:false,total:0,completed:0,passed:0,failed:0,active:0,tests:{},project:m.project||null,cwd:m.cwd||null,runId:rid,_lastEvent:Date.now()};
458
+ S.liveRuns[rid]._lastEvent=Date.now();
459
+ return S.liveRuns[rid];
460
+ }
461
+ function anyLiveRunning(){for(var k in S.liveRuns)if(S.liveRuns[k].on)return true;return false}
462
+
463
+ /* Staleness guard: if a live run gets no events for 15s and all tests are complete, auto-finish it */
464
+ setInterval(function(){
465
+ var changed=false;
466
+ for(var k in S.liveRuns){
467
+ var r=S.liveRuns[k];
468
+ if(r.on&&!r.done&&Date.now()-r._lastEvent>15000){
469
+ if(r.completed>=r.total&&r.total>0){r.on=false;r.done=true;r.active=0;changed=true}
470
+ else if(Date.now()-r._lastEvent>30000){r.on=false;r.done=true;r.stale=true;r.active=0;changed=true}
471
+ }
472
+ }
473
+ if(changed)renderLive();
474
+ },5000);
475
+
476
+ function handleWS(m){
477
+ switch(m.event){
478
+ case 'pool:status':renderPool(m.data);break;
479
+ case 'run:start':
480
+ var r=getLiveRun(m);
481
+ r.total=m.total;r.on=true;r.done=false;
482
+ S.liveExpanded=new Set();S.liveSSOpen=new Set();
483
+ showView('live');renderLive();break;
484
+ case 'test:start':
485
+ var r=getLiveRun(m);if(!r)break;
486
+ r.active=m.activeCount;
487
+ r.tests[m.name]={status:'running',actions:0,totalActions:0,error:null,actionLog:[],screenshots:[]};
488
+ renderLive();break;
489
+ case 'test:action':
490
+ var r=getLiveRun(m);if(!r||!r.tests[m.name])break;
491
+ var t=r.tests[m.name];
492
+ t.actions=m.actionIndex+1;t.totalActions=m.totalActions;t.actionType=m.action.type;
493
+ t.actionLog.push({type:m.action.type,selector:m.action.selector||null,value:m.action.value||null,text:m.action.text||null,success:m.success,duration:m.duration,error:m.error||null});
494
+ if(m.screenshotPath)t.screenshots.push(m.screenshotPath);
495
+ renderLive();break;
496
+ case 'test:retry':
497
+ var r=getLiveRun(m);if(!r||!r.tests[m.name])break;
498
+ r.tests[m.name].retry=m.attempt+'/'+m.maxAttempts;
499
+ renderLive();break;
500
+ case 'test:complete':
501
+ var r=getLiveRun(m);if(!r)break;
502
+ r.completed++;
503
+ if(m.success){r.passed++;if(r.tests[m.name])r.tests[m.name].status='passed'}
504
+ else{r.failed++;if(r.tests[m.name]){r.tests[m.name].status='failed';r.tests[m.name].error=m.error}}
505
+ if(r.tests[m.name]){
506
+ r.tests[m.name].duration=m.duration;
507
+ if(m.screenshots&&m.screenshots.length)r.tests[m.name].screenshots=m.screenshots;
508
+ if(m.errorScreenshot)r.tests[m.name].errorScreenshot=m.errorScreenshot;
509
+ }
510
+ r.active=Math.max(0,r.active-1);
511
+ renderLive();break;
512
+ case 'run:complete':
513
+ var r=getLiveRun(m);if(r){r.on=false;r.done=true;r.active=0}
514
+ renderLive();refreshRuns();refreshProjects();break;
515
+ case 'run:error':
516
+ var r=getLiveRun(m);if(r){r.on=false;r.done=true;r.tests.__error={status:'failed',error:m.error}}
517
+ renderLive();break;
518
+ case 'db:updated':
519
+ refreshRuns();refreshProjects();refreshScreenshots();break;
520
+ }
521
+ }
522
+
523
+ /* ── API ── */
524
+ function api(p){return fetch(p).then(function(r){return r.json()})}
525
+ function triggerRun(suite,projectId){
526
+ if(anyLiveRunning())return;
527
+ var body={};
528
+ if(suite)body.suite=suite;
529
+ if(projectId)body.projectId=projectId;
530
+ else if(S.project)body.projectId=S.project;
531
+ fetch('/api/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
532
+ }
533
+
534
+ /* ── Pool ── */
535
+ function renderPool(d){
536
+ if(!d)return;
537
+ $('#poolDot').className='pool-dot '+(d.error||!d.available?'off':'on');
538
+ $('#poolLabel').textContent=d.error?'offline':d.available?'ready':'busy';
539
+ $('#poolSessions').textContent=(d.running||0)+'/'+(d.maxConcurrent||0);
540
+ }
541
+ function refreshStatus(){api('/api/status').then(function(d){renderPool(d.pool)}).catch(function(){})}
542
+
543
+ /* ── Projects ── */
544
+ function refreshProjects(){
545
+ api('/api/db/projects').then(function(projects){
546
+ var sel=$('#projectSelect'),prev=sel.value;
547
+ while(sel.options.length>1)sel.remove(1);
548
+ if(Array.isArray(projects))projects.forEach(function(p){
549
+ var o=document.createElement('option');o.value=p.id;o.textContent=p.name;sel.appendChild(o);
550
+ });
551
+ sel.value=prev||'';
552
+ }).catch(function(){});
553
+ }
554
+ $('#projectSelect').addEventListener('change',function(){
555
+ S.project=this.value?parseInt(this.value,10):null;
556
+ S.selectedRun=null;
557
+ refreshRuns();refreshSuites();refreshScreenshots();
558
+ });
559
+
560
+ /* ── Suites ── */
561
+ function refreshSuites(){
562
+ var grid=$('#suiteGrid'),empty=$('#suitesEmpty');
563
+ grid.textContent='';
564
+
565
+ if(S.project){
566
+ // Single project — fetch its suites
567
+ api('/api/db/projects/'+S.project+'/suites').then(function(suites){
568
+ if(!Array.isArray(suites)||suites.length===0){empty.style.display='block';empty.querySelector('p').textContent='No test suites found for this project.';return}
569
+ empty.style.display='none';
570
+ renderSuiteCards(grid,suites,S.project);
571
+ }).catch(function(){});
572
+ } else {
573
+ // All projects — fetch each project's suites
574
+ api('/api/db/projects').then(function(projects){
575
+ if(!Array.isArray(projects)||projects.length===0){empty.style.display='block';empty.querySelector('p').textContent='No projects registered yet.';return}
576
+ var loaded=0,hasAny=false;
577
+ projects.forEach(function(p){
578
+ api('/api/db/projects/'+p.id+'/suites').then(function(suites){
579
+ loaded++;
580
+ if(Array.isArray(suites)&&suites.length>0){
581
+ hasAny=true;
582
+ var label=el('div',{style:'grid-column:1/-1;font-family:var(--sans);font-size:13px;font-weight:600;margin-top:'+(grid.children.length?'16':'0')+'px;padding-bottom:6px;border-bottom:1px solid var(--border);color:var(--text2)'},p.name);
583
+ grid.appendChild(label);
584
+ renderSuiteCards(grid,suites,p.id);
585
+ }
586
+ if(loaded===projects.length&&!hasAny){empty.style.display='block';empty.querySelector('p').textContent='No test suites found.'}
587
+ }).catch(function(){loaded++;});
588
+ });
589
+ }).catch(function(){});
590
+ }
591
+ }
592
+ function renderSuiteCards(container,suites,projectId){
593
+ suites.forEach(function(s){
594
+ var tests=el('ul',{className:'suite-card-tests'});
595
+ (s.tests||[]).forEach(function(t){tests.appendChild(el('li',null,t))});
596
+ var pid=projectId;
597
+ var card=el('div',{className:'suite-card'},[
598
+ el('div',{className:'suite-card-head'},[
599
+ el('div',{className:'suite-card-name'},s.name),
600
+ el('span',{className:'suite-card-count'},s.testCount+' tests')
601
+ ]),
602
+ tests,
603
+ el('button',{className:'btn sm primary',onclick:function(){triggerRun(s.name,pid)}},'Run Suite')
604
+ ]);
605
+ container.appendChild(card);
606
+ });
607
+ }
608
+
609
+ /* ── Runs ── */
610
+ function refreshRuns(){
611
+ var url=S.project?'/api/db/projects/'+S.project+'/runs':'/api/db/runs';
612
+ api(url).then(function(rows){
613
+ var chart=$('#trendChart'),body=$('#runsBody'),empty=$('#runsEmpty'),head=$('#runsHead');
614
+ chart.textContent='';body.textContent='';
615
+ if(!Array.isArray(rows)||rows.length===0){empty.style.display='block';head.parentNode.parentNode.style.display='none';return}
616
+ empty.style.display='none';head.parentNode.parentNode.style.display='';
617
+
618
+ // Thead
619
+ var htr=document.createElement('tr');
620
+ var cols=[];
621
+ if(!S.project)cols.push('Project');
622
+ cols=cols.concat(['Suite','Date','Total','Pass','Fail','Rate','Time']);
623
+ cols.forEach(function(c){htr.appendChild(el('th',null,c))});
624
+ head.textContent='';head.appendChild(htr);
625
+ var colSpan=cols.length;
626
+
627
+ // Chart
628
+ rows.slice(0,40).slice().reverse().forEach(function(r){
629
+ var rate=parseFloat(r.pass_rate)||0;
630
+ var color=rate>=90?'var(--green)':rate>=70?'var(--amber)':'var(--red)';
631
+ var bar=el('div',{className:'chart-bar',style:'height:'+Math.max(rate,4)+'%;background:'+color});
632
+ bar.appendChild(el('div',{className:'tip'},(r.project_name||'')+(r.suite_name?' / '+r.suite_name:'')+': '+r.pass_rate));
633
+ chart.appendChild(bar);
634
+ });
635
+
636
+ // Rows
637
+ rows.forEach(function(r){
638
+ var tr=document.createElement('tr');
639
+ tr.dataset.runId=r.id;
640
+ if(r.id===S.selectedRun)tr.classList.add('expanded');
641
+ if(!S.project)tr.appendChild(el('td',{style:'font-weight:600'},r.project_name||'-'));
642
+ tr.appendChild(el('td',{style:'color:var(--accent)'},r.suite_name||'all'));
643
+ tr.appendChild(el('td',null,fdate(r.generated_at)));
644
+ tr.appendChild(el('td',null,String(r.total||0)));
645
+ tr.appendChild(el('td',{style:'color:var(--green)'},String(r.passed||0)));
646
+ tr.appendChild(el('td',{style:'color:var(--red)'},String(r.failed||0)));
647
+ var rv=parseFloat(r.pass_rate)||0;
648
+ tr.appendChild(el('td',{style:'font-weight:600;color:'+(rv>=90?'var(--green)':rv>=70?'var(--amber)':'var(--red)')},r.pass_rate||'-'));
649
+ tr.appendChild(el('td',{style:'color:var(--text2)'},r.duration||'-'));
650
+ tr.addEventListener('click',function(){toggleDetail(r.id,tr,colSpan)});
651
+ body.appendChild(tr);
652
+
653
+ // If this run was already expanded, re-expand it
654
+ if(r.id===S.selectedRun){
655
+ var detailTr=createDetailRow(colSpan);
656
+ body.appendChild(detailTr);
657
+ loadDetailInline(r.id,detailTr);
658
+ }
659
+ });
660
+ }).catch(function(){});
661
+ }
662
+
663
+ function createDetailRow(colSpan){
664
+ var detailTr=document.createElement('tr');
665
+ detailTr.className='run-detail-row';
666
+ var td=document.createElement('td');
667
+ td.setAttribute('colspan',colSpan);
668
+ var wrap=el('div',{className:'rd-wrap'});
669
+ var inner=el('div',{className:'rd-inner'});
670
+ inner.innerHTML='<div style="color:var(--text3);font-size:11px"><span class="spinner-small"></span> Loading...</div>';
671
+ wrap.appendChild(inner);
672
+ td.appendChild(wrap);
673
+ detailTr.appendChild(td);
674
+ return detailTr;
675
+ }
676
+
677
+ function toggleDetail(id,clickedTr,colSpan){
678
+ // If already expanded, collapse
679
+ if(S.selectedRun===id){
680
+ var existing=clickedTr.nextElementSibling;
681
+ if(existing&&existing.classList.contains('run-detail-row')){
682
+ var w=existing.querySelector('.rd-wrap');
683
+ if(w)w.classList.remove('open');
684
+ clickedTr.classList.remove('expanded');
685
+ setTimeout(function(){if(existing.parentNode)existing.parentNode.removeChild(existing)},350);
686
+ }
687
+ S.selectedRun=null;
688
+ return;
689
+ }
690
+
691
+ // Collapse any other open detail
692
+ var prevTr=document.querySelector('#runsBody tr.expanded');
693
+ if(prevTr){
694
+ prevTr.classList.remove('expanded');
695
+ var prevDetail=prevTr.nextElementSibling;
696
+ if(prevDetail&&prevDetail.classList.contains('run-detail-row')){
697
+ var pw=prevDetail.querySelector('.rd-wrap');
698
+ if(pw)pw.classList.remove('open');
699
+ setTimeout(function(){if(prevDetail.parentNode)prevDetail.parentNode.removeChild(prevDetail)},350);
700
+ }
701
+ }
702
+
703
+ // Expand new
704
+ S.selectedRun=id;
705
+ clickedTr.classList.add('expanded');
706
+ var detailTr=createDetailRow(colSpan);
707
+ clickedTr.parentNode.insertBefore(detailTr,clickedTr.nextSibling);
708
+
709
+ // Animate open
710
+ requestAnimationFrame(function(){
711
+ requestAnimationFrame(function(){
712
+ var w2=detailTr.querySelector('.rd-wrap');
713
+ if(w2)w2.classList.add('open');
714
+ });
715
+ });
716
+
717
+ loadDetailInline(id,detailTr);
718
+ }
719
+
720
+ function loadDetailInline(id,detailTr){
721
+ api('/api/db/runs/'+id).then(function(d){
722
+ if(d.error)return;
723
+ var inner=detailTr.querySelector('.rd-inner');
724
+ inner.textContent='';
725
+
726
+ var results=d.results||[];
727
+
728
+ // Summary bar
729
+ var summ=el('div',{className:'rd-summary'},[
730
+ el('div',null,[el('div',{className:'rd-s-label'},'Suite'),el('div',{className:'rd-s-val',style:'font-size:13px;color:var(--accent)'},d.suiteName||'all')]),
731
+ el('div',null,[el('div',{className:'rd-s-label'},'Total'),el('div',{className:'rd-s-val'},String(d.summary.total))]),
732
+ el('div',null,[el('div',{className:'rd-s-label'},'Passed'),el('div',{className:'rd-s-val',style:'color:var(--green)'},String(d.summary.passed))]),
733
+ el('div',null,[el('div',{className:'rd-s-label'},'Failed'),el('div',{className:'rd-s-val',style:'color:var(--red)'},String(d.summary.failed))]),
734
+ el('div',null,[el('div',{className:'rd-s-label'},'Duration'),el('div',{className:'rd-s-val',style:'font-size:13px;color:var(--text2)'},d.summary.duration||'-')])
735
+ ]);
736
+ inner.appendChild(summ);
737
+
738
+ // Test result cards
739
+ results.forEach(function(r){
740
+ var d2=r.durationMs?dur(r.durationMs):r.endTime&&r.startTime?dur(new Date(r.endTime)-new Date(r.startTime)):'-';
741
+ var flaky=r.success&&r.attempt>1;
742
+
743
+ // Header: badge + name + duration
744
+ var badges=el('div',{style:'display:flex;gap:6px;align-items:center;flex-shrink:0'});
745
+ badges.appendChild(el('span',{className:'badge '+(r.success?'pass':'fail')},r.success?'PASS':'FAIL'));
746
+ if(flaky)badges.appendChild(el('span',{className:'badge flaky'},'FLAKY'));
747
+
748
+ var head=el('div',{className:'rd-test-head'},[
749
+ badges,
750
+ el('div',{className:'rd-test-name'},r.name),
751
+ el('div',{className:'rd-test-dur'},d2)
752
+ ]);
753
+
754
+ // Body content
755
+ var body=el('div',{className:'rd-test-body'});
756
+
757
+ // Retries info
758
+ if(r.maxAttempts>1){
759
+ body.appendChild(el('div',{className:'rd-retries'},'Attempt '+r.attempt+' of '+r.maxAttempts));
760
+ }
761
+
762
+ // Error message
763
+ if(r.error){
764
+ body.appendChild(el('div',{className:'rd-error-msg'},r.error));
765
+ }
766
+
767
+ // Screenshots per test
768
+ var shots=[];
769
+ var hashes=r.screenshotHashes||{};
770
+ (r.screenshots||[]).forEach(function(p){
771
+ var fname=p.split('/').pop();
772
+ shots.push({path:p,label:fname,type:'screenshot',hash:hashes[p]||null});
773
+ });
774
+ if(r.errorScreenshot){
775
+ var ename=r.errorScreenshot.split('/').pop();
776
+ shots.push({path:r.errorScreenshot,label:ename,type:'error',hash:hashes[r.errorScreenshot]||null});
777
+ }
778
+ if(shots.length){
779
+ var shotsWrap=el('div',{className:'rd-shots'});
780
+ shots.forEach(function(s){
781
+ var src='/api/image?path='+encodeURIComponent(s.path);
782
+ var img=document.createElement('img');img.src=src;img.alt=s.label;img.loading='lazy';
783
+ var capEl=el('div',{className:'rd-shot-cap'},[el('span',{className:'cap-name'},s.label)]);
784
+ if(s.hash){capEl.appendChild(createHashBadge(s.hash))}
785
+ else{(function(c,fp){ssHash(fp).then(function(h){c.appendChild(createHashBadge(h))})})(capEl,s.path)}
786
+ var shotEl=el('div',{className:'rd-shot'+(s.type==='error'?' err-shot':''),onclick:function(e){e.stopPropagation();openModal(src)}},[
787
+ img,capEl
788
+ ]);
789
+ shotsWrap.appendChild(shotEl);
790
+ });
791
+ body.appendChild(shotsWrap);
792
+ }
793
+
794
+ // Console logs (errors and warnings only)
795
+ var cIssues=(r.consoleLogs||[]).filter(function(l){return l.type==='error'||l.type==='warn'||l.type==='warning'});
796
+ if(cIssues.length){
797
+ var logSec=el('div',{className:'rd-logs'});
798
+ logSec.appendChild(el('div',{className:'rd-log-label'},'Console'));
799
+ cIssues.forEach(function(l){
800
+ logSec.appendChild(el('div',{className:'rd-log-item '+l.type},'['+l.type+'] '+l.text));
801
+ });
802
+ body.appendChild(logSec);
803
+ }
804
+
805
+ // Network errors
806
+ if(r.networkErrors&&r.networkErrors.length){
807
+ var netSec=el('div',{className:'rd-logs'});
808
+ netSec.appendChild(el('div',{className:'rd-log-label'},'Network Errors'));
809
+ r.networkErrors.forEach(function(ne){
810
+ netSec.appendChild(el('div',{className:'rd-log-item error'},'['+ne.error+'] '+ne.url));
811
+ });
812
+ body.appendChild(netSec);
813
+ }
814
+
815
+ var testCard=el('div',{className:'rd-test'},[head,body]);
816
+ inner.appendChild(testCard);
817
+ });
818
+
819
+ // Re-trigger open animation if not yet open
820
+ var w=detailTr.querySelector('.rd-wrap');
821
+ if(w&&!w.classList.contains('open')){
822
+ requestAnimationFrame(function(){w.classList.add('open')});
823
+ }
824
+ }).catch(function(err){
825
+ var inner=detailTr.querySelector('.rd-inner');
826
+ if(inner)inner.textContent='Failed to load run detail';
827
+ });
828
+ }
829
+
830
+ /* ── Screenshots ── */
831
+ function refreshScreenshots(){
832
+ var gal=$('#screenshotGallery'),empty=$('#screenshotsEmpty');
833
+ gal.textContent='';
834
+ if(!S.project){empty.style.display='block';empty.querySelector('p').textContent='Select a project to view screenshots.';return}
835
+ api('/api/db/projects/'+S.project+'/screenshots').then(function(files){
836
+ if(!Array.isArray(files)||!files.length){empty.style.display='block';empty.querySelector('p').textContent='No screenshots for this project.';return}
837
+ empty.style.display='none';
838
+ files.forEach(function(f){
839
+ var src='/api/image?path='+encodeURIComponent(f.path);
840
+ var img=document.createElement('img');img.src=src;img.alt=f.name;img.loading='lazy';
841
+ var capEl=el('div',{className:'cap'},[el('span',{className:'cap-name'},f.name)]);
842
+ (function(c,fp){
843
+ ssHash(fp).then(function(h){c.appendChild(createHashBadge(h))});
844
+ })(capEl,f.path);
845
+ var item=el('div',{className:'gallery-item',onclick:function(){openModal(src)}},[
846
+ img,capEl
847
+ ]);gal.appendChild(item);
848
+ });
849
+ }).catch(function(){});
850
+ }
851
+
852
+ /* ── Live Execution ── */
853
+ function renderLive(){
854
+ var panel=$('#livePanel'),grid=$('#liveTests'),navLive=$('#navLive'),liveEmpty=$('#liveEmpty');
855
+ var runs=S.liveRuns;
856
+ var runIds=Object.keys(runs);
857
+
858
+ if(runIds.length===0){
859
+ panel.classList.remove('active');
860
+ navLive.style.display='none';
861
+ liveEmpty.style.display='block';
862
+ return;
863
+ }
864
+
865
+ navLive.style.display='';
866
+ liveEmpty.style.display='none';
867
+ panel.classList.add('active');
868
+
869
+ // Aggregate stats across all runs
870
+ var gTotal=0,gCompleted=0,gPassed=0,gFailed=0,gActive=0,gRunning=false,gDone=true;
871
+ runIds.forEach(function(rid){
872
+ var r=runs[rid];gTotal+=r.total;gCompleted+=r.completed;gPassed+=r.passed;gFailed+=r.failed;gActive+=r.active;
873
+ if(r.on)gRunning=true;if(!r.done)gDone=false;
874
+ });
875
+
876
+ var badgeActive=0;
877
+ runIds.forEach(function(rid){var r=runs[rid];Object.keys(r.tests).forEach(function(n){if(n!=='__error'&&r.tests[n].status==='running')badgeActive++})});
878
+ $('#liveBadge').textContent=gRunning?badgeActive:gCompleted;
879
+ $('#liveBadge').style.background=gRunning?'var(--purple-dim)':gFailed>0?'var(--red-dim)':'var(--green-dim)';
880
+ $('#liveBadge').style.color=gRunning?'var(--purple)':gFailed>0?'var(--red)':'var(--green)';
881
+
882
+ $('#liveTotal').textContent=gTotal;
883
+ $('#livePass').textContent=gPassed;
884
+ $('#liveFail').textContent=gFailed;
885
+ $('#liveActive').textContent=gActive;
886
+ var pct=gTotal>0?gCompleted/gTotal*100:0;
887
+ $('#liveProgressFill').style.width=pct+'%';
888
+
889
+ // Hide single-project info (now shown per-section)
890
+ $('#liveProject').style.display='none';
891
+
892
+ // Header state
893
+ var lbl=panel.querySelector('.live-header .label');
894
+ var anyStale=runIds.some(function(rid){return runs[rid].stale});
895
+ if(!gRunning&&gDone){
896
+ lbl.textContent=anyStale?'COMPLETED (connection lost)':gFailed>0?'COMPLETED WITH FAILURES':'ALL TESTS PASSED';
897
+ lbl.style.color=anyStale?'var(--yellow)':gFailed>0?'var(--red)':'var(--green)';
898
+ var dot=lbl.querySelector('.dot');if(dot)dot.remove();
899
+ $('#liveProgressFill').style.background=anyStale?'var(--yellow)':gFailed>0?'var(--red)':'var(--green)';
900
+ } else {
901
+ if(!lbl.querySelector('.dot')){
902
+ lbl.textContent='';
903
+ var d=el('span',{className:'dot'});lbl.appendChild(d);lbl.appendChild(document.createTextNode(' RUNNING'));
904
+ }
905
+ lbl.style.color='var(--purple)';
906
+ $('#liveProgressFill').style.background='var(--purple)';
907
+ }
908
+
909
+ // Render per-run sections
910
+ grid.textContent='';
911
+ runIds.forEach(function(rid){
912
+ var L=runs[rid];
913
+ // Project section header
914
+ var projLabel=L.project||(L.cwd?L.cwd.split('/').pop():'Run');
915
+ var runStatus=L.done?(L.failed>0?'fail':'pass'):'running';
916
+ var sectionHeader=el('div',{className:'lr-section-header '+runStatus},[
917
+ el('span',{className:'lr-project-name'},projLabel),
918
+ el('span',{className:'lr-section-stats'},[
919
+ el('span',{},L.completed+'/'+L.total),
920
+ L.failed>0?el('span',{style:'color:var(--red);margin-left:6px'},L.failed+' failed'):null,
921
+ L.on?el('span',{className:'spinner-small',style:'margin-left:6px'}):null
922
+ ])
923
+ ]);
924
+ grid.appendChild(sectionHeader);
925
+
926
+ var testGrid=el('div',{className:'lr-test-grid'});
927
+ var names=Object.keys(L.tests);
928
+ names.forEach(function(name){
929
+ if(name==='__error')return;
930
+ var t=L.tests[name];
931
+ var testKey=rid+'::'+name;
932
+ var iconText=t.status==='passed'?'\u2714':t.status==='failed'?'\u2718':'\u25CF';
933
+ var iconColor=t.status==='passed'?'color:var(--green)':t.status==='failed'?'color:var(--red)':'color:var(--purple)';
934
+ var meta='';
935
+ if(t.status==='running'){
936
+ meta=t.actionType?('Step '+(t.actions||0)+'/'+(t.totalActions||'?')):'starting...';
937
+ if(t.retry)meta='Retry '+t.retry;
938
+ } else if(t.status==='passed'){meta=t.duration||'done'}
939
+ else if(t.status==='failed'){meta=t.error||'failed'}
940
+ // Action log
941
+ var stepsEl=el('div',{className:'lt-actions'});
942
+ if(t.actionLog&&t.actionLog.length>0){
943
+ t.actionLog.forEach(function(a){
944
+ var detail=a.selector||a.value||a.text||'';
945
+ var durText=a.duration!=null?(a.duration<1000?a.duration+'ms':(a.duration/1000).toFixed(1)+'s'):'';
946
+ stepsEl.appendChild(el('div',{className:'lt-step'},[
947
+ el('span',{className:'step-icon '+(a.success?'ok':'fail')},a.success?'\u2714':'\u2718'),
948
+ el('span',{className:'step-type'},a.type),
949
+ el('span',{className:'step-detail'},detail),
950
+ el('span',{className:'step-dur'},durText)
951
+ ]));
952
+ });
953
+ if(t.status==='running'&&t.actions<t.totalActions){
954
+ stepsEl.appendChild(el('div',{className:'lt-step'},[el('span',{className:'step-icon run spinner-small'}),el('span',{className:'step-type',style:'opacity:.6'},'waiting...')]));
955
+ }
956
+ } else if(t.status==='running'){
957
+ stepsEl.appendChild(el('div',{className:'lt-step'},[el('span',{className:'step-icon run spinner-small'}),el('span',{className:'step-type',style:'opacity:.6'},'connecting...')]));
958
+ }
959
+ var isFinished=t.status==='passed'||t.status==='failed';
960
+ var isCollapsed=isFinished&&!S.liveExpanded.has(testKey);
961
+ var summaryEl=el('div',{className:'lt-summary'},[
962
+ el('span',{className:'lt-dur'},t.duration||''),
963
+ el('span',{className:'lt-expand'},isCollapsed?'\u25BC':'\u25B2')
964
+ ]);
965
+ // Screenshots section
966
+ var ssEl=null;
967
+ var allSS=(t.screenshots||[]).slice();
968
+ if(t.errorScreenshot)allSS.push(t.errorScreenshot);
969
+ if(allSS.length>0){
970
+ var ssOpen=S.liveSSOpen&&S.liveSSOpen.has(testKey);
971
+ var toggle=el('div',{className:'lt-screenshots-toggle'+(ssOpen?' open':'')},[
972
+ el('span',{className:'ss-arrow'},'\u25B6'),
973
+ el('span',{},'Screenshots ('+allSS.length+')')
974
+ ]);
975
+ var ssGridEl=el('div',{className:'lt-screenshots-grid'});
976
+ allSS.forEach(function(ssPath){
977
+ var fname=ssPath.split('/').pop();
978
+ var isErr=t.errorScreenshot&&ssPath===t.errorScreenshot;
979
+ var thumb=el('div',{className:'lt-ss-thumb'});
980
+ var img=document.createElement('img');
981
+ img.src='/api/image?path='+encodeURIComponent(ssPath);
982
+ img.alt=fname;img.loading='lazy';
983
+ if(isErr)thumb.style.borderColor='var(--red)';
984
+ thumb.appendChild(img);
985
+ thumb.addEventListener('click',function(e){e.stopPropagation();openModal('/api/image?path='+encodeURIComponent(ssPath),fname)});
986
+ var labelEl=el('div',{className:'lt-ss-label'},[el('span',{style:'overflow:hidden;text-overflow:ellipsis;white-space:nowrap'},fname)]);
987
+ // Compute hash async and append badge
988
+ (function(lbl,sp){
989
+ ssHash(sp).then(function(h){lbl.appendChild(createHashBadge(h))});
990
+ })(labelEl,ssPath);
991
+ ssGridEl.appendChild(el('div',{},[thumb,labelEl]));
992
+ });
993
+ toggle.addEventListener('click',function(e){
994
+ e.stopPropagation();
995
+ if(S.liveSSOpen.has(testKey))S.liveSSOpen.delete(testKey);else S.liveSSOpen.add(testKey);
996
+ toggle.classList.toggle('open');
997
+ ssGridEl.style.display=ssGridEl.style.display==='grid'?'none':'grid';
998
+ });
999
+ if(ssOpen)ssGridEl.style.display='grid';
1000
+ ssEl=el('div',{className:'lt-screenshots'},[toggle,ssGridEl]);
1001
+ }
1002
+ var card=el('div',{className:'live-test '+t.status+(isCollapsed?' collapsed':'')},[
1003
+ el('div',{className:'lt-name'},[
1004
+ t.status==='running'?el('span',{className:'spinner'}):el('span',{className:'lt-icon',style:iconColor},iconText),
1005
+ document.createTextNode(' '+name),
1006
+ summaryEl
1007
+ ]),
1008
+ el('div',{className:'lt-meta'},meta),
1009
+ stepsEl
1010
+ ]);
1011
+ if(ssEl)card.appendChild(ssEl);
1012
+ if(isFinished){
1013
+ card.addEventListener('click',function(){
1014
+ if(S.liveExpanded.has(testKey))S.liveExpanded.delete(testKey);
1015
+ else S.liveExpanded.add(testKey);
1016
+ renderLive();
1017
+ });
1018
+ }
1019
+ testGrid.appendChild(card);
1020
+ if(!isCollapsed)stepsEl.scrollTop=stepsEl.scrollHeight;
1021
+ });
1022
+ grid.appendChild(testGrid);
1023
+ });
1024
+ }
1025
+
1026
+ /* ── Actions ── */
1027
+ $('#btnRunAll').addEventListener('click',function(){triggerRun()});
1028
+
1029
+ /* ── Modal ── */
1030
+ function openModal(src){$('#modalImg').src=src;$('#modal').classList.add('open')}
1031
+ $('#modal').addEventListener('click',function(){$('#modal').classList.remove('open')});
1032
+ document.addEventListener('keydown',function(e){if(e.key==='Escape')$('#modal').classList.remove('open')});
1033
+
1034
+ /* ── Init ── */
1035
+ connectWS();
1036
+ refreshStatus();
1037
+ refreshProjects();
1038
+ refreshSuites();
1039
+ refreshRuns();
1040
+ refreshScreenshots();
1041
+ })();
1042
+ </script>
1043
+ </body>
1044
+ </html>