lexxit-automation-framework 3.0.5 → 3.0.6

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,591 @@
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>PW — Execution Dashboard</title>
7
+ <style>
8
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
9
+ :root{
10
+ --bg:#0a0d14;--surface:#111827;--s2:#1a2236;
11
+ --b:#1e2d45;--b2:#2d3f5e;
12
+ --t:#e2e8f0;--m:#64748b;--m2:#94a3b8;
13
+ --pass:#22c55e;--pb:#052e16;--pbd:#14532d;
14
+ --fail:#ef4444;--fb:#2d0a0a;--fbd:#7f1d1d;
15
+ --skip:#f59e0b;--sb:#2d1d00;--sbd:#78350f;
16
+ --run:#3b82f6;--rb:#0c1f3d;--rbd:#1e3a6e;
17
+ --r:10px;--rs:6px;
18
+ }
19
+ html,body{height:100%;background:var(--bg);color:var(--t);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
20
+ body{display:flex;flex-direction:column}
21
+
22
+ header{background:var(--surface);border-bottom:1px solid var(--b);padding:12px 24px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100;flex-shrink:0}
23
+ .h-left{display:flex;align-items:center;gap:12px}
24
+ .h-logo{font-size:1.4rem}
25
+ .h-title{font-size:.95rem;font-weight:700}
26
+ .h-sub{font-size:.68rem;color:var(--m);font-family:monospace;margin-top:2px}
27
+ .h-status{display:flex;align-items:center;gap:8px;font-size:.68rem;color:var(--m)}
28
+ .sse-dot{width:8px;height:8px;border-radius:50%;background:var(--m);flex-shrink:0;transition:background .3s}
29
+ .sse-dot.connected{background:var(--pass);box-shadow:0 0 0 2px rgba(34,197,94,.2);animation:pulse .9s ease-in-out infinite}
30
+ .sse-dot.error{background:var(--fail)}
31
+
32
+ .badge{display:inline-flex;align-items:center;gap:6px;padding:5px 12px;border-radius:99px;font-size:.72rem;font-weight:700;letter-spacing:.05em;border:1px solid transparent;text-transform:uppercase}
33
+ .badge .dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
34
+ .badge.running{background:var(--rb);color:var(--run);border-color:var(--rbd)}
35
+ .badge.running .dot{background:var(--run);animation:pulse .9s ease-in-out infinite}
36
+ .badge.pass{background:var(--pb);color:var(--pass);border-color:var(--pbd)}
37
+ .badge.pass .dot{background:var(--pass)}
38
+ .badge.fail{background:var(--fb);color:var(--fail);border-color:var(--fbd)}
39
+ .badge.fail .dot{background:var(--fail)}
40
+ .badge.pending{background:var(--s2);color:var(--m);border-color:var(--b2)}
41
+ .badge.pending .dot{background:var(--m)}
42
+ .badge.skip{background:var(--sb);color:var(--skip);border-color:var(--sbd)}
43
+ .badge.skip .dot{background:var(--skip)}
44
+
45
+ @keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.4;transform:scale(.8)}}
46
+ @keyframes spin{to{transform:rotate(360deg)}}
47
+ @keyframes fadeIn{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}
48
+ @keyframes shimmer{0%{background-position:-200% 0}100%{background-position:200% 0}}
49
+ @keyframes ringPulse{0%,100%{box-shadow:0 0 0 3px rgba(59,130,246,.2)}50%{box-shadow:0 0 0 6px rgba(59,130,246,.05)}}
50
+
51
+ .layout{display:flex;flex:1}
52
+
53
+ /* ── Sidebar ── */
54
+ .sidebar{width:280px;background:var(--surface);border-right:1px solid var(--b);overflow-y:auto;flex-shrink:0;display:flex;flex-direction:column;position:sticky;top:57px;height:calc(100vh - 57px);align-self:flex-start}
55
+ .sidebar-header{padding:14px 16px;font-size:.62rem;font-weight:700;color:var(--m);text-transform:uppercase;letter-spacing:.12em;border-bottom:1px solid var(--b);flex-shrink:0}
56
+ .exec-item{padding:12px 16px;cursor:pointer;border-bottom:1px solid var(--b);transition:background .15s;border-left:3px solid transparent}
57
+ .exec-item:hover{background:var(--s2)}
58
+ .exec-item.active{background:var(--s2);border-left-color:var(--run)}
59
+ .exec-name{font-size:.8rem;font-weight:600;color:var(--t);margin-bottom:5px}
60
+ .exec-meta{font-size:.66rem;color:var(--m);display:flex;align-items:center;gap:6px;flex-wrap:wrap}
61
+
62
+ /* ── Main ── */
63
+ .main{flex:1;padding:20px;display:flex;flex-direction:column;gap:18px}
64
+ .main-header{display:flex;justify-content:space-between;align-items:flex-start;gap:12px}
65
+ .main-title{font-size:1.1rem;font-weight:700}
66
+ .main-sub{font-size:.7rem;color:var(--m);margin-top:4px;font-family:monospace;line-height:1.6}
67
+
68
+ /* ── Scroll hint ── */
69
+ .scroll-hint{position:fixed;bottom:24px;right:24px;background:var(--run);color:#fff;border:none;border-radius:99px;padding:8px 16px;font-size:.72rem;font-weight:700;cursor:pointer;display:none;align-items:center;gap:6px;z-index:200;box-shadow:0 4px 16px rgba(59,130,246,.4);transition:opacity .2s}
70
+ .scroll-hint.visible{display:flex}
71
+ /* ── Progress ── */
72
+ .prog-wrap{display:flex;flex-direction:column;gap:6px}
73
+ .prog-labels{display:flex;justify-content:space-between;font-size:.7rem;color:var(--m)}
74
+ .prog-track{background:var(--s2);border-radius:99px;height:6px;border:1px solid var(--b);overflow:hidden}
75
+ .prog-fill{height:100%;border-radius:99px;background:linear-gradient(90deg,#4f46e5,var(--run));transition:width .6s cubic-bezier(.4,0,.2,1)}
76
+ .prog-fill.done{background:linear-gradient(90deg,#14532d,var(--pass))}
77
+ .prog-fill.fail{background:linear-gradient(90deg,#7f1d1d,var(--fail))}
78
+
79
+ /* ── Script block ── */
80
+ .script-block{background:var(--surface);border:1px solid var(--b);border-radius:var(--r);overflow:hidden;animation:fadeIn .3s ease}
81
+ .script-hdr{background:var(--s2);padding:14px 18px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--b)}
82
+ .script-hdr-left{display:flex;flex-direction:column;gap:3px}
83
+ .script-name{font-size:.9rem;font-weight:700}
84
+ .script-meta{font-size:.65rem;color:var(--m);font-family:monospace}
85
+
86
+ /* ── Stats ── */
87
+ .stats{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;padding:14px 16px;border-bottom:1px solid var(--b);background:rgba(0,0,0,.15)}
88
+ .stat{background:var(--s2);border:1px solid var(--b);border-radius:var(--r);padding:12px;text-align:center;transition:border-color .3s}
89
+ .stat-v{font-size:1.6rem;font-weight:800;line-height:1;transition:color .3s}
90
+ .stat-l{font-size:.58rem;color:var(--m);margin-top:5px;text-transform:uppercase;letter-spacing:.1em}
91
+ .stat.total .stat-v{color:var(--t)}
92
+ .stat.pass{border-color:var(--pbd)}.stat.pass .stat-v{color:var(--pass)}
93
+ .stat.fail{border-color:var(--fbd)}.stat.fail .stat-v{color:var(--fail)}
94
+ .stat.skip{border-color:var(--sbd)}.stat.skip .stat-v{color:var(--skip)}
95
+
96
+ /* ── Step timeline ── */
97
+ .steps-wrap{padding:10px 16px 10px;display:flex;flex-direction:column;gap:6px}
98
+
99
+ .step-item{display:flex;gap:12px;position:relative}
100
+ .step-item:not(:last-child)::after{content:'';position:absolute;left:19px;top:36px;bottom:-6px;width:2px;background:var(--b2);z-index:0}
101
+ .step-item.pass::after{background:var(--pbd)}
102
+ .step-item.fail::after{background:var(--fbd)}
103
+ .step-item.running::after{background:linear-gradient(to bottom,var(--rbd),var(--b2))}
104
+
105
+ .step-left{display:flex;flex-direction:column;align-items:center;flex-shrink:0;padding-top:8px;position:relative;z-index:1}
106
+ .snum{width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:.62rem;font-weight:800;flex-shrink:0;border:2px solid var(--b2);background:var(--surface);color:var(--m);transition:all .3s}
107
+ .step-item.pass .snum{background:var(--pb);border-color:var(--pass);color:var(--pass)}
108
+ .step-item.fail .snum{background:var(--fb);border-color:var(--fail);color:var(--fail)}
109
+ .step-item.skip .snum{background:var(--sb);border-color:var(--skip);color:var(--skip);opacity:.8}
110
+ .step-item.running .snum{background:var(--rb);border-color:var(--run);color:var(--run);animation:ringPulse 1.2s ease-in-out infinite}
111
+
112
+ .step-right{flex:1;min-width:0;padding:4px 0 4px}
113
+ .step-card{border-radius:var(--rs);border:1px solid var(--b);background:var(--s2);overflow:hidden;cursor:pointer;transition:border-color .2s,background .2s}
114
+ .step-card:hover{background:#1e2840;border-color:var(--b2)}
115
+ .step-item.running .step-card{border-color:var(--rbd);background:linear-gradient(135deg,var(--rb) 0%,#0d2040 100%)}
116
+ .step-item.pass .step-card{border-color:var(--pbd)}
117
+ .step-item.fail .step-card{border-color:var(--fbd)}
118
+ .step-item.skip .step-card{border-color:var(--sbd);opacity:.85}
119
+
120
+ .step-card-top{display:flex;align-items:center;gap:10px;padding:9px 12px}
121
+ .step-status-icon{flex-shrink:0;font-size:.78rem;width:18px;text-align:center}
122
+ .step-name-text{flex:1;font-size:.8rem;font-weight:500;color:#dde4f0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
123
+ .step-dur{font-size:.65rem;color:var(--m);flex-shrink:0;font-family:monospace;min-width:56px;text-align:right}
124
+ .step-chevron{color:var(--m);font-size:.6rem;flex-shrink:0;transition:transform .2s;margin-left:4px}
125
+ .step-chevron.open{transform:rotate(90deg)}
126
+
127
+ .running-bar{height:2px;background:linear-gradient(90deg,transparent,var(--run),transparent);background-size:200% 100%;animation:shimmer 1.5s linear infinite}
128
+
129
+ .step-details{display:none;padding:8px 12px 12px;border-top:1px solid var(--b);background:#060b12;font-size:.72rem;line-height:1.6}
130
+ .step-details.open{display:block;animation:fadeIn .15s ease}
131
+ .detail-row{padding:6px 10px;border-radius:var(--rs);background:var(--surface);border:1px solid var(--b2);color:var(--m2);margin-bottom:5px}
132
+ .detail-row:last-child{margin-bottom:0}
133
+ .detail-row b{color:#cfd6e2}
134
+ .detail-row.is-fail{background:var(--fb);border-color:var(--fbd);color:#fca5a5}
135
+ .detail-row.is-skip{background:var(--sb);border-color:var(--sbd);color:#fcd34d}
136
+ .detail-row.is-running{background:var(--rb);border-color:var(--rbd);color:#93c5fd}
137
+
138
+ .spinner{display:inline-block;width:10px;height:10px;border:2px solid var(--run);border-top-color:transparent;border-radius:50%;animation:spin .7s linear infinite;flex-shrink:0;vertical-align:middle}
139
+
140
+ .empty-state{color:var(--m);font-size:.85rem;text-align:center;margin-top:80px}
141
+ .empty-icon{font-size:2.5rem;margin-bottom:10px}
142
+ .wait-msg{padding:14px 16px;font-size:.72rem;color:var(--m);display:flex;align-items:center;gap:8px}
143
+ </style>
144
+ </head>
145
+ <body>
146
+
147
+ <header>
148
+ <div class="h-left">
149
+ <span class="h-logo">⚡</span>
150
+ <div>
151
+ <div class="h-title">Playwright Execution Dashboard</div>
152
+ <div class="h-sub">Real-time SSE &middot; port 5500</div>
153
+ </div>
154
+ </div>
155
+ <div class="h-status">
156
+ <div class="sse-dot" id="sseDot"></div>
157
+ <span id="sseLabel">Disconnected</span>
158
+ </div>
159
+ </header>
160
+
161
+ <div class="layout">
162
+ <div class="sidebar">
163
+ <div class="sidebar-header">Executions</div>
164
+ <div id="execList"></div>
165
+ </div>
166
+ <div class="main" id="main">
167
+ <div class="empty-state">
168
+ <div class="empty-icon">🚀</div>
169
+ Select an execution from the left to view live progress
170
+ </div>
171
+ </div>
172
+ </div>
173
+
174
+ <script>
175
+ let currentExecutionId = new URLSearchParams(window.location.search).get('execution_id') || null;
176
+ let openSteps = new Set();
177
+ let lastExecution = null;
178
+ let mainInitialized = false;
179
+ let es = null;
180
+
181
+ // ── SSE status indicator ───────────────────────────────────────────────────
182
+ function setSseStatus(state) {
183
+ const dot = document.getElementById('sseDot');
184
+ const label = document.getElementById('sseLabel');
185
+ if (!dot || !label) return;
186
+ dot.className = 'sse-dot ' + (state === 'connected' ? 'connected' : state === 'error' ? 'error' : '');
187
+ label.textContent = state === 'connected' ? 'Live' : state === 'error' ? 'Error' : 'Disconnected';
188
+ }
189
+
190
+ // ── Helpers ────────────────────────────────────────────────────────────────
191
+ function badge(status) {
192
+ return `<span class="badge ${status}"><span class="dot"></span>${status}</span>`;
193
+ }
194
+
195
+ function stepIcon(status) {
196
+ status=status.toLowerCase();
197
+ if (status === 'pass') return '<span style="color:var(--pass)">✓</span>';
198
+ if (status === 'fail') return '<span style="color:var(--fail)">✗</span>';
199
+ if (status === 'skip') return '<span style="color:var(--skip)">⏭</span>';
200
+ if (status === 'running') return '<span class="spinner"></span>';
201
+ return '<span style="color:var(--m)">○</span>';
202
+ }
203
+
204
+ function calcProgress(execution) {
205
+ let total = 0, done = 0;
206
+ execution.scripts.forEach(s => {
207
+ total += s.steps.length;
208
+ done += s.steps.filter(st => ['pass','fail','skip'].includes(st.status.toLowerCase())).length;
209
+ });
210
+ return total > 0 ? Math.round((done / total) * 100) : 0;
211
+ }
212
+
213
+ function setText(el, text) { if (el && el.textContent !== text) el.textContent = text; }
214
+ function setHTML(el, html) { if (el && el.innerHTML !== html) el.innerHTML = html; }
215
+
216
+ function scriptCounts(script) {
217
+ return {
218
+ total: script.steps.length,
219
+ passed: script.steps.filter(s => s.status.toLowerCase() === 'pass').length,
220
+ failed: script.steps.filter(s => s.status.toLowerCase() === 'fail').length,
221
+ skipped: script.steps.filter(s => s.status.toLowerCase() === 'skip').length
222
+ };
223
+ }
224
+
225
+ function buildDetailContent(step) {
226
+ const st = step.status.toLowerCase();
227
+ const cls = st === 'fail' ? 'detail-row is-fail'
228
+ : st === 'skip' ? 'detail-row is-skip'
229
+ : st === 'running' ? 'detail-row is-running'
230
+ : 'detail-row';
231
+ if (st === 'running') {
232
+ return `<div class="${cls}"><span class="spinner"></span>&nbsp; Step is executing...</div>`;
233
+ }
234
+ let html = '';
235
+ if (step.expected_result || step.comments) {
236
+ html += `<div class="${cls}">
237
+ ${step.expected_result ? `<b>Expected:</b> ${step.expected_result}<br>` : ''}
238
+ ${step.comments ? `<b>Comments:</b> ${step.comments}` : ''}
239
+ </div>`;
240
+ }
241
+ if (st === 'fail' && step.screenshot) {
242
+ html += `<div class="detail-row is-fail" style="padding:8px">
243
+ <div style="font-size:.65rem;color:#fca5a5;margin-bottom:6px;font-weight:700">&#128248; Screenshot</div>
244
+ <img src="data:image/png;base64,${step.screenshot}" style="width:100%;border-radius:4px;border:1px solid var(--fbd);display:block" alt="failure screenshot">
245
+ </div>`;
246
+ }
247
+ if (!html) {
248
+ html = `<div class="detail-row"><i style="color:var(--m)">No details available</i></div>`;
249
+ }
250
+ return html;
251
+ }
252
+
253
+ function buildStepsHTML(script) {
254
+ let html = '';
255
+ script.steps.forEach((step, index) => {
256
+ const key = `${script.test_script_uid}::${index}`;
257
+ const isOpen = openSteps.has(key);
258
+ const st = step.status;
259
+ html += `
260
+ <div class="step-item ${st}" id="step_${key}">
261
+ <div class="step-left"><div class="snum">${index + 1}</div></div>
262
+ <div class="step-right">
263
+ <div class="step-card" onclick="toggleStep('${key}')">
264
+ <div class="step-card-top">
265
+ <div class="step-status-icon">${stepIcon(st)}</div>
266
+ <div class="step-name-text">${step.step_name}</div>
267
+ <div class="step-dur">${step.duration || (st === 'running' ? '...' : '—')}</div>
268
+ <span class="step-chevron${isOpen ? ' open' : ''}">&#9656;</span>
269
+ </div>
270
+ ${st === 'running' ? '<div class="running-bar"></div>' : ''}
271
+ </div>
272
+ <div class="step-details${isOpen ? ' open' : ''}" id="det_${key}">
273
+ ${buildDetailContent(step)}
274
+ </div>
275
+ </div>
276
+ </div>`;
277
+ });
278
+ return html;
279
+ }
280
+
281
+ // ── Toggle accordion ───────────────────────────────────────────────────────
282
+ function toggleStep(key) {
283
+ if (openSteps.has(key)) openSteps.delete(key);
284
+ else openSteps.add(key);
285
+ if (lastExecution) updateMain(lastExecution);
286
+ }
287
+
288
+ // ── Sidebar ────────────────────────────────────────────────────────────────
289
+ async function fetchExecutions() {
290
+ try {
291
+ const res = await fetch('/api/executions', { cache: 'no-store' });
292
+ const list = await res.json();
293
+ renderSidebar(list);
294
+ } catch (e) {}
295
+ }
296
+
297
+ function renderSidebar(list) {
298
+ const container = document.getElementById('execList');
299
+
300
+ if (list.length === 0 && currentExecutionId) {
301
+ setHTML(container, `<div class="wait-msg"><span class="spinner"></span> Waiting for execution...</div>`);
302
+ return;
303
+ }
304
+
305
+ const existingIds = Array.from(container.querySelectorAll('.exec-item')).map(el => el.dataset.id);
306
+ const newIds = list.map(e => e.execution_id);
307
+
308
+ if (JSON.stringify(existingIds) !== JSON.stringify(newIds)) {
309
+ container.innerHTML = '';
310
+ list.forEach(exec => {
311
+ const item = document.createElement('div');
312
+ item.className = 'exec-item' + (exec.execution_id === currentExecutionId ? ' active' : '');
313
+ item.dataset.id = exec.execution_id;
314
+ item.onclick = () => {
315
+ currentExecutionId = exec.execution_id;
316
+ openSteps.clear();
317
+ mainInitialized = false;
318
+ const url = new URL(window.location);
319
+ url.searchParams.set('execution_id', currentExecutionId);
320
+ window.history.replaceState({}, '', url);
321
+ fetchExecutions();
322
+ fetchLive().then(() => connectSSE(currentExecutionId));
323
+ };
324
+ item.innerHTML = `
325
+ <div class="exec-name">${exec.test_set_name}</div>
326
+ <div class="exec-meta">${exec.start_time} ${badge(exec.status)}</div>`;
327
+ container.appendChild(item);
328
+ });
329
+ } else {
330
+ list.forEach(exec => {
331
+ const el = container.querySelector(`[data-id="${exec.execution_id}"]`);
332
+ if (!el) return;
333
+ el.className = 'exec-item' + (exec.execution_id === currentExecutionId ? ' active' : '');
334
+ const meta = el.querySelector('.exec-meta');
335
+ if (meta) meta.innerHTML = `${exec.start_time} ${badge(exec.status)}`;
336
+ });
337
+ }
338
+
339
+ if (!currentExecutionId && list.length > 0) {
340
+ currentExecutionId = list[0].execution_id;
341
+ renderSidebar(list);
342
+ }
343
+ }
344
+
345
+ // ── Fetch live snapshot ────────────────────────────────────────────────────
346
+ async function fetchLive() {
347
+ if (!currentExecutionId) return;
348
+ try {
349
+ const res = await fetch(`/api/live/${currentExecutionId}`, { cache: 'no-store' });
350
+ if (!res.ok) return;
351
+ const execution = await res.json();
352
+ lastExecution = execution;
353
+ updateMain(execution);
354
+ } catch (e) {}
355
+ }
356
+
357
+ // ── SSE connection ─────────────────────────────────────────────────────────
358
+ function connectSSE(executionId) {
359
+ if (es) { es.close(); es = null; }
360
+
361
+ setSseStatus('connecting');
362
+ es = new EventSource(`/api/stream/${executionId}`);
363
+
364
+ es.onopen = () => setSseStatus('connected');
365
+
366
+ es.onmessage = (e) => {
367
+ try {
368
+ const event = JSON.parse(e.data);
369
+ handleSSEEvent(event);
370
+ } catch (err) {}
371
+ };
372
+
373
+ es.onerror = () => {
374
+ setSseStatus('error');
375
+ };
376
+ }
377
+
378
+ // ── SSE event handler ──────────────────────────────────────────────────────
379
+ function handleSSEEvent(event) {
380
+ switch (event.type) {
381
+
382
+ case 'execution_start':
383
+ fetchLive();
384
+ break;
385
+
386
+ case 'script_start':
387
+ fetchLive();
388
+ break;
389
+
390
+ case 'step_start': {
391
+ if (!lastExecution) { fetchLive(); break; }
392
+ const sc1 = lastExecution.scripts.find(s => s.test_script_uid === event.data.test_script_uid);
393
+ if (sc1 && sc1.steps[event.data.step_index]) {
394
+ sc1.steps[event.data.step_index].status = event.data.status;
395
+ updateMain(lastExecution);
396
+ }
397
+ break;
398
+ }
399
+
400
+ case 'step_complete': {
401
+ if (!lastExecution) { fetchLive(); break; }
402
+ const sc2 = lastExecution.scripts.find(s => s.test_script_uid === event.data.test_script_uid);
403
+ if (sc2 && sc2.steps[event.data.step_index]) {
404
+ const step = sc2.steps[event.data.step_index];
405
+ step.status = event.data.status;
406
+ step.expected_result = event.data.expected_result;
407
+ step.comments = event.data.comments;
408
+ step.duration = event.data.duration;
409
+ step.screenshot = event.data.screenshot || null;
410
+ updateMain(lastExecution);
411
+ }
412
+ break;
413
+ }
414
+
415
+ case 'script_complete': {
416
+ if (!lastExecution) { fetchLive(); break; }
417
+ const sc3 = lastExecution.scripts.find(s => s.test_script_uid === event.data.test_script_uid);
418
+ if (sc3) {
419
+ sc3.status = event.data.status;
420
+ updateMain(lastExecution);
421
+ }
422
+ break;
423
+ }
424
+
425
+ case 'execution_complete': {
426
+ if (lastExecution) {
427
+ lastExecution.status = event.data.status;
428
+ lastExecution.end_time = event.timestamp.replace('T', ' ').split('.')[0];
429
+ updateMain(lastExecution);
430
+ }
431
+ fetchExecutions();
432
+ setSseStatus('disconnected');
433
+ if (es) { es.close(); es = null; }
434
+ break;
435
+ }
436
+
437
+ case 'log':
438
+ default:
439
+ break;
440
+ }
441
+ }
442
+
443
+ // ── Main panel — full build on first load, diff patch on updates ───────────
444
+ function updateMain(execution) {
445
+ const main = document.getElementById('main');
446
+ if (!mainInitialized) {
447
+ buildMainFull(main, execution);
448
+ mainInitialized = true;
449
+ return;
450
+ }
451
+ patchMain(main, execution);
452
+ }
453
+
454
+ function buildMainFull(main, execution) {
455
+ const progress = calcProgress(execution);
456
+ const status = execution.status.toLowerCase();
457
+ const isDone = status !== 'running';
458
+ const progClass = isDone ? (status === 'pass' ? 'done' : 'fail') : '';
459
+ const progColor = isDone ? (status === 'pass' ? 'var(--pass)' : 'var(--fail)') : 'var(--run)';
460
+
461
+ let html = `
462
+ <div id="mhdr" class="main-header">
463
+ <div style="flex:1;min-width:0">
464
+ <div class="main-title">${execution.test_set_name}</div>
465
+ <div class="main-sub" id="mSub">
466
+ 🕐 Started: ${execution.start_time}
467
+ ${execution.end_time ? ' &nbsp;·&nbsp; 🏁 Ended: ' + execution.end_time : ''}
468
+ <br>🆔 ${execution.execution_id}
469
+ </div>
470
+ </div>
471
+ <div id="mBadge">${badge(execution.status)}</div>
472
+ </div>
473
+ <div class="prog-wrap">
474
+ <div class="prog-labels">
475
+ <span>Overall progress</span>
476
+ <span id="progPct" style="font-weight:700;color:${progColor}">${progress}%</span>
477
+ </div>
478
+ <div class="prog-track">
479
+ <div id="progFill" class="prog-fill ${progClass}" style="width:${progress}%"></div>
480
+ </div>
481
+ </div>`;
482
+
483
+ execution.scripts.forEach(script => {
484
+ const {total, passed, failed, skipped} = scriptCounts(script);
485
+ html += `
486
+ <div class="script-block" id="sb_${script.test_script_uid}">
487
+ <div class="script-hdr">
488
+ <div class="script-hdr-left">
489
+ <div class="script-name">${script.test_case_name}</div>
490
+ <div class="script-meta">UID: ${script.test_script_uid}</div>
491
+ </div>
492
+ <div id="sbBadge_${script.test_script_uid}">${badge(script.status)}</div>
493
+ </div>
494
+ <div class="stats">
495
+ <div class="stat total"><div class="stat-v" id="stTotal_${script.test_script_uid}">${total}</div><div class="stat-l">Total</div></div>
496
+ <div class="stat pass"><div class="stat-v" id="stPass_${script.test_script_uid}">${passed}</div><div class="stat-l">Passed</div></div>
497
+ <div class="stat fail"><div class="stat-v" id="stFail_${script.test_script_uid}">${failed}</div><div class="stat-l">Failed</div></div>
498
+ <div class="stat skip"><div class="stat-v" id="stSkip_${script.test_script_uid}">${skipped}</div><div class="stat-l">Skipped</div></div>
499
+ </div>
500
+ <div class="steps-wrap" id="sw_${script.test_script_uid}">
501
+ ${buildStepsHTML(script)}
502
+ </div>
503
+ </div>`;
504
+ });
505
+
506
+ main.innerHTML = html;
507
+ }
508
+
509
+ function patchMain(main, execution) {
510
+ const progress = calcProgress(execution);
511
+ const status = execution.status.toLowerCase();
512
+ const isDone = status !== 'running';
513
+ const progClass = isDone ? (status === 'pass' ? 'done' : 'fail') : '';
514
+ const progColor = isDone ? (status === 'pass' ? 'var(--pass)' : 'var(--fail)') : 'var(--run)';
515
+
516
+ const pct = document.getElementById('progPct');
517
+ const fill = document.getElementById('progFill');
518
+ if (pct) { pct.textContent = progress + '%'; pct.style.color = progColor; }
519
+ if (fill) { fill.style.width = progress + '%'; fill.className = `prog-fill ${progClass}`; }
520
+
521
+ const mBadge = document.getElementById('mBadge');
522
+ if (mBadge) setHTML(mBadge, badge(execution.status));
523
+
524
+ const mSub = document.getElementById('mSub');
525
+ if (mSub) {
526
+ const newSub = `🕐 Started: ${execution.start_time}${execution.end_time ? ' &nbsp;·&nbsp; 🏁 Ended: ' + execution.end_time : ''}<br>🆔 ${execution.execution_id}`;
527
+ if (mSub.innerHTML !== newSub) mSub.innerHTML = newSub;
528
+ }
529
+
530
+ execution.scripts.forEach(script => {
531
+ const sbBadge = document.getElementById(`sbBadge_${script.test_script_uid}`);
532
+ if (sbBadge) setHTML(sbBadge, badge(script.status));
533
+
534
+ const {total, passed, failed, skipped} = scriptCounts(script);
535
+ setText(document.getElementById(`stTotal_${script.test_script_uid}`), String(total));
536
+ setText(document.getElementById(`stPass_${script.test_script_uid}`), String(passed));
537
+ setText(document.getElementById(`stFail_${script.test_script_uid}`), String(failed));
538
+ setText(document.getElementById(`stSkip_${script.test_script_uid}`), String(skipped));
539
+
540
+ script.steps.forEach((step, index) => {
541
+ const key = `${script.test_script_uid}::${index}`;
542
+ const isOpen = openSteps.has(key);
543
+ const st = step.status;
544
+
545
+ const stepEl = document.getElementById(`step_${key}`);
546
+ if (!stepEl) return;
547
+
548
+ if (stepEl.className !== `step-item ${st}`) stepEl.className = `step-item ${st}`;
549
+
550
+ const iconEl = stepEl.querySelector('.step-status-icon');
551
+ if (iconEl) setHTML(iconEl, stepIcon(st));
552
+
553
+ const durEl = stepEl.querySelector('.step-dur');
554
+ if (durEl) setText(durEl, step.duration || (st === 'running' ? '...' : '—'));
555
+
556
+ const card = stepEl.querySelector('.step-card');
557
+ const existingBar = card ? card.querySelector('.running-bar') : null;
558
+ if (st === 'running' && !existingBar && card) {
559
+ const bar = document.createElement('div');
560
+ bar.className = 'running-bar';
561
+ card.appendChild(bar);
562
+ } else if (st !== 'running' && existingBar) {
563
+ existingBar.remove();
564
+ }
565
+
566
+ const chev = stepEl.querySelector('.step-chevron');
567
+ if (chev) chev.className = `step-chevron${isOpen ? ' open' : ''}`;
568
+
569
+ const det = document.getElementById(`det_${key}`);
570
+ if (det) {
571
+ det.className = `step-details${isOpen ? ' open' : ''}`;
572
+ const newContent = buildDetailContent(step);
573
+ if (det.innerHTML !== newContent) det.innerHTML = newContent;
574
+ }
575
+ });
576
+ });
577
+ }
578
+
579
+ // ── Init ───────────────────────────────────────────────────────────────────
580
+ fetchExecutions();
581
+
582
+ if (currentExecutionId) {
583
+ fetchLive().then(() => connectSSE(currentExecutionId));
584
+ }
585
+
586
+ // Sidebar refresh every 5s (lightweight)
587
+ setInterval(fetchExecutions, 5000);
588
+ </script>
589
+
590
+ </body>
591
+ </html>