@matware/e2e-runner 1.0.3 → 1.1.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.
@@ -0,0 +1,1281 @@
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;display:flex;flex-direction:column}
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
+ #view-live.active{display:flex;flex-direction:column;flex:1;min-height:calc(100vh - 0px);padding:16px}
63
+
64
+ /* ── Buttons ── */
65
+ .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}
66
+ .btn:hover{background:var(--surface3);border-color:var(--border-hi)}
67
+ .btn.primary{background:var(--accent);border-color:var(--accent);color:#fff}
68
+ .btn.primary:hover{background:#2563eb}
69
+ .btn.danger{background:var(--red-dim);border-color:rgba(239,68,68,.3);color:var(--red)}
70
+ .btn:disabled{opacity:.4;cursor:not-allowed}
71
+ .btn.sm{padding:4px 10px;font-size:10px}
72
+
73
+ /* ── Cards ── */
74
+ .card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:16px;margin-bottom:16px}
75
+ .card-label{font-size:9px;font-weight:600;color:var(--text3);letter-spacing:.1em;text-transform:uppercase;margin-bottom:10px}
76
+
77
+ /* ── Stats Row ── */
78
+ .stats{display:flex;gap:24px;margin-bottom:20px}
79
+ .stat-block{text-align:center;min-width:80px}
80
+ .stat-val{font-size:26px;font-weight:700}
81
+ .stat-lbl{font-size:9px;color:var(--text3);text-transform:uppercase;letter-spacing:.1em;margin-top:2px}
82
+ .stat-val.green{color:var(--green)}
83
+ .stat-val.red{color:var(--red)}
84
+ .stat-val.accent{color:var(--accent)}
85
+ .stat-val.purple{color:var(--purple)}
86
+
87
+ /* ── Tables ── */
88
+ .tbl-wrap{overflow-x:auto}
89
+ table{width:100%;border-collapse:collapse;font-size:12px}
90
+ 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)}
91
+ td{padding:8px 12px;border-bottom:1px solid var(--border)}
92
+ tbody tr{cursor:pointer;transition:background .1s}
93
+ tbody tr:hover td{background:var(--surface2)}
94
+ tbody tr.selected td{background:var(--accent-dim)}
95
+ .badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:600}
96
+ .badge.pass{background:var(--green-dim);color:var(--green)}
97
+ .badge.fail{background:var(--red-dim);color:var(--red)}
98
+ .badge.flaky{background:var(--amber-dim);color:var(--amber)}
99
+ .badge.run{background:var(--purple-dim);color:var(--purple)}
100
+
101
+ /* ── Trend Chart ── */
102
+ .chart{display:flex;align-items:flex-end;gap:3px;height:60px}
103
+ .chart-bar{flex:1;min-width:3px;max-width:16px;border-radius:2px 2px 0 0;cursor:pointer;position:relative;transition:opacity .15s}
104
+ .chart-bar:hover{opacity:.75}
105
+ .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}
106
+ .chart-bar:hover .tip{display:block}
107
+
108
+ /* ── Suite Cards ── */
109
+ .suite-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px}
110
+ .suite-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:16px;transition:border-color .15s}
111
+ .suite-card:hover{border-color:var(--border-hi)}
112
+ .suite-card-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}
113
+ .suite-card-name{font-weight:600;font-size:13px}
114
+ .suite-card-count{font-size:10px;color:var(--text3)}
115
+ .suite-card-tests{list-style:none;margin-bottom:12px}
116
+ .suite-card-tests li{font-size:11px;color:var(--text2);padding:2px 0;padding-left:14px;position:relative}
117
+ .suite-card-tests li::before{content:">";position:absolute;left:0;color:var(--text3)}
118
+
119
+ /* ── Live Execution ── */
120
+ .live-panel{display:none;background:var(--surface);border:1px solid var(--purple);border-radius:var(--r);overflow:hidden;animation:fadeSlide .3s ease;flex-direction:column}
121
+ .live-panel.active{display:flex;flex:1;min-height:0}
122
+ @keyframes fadeSlide{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:translateY(0)}}
123
+ .live-header{padding:14px 16px;display:flex;align-items:center;gap:16px;border-bottom:1px solid var(--border);background:var(--purple-dim)}
124
+ .live-header .label{font-weight:600;color:var(--purple);font-size:12px;display:flex;align-items:center;gap:8px}
125
+ .live-header .label .dot{width:8px;height:8px;border-radius:50%;background:var(--purple);animation:pulse 1.5s infinite}
126
+ @keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
127
+ .live-project{display:flex;align-items:center;padding:2px 10px;background:rgba(255,255,255,.05);border-radius:4px;border:1px solid var(--border)}
128
+ .live-stats{display:flex;gap:16px;font-size:11px;color:var(--text2);margin-left:auto}
129
+ .live-stats span strong{color:var(--text)}
130
+ .live-progress{height:3px;background:var(--surface3)}
131
+ .live-progress-fill{height:100%;background:var(--purple);transition:width .4s;border-radius:0 2px 2px 0}
132
+ .live-tests{padding:12px 16px;display:flex;flex-direction:column;gap:2px;flex:1;overflow-y:auto;min-height:0}
133
+ .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)}
134
+ .live-test.running{border-left-color:var(--purple)}
135
+ .live-test.passed{border-left-color:var(--green)}
136
+ .live-test.failed{border-left-color:var(--red)}
137
+ .live-test.collapsed{cursor:pointer;padding:6px 12px}
138
+ .live-test.collapsed:hover{background:var(--surface3)}
139
+ .live-test.collapsed .lt-meta,.live-test.collapsed .lt-actions,.live-test.collapsed .lt-screenshots{display:none}
140
+ .live-test.collapsed .lt-name{margin-bottom:0}
141
+ .live-test.collapsed .lt-summary{display:flex}
142
+ .live-test .lt-name{font-weight:600;margin-bottom:4px;display:flex;align-items:center;gap:6px}
143
+ .live-test .lt-summary{display:none;align-items:center;gap:8px;margin-left:auto;font-size:10px;color:var(--text3);font-family:var(--mono)}
144
+ .live-test .lt-summary .lt-dur{color:var(--text2)}
145
+ .live-test .lt-summary .lt-expand{color:var(--purple);font-size:9px;opacity:.6}
146
+ .live-test .lt-meta{color:var(--text2);font-size:10px;margin-bottom:6px}
147
+ .live-test .lt-icon{font-size:12px}
148
+ .lt-actions{max-height:180px;overflow-y:auto;border-top:1px solid var(--border);padding-top:6px;margin-top:4px}
149
+ .lt-step{display:flex;align-items:flex-start;gap:6px;padding:2px 0;font-size:10px;font-family:var(--mono);line-height:1.4}
150
+ .lt-step .step-icon{flex-shrink:0;width:14px;text-align:center}
151
+ .lt-step .step-icon.ok{color:var(--green)}
152
+ .lt-step .step-icon.fail{color:var(--red)}
153
+ .lt-step .step-icon.run{color:var(--purple)}
154
+ .lt-step .step-type{color:var(--purple);font-weight:600;flex-shrink:0}
155
+ .lt-step .step-detail{color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}
156
+ .lt-step .step-dur{color:var(--text3);flex-shrink:0;margin-left:auto}
157
+ .lt-screenshots{border-top:1px solid var(--border);margin-top:6px;padding-top:6px}
158
+ .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}
159
+ .lt-screenshots-toggle:hover{color:var(--text)}
160
+ .lt-screenshots-toggle .ss-arrow{transition:transform .2s;font-size:8px}
161
+ .lt-screenshots-toggle.open .ss-arrow{transform:rotate(90deg)}
162
+ .lt-screenshots-grid{display:none;grid-template-columns:repeat(auto-fill,minmax(80px,1fr));gap:6px;padding-top:6px}
163
+ .lt-screenshots-toggle.open+.lt-screenshots-grid{display:grid}
164
+ .lt-ss-thumb{position:relative;border-radius:4px;overflow:hidden;border:1px solid var(--border);cursor:pointer;aspect-ratio:16/10;background:var(--surface2)}
165
+ .lt-ss-thumb img{width:100%;height:100%;object-fit:cover;display:block}
166
+ .lt-ss-thumb:hover{border-color:var(--purple);box-shadow:0 0 0 1px var(--purple)}
167
+ .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}
168
+ .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)}
169
+ .lr-section-header.running{border-color:var(--purple);background:rgba(139,92,246,.08);color:var(--purple)}
170
+ .lr-section-header.pass{border-color:var(--green);background:rgba(52,211,153,.08);color:var(--green)}
171
+ .lr-section-header.fail{border-color:var(--red);background:rgba(248,113,113,.08);color:var(--red)}
172
+ .lr-section-stats{display:flex;align-items:center;gap:4px;font-size:10px;color:var(--text2);font-weight:400}
173
+ .lr-project-name{letter-spacing:.5px}
174
+ .lr-test-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:8px;padding:4px 0 8px}
175
+ .live-done{background:var(--green-dim);color:var(--green);text-align:center;padding:10px;font-weight:600;font-size:12px}
176
+ .live-done.has-failures{background:var(--red-dim);color:var(--red)}
177
+ .live-close{padding:4px 10px;font-size:10px;background:transparent;border:1px solid var(--border);border-radius:var(--r);color:var(--text2);cursor:pointer}
178
+ .live-close:hover{color:var(--text);border-color:var(--border-hi)}
179
+ .live-clear-btn{padding:5px 12px;font-size:10px;font-family:var(--mono);font-weight:500;background:var(--surface2);border:1px solid var(--border);border-radius:var(--r);color:var(--text2);cursor:pointer;display:none;transition:all .15s}
180
+ .live-clear-btn:hover{color:var(--text);border-color:var(--border-hi);background:var(--surface3)}
181
+ .lr-dismiss{padding:2px 6px;font-size:9px;font-family:var(--mono);background:transparent;border:1px solid transparent;border-radius:4px;color:var(--text3);cursor:pointer;transition:all .15s;margin-left:auto}
182
+ .lr-dismiss:hover{color:var(--red);border-color:rgba(239,68,68,.3);background:var(--red-dim)}
183
+
184
+ .live-nav-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--purple);animation:pulse 1.5s infinite}
185
+ .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}
186
+ .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}
187
+ @keyframes spin{to{transform:rotate(360deg)}}
188
+
189
+ /* ── Screenshots ── */
190
+ .gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}
191
+ .gallery-item{background:var(--surface2);border:1px solid var(--border);border-radius:var(--r);overflow:hidden;cursor:pointer;transition:border-color .15s}
192
+ .gallery-item:hover{border-color:var(--accent)}
193
+ .gallery-item img{width:100%;height:150px;object-fit:cover;display:block}
194
+ .gallery-item .cap{padding:6px 10px;font-size:10px;color:var(--text2);display:flex;align-items:center;gap:4px}
195
+ .gallery-item .cap .cap-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}
196
+ .gallery-item .cap .ss-hash{flex-shrink:0}
197
+ .ss-search{display:flex;gap:8px;margin-bottom:16px;align-items:center}
198
+ .ss-search input{flex:1;max-width:320px;padding:8px 12px;border-radius:var(--r);border:1px solid var(--border);background:var(--surface2);color:var(--text);font-family:var(--mono);font-size:12px}
199
+ .ss-search input:focus{outline:none;border-color:var(--accent)}
200
+ .ss-search input::placeholder{color:var(--text3)}
201
+ .ss-search button{padding:8px 16px;border-radius:var(--r);border:1px solid var(--border);background:var(--surface2);color:var(--text);font-family:var(--mono);font-size:12px;cursor:pointer;transition:background .15s,border-color .15s}
202
+ .ss-search button:hover{background:var(--surface3);border-color:var(--accent)}
203
+ .ss-search-result{margin-bottom:20px;padding:12px;background:var(--surface);border:1px solid var(--border);border-radius:var(--r)}
204
+ .ss-search-result img{max-width:100%;max-height:500px;border-radius:var(--r);cursor:pointer;display:block;margin-top:8px}
205
+ .ss-search-result .ss-result-label{font-size:11px;color:var(--text2);display:flex;align-items:center;gap:6px}
206
+ .ss-search-error{font-size:11px;color:var(--red);margin-bottom:12px}
207
+
208
+ /* ── Inline Run Detail ── */
209
+ .run-detail-row td{padding:0!important;border-bottom:2px solid var(--border-hi)}
210
+ .run-detail-row:hover td{background:transparent!important}
211
+ .rd-wrap{overflow:hidden;transition:max-height .35s cubic-bezier(.4,0,.2,1);max-height:0}
212
+ .rd-wrap.open{max-height:2000px}
213
+ .rd-inner{padding:16px 20px;background:var(--bg);border-left:3px solid var(--purple);margin:0 8px 8px}
214
+ .rd-test{margin-bottom:14px;background:var(--surface);border:1px solid var(--border);border-radius:var(--r);overflow:hidden}
215
+ .rd-test:last-child{margin-bottom:0}
216
+ .rd-test-head{display:flex;align-items:center;gap:10px;padding:10px 14px;border-bottom:1px solid var(--border);background:var(--surface2)}
217
+ .rd-test-name{font-weight:600;font-size:12px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
218
+ .rd-test-dur{font-size:11px;color:var(--text2);flex-shrink:0}
219
+ .rd-test-body{padding:10px 14px}
220
+ .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)}
221
+ .rd-shots{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}
222
+ .rd-shot{width:120px;border-radius:4px;overflow:hidden;border:1px solid var(--border);cursor:pointer;transition:border-color .15s,transform .15s}
223
+ .rd-shot:hover{border-color:var(--accent);transform:scale(1.03)}
224
+ .rd-shot img{width:100%;height:72px;object-fit:cover;display:block}
225
+ .rd-shot .rd-shot-cap{font-size:9px;color:var(--text3);padding:4px 6px;background:var(--surface2)}
226
+ .rd-shot .rd-shot-cap .cap-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
227
+ .rd-shot .rd-shot-cap .ss-hash{margin-top:3px}
228
+ .rd-shot.err-shot{border-color:rgba(239,68,68,.4)}
229
+ .rd-shot.err-shot .rd-shot-cap{color:var(--red)}
230
+ .rd-logs{margin-top:10px}
231
+ .rd-log-label{font-size:9px;font-weight:600;color:var(--text3);letter-spacing:.08em;text-transform:uppercase;margin-bottom:4px}
232
+ .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}
233
+ .rd-log-item.error{border-left-color:var(--red);color:var(--red)}
234
+ .rd-log-item.warning,.rd-log-item.warn{border-left-color:var(--amber);color:var(--amber)}
235
+ .rd-net-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;margin-top:10px}
236
+ .rd-net-toggle:hover{color:var(--text)}
237
+ .rd-net-toggle .net-arrow{transition:transform .2s;font-size:8px}
238
+ .rd-net-toggle.open .net-arrow{transform:rotate(90deg)}
239
+ .rd-net-list{display:none;margin-top:6px}
240
+ .rd-net-toggle.open+.rd-net-list{display:block}
241
+ .rd-net-row{display:flex;align-items:center;gap:8px;padding:3px 8px;font-size:10px;font-family:var(--mono);border-left:2px solid var(--border);margin-bottom:0;cursor:pointer;transition:background .15s}
242
+ .rd-net-row:hover{background:var(--surface2)}
243
+ .rd-net-row.has-error{border-left-color:var(--red)}
244
+ .rd-net-method{display:inline-block;padding:1px 5px;border-radius:3px;font-size:9px;font-weight:700;min-width:36px;text-align:center}
245
+ .rd-net-method.get{background:var(--accent-dim);color:var(--accent)}
246
+ .rd-net-method.post{background:var(--green-dim);color:var(--green)}
247
+ .rd-net-method.put,.rd-net-method.patch{background:var(--amber-dim);color:var(--amber)}
248
+ .rd-net-method.delete{background:var(--red-dim);color:var(--red)}
249
+ .rd-net-status{display:inline-block;padding:1px 5px;border-radius:3px;font-size:9px;font-weight:600;min-width:28px;text-align:center}
250
+ .rd-net-status.s2xx{background:var(--green-dim);color:var(--green)}
251
+ .rd-net-status.s3xx{background:var(--amber-dim);color:var(--amber)}
252
+ .rd-net-status.s4xx,.rd-net-status.s5xx{background:var(--red-dim);color:var(--red)}
253
+ .rd-net-url{color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}
254
+ .rd-net-dur{color:var(--text3);flex-shrink:0}
255
+ .rd-net-expand{font-size:8px;color:var(--text3);transition:transform .2s;flex-shrink:0}
256
+ .rd-net-row.open .rd-net-expand{transform:rotate(90deg)}
257
+ .rd-net-detail{display:none;margin:0 0 4px 0;padding:8px 12px;background:var(--surface);border:1px solid var(--border);border-radius:var(--r);font-size:10px;font-family:var(--mono);overflow:hidden}
258
+ .rd-net-row.open+.rd-net-detail{display:block}
259
+ .rd-net-detail-section{margin-bottom:8px}
260
+ .rd-net-detail-section:last-child{margin-bottom:0}
261
+ .rd-net-detail-title{font-size:9px;font-weight:700;color:var(--text3);text-transform:uppercase;letter-spacing:.08em;margin-bottom:4px}
262
+ .rd-net-detail-body{white-space:pre-wrap;word-break:break-all;color:var(--text2);max-height:300px;overflow-y:auto;line-height:1.5}
263
+ .rd-retries{font-size:10px;color:var(--amber);margin-bottom:6px}
264
+ .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}
265
+ .rd-summary .rd-s-label{color:var(--text3);font-size:9px;text-transform:uppercase;letter-spacing:.08em}
266
+ .rd-summary .rd-s-val{font-weight:700;font-size:16px;margin-top:2px}
267
+ tr.expanded td{background:var(--surface2)!important}
268
+ tr.expanded td:first-child{position:relative}
269
+ tr.expanded td:first-child::before{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;background:var(--purple)}
270
+
271
+ /* ── Empty ── */
272
+ .empty{text-align:center;padding:48px 24px;color:var(--text3)}
273
+ .empty-icon{font-size:36px;margin-bottom:8px;opacity:.5}
274
+
275
+ /* ── Modal ── */
276
+ .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}
277
+ .modal.open{display:flex}
278
+ .modal img{max-width:100%;max-height:90vh;border-radius:var(--r);cursor:default}
279
+
280
+ /* ── Screenshot Hash Badge ── */
281
+ .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}
282
+ .ss-hash:hover{border-color:var(--accent);color:var(--accent);background:var(--accent-dim)}
283
+ .ss-hash.copied{border-color:var(--green);color:var(--green);background:var(--green-dim)}
284
+ .ss-hash .ss-icon{font-size:10px;line-height:1}
285
+
286
+ /* ── Trigger Source Badges ── */
287
+ .trigger-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:10px;font-size:10px;font-weight:600;font-family:var(--mono);white-space:nowrap}
288
+ .trigger-badge.src-dashboard{background:rgba(127,140,162,.10);color:var(--text2)}
289
+ .trigger-badge.src-mcp{background:var(--purple-dim);color:var(--purple)}
290
+ .trigger-badge.src-cli{background:var(--accent-dim);color:var(--accent)}
291
+ .trigger-badge.src-unknown{background:rgba(70,75,98,.15);color:var(--text3)}
292
+ .trigger-badge .trig-icon{font-size:11px;line-height:1}
293
+
294
+ /* ── Responsive ── */
295
+ @media(max-width:768px){
296
+ .sidebar{width:60px}.sidebar-logo h1,.sidebar-section-label,.nav-item span:not(.icon),.pool-info,.sidebar select,.sidebar-logo .ver{display:none}
297
+ .nav-item{justify-content:center;padding:12px}.nav-item .icon{width:auto}
298
+ .main{margin-left:60px}
299
+ .suite-grid,.gallery{grid-template-columns:1fr}
300
+ .lr-test-grid{grid-template-columns:1fr}
301
+ }
302
+ </style>
303
+ </head>
304
+ <body>
305
+
306
+ <aside class="sidebar">
307
+ <div class="sidebar-logo">
308
+ <h1><span>E2E</span> Runner</h1>
309
+ <div class="ver">Dashboard</div>
310
+ </div>
311
+
312
+ <div class="sidebar-section">
313
+ <div class="sidebar-section-label">Project</div>
314
+ <select id="projectSelect">
315
+ <option value="">All Projects</option>
316
+ </select>
317
+ </div>
318
+
319
+ <div class="sidebar-section">
320
+ <div class="sidebar-section-label">Navigation</div>
321
+ </div>
322
+ <div class="nav-item" data-view="live" id="navLive" style="display:none">
323
+ <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>
324
+ </div>
325
+ <div class="nav-item active" data-view="suites">
326
+ <i class="icon">&#9655;</i><span>Suites</span>
327
+ </div>
328
+ <div class="nav-item" data-view="runs">
329
+ <i class="icon">&#9776;</i><span>Runs</span>
330
+ </div>
331
+ <div class="nav-item" data-view="screenshots">
332
+ <i class="icon">&#9635;</i><span>Screenshots</span>
333
+ </div>
334
+
335
+ <div class="pool-status" id="poolStatus">
336
+ <div class="pool-info">
337
+ <span class="pool-dot off" id="poolDot"></span>
338
+ <strong>Pool</strong> <span id="poolLabel">--</span>
339
+ </div>
340
+ <div class="pool-info">Sessions: <strong id="poolSessions">-/-</strong></div>
341
+ <div class="pool-info" style="margin-top:6px">
342
+ <span class="ws-dot" id="wsDot" style="background:var(--red)"></span>
343
+ <span id="wsLabel" style="font-size:10px;color:var(--text3)">ws: connecting</span>
344
+ </div>
345
+ </div>
346
+ </aside>
347
+
348
+ <div class="main">
349
+
350
+ <!-- Live View -->
351
+ <div class="view" id="view-live">
352
+ <div class="live-panel active" id="livePanel">
353
+ <div class="live-header">
354
+ <div class="label"><span class="dot"></span> RUNNING</div>
355
+ <div class="live-project" id="liveProject" style="display:none">
356
+ <span style="color:var(--text3);font-size:11px">Project:</span>
357
+ <strong id="liveProjectName" style="font-size:12px;color:var(--text);margin-left:4px"></strong>
358
+ <span id="liveProjectCwd" style="font-size:10px;color:var(--text3);margin-left:8px;font-family:var(--mono);opacity:.7"></span>
359
+ </div>
360
+ <div class="live-stats">
361
+ <span>Total: <strong id="liveTotal">0</strong></span>
362
+ <span style="color:var(--green)">Pass: <strong id="livePass">0</strong></span>
363
+ <span style="color:var(--red)">Fail: <strong id="liveFail">0</strong></span>
364
+ <span style="color:var(--purple)">Active: <strong id="liveActive">0</strong></span>
365
+ </div>
366
+ <button class="live-clear-btn" id="liveClearBtn">Clear All</button>
367
+ </div>
368
+ <div class="live-progress"><div class="live-progress-fill" id="liveProgressFill" style="width:0"></div></div>
369
+ <div class="live-tests" id="liveTests"></div>
370
+ </div>
371
+ <div class="empty" id="liveEmpty">
372
+ <div class="empty-icon" style="font-size:48px;opacity:.3">&#9679;</div>
373
+ <p>No tests running. Start a test from the Suites view or another console.</p>
374
+ <p style="margin-top:8px;font-size:11px;color:var(--text3)">This view activates automatically when tests are detected.</p>
375
+ </div>
376
+ </div>
377
+
378
+ <!-- Suites View -->
379
+ <div class="view active" id="view-suites">
380
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px">
381
+ <div style="font-family:var(--sans);font-size:16px;font-weight:600">Test Suites</div>
382
+ <button class="btn primary" id="btnRunAll">Run All Tests</button>
383
+ </div>
384
+ <div class="suite-grid" id="suiteGrid"></div>
385
+ <div class="empty" id="suitesEmpty" style="display:none">
386
+ <div class="empty-icon">&#9655;</div>
387
+ <p>No test suites found.</p>
388
+ </div>
389
+ </div>
390
+
391
+ <!-- Runs View -->
392
+ <div class="view" id="view-runs">
393
+ <div style="font-family:var(--sans);font-size:16px;font-weight:600;margin-bottom:20px">Run History</div>
394
+ <div class="card">
395
+ <div class="card-label">Pass Rate Trend</div>
396
+ <div class="chart" id="trendChart"></div>
397
+ </div>
398
+ <div class="card" style="padding:0">
399
+ <div class="tbl-wrap">
400
+ <table>
401
+ <thead id="runsHead"><tr></tr></thead>
402
+ <tbody id="runsBody"></tbody>
403
+ </table>
404
+ </div>
405
+ </div>
406
+ <div class="empty" id="runsEmpty" style="display:none">
407
+ <div class="empty-icon">&#9776;</div>
408
+ <p>No runs recorded yet.</p>
409
+ </div>
410
+ </div>
411
+
412
+ <!-- Screenshots View -->
413
+ <div class="view" id="view-screenshots">
414
+ <div style="font-family:var(--sans);font-size:16px;font-weight:600;margin-bottom:20px">Screenshots</div>
415
+ <div class="ss-search">
416
+ <input type="text" id="ssHashInput" placeholder="Search by hash (e.g. ss:a3f2b1c9)" spellcheck="false">
417
+ <button id="ssHashBtn">Search</button>
418
+ </div>
419
+ <div id="ssSearchResult"></div>
420
+ <div class="gallery" id="screenshotGallery"></div>
421
+ <div class="empty" id="screenshotsEmpty" style="display:none">
422
+ <div class="empty-icon">&#9635;</div>
423
+ <p>Select a project to view screenshots.</p>
424
+ </div>
425
+ </div>
426
+
427
+ </div>
428
+
429
+ <div class="modal" id="modal"><img id="modalImg" src="" alt=""></div>
430
+
431
+ <script>
432
+ (function(){
433
+ 'use strict';
434
+ var $=function(s){return document.querySelector(s)};
435
+ var $$=function(s){return document.querySelectorAll(s)};
436
+
437
+ function el(tag,a,ch){
438
+ var e=document.createElement(tag);
439
+ if(a)Object.keys(a).forEach(function(k){
440
+ if(k==='className')e.className=a[k];
441
+ else if(k==='style')e.style.cssText=a[k];
442
+ else if(k.indexOf('on')===0)e.addEventListener(k.slice(2),a[k]);
443
+ else e.setAttribute(k,a[k]);
444
+ });
445
+ if(typeof ch==='string')e.textContent=ch;
446
+ else if(Array.isArray(ch))ch.forEach(function(c){if(c)e.appendChild(c)});
447
+ return e;
448
+ }
449
+ function css(n){return n.replace(/[^a-zA-Z0-9\-_]/g,'_')}
450
+ function dur(ms){return ms>=1000?(ms/1000).toFixed(1)+'s':ms+'ms'}
451
+ function fdate(iso){return iso?new Date(iso).toLocaleString():'--'}
452
+
453
+ /** Pretty-print a string if it's JSON, otherwise return as-is */
454
+ function prettyJson(str){
455
+ if(!str)return '';
456
+ try{return JSON.stringify(JSON.parse(str),null,2)}catch(e){return str}
457
+ }
458
+
459
+ /** Format headers object as readable string */
460
+ function fmtHeaders(h){
461
+ if(!h||typeof h!=='object')return '';
462
+ return Object.keys(h).map(function(k){return k+': '+h[k]}).join('\n');
463
+ }
464
+
465
+ /** Build a clickable network row + expandable detail panel */
466
+ function buildNetRow(n){
467
+ var mCls='rd-net-method '+n.method.toLowerCase();
468
+ var sCls='rd-net-status '+(n.status<300?'s2xx':n.status<400?'s3xx':n.status<500?'s4xx':'s5xx');
469
+ var hasDetail=n.requestBody||n.responseBody||n.requestHeaders||n.responseHeaders;
470
+ var rowCls='rd-net-row'+(n.status>=400?' has-error':'');
471
+ var row=el('div',{className:rowCls},[
472
+ hasDetail?el('span',{className:'rd-net-expand'},'\u25B6'):null,
473
+ el('span',{className:mCls},n.method),
474
+ el('span',{className:sCls},String(n.status)+(n.statusText?' '+n.statusText:'')),
475
+ el('span',{className:'rd-net-url'},n.url),
476
+ el('span',{className:'rd-net-dur'},dur(n.duration))
477
+ ]);
478
+ var detail=null;
479
+ if(hasDetail){
480
+ var sections=[];
481
+ if(n.requestHeaders){
482
+ var s=el('div',{className:'rd-net-detail-section'},[el('div',{className:'rd-net-detail-title'},'Request Headers')]);
483
+ s.appendChild(el('div',{className:'rd-net-detail-body'},fmtHeaders(n.requestHeaders)));
484
+ sections.push(s);
485
+ }
486
+ if(n.requestBody){
487
+ var s2=el('div',{className:'rd-net-detail-section'},[el('div',{className:'rd-net-detail-title'},'Request Body')]);
488
+ s2.appendChild(el('div',{className:'rd-net-detail-body'},prettyJson(n.requestBody)));
489
+ sections.push(s2);
490
+ }
491
+ if(n.responseHeaders){
492
+ var s3=el('div',{className:'rd-net-detail-section'},[el('div',{className:'rd-net-detail-title'},'Response Headers')]);
493
+ s3.appendChild(el('div',{className:'rd-net-detail-body'},fmtHeaders(n.responseHeaders)));
494
+ sections.push(s3);
495
+ }
496
+ if(n.responseBody){
497
+ var s4=el('div',{className:'rd-net-detail-section'},[el('div',{className:'rd-net-detail-title'},'Response Body')]);
498
+ s4.appendChild(el('div',{className:'rd-net-detail-body'},prettyJson(n.responseBody)));
499
+ sections.push(s4);
500
+ }
501
+ detail=el('div',{className:'rd-net-detail'},sections);
502
+ row.addEventListener('click',function(e){e.stopPropagation();row.classList.toggle('open')});
503
+ }
504
+ return {row:row,detail:detail};
505
+ }
506
+
507
+ /* ── Screenshot hash helpers ── */
508
+ var ssHashCache={};
509
+ async function ssHash(filePath){
510
+ if(ssHashCache[filePath])return ssHashCache[filePath];
511
+ var data=new TextEncoder().encode(filePath);
512
+ var buf=await crypto.subtle.digest('SHA-256',data);
513
+ var hex=Array.from(new Uint8Array(buf)).map(function(b){return b.toString(16).padStart(2,'0')}).join('');
514
+ var h=hex.slice(0,8);
515
+ ssHashCache[filePath]=h;
516
+ return h;
517
+ }
518
+ function ssHashSync(filePath){return ssHashCache[filePath]||null}
519
+ function copyHash(hash,badge){
520
+ navigator.clipboard.writeText('ss:'+hash).then(function(){
521
+ badge.classList.add('copied');
522
+ setTimeout(function(){badge.classList.remove('copied')},1200);
523
+ });
524
+ }
525
+ function createHashBadge(hash){
526
+ var badge=el('span',{className:'ss-hash',onclick:function(e){e.stopPropagation();copyHash(hash,badge)}},[
527
+ el('span',{className:'ss-icon'},'\u2318'),
528
+ document.createTextNode('ss:'+hash)
529
+ ]);
530
+ return badge;
531
+ }
532
+
533
+ /* ── Trigger source badge helper ── */
534
+ function createTriggerBadge(source){
535
+ var s=source||'unknown';
536
+ var labels={dashboard:'Dashboard',mcp:'MCP',cli:'CLI',unknown:'--'};
537
+ var icons={dashboard:'\u{1F464}',mcp:'\u{1F916}',cli:'>_',unknown:'\u2022'};
538
+ var badge=el('span',{className:'trigger-badge src-'+s},[
539
+ el('span',{className:'trig-icon'},icons[s]||icons.unknown),
540
+ document.createTextNode(labels[s]||s)
541
+ ]);
542
+ return badge;
543
+ }
544
+
545
+ /* ── State ── */
546
+ var S={
547
+ ws:null,project:null,view:'suites',selectedRun:null,
548
+ liveRuns:{},liveExpanded:new Set(),liveSSOpen:new Set()
549
+ };
550
+
551
+ /* ── Navigation ── */
552
+ $$('.nav-item').forEach(function(n){
553
+ n.addEventListener('click',function(){
554
+ $$('.nav-item').forEach(function(x){x.classList.remove('active')});
555
+ n.classList.add('active');
556
+ S.view=n.dataset.view;
557
+ $$('.view').forEach(function(v){v.classList.remove('active')});
558
+ $('#view-'+S.view).classList.add('active');
559
+ });
560
+ });
561
+ function showView(v){
562
+ S.view=v;
563
+ $$('.nav-item').forEach(function(n){n.classList.toggle('active',n.dataset.view===v)});
564
+ $$('.view').forEach(function(x){x.classList.remove('active')});
565
+ $('#view-'+v).classList.add('active');
566
+ }
567
+
568
+ /* ── WebSocket ── */
569
+ function connectWS(){
570
+ var proto=location.protocol==='https:'?'wss:':'ws:';
571
+ S.ws=new WebSocket(proto+'//'+location.host);
572
+ S.ws.onopen=function(){$('#wsDot').style.background='var(--green)';$('#wsLabel').textContent='ws: connected';$('#wsLabel').style.color='var(--green)'};
573
+ S.ws.onclose=function(){$('#wsDot').style.background='var(--red)';$('#wsLabel').textContent='ws: disconnected';$('#wsLabel').style.color='var(--text3)';setTimeout(connectWS,3000)};
574
+ S.ws.onerror=function(){};
575
+ S.ws.onmessage=function(e){try{handleWS(JSON.parse(e.data))}catch(x){}};
576
+ }
577
+
578
+ function getLiveRun(m){
579
+ var rid=m.runId;if(!rid)return null;
580
+ 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,triggeredBy:m.triggeredBy||null,runId:rid,_lastEvent:Date.now()};
581
+ S.liveRuns[rid]._lastEvent=Date.now();
582
+ return S.liveRuns[rid];
583
+ }
584
+ function anyLiveRunning(){for(var k in S.liveRuns)if(S.liveRuns[k].on)return true;return false}
585
+
586
+ /* Staleness guard: auto-finish stuck runs, garbage-collect old finished runs */
587
+ setInterval(function(){
588
+ var changed=false;
589
+ for(var k in S.liveRuns){
590
+ var r=S.liveRuns[k];
591
+ var age=Date.now()-r._lastEvent;
592
+ /* Mark stuck runs as done */
593
+ if(r.on&&!r.done){
594
+ /* 0/0 runs (never received any tests) — mark stale after 10s */
595
+ if(r.total===0&&age>10000){r.on=false;r.done=true;r.stale=true;r.active=0;changed=true}
596
+ /* All tests completed but run:complete never arrived */
597
+ else if(r.completed>=r.total&&r.total>0&&age>15000){r.on=false;r.done=true;r.active=0;changed=true}
598
+ /* General staleness — no events for 30s */
599
+ else if(age>30000){r.on=false;r.done=true;r.stale=true;r.active=0;changed=true}
600
+ }
601
+ /* Auto-remove stale 0/0 runs after 15s, finished runs after 120s */
602
+ if(r.done&&r.stale&&r.total===0&&age>15000){delete S.liveRuns[k];changed=true}
603
+ else if(r.done&&age>120000){delete S.liveRuns[k];changed=true}
604
+ }
605
+ if(changed)renderLive();
606
+ },5000);
607
+
608
+ function handleWS(m){
609
+ switch(m.event){
610
+ case 'pool:status':renderPool(m.data);break;
611
+ case 'run:start':
612
+ /* Clear all finished/stale runs when a new one starts */
613
+ for(var dk in S.liveRuns){if(S.liveRuns[dk].done)delete S.liveRuns[dk]}
614
+ var r=getLiveRun(m);
615
+ r.total=m.total;r.on=true;r.done=false;
616
+ S.liveExpanded=new Set();S.liveSSOpen=new Set();
617
+ showView('live');renderLive();break;
618
+ case 'test:start':
619
+ var r=getLiveRun(m);if(!r)break;
620
+ r.active=m.activeCount;
621
+ r.tests[m.name]={status:'running',actions:0,totalActions:0,error:null,actionLog:[],screenshots:[]};
622
+ renderLive();break;
623
+ case 'test:action':
624
+ var r=getLiveRun(m);if(!r||!r.tests[m.name])break;
625
+ var t=r.tests[m.name];
626
+ t.actions=m.actionIndex+1;t.totalActions=m.totalActions;t.actionType=m.action.type;
627
+ 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});
628
+ if(m.screenshotPath)t.screenshots.push(m.screenshotPath);
629
+ renderLive();break;
630
+ case 'test:retry':
631
+ var r=getLiveRun(m);if(!r||!r.tests[m.name])break;
632
+ r.tests[m.name].retry=m.attempt+'/'+m.maxAttempts;
633
+ renderLive();break;
634
+ case 'test:complete':
635
+ var r=getLiveRun(m);if(!r)break;
636
+ r.completed++;
637
+ if(m.success){r.passed++;if(r.tests[m.name])r.tests[m.name].status='passed'}
638
+ else{r.failed++;if(r.tests[m.name]){r.tests[m.name].status='failed';r.tests[m.name].error=m.error}}
639
+ if(r.tests[m.name]){
640
+ r.tests[m.name].duration=m.duration;
641
+ if(m.screenshots&&m.screenshots.length)r.tests[m.name].screenshots=m.screenshots;
642
+ if(m.errorScreenshot)r.tests[m.name].errorScreenshot=m.errorScreenshot;
643
+ if(m.networkLogs&&m.networkLogs.length)r.tests[m.name].networkLogs=m.networkLogs;
644
+ }
645
+ r.active=Math.max(0,r.active-1);
646
+ renderLive();break;
647
+ case 'run:complete':
648
+ var r=getLiveRun(m);if(r){r.on=false;r.done=true;r.active=0}
649
+ renderLive();refreshRuns();refreshProjects();break;
650
+ case 'run:error':
651
+ var r=getLiveRun(m);if(r){r.on=false;r.done=true;r.tests.__error={status:'failed',error:m.error}}
652
+ renderLive();break;
653
+ case 'db:updated':
654
+ refreshRuns();refreshProjects();refreshScreenshots();break;
655
+ }
656
+ }
657
+
658
+ /* ── API ── */
659
+ function api(p){return fetch(p).then(function(r){return r.json()})}
660
+ function triggerRun(suite,projectId){
661
+ if(anyLiveRunning())return;
662
+ var body={};
663
+ if(suite)body.suite=suite;
664
+ if(projectId)body.projectId=projectId;
665
+ else if(S.project)body.projectId=S.project;
666
+ fetch('/api/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
667
+ }
668
+
669
+ /* ── Pool ── */
670
+ function renderPool(d){
671
+ if(!d)return;
672
+ $('#poolDot').className='pool-dot '+(d.error||!d.available?'off':'on');
673
+ $('#poolLabel').textContent=d.error?'offline':d.available?'ready':'busy';
674
+ $('#poolSessions').textContent=(d.running||0)+'/'+(d.maxConcurrent||0);
675
+ }
676
+ function refreshStatus(){api('/api/status').then(function(d){renderPool(d.pool)}).catch(function(){})}
677
+
678
+ /* ── Projects ── */
679
+ function refreshProjects(){
680
+ api('/api/db/projects').then(function(projects){
681
+ var sel=$('#projectSelect'),prev=sel.value;
682
+ while(sel.options.length>1)sel.remove(1);
683
+ if(Array.isArray(projects))projects.forEach(function(p){
684
+ var o=document.createElement('option');o.value=p.id;o.textContent=p.name;sel.appendChild(o);
685
+ });
686
+ sel.value=prev||'';
687
+ }).catch(function(){});
688
+ }
689
+ $('#projectSelect').addEventListener('change',function(){
690
+ S.project=this.value?parseInt(this.value,10):null;
691
+ S.selectedRun=null;
692
+ refreshRuns();refreshSuites();refreshScreenshots();
693
+ });
694
+
695
+ /* ── Suites ── */
696
+ function refreshSuites(){
697
+ var grid=$('#suiteGrid'),empty=$('#suitesEmpty');
698
+ grid.textContent='';
699
+
700
+ if(S.project){
701
+ // Single project — fetch its suites
702
+ api('/api/db/projects/'+S.project+'/suites').then(function(suites){
703
+ if(!Array.isArray(suites)||suites.length===0){empty.style.display='block';empty.querySelector('p').textContent='No test suites found for this project.';return}
704
+ empty.style.display='none';
705
+ renderSuiteCards(grid,suites,S.project);
706
+ }).catch(function(){});
707
+ } else {
708
+ // All projects — fetch each project's suites
709
+ api('/api/db/projects').then(function(projects){
710
+ if(!Array.isArray(projects)||projects.length===0){empty.style.display='block';empty.querySelector('p').textContent='No projects registered yet.';return}
711
+ var loaded=0,hasAny=false;
712
+ projects.forEach(function(p){
713
+ api('/api/db/projects/'+p.id+'/suites').then(function(suites){
714
+ loaded++;
715
+ if(Array.isArray(suites)&&suites.length>0){
716
+ hasAny=true;
717
+ 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);
718
+ grid.appendChild(label);
719
+ renderSuiteCards(grid,suites,p.id);
720
+ }
721
+ if(loaded===projects.length&&!hasAny){empty.style.display='block';empty.querySelector('p').textContent='No test suites found.'}
722
+ }).catch(function(){loaded++;});
723
+ });
724
+ }).catch(function(){});
725
+ }
726
+ }
727
+ function renderSuiteCards(container,suites,projectId){
728
+ suites.forEach(function(s){
729
+ var tests=el('ul',{className:'suite-card-tests'});
730
+ (s.tests||[]).forEach(function(t){tests.appendChild(el('li',null,t))});
731
+ var pid=projectId;
732
+ var card=el('div',{className:'suite-card'},[
733
+ el('div',{className:'suite-card-head'},[
734
+ el('div',{className:'suite-card-name'},s.name),
735
+ el('span',{className:'suite-card-count'},s.testCount+' tests')
736
+ ]),
737
+ tests,
738
+ el('button',{className:'btn sm primary',onclick:function(){triggerRun(s.name,pid)}},'Run Suite')
739
+ ]);
740
+ container.appendChild(card);
741
+ });
742
+ }
743
+
744
+ /* ── Runs ── */
745
+ function refreshRuns(){
746
+ var url=S.project?'/api/db/projects/'+S.project+'/runs':'/api/db/runs';
747
+ api(url).then(function(rows){
748
+ var chart=$('#trendChart'),body=$('#runsBody'),empty=$('#runsEmpty'),head=$('#runsHead');
749
+ chart.textContent='';body.textContent='';
750
+ if(!Array.isArray(rows)||rows.length===0){empty.style.display='block';head.parentNode.parentNode.style.display='none';return}
751
+ empty.style.display='none';head.parentNode.parentNode.style.display='';
752
+
753
+ // Thead
754
+ var htr=document.createElement('tr');
755
+ var cols=[];
756
+ if(!S.project)cols.push('Project');
757
+ cols=cols.concat(['Suite','Source','Date','Total','Pass','Fail','Rate','Time']);
758
+ cols.forEach(function(c){htr.appendChild(el('th',null,c))});
759
+ head.textContent='';head.appendChild(htr);
760
+ var colSpan=cols.length;
761
+
762
+ // Chart
763
+ rows.slice(0,40).slice().reverse().forEach(function(r){
764
+ var rate=parseFloat(r.pass_rate)||0;
765
+ var color=rate>=90?'var(--green)':rate>=70?'var(--amber)':'var(--red)';
766
+ var bar=el('div',{className:'chart-bar',style:'height:'+Math.max(rate,4)+'%;background:'+color});
767
+ bar.appendChild(el('div',{className:'tip'},(r.project_name||'')+(r.suite_name?' / '+r.suite_name:'')+': '+r.pass_rate));
768
+ chart.appendChild(bar);
769
+ });
770
+
771
+ // Rows
772
+ rows.forEach(function(r){
773
+ var tr=document.createElement('tr');
774
+ tr.dataset.runId=r.id;
775
+ if(r.id===S.selectedRun)tr.classList.add('expanded');
776
+ if(!S.project)tr.appendChild(el('td',{style:'font-weight:600'},r.project_name||'-'));
777
+ tr.appendChild(el('td',{style:'color:var(--accent)'},r.suite_name||'all'));
778
+ var srcTd=document.createElement('td');srcTd.appendChild(createTriggerBadge(r.triggered_by));tr.appendChild(srcTd);
779
+ tr.appendChild(el('td',null,fdate(r.generated_at)));
780
+ tr.appendChild(el('td',null,String(r.total||0)));
781
+ tr.appendChild(el('td',{style:'color:var(--green)'},String(r.passed||0)));
782
+ tr.appendChild(el('td',{style:'color:var(--red)'},String(r.failed||0)));
783
+ var rv=parseFloat(r.pass_rate)||0;
784
+ tr.appendChild(el('td',{style:'font-weight:600;color:'+(rv>=90?'var(--green)':rv>=70?'var(--amber)':'var(--red)')},r.pass_rate||'-'));
785
+ tr.appendChild(el('td',{style:'color:var(--text2)'},r.duration||'-'));
786
+ tr.addEventListener('click',function(){toggleDetail(r.id,tr,colSpan)});
787
+ body.appendChild(tr);
788
+
789
+ // If this run was already expanded, re-expand it
790
+ if(r.id===S.selectedRun){
791
+ var detailTr=createDetailRow(colSpan);
792
+ body.appendChild(detailTr);
793
+ loadDetailInline(r.id,detailTr);
794
+ }
795
+ });
796
+ }).catch(function(){});
797
+ }
798
+
799
+ function createDetailRow(colSpan){
800
+ var detailTr=document.createElement('tr');
801
+ detailTr.className='run-detail-row';
802
+ var td=document.createElement('td');
803
+ td.setAttribute('colspan',colSpan);
804
+ var wrap=el('div',{className:'rd-wrap'});
805
+ var inner=el('div',{className:'rd-inner'});
806
+ inner.innerHTML='<div style="color:var(--text3);font-size:11px"><span class="spinner-small"></span> Loading...</div>';
807
+ wrap.appendChild(inner);
808
+ td.appendChild(wrap);
809
+ detailTr.appendChild(td);
810
+ return detailTr;
811
+ }
812
+
813
+ function toggleDetail(id,clickedTr,colSpan){
814
+ // If already expanded, collapse
815
+ if(S.selectedRun===id){
816
+ var existing=clickedTr.nextElementSibling;
817
+ if(existing&&existing.classList.contains('run-detail-row')){
818
+ var w=existing.querySelector('.rd-wrap');
819
+ if(w)w.classList.remove('open');
820
+ clickedTr.classList.remove('expanded');
821
+ setTimeout(function(){if(existing.parentNode)existing.parentNode.removeChild(existing)},350);
822
+ }
823
+ S.selectedRun=null;
824
+ return;
825
+ }
826
+
827
+ // Collapse any other open detail
828
+ var prevTr=document.querySelector('#runsBody tr.expanded');
829
+ if(prevTr){
830
+ prevTr.classList.remove('expanded');
831
+ var prevDetail=prevTr.nextElementSibling;
832
+ if(prevDetail&&prevDetail.classList.contains('run-detail-row')){
833
+ var pw=prevDetail.querySelector('.rd-wrap');
834
+ if(pw)pw.classList.remove('open');
835
+ setTimeout(function(){if(prevDetail.parentNode)prevDetail.parentNode.removeChild(prevDetail)},350);
836
+ }
837
+ }
838
+
839
+ // Expand new
840
+ S.selectedRun=id;
841
+ clickedTr.classList.add('expanded');
842
+ var detailTr=createDetailRow(colSpan);
843
+ clickedTr.parentNode.insertBefore(detailTr,clickedTr.nextSibling);
844
+
845
+ // Animate open
846
+ requestAnimationFrame(function(){
847
+ requestAnimationFrame(function(){
848
+ var w2=detailTr.querySelector('.rd-wrap');
849
+ if(w2)w2.classList.add('open');
850
+ });
851
+ });
852
+
853
+ loadDetailInline(id,detailTr);
854
+ }
855
+
856
+ function loadDetailInline(id,detailTr){
857
+ api('/api/db/runs/'+id).then(function(d){
858
+ if(d.error)return;
859
+ var inner=detailTr.querySelector('.rd-inner');
860
+ inner.textContent='';
861
+
862
+ var results=d.results||[];
863
+
864
+ // Summary bar
865
+ var srcBlock=el('div',null,[el('div',{className:'rd-s-label'},'Source'),el('div',{style:'margin-top:4px'},[createTriggerBadge(d.triggeredBy)])]);
866
+ var summ=el('div',{className:'rd-summary'},[
867
+ 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')]),
868
+ srcBlock,
869
+ el('div',null,[el('div',{className:'rd-s-label'},'Total'),el('div',{className:'rd-s-val'},String(d.summary.total))]),
870
+ el('div',null,[el('div',{className:'rd-s-label'},'Passed'),el('div',{className:'rd-s-val',style:'color:var(--green)'},String(d.summary.passed))]),
871
+ el('div',null,[el('div',{className:'rd-s-label'},'Failed'),el('div',{className:'rd-s-val',style:'color:var(--red)'},String(d.summary.failed))]),
872
+ 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||'-')])
873
+ ]);
874
+ inner.appendChild(summ);
875
+
876
+ // Test result cards
877
+ results.forEach(function(r){
878
+ var d2=r.durationMs?dur(r.durationMs):r.endTime&&r.startTime?dur(new Date(r.endTime)-new Date(r.startTime)):'-';
879
+ var flaky=r.success&&r.attempt>1;
880
+
881
+ // Header: badge + name + duration
882
+ var badges=el('div',{style:'display:flex;gap:6px;align-items:center;flex-shrink:0'});
883
+ badges.appendChild(el('span',{className:'badge '+(r.success?'pass':'fail')},r.success?'PASS':'FAIL'));
884
+ if(flaky)badges.appendChild(el('span',{className:'badge flaky'},'FLAKY'));
885
+
886
+ var head=el('div',{className:'rd-test-head'},[
887
+ badges,
888
+ el('div',{className:'rd-test-name'},r.name),
889
+ el('div',{className:'rd-test-dur'},d2)
890
+ ]);
891
+
892
+ // Body content
893
+ var body=el('div',{className:'rd-test-body'});
894
+
895
+ // Retries info
896
+ if(r.maxAttempts>1){
897
+ body.appendChild(el('div',{className:'rd-retries'},'Attempt '+r.attempt+' of '+r.maxAttempts));
898
+ }
899
+
900
+ // Error message
901
+ if(r.error){
902
+ body.appendChild(el('div',{className:'rd-error-msg'},r.error));
903
+ }
904
+
905
+ // Screenshots per test
906
+ var shots=[];
907
+ var hashes=r.screenshotHashes||{};
908
+ (r.screenshots||[]).forEach(function(p){
909
+ var fname=p.split('/').pop();
910
+ shots.push({path:p,label:fname,type:'screenshot',hash:hashes[p]||null});
911
+ });
912
+ if(r.errorScreenshot){
913
+ var ename=r.errorScreenshot.split('/').pop();
914
+ shots.push({path:r.errorScreenshot,label:ename,type:'error',hash:hashes[r.errorScreenshot]||null});
915
+ }
916
+ if(shots.length){
917
+ var shotsWrap=el('div',{className:'rd-shots'});
918
+ shots.forEach(function(s){
919
+ var src='/api/image?path='+encodeURIComponent(s.path);
920
+ var img=document.createElement('img');img.src=src;img.alt=s.label;img.loading='lazy';
921
+ var capEl=el('div',{className:'rd-shot-cap'},[el('span',{className:'cap-name'},s.label)]);
922
+ if(s.hash){capEl.appendChild(createHashBadge(s.hash))}
923
+ else{(function(c,fp){ssHash(fp).then(function(h){c.appendChild(createHashBadge(h))})})(capEl,s.path)}
924
+ var shotEl=el('div',{className:'rd-shot'+(s.type==='error'?' err-shot':''),onclick:function(e){e.stopPropagation();openModal(src)}},[
925
+ img,capEl
926
+ ]);
927
+ shotsWrap.appendChild(shotEl);
928
+ });
929
+ body.appendChild(shotsWrap);
930
+ }
931
+
932
+ // Console logs (errors and warnings only)
933
+ var cIssues=(r.consoleLogs||[]).filter(function(l){return l.type==='error'||l.type==='warn'||l.type==='warning'});
934
+ if(cIssues.length){
935
+ var logSec=el('div',{className:'rd-logs'});
936
+ logSec.appendChild(el('div',{className:'rd-log-label'},'Console'));
937
+ cIssues.forEach(function(l){
938
+ logSec.appendChild(el('div',{className:'rd-log-item '+l.type},'['+l.type+'] '+l.text));
939
+ });
940
+ body.appendChild(logSec);
941
+ }
942
+
943
+ // Network errors
944
+ if(r.networkErrors&&r.networkErrors.length){
945
+ var netSec=el('div',{className:'rd-logs'});
946
+ netSec.appendChild(el('div',{className:'rd-log-label'},'Network Errors'));
947
+ r.networkErrors.forEach(function(ne){
948
+ netSec.appendChild(el('div',{className:'rd-log-item error'},'['+ne.error+'] '+ne.url));
949
+ });
950
+ body.appendChild(netSec);
951
+ }
952
+
953
+ // Network API logs (collapsible, clickable rows)
954
+ if(r.networkLogs&&r.networkLogs.length){
955
+ var errCount=r.networkLogs.filter(function(n){return n.status>=400}).length;
956
+ var netLabel='Network ('+r.networkLogs.length+' request'+(r.networkLogs.length!==1?'s':'')+(errCount?', '+errCount+' error'+(errCount!==1?'s':''):'')+')';
957
+ var netToggle=el('div',{className:'rd-net-toggle'},[
958
+ el('span',{className:'net-arrow'},'\u25B6'),
959
+ el('span',{},netLabel)
960
+ ]);
961
+ var netList=el('div',{className:'rd-net-list'});
962
+ r.networkLogs.forEach(function(n){
963
+ var built=buildNetRow(n);
964
+ netList.appendChild(built.row);
965
+ if(built.detail)netList.appendChild(built.detail);
966
+ });
967
+ netToggle.addEventListener('click',function(){
968
+ netToggle.classList.toggle('open');
969
+ });
970
+ body.appendChild(netToggle);
971
+ body.appendChild(netList);
972
+ }
973
+
974
+ var testCard=el('div',{className:'rd-test'},[head,body]);
975
+ inner.appendChild(testCard);
976
+ });
977
+
978
+ // Re-trigger open animation if not yet open
979
+ var w=detailTr.querySelector('.rd-wrap');
980
+ if(w&&!w.classList.contains('open')){
981
+ requestAnimationFrame(function(){w.classList.add('open')});
982
+ }
983
+ }).catch(function(err){
984
+ var inner=detailTr.querySelector('.rd-inner');
985
+ if(inner)inner.textContent='Failed to load run detail';
986
+ });
987
+ }
988
+
989
+ /* ── Screenshots ── */
990
+ function refreshScreenshots(){
991
+ var gal=$('#screenshotGallery'),empty=$('#screenshotsEmpty');
992
+ gal.textContent='';
993
+ if(!S.project){empty.style.display='block';empty.querySelector('p').textContent='Select a project to view screenshots.';return}
994
+ api('/api/db/projects/'+S.project+'/screenshots').then(function(files){
995
+ if(!Array.isArray(files)||!files.length){empty.style.display='block';empty.querySelector('p').textContent='No screenshots for this project.';return}
996
+ empty.style.display='none';
997
+ files.forEach(function(f){
998
+ var src='/api/image?path='+encodeURIComponent(f.path);
999
+ var img=document.createElement('img');img.src=src;img.alt=f.name;img.loading='lazy';
1000
+ var capEl=el('div',{className:'cap'},[el('span',{className:'cap-name'},f.name)]);
1001
+ (function(c,fp){
1002
+ ssHash(fp).then(function(h){c.appendChild(createHashBadge(h))});
1003
+ })(capEl,f.path);
1004
+ var item=el('div',{className:'gallery-item',onclick:function(){openModal(src)}},[
1005
+ img,capEl
1006
+ ]);gal.appendChild(item);
1007
+ });
1008
+ }).catch(function(){});
1009
+ }
1010
+
1011
+ /* ── Screenshot Hash Search ── */
1012
+ function searchByHash(){
1013
+ var container=$('#ssSearchResult');
1014
+ container.textContent='';
1015
+ var raw=$('#ssHashInput').value.trim();
1016
+ if(!raw)return;
1017
+ var hash=raw.replace(/^ss:/,'');
1018
+ if(!/^[a-f0-9]{1,8}$/i.test(hash)){
1019
+ container.appendChild(el('div',{className:'ss-search-error'},'Invalid hash format. Expected 8 hex characters (e.g. ss:a3f2b1c9).'));
1020
+ return;
1021
+ }
1022
+ fetch('/api/screenshot-hash/'+hash).then(function(res){
1023
+ if(!res.ok){
1024
+ container.appendChild(el('div',{className:'ss-search-error'},'Screenshot not found for hash: ss:'+hash));
1025
+ return;
1026
+ }
1027
+ return res.blob();
1028
+ }).then(function(blob){
1029
+ if(!blob)return;
1030
+ var url=URL.createObjectURL(blob);
1031
+ var wrap=el('div',{className:'ss-search-result'},[
1032
+ el('div',{className:'ss-result-label'},[
1033
+ createHashBadge(hash),
1034
+ el('span',{},'Found')
1035
+ ])
1036
+ ]);
1037
+ var img=document.createElement('img');
1038
+ img.src=url;
1039
+ img.alt='ss:'+hash;
1040
+ img.addEventListener('click',function(){openModal(url)});
1041
+ wrap.appendChild(img);
1042
+ container.appendChild(wrap);
1043
+ }).catch(function(){
1044
+ container.appendChild(el('div',{className:'ss-search-error'},'Error searching for screenshot.'));
1045
+ });
1046
+ }
1047
+ $('#ssHashBtn').addEventListener('click',searchByHash);
1048
+ $('#ssHashInput').addEventListener('keydown',function(e){if(e.key==='Enter')searchByHash()});
1049
+
1050
+ /* ── Live Execution ── */
1051
+ function clearFinishedLiveRuns(){
1052
+ for(var k in S.liveRuns){if(S.liveRuns[k].done||!S.liveRuns[k].on)delete S.liveRuns[k]}
1053
+ renderLive();
1054
+ }
1055
+ function dismissLiveRun(rid){
1056
+ delete S.liveRuns[rid];
1057
+ renderLive();
1058
+ }
1059
+ $('#liveClearBtn').addEventListener('click',clearFinishedLiveRuns);
1060
+
1061
+ function renderLive(){
1062
+ var panel=$('#livePanel'),grid=$('#liveTests'),navLive=$('#navLive'),liveEmpty=$('#liveEmpty');
1063
+ var runs=S.liveRuns;
1064
+ var runIds=Object.keys(runs);
1065
+
1066
+ if(runIds.length===0){
1067
+ panel.classList.remove('active');
1068
+ navLive.style.display='none';
1069
+ liveEmpty.style.display='block';
1070
+ $('#liveClearBtn').style.display='none';
1071
+ return;
1072
+ }
1073
+
1074
+ navLive.style.display='';
1075
+ liveEmpty.style.display='none';
1076
+ panel.classList.add('active');
1077
+
1078
+ // Aggregate stats across all runs
1079
+ var gTotal=0,gCompleted=0,gPassed=0,gFailed=0,gActive=0,gRunning=false,gDone=true;
1080
+ runIds.forEach(function(rid){
1081
+ var r=runs[rid];gTotal+=r.total;gCompleted+=r.completed;gPassed+=r.passed;gFailed+=r.failed;gActive+=r.active;
1082
+ if(r.on)gRunning=true;if(!r.done)gDone=false;
1083
+ });
1084
+
1085
+ var badgeActive=0;
1086
+ runIds.forEach(function(rid){var r=runs[rid];Object.keys(r.tests).forEach(function(n){if(n!=='__error'&&r.tests[n].status==='running')badgeActive++})});
1087
+ $('#liveBadge').textContent=gRunning?badgeActive:gCompleted;
1088
+ $('#liveBadge').style.background=gRunning?'var(--purple-dim)':gFailed>0?'var(--red-dim)':'var(--green-dim)';
1089
+ $('#liveBadge').style.color=gRunning?'var(--purple)':gFailed>0?'var(--red)':'var(--green)';
1090
+
1091
+ $('#liveTotal').textContent=gTotal;
1092
+ $('#livePass').textContent=gPassed;
1093
+ $('#liveFail').textContent=gFailed;
1094
+ $('#liveActive').textContent=gActive;
1095
+ var pct=gTotal>0?gCompleted/gTotal*100:0;
1096
+ $('#liveProgressFill').style.width=pct+'%';
1097
+
1098
+ // Hide single-project info (now shown per-section)
1099
+ $('#liveProject').style.display='none';
1100
+
1101
+ // Show "Clear All" when there are finished/stale runs
1102
+ var hasFinished=runIds.some(function(rid){return runs[rid].done||!runs[rid].on});
1103
+ $('#liveClearBtn').style.display=hasFinished?'inline-block':'none';
1104
+
1105
+ // Header state
1106
+ var lbl=panel.querySelector('.live-header .label');
1107
+ var anyStale=runIds.some(function(rid){return runs[rid].stale});
1108
+ if(!gRunning&&gDone){
1109
+ lbl.textContent=anyStale?'COMPLETED (connection lost)':gFailed>0?'COMPLETED WITH FAILURES':'ALL TESTS PASSED';
1110
+ lbl.style.color=anyStale?'var(--yellow)':gFailed>0?'var(--red)':'var(--green)';
1111
+ var dot=lbl.querySelector('.dot');if(dot)dot.remove();
1112
+ $('#liveProgressFill').style.background=anyStale?'var(--yellow)':gFailed>0?'var(--red)':'var(--green)';
1113
+ } else {
1114
+ if(!lbl.querySelector('.dot')){
1115
+ lbl.textContent='';
1116
+ var d=el('span',{className:'dot'});lbl.appendChild(d);lbl.appendChild(document.createTextNode(' RUNNING'));
1117
+ }
1118
+ lbl.style.color='var(--purple)';
1119
+ $('#liveProgressFill').style.background='var(--purple)';
1120
+ }
1121
+
1122
+ // Render per-run sections
1123
+ grid.textContent='';
1124
+ runIds.forEach(function(rid){
1125
+ var L=runs[rid];
1126
+ // Project section header
1127
+ var projLabel=L.project||(L.cwd?L.cwd.split('/').pop():'Run');
1128
+ var runStatus=L.done?(L.failed>0?'fail':'pass'):'running';
1129
+ var dismissBtn=null;
1130
+ if(L.done||!L.on){
1131
+ dismissBtn=el('button',{className:'lr-dismiss',onclick:function(e){e.stopPropagation();dismissLiveRun(rid)}},'\u2715');
1132
+ }
1133
+ var sectionHeader=el('div',{className:'lr-section-header '+runStatus},[
1134
+ el('span',{className:'lr-project-name'},projLabel),
1135
+ createTriggerBadge(L.triggeredBy),
1136
+ el('span',{className:'lr-section-stats'},[
1137
+ el('span',{},L.completed+'/'+L.total),
1138
+ L.failed>0?el('span',{style:'color:var(--red);margin-left:6px'},L.failed+' failed'):null,
1139
+ L.on?el('span',{className:'spinner-small',style:'margin-left:6px'}):null
1140
+ ]),
1141
+ dismissBtn
1142
+ ]);
1143
+ grid.appendChild(sectionHeader);
1144
+
1145
+ var testGrid=el('div',{className:'lr-test-grid'});
1146
+ var names=Object.keys(L.tests);
1147
+ names.forEach(function(name){
1148
+ if(name==='__error')return;
1149
+ var t=L.tests[name];
1150
+ var testKey=rid+'::'+name;
1151
+ var iconText=t.status==='passed'?'\u2714':t.status==='failed'?'\u2718':'\u25CF';
1152
+ var iconColor=t.status==='passed'?'color:var(--green)':t.status==='failed'?'color:var(--red)':'color:var(--purple)';
1153
+ var meta='';
1154
+ if(t.status==='running'){
1155
+ meta=t.actionType?('Step '+(t.actions||0)+'/'+(t.totalActions||'?')):'starting...';
1156
+ if(t.retry)meta='Retry '+t.retry;
1157
+ } else if(t.status==='passed'){meta=t.duration||'done'}
1158
+ else if(t.status==='failed'){meta=t.error||'failed'}
1159
+ // Action log
1160
+ var stepsEl=el('div',{className:'lt-actions'});
1161
+ if(t.actionLog&&t.actionLog.length>0){
1162
+ t.actionLog.forEach(function(a){
1163
+ var detail=a.selector||a.value||a.text||'';
1164
+ var durText=a.duration!=null?(a.duration<1000?a.duration+'ms':(a.duration/1000).toFixed(1)+'s'):'';
1165
+ stepsEl.appendChild(el('div',{className:'lt-step'},[
1166
+ el('span',{className:'step-icon '+(a.success?'ok':'fail')},a.success?'\u2714':'\u2718'),
1167
+ el('span',{className:'step-type'},a.type),
1168
+ el('span',{className:'step-detail'},detail),
1169
+ el('span',{className:'step-dur'},durText)
1170
+ ]));
1171
+ });
1172
+ if(t.status==='running'&&t.actions<t.totalActions){
1173
+ stepsEl.appendChild(el('div',{className:'lt-step'},[el('span',{className:'step-icon run spinner-small'}),el('span',{className:'step-type',style:'opacity:.6'},'waiting...')]));
1174
+ }
1175
+ } else if(t.status==='running'){
1176
+ stepsEl.appendChild(el('div',{className:'lt-step'},[el('span',{className:'step-icon run spinner-small'}),el('span',{className:'step-type',style:'opacity:.6'},'connecting...')]));
1177
+ }
1178
+ var isFinished=t.status==='passed'||t.status==='failed';
1179
+ var isCollapsed=isFinished&&!S.liveExpanded.has(testKey);
1180
+ var summaryEl=el('div',{className:'lt-summary'},[
1181
+ el('span',{className:'lt-dur'},t.duration||''),
1182
+ el('span',{className:'lt-expand'},isCollapsed?'\u25BC':'\u25B2')
1183
+ ]);
1184
+ // Screenshots section
1185
+ var ssEl=null;
1186
+ var allSS=(t.screenshots||[]).slice();
1187
+ if(t.errorScreenshot)allSS.push(t.errorScreenshot);
1188
+ if(allSS.length>0){
1189
+ var ssOpen=S.liveSSOpen&&S.liveSSOpen.has(testKey);
1190
+ var toggle=el('div',{className:'lt-screenshots-toggle'+(ssOpen?' open':'')},[
1191
+ el('span',{className:'ss-arrow'},'\u25B6'),
1192
+ el('span',{},'Screenshots ('+allSS.length+')')
1193
+ ]);
1194
+ var ssGridEl=el('div',{className:'lt-screenshots-grid'});
1195
+ allSS.forEach(function(ssPath){
1196
+ var fname=ssPath.split('/').pop();
1197
+ var isErr=t.errorScreenshot&&ssPath===t.errorScreenshot;
1198
+ var thumb=el('div',{className:'lt-ss-thumb'});
1199
+ var img=document.createElement('img');
1200
+ img.src='/api/image?path='+encodeURIComponent(ssPath);
1201
+ img.alt=fname;img.loading='lazy';
1202
+ if(isErr)thumb.style.borderColor='var(--red)';
1203
+ thumb.appendChild(img);
1204
+ thumb.addEventListener('click',function(e){e.stopPropagation();openModal('/api/image?path='+encodeURIComponent(ssPath),fname)});
1205
+ var labelEl=el('div',{className:'lt-ss-label'},[el('span',{style:'overflow:hidden;text-overflow:ellipsis;white-space:nowrap'},fname)]);
1206
+ // Compute hash async and append badge
1207
+ (function(lbl,sp){
1208
+ ssHash(sp).then(function(h){lbl.appendChild(createHashBadge(h))});
1209
+ })(labelEl,ssPath);
1210
+ ssGridEl.appendChild(el('div',{},[thumb,labelEl]));
1211
+ });
1212
+ toggle.addEventListener('click',function(e){
1213
+ e.stopPropagation();
1214
+ if(S.liveSSOpen.has(testKey))S.liveSSOpen.delete(testKey);else S.liveSSOpen.add(testKey);
1215
+ toggle.classList.toggle('open');
1216
+ ssGridEl.style.display=ssGridEl.style.display==='grid'?'none':'grid';
1217
+ });
1218
+ if(ssOpen)ssGridEl.style.display='grid';
1219
+ ssEl=el('div',{className:'lt-screenshots'},[toggle,ssGridEl]);
1220
+ }
1221
+ var card=el('div',{className:'live-test '+t.status+(isCollapsed?' collapsed':'')},[
1222
+ el('div',{className:'lt-name'},[
1223
+ t.status==='running'?el('span',{className:'spinner'}):el('span',{className:'lt-icon',style:iconColor},iconText),
1224
+ document.createTextNode(' '+name),
1225
+ summaryEl
1226
+ ]),
1227
+ el('div',{className:'lt-meta'},meta),
1228
+ stepsEl
1229
+ ]);
1230
+ if(ssEl)card.appendChild(ssEl);
1231
+ // Network API logs in live view (clickable rows)
1232
+ if(t.networkLogs&&t.networkLogs.length&&!isCollapsed){
1233
+ var liveErrCount=t.networkLogs.filter(function(n){return n.status>=400}).length;
1234
+ var liveNetLabel='Network ('+t.networkLogs.length+(liveErrCount?', '+liveErrCount+' error'+(liveErrCount!==1?'s':'')+')':')');
1235
+ var liveNetToggle=el('div',{className:'rd-net-toggle',style:'margin:6px 0 0;padding-left:0'},[
1236
+ el('span',{className:'net-arrow'},'\u25B6'),
1237
+ el('span',{},liveNetLabel)
1238
+ ]);
1239
+ var liveNetList=el('div',{className:'rd-net-list'});
1240
+ t.networkLogs.forEach(function(n){
1241
+ var built=buildNetRow(n);
1242
+ liveNetList.appendChild(built.row);
1243
+ if(built.detail)liveNetList.appendChild(built.detail);
1244
+ });
1245
+ liveNetToggle.addEventListener('click',function(e){e.stopPropagation();liveNetToggle.classList.toggle('open')});
1246
+ card.appendChild(liveNetToggle);
1247
+ card.appendChild(liveNetList);
1248
+ }
1249
+ if(isFinished){
1250
+ card.addEventListener('click',function(){
1251
+ if(S.liveExpanded.has(testKey))S.liveExpanded.delete(testKey);
1252
+ else S.liveExpanded.add(testKey);
1253
+ renderLive();
1254
+ });
1255
+ }
1256
+ testGrid.appendChild(card);
1257
+ if(!isCollapsed)stepsEl.scrollTop=stepsEl.scrollHeight;
1258
+ });
1259
+ grid.appendChild(testGrid);
1260
+ });
1261
+ }
1262
+
1263
+ /* ── Actions ── */
1264
+ $('#btnRunAll').addEventListener('click',function(){triggerRun()});
1265
+
1266
+ /* ── Modal ── */
1267
+ function openModal(src){$('#modalImg').src=src;$('#modal').classList.add('open')}
1268
+ $('#modal').addEventListener('click',function(){$('#modal').classList.remove('open')});
1269
+ document.addEventListener('keydown',function(e){if(e.key==='Escape')$('#modal').classList.remove('open')});
1270
+
1271
+ /* ── Init ── */
1272
+ connectWS();
1273
+ refreshStatus();
1274
+ refreshProjects();
1275
+ refreshSuites();
1276
+ refreshRuns();
1277
+ refreshScreenshots();
1278
+ })();
1279
+ </script>
1280
+ </body>
1281
+ </html>