@scenetest/vite-plugin 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/dist/dashboard.d.ts +8 -2
- package/dist/dashboard.d.ts.map +1 -1
- package/dist/dashboard.js +25 -1210
- package/dist/dashboard.js.map +1 -1
- package/dist/event-hub.d.ts +5 -0
- package/dist/event-hub.d.ts.map +1 -1
- package/dist/event-hub.js +15 -2
- package/dist/event-hub.js.map +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/middleware.d.ts +2 -0
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +235 -65
- package/dist/middleware.js.map +1 -1
- package/dist/virtual-module.d.ts +3 -2
- package/dist/virtual-module.d.ts.map +1 -1
- package/dist/virtual-module.js +4 -3
- package/dist/virtual-module.js.map +1 -1
- package/package.json +10 -3
package/dist/dashboard.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Dev-mode shell for the dashboard at `/__scenetest/dashboard`.
|
|
3
|
+
*
|
|
4
|
+
* The dashboard UI itself lives in `@scenetest/dashboard` as a mountable
|
|
5
|
+
* widget (`mountDashboard`), shared with scenetest-cloud. This page is only a
|
|
6
|
+
* host: an importmap that points the widget's bare imports at the plugin's
|
|
7
|
+
* disk-served ESM (see the `/__scenetest/widget/*` and `/__scenetest/vendor/*`
|
|
8
|
+
* middleware routes), a mount point, and a one-line bootstrap that wires the
|
|
9
|
+
* widget to the dev transport (fetch + SSE against this same middleware).
|
|
4
10
|
*/
|
|
5
11
|
export function generateDashboardHtml() {
|
|
6
12
|
return `<!DOCTYPE html>
|
|
@@ -10,1218 +16,27 @@ export function generateDashboardHtml() {
|
|
|
10
16
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
11
17
|
<title>Scenetest Dashboard</title>
|
|
12
18
|
<style>
|
|
13
|
-
|
|
14
|
-
--bg: #0f1117;
|
|
15
|
-
--bg2: #1a1d27;
|
|
16
|
-
--bg3: #252833;
|
|
17
|
-
--border: #2e3140;
|
|
18
|
-
--text: #e1e4ed;
|
|
19
|
-
--text2: #8b8fa3;
|
|
20
|
-
--green: #22c55e;
|
|
21
|
-
--red: #ef4444;
|
|
22
|
-
--amber: #f59e0b;
|
|
23
|
-
--blue: #3b82f6;
|
|
24
|
-
--purple: #8b5cf6;
|
|
25
|
-
--cyan: #06b6d4;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
29
|
-
|
|
30
|
-
body {
|
|
31
|
-
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
|
|
32
|
-
background: var(--bg);
|
|
33
|
-
color: var(--text);
|
|
34
|
-
min-height: 100vh;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
header {
|
|
38
|
-
position: sticky;
|
|
39
|
-
top: 0;
|
|
40
|
-
padding: 16px 24px;
|
|
41
|
-
border-bottom: 1px solid var(--border);
|
|
42
|
-
display: flex;
|
|
43
|
-
align-items: center;
|
|
44
|
-
gap: 16px;
|
|
45
|
-
background: var(--bg2);
|
|
46
|
-
z-index: 100;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
header h1 {
|
|
50
|
-
font-size: 16px;
|
|
51
|
-
font-weight: 600;
|
|
52
|
-
display: flex;
|
|
53
|
-
align-items: center;
|
|
54
|
-
gap: 8px;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
.logo {
|
|
58
|
-
display: inline-flex;
|
|
59
|
-
align-items: center;
|
|
60
|
-
justify-content: center;
|
|
61
|
-
width: 28px;
|
|
62
|
-
height: 28px;
|
|
63
|
-
border-radius: 6px;
|
|
64
|
-
background: rgba(80, 70, 229, 0.15);
|
|
65
|
-
box-shadow: inset 0 1px 4px rgba(80, 70, 229, 0.3);
|
|
66
|
-
font-size: 14px;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
.status-bar {
|
|
70
|
-
display: flex;
|
|
71
|
-
gap: 16px;
|
|
72
|
-
margin-left: auto;
|
|
73
|
-
font-size: 13px;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
.stat {
|
|
77
|
-
display: flex;
|
|
78
|
-
align-items: center;
|
|
79
|
-
gap: 4px;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
.stat .label { color: var(--text2); }
|
|
83
|
-
.stat .value { font-weight: 600; }
|
|
84
|
-
.stat.pass .value { color: var(--green); }
|
|
85
|
-
.stat.fail .value { color: var(--red); }
|
|
86
|
-
|
|
87
|
-
.connection {
|
|
88
|
-
width: 8px;
|
|
89
|
-
height: 8px;
|
|
90
|
-
border-radius: 50%;
|
|
91
|
-
background: var(--amber);
|
|
92
|
-
transition: background 0.3s;
|
|
93
|
-
}
|
|
94
|
-
.connection.connected { background: var(--green); }
|
|
95
|
-
.connection.disconnected { background: var(--red); }
|
|
96
|
-
|
|
97
|
-
/* ─── Replay buttons ─────────────────────────────── */
|
|
98
|
-
.replay-btn {
|
|
99
|
-
display: inline-flex;
|
|
100
|
-
align-items: center;
|
|
101
|
-
gap: 5px;
|
|
102
|
-
padding: 4px 12px;
|
|
103
|
-
border: 1px solid var(--border);
|
|
104
|
-
border-radius: 4px;
|
|
105
|
-
background: transparent;
|
|
106
|
-
color: var(--text2);
|
|
107
|
-
font-family: inherit;
|
|
108
|
-
font-size: 12px;
|
|
109
|
-
cursor: pointer;
|
|
110
|
-
transition: all 0.15s;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
.replay-btn:hover {
|
|
114
|
-
background: rgba(59, 130, 246, 0.15);
|
|
115
|
-
border-color: var(--blue);
|
|
116
|
-
color: var(--blue);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
.replay-btn:disabled {
|
|
120
|
-
opacity: 0.4;
|
|
121
|
-
cursor: not-allowed;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
.replay-btn:disabled:hover {
|
|
125
|
-
background: transparent;
|
|
126
|
-
border-color: var(--border);
|
|
127
|
-
color: var(--text2);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
.replay-btn .play-icon {
|
|
131
|
-
font-size: 10px;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
.replay-all-btn {
|
|
135
|
-
margin-left: 12px;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
.team-select-wrap {
|
|
139
|
-
display: inline-flex;
|
|
140
|
-
align-items: center;
|
|
141
|
-
gap: 6px;
|
|
142
|
-
margin-left: 8px;
|
|
143
|
-
font-size: 12px;
|
|
144
|
-
color: var(--text2);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
.team-select {
|
|
148
|
-
background: var(--bg2);
|
|
149
|
-
color: var(--text1);
|
|
150
|
-
border: 1px solid var(--border);
|
|
151
|
-
border-radius: 4px;
|
|
152
|
-
padding: 3px 6px;
|
|
153
|
-
font-family: inherit;
|
|
154
|
-
font-size: 12px;
|
|
155
|
-
cursor: pointer;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
.team-select:hover {
|
|
159
|
-
border-color: var(--blue);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
.scene-replay-btn {
|
|
163
|
-
margin-left: auto;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
.copy-btn {
|
|
167
|
-
display: inline-flex;
|
|
168
|
-
align-items: center;
|
|
169
|
-
justify-content: center;
|
|
170
|
-
padding: 4px 8px;
|
|
171
|
-
border: 1px solid var(--border);
|
|
172
|
-
border-radius: 4px;
|
|
173
|
-
background: transparent;
|
|
174
|
-
color: var(--text2);
|
|
175
|
-
font-family: inherit;
|
|
176
|
-
font-size: 12px;
|
|
177
|
-
cursor: pointer;
|
|
178
|
-
transition: all 0.15s;
|
|
179
|
-
margin-left: 6px;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
.copy-btn:hover {
|
|
183
|
-
background: rgba(139, 92, 246, 0.15);
|
|
184
|
-
border-color: var(--purple);
|
|
185
|
-
color: var(--purple);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
.copy-btn.copied {
|
|
189
|
-
background: rgba(34, 197, 94, 0.15);
|
|
190
|
-
border-color: var(--green);
|
|
191
|
-
color: var(--green);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
.copy-btn .copy-icon {
|
|
195
|
-
font-size: 12px;
|
|
196
|
-
line-height: 1;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/* ─── Follow-output toggle ──────────────────────── */
|
|
200
|
-
.follow-toggle {
|
|
201
|
-
display: inline-flex;
|
|
202
|
-
align-items: center;
|
|
203
|
-
gap: 6px;
|
|
204
|
-
padding: 4px 10px;
|
|
205
|
-
border: 1px solid var(--border);
|
|
206
|
-
border-radius: 4px;
|
|
207
|
-
background: transparent;
|
|
208
|
-
color: var(--text2);
|
|
209
|
-
font-family: inherit;
|
|
210
|
-
font-size: 12px;
|
|
211
|
-
cursor: pointer;
|
|
212
|
-
transition: all 0.15s;
|
|
213
|
-
user-select: none;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
.follow-toggle:hover {
|
|
217
|
-
border-color: var(--cyan);
|
|
218
|
-
color: var(--cyan);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
.follow-toggle input {
|
|
222
|
-
margin: 0;
|
|
223
|
-
cursor: pointer;
|
|
224
|
-
accent-color: var(--cyan);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
.follow-toggle.active {
|
|
228
|
-
border-color: var(--cyan);
|
|
229
|
-
color: var(--cyan);
|
|
230
|
-
background: rgba(6, 182, 212, 0.08);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
.stop-btn, .pause-btn {
|
|
234
|
-
display: none;
|
|
235
|
-
align-items: center;
|
|
236
|
-
gap: 5px;
|
|
237
|
-
padding: 4px 12px;
|
|
238
|
-
border: 1px solid var(--border);
|
|
239
|
-
border-radius: 4px;
|
|
240
|
-
background: transparent;
|
|
241
|
-
color: var(--text2);
|
|
242
|
-
font-family: inherit;
|
|
243
|
-
font-size: 12px;
|
|
244
|
-
cursor: pointer;
|
|
245
|
-
transition: all 0.15s;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
.stop-btn:hover {
|
|
249
|
-
background: rgba(239, 68, 68, 0.15);
|
|
250
|
-
border-color: var(--red);
|
|
251
|
-
color: var(--red);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
.pause-btn:hover {
|
|
255
|
-
background: rgba(245, 158, 11, 0.15);
|
|
256
|
-
border-color: var(--amber);
|
|
257
|
-
color: var(--amber);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
.stop-btn .btn-icon, .pause-btn .btn-icon {
|
|
261
|
-
font-size: 10px;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
.running .stop-btn, .running .pause-btn {
|
|
265
|
-
display: inline-flex;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/* ─── Progress bar ───────────────────────────────── */
|
|
269
|
-
.progress-bar {
|
|
270
|
-
position: absolute;
|
|
271
|
-
bottom: 0;
|
|
272
|
-
left: 0;
|
|
273
|
-
right: 0;
|
|
274
|
-
height: 3px;
|
|
275
|
-
background: var(--border);
|
|
276
|
-
display: none;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
.progress-bar.visible {
|
|
280
|
-
display: block;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
.progress-bar .progress-fill {
|
|
284
|
-
height: 100%;
|
|
285
|
-
background: var(--blue);
|
|
286
|
-
transition: width 0.3s ease;
|
|
287
|
-
width: 0%;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
.progress-bar.done .progress-fill {
|
|
291
|
-
background: var(--green);
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
.progress-bar.has-failures .progress-fill {
|
|
295
|
-
background: var(--red);
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
main {
|
|
299
|
-
padding: 24px;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
.waiting {
|
|
303
|
-
text-align: center;
|
|
304
|
-
padding: 80px 24px;
|
|
305
|
-
color: var(--text2);
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
.waiting h2 {
|
|
309
|
-
font-size: 18px;
|
|
310
|
-
font-weight: 500;
|
|
311
|
-
margin-bottom: 8px;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
.waiting p {
|
|
315
|
-
font-size: 13px;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/* ─── Scene sections ──────────────────────────────── */
|
|
319
|
-
.scene-section {
|
|
320
|
-
margin-bottom: 32px;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
.scene-header {
|
|
324
|
-
display: flex;
|
|
325
|
-
align-items: center;
|
|
326
|
-
gap: 10px;
|
|
327
|
-
padding: 10px 14px;
|
|
328
|
-
background: var(--bg2);
|
|
329
|
-
border: 1px solid var(--border);
|
|
330
|
-
border-radius: 8px 8px 0 0;
|
|
331
|
-
font-size: 13px;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
.scene-header .icon {
|
|
335
|
-
font-size: 14px;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
.scene-header .name {
|
|
339
|
-
font-weight: 600;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
.scene-header .file {
|
|
343
|
-
color: var(--text2);
|
|
344
|
-
margin-left: auto;
|
|
345
|
-
font-size: 12px;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
.scene-header .team {
|
|
349
|
-
color: var(--blue);
|
|
350
|
-
font-size: 11px;
|
|
351
|
-
padding: 2px 6px;
|
|
352
|
-
border: 1px solid var(--border);
|
|
353
|
-
border-radius: 3px;
|
|
354
|
-
background: rgba(59, 130, 246, 0.08);
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
.scene-header .duration {
|
|
358
|
-
color: var(--text2);
|
|
359
|
-
font-size: 12px;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
/* ─── Scene errors ────────────────────────────────── */
|
|
363
|
-
.scene-errors {
|
|
364
|
-
border: 1px solid var(--border);
|
|
365
|
-
border-top: none;
|
|
366
|
-
background: rgba(239, 68, 68, 0.05);
|
|
367
|
-
padding: 8px 14px;
|
|
368
|
-
display: flex;
|
|
369
|
-
flex-direction: column;
|
|
370
|
-
gap: 4px;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
.scene-error-line {
|
|
374
|
-
font-size: 12px;
|
|
375
|
-
color: var(--red);
|
|
376
|
-
display: flex;
|
|
377
|
-
align-items: baseline;
|
|
378
|
-
gap: 8px;
|
|
379
|
-
line-height: 1.4;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
.scene-error-line .error-icon {
|
|
383
|
-
flex-shrink: 0;
|
|
384
|
-
font-size: 10px;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
.scene-error-line .error-action {
|
|
388
|
-
color: var(--text2);
|
|
389
|
-
flex-shrink: 0;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
.scene-error-line .error-msg {
|
|
393
|
-
word-break: break-word;
|
|
394
|
-
display: -webkit-box;
|
|
395
|
-
-webkit-line-clamp: 2;
|
|
396
|
-
line-clamp: 2;
|
|
397
|
-
-webkit-box-orient: vertical;
|
|
398
|
-
overflow: hidden;
|
|
399
|
-
flex: 1;
|
|
400
|
-
min-width: 0;
|
|
401
|
-
cursor: pointer;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
.scene-error-line.expanded .error-msg {
|
|
405
|
-
display: block;
|
|
406
|
-
-webkit-line-clamp: unset;
|
|
407
|
-
line-clamp: unset;
|
|
408
|
-
overflow: visible;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
.scene-error-line .expand-hint {
|
|
412
|
-
color: var(--text2);
|
|
413
|
-
font-size: 10px;
|
|
414
|
-
flex-shrink: 0;
|
|
415
|
-
opacity: 0.6;
|
|
416
|
-
margin-left: 4px;
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
/* ─── Swim lanes ──────────────────────────────────── */
|
|
420
|
-
.swim-lanes {
|
|
421
|
-
border: 1px solid var(--border);
|
|
422
|
-
border-top: none;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
.lane {
|
|
426
|
-
display: flex;
|
|
427
|
-
align-items: stretch;
|
|
428
|
-
border-bottom: 1px solid var(--border);
|
|
429
|
-
min-height: 48px;
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
.lane:last-child {
|
|
433
|
-
border-bottom: none;
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
.lane-label {
|
|
437
|
-
width: 120px;
|
|
438
|
-
min-width: 120px;
|
|
439
|
-
padding: 8px 14px;
|
|
440
|
-
background: var(--bg2);
|
|
441
|
-
border-right: 1px solid var(--border);
|
|
442
|
-
display: flex;
|
|
443
|
-
align-items: center;
|
|
444
|
-
font-size: 12px;
|
|
445
|
-
font-weight: 600;
|
|
446
|
-
color: var(--cyan);
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
.lane-track {
|
|
450
|
-
flex: 1;
|
|
451
|
-
position: relative;
|
|
452
|
-
padding: 6px 8px;
|
|
453
|
-
min-height: 48px;
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
.action-bar {
|
|
457
|
-
display: inline-flex;
|
|
458
|
-
align-items: center;
|
|
459
|
-
height: 28px;
|
|
460
|
-
margin: 3px 2px;
|
|
461
|
-
padding: 0 8px;
|
|
462
|
-
border-radius: 4px;
|
|
463
|
-
font-size: 11px;
|
|
464
|
-
white-space: nowrap;
|
|
465
|
-
position: relative;
|
|
466
|
-
transition: opacity 0.2s;
|
|
467
|
-
cursor: default;
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
.action-bar.success {
|
|
471
|
-
background: rgba(34, 197, 94, 0.15);
|
|
472
|
-
border: 1px solid rgba(34, 197, 94, 0.3);
|
|
473
|
-
color: var(--green);
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
.action-bar.error {
|
|
477
|
-
background: rgba(239, 68, 68, 0.15);
|
|
478
|
-
border: 1px solid rgba(239, 68, 68, 0.3);
|
|
479
|
-
color: var(--red);
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
.action-bar.running {
|
|
483
|
-
background: rgba(59, 130, 246, 0.15);
|
|
484
|
-
border: 1px solid rgba(59, 130, 246, 0.3);
|
|
485
|
-
color: var(--blue);
|
|
486
|
-
animation: pulse 1.5s ease-in-out infinite;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
.action-bar.slow {
|
|
490
|
-
background: rgba(245, 158, 11, 0.15);
|
|
491
|
-
border: 1px solid rgba(245, 158, 11, 0.3);
|
|
492
|
-
color: var(--amber);
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
.action-bar .duration {
|
|
496
|
-
margin-left: 6px;
|
|
497
|
-
opacity: 0.7;
|
|
498
|
-
font-size: 10px;
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
.action-bar:hover::after {
|
|
502
|
-
content: attr(data-tooltip);
|
|
503
|
-
position: absolute;
|
|
504
|
-
bottom: calc(100% + 4px);
|
|
505
|
-
left: 0;
|
|
506
|
-
background: var(--bg3);
|
|
507
|
-
border: 1px solid var(--border);
|
|
508
|
-
border-radius: 4px;
|
|
509
|
-
padding: 4px 8px;
|
|
510
|
-
font-size: 11px;
|
|
511
|
-
white-space: nowrap;
|
|
512
|
-
z-index: 50;
|
|
513
|
-
color: var(--text);
|
|
514
|
-
pointer-events: none;
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
/* ─── Assertion markers ───────────────────────────── */
|
|
518
|
-
.assertion-marker {
|
|
519
|
-
display: inline-flex;
|
|
520
|
-
align-items: center;
|
|
521
|
-
justify-content: center;
|
|
522
|
-
width: 20px;
|
|
523
|
-
height: 20px;
|
|
524
|
-
margin: 7px 1px;
|
|
525
|
-
border-radius: 3px;
|
|
526
|
-
font-size: 10px;
|
|
527
|
-
cursor: default;
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
.assertion-marker.pass {
|
|
531
|
-
background: rgba(34, 197, 94, 0.2);
|
|
532
|
-
color: var(--green);
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
.assertion-marker.fail {
|
|
536
|
-
background: rgba(239, 68, 68, 0.2);
|
|
537
|
-
color: var(--red);
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
/* ─── Time ruler ──────────────────────────────────── */
|
|
541
|
-
.time-ruler {
|
|
542
|
-
display: flex;
|
|
543
|
-
align-items: stretch;
|
|
544
|
-
border-bottom: 1px solid var(--border);
|
|
545
|
-
height: 24px;
|
|
546
|
-
background: var(--bg2);
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
.time-ruler .lane-label {
|
|
550
|
-
height: 24px;
|
|
551
|
-
min-height: 24px;
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
.time-ruler .ruler-track {
|
|
555
|
-
flex: 1;
|
|
556
|
-
position: relative;
|
|
557
|
-
overflow: hidden;
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
.tick {
|
|
561
|
-
position: absolute;
|
|
562
|
-
top: 0;
|
|
563
|
-
height: 100%;
|
|
564
|
-
border-left: 1px solid var(--border);
|
|
565
|
-
font-size: 9px;
|
|
566
|
-
color: var(--text2);
|
|
567
|
-
padding-left: 4px;
|
|
568
|
-
line-height: 24px;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
/* ─── Event log ───────────────────────────────────── */
|
|
572
|
-
.event-log {
|
|
573
|
-
margin-top: 24px;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
.event-log h3 {
|
|
577
|
-
font-size: 13px;
|
|
578
|
-
font-weight: 600;
|
|
579
|
-
margin-bottom: 8px;
|
|
580
|
-
color: var(--text2);
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
.log-entry {
|
|
584
|
-
font-size: 12px;
|
|
585
|
-
padding: 3px 0;
|
|
586
|
-
color: var(--text2);
|
|
587
|
-
display: flex;
|
|
588
|
-
gap: 8px;
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
.log-entry .ts {
|
|
592
|
-
color: var(--text2);
|
|
593
|
-
opacity: 0.6;
|
|
594
|
-
min-width: 70px;
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
.log-entry .actor {
|
|
598
|
-
color: var(--cyan);
|
|
599
|
-
min-width: 80px;
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
.log-entry .msg { color: var(--text); }
|
|
603
|
-
.log-entry.error .msg { color: var(--red); }
|
|
604
|
-
.log-entry.pass .msg { color: var(--green); }
|
|
605
|
-
|
|
606
|
-
@keyframes pulse {
|
|
607
|
-
0%, 100% { opacity: 1; }
|
|
608
|
-
50% { opacity: 0.6; }
|
|
609
|
-
}
|
|
19
|
+
html, body, #root { height: 100%; margin: 0; background: #0f1117; }
|
|
610
20
|
</style>
|
|
21
|
+
<script type="importmap">
|
|
22
|
+
{
|
|
23
|
+
"imports": {
|
|
24
|
+
"preact": "/__scenetest/vendor/preact.js",
|
|
25
|
+
"preact/hooks": "/__scenetest/vendor/preact-hooks.js",
|
|
26
|
+
"htm": "/__scenetest/vendor/htm.js",
|
|
27
|
+
"@scenetest/protocol": "/__scenetest/widget/protocol/index.js",
|
|
28
|
+
"@scenetest/dashboard": "/__scenetest/widget/dashboard/index.js"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
</script>
|
|
611
32
|
</head>
|
|
612
33
|
<body>
|
|
613
|
-
<
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
<label class="team-select-wrap" title="When set, replays run only on the selected team. Populated as scenes execute.">
|
|
619
|
-
Team:
|
|
620
|
-
<select id="team-select" class="team-select" onchange="onTeamSelectChange(event)">
|
|
621
|
-
<option value="">all teams</option>
|
|
622
|
-
</select>
|
|
623
|
-
</label>
|
|
624
|
-
<button class="pause-btn" id="pause-btn" onclick="togglePause()">
|
|
625
|
-
<span class="btn-icon" id="pause-icon">▮▮</span> <span id="pause-label">Pause</span>
|
|
626
|
-
</button>
|
|
627
|
-
<button class="stop-btn" id="stop-btn" onclick="stopRun()">
|
|
628
|
-
<span class="btn-icon">■</span> Stop
|
|
629
|
-
</button>
|
|
630
|
-
<label class="follow-toggle active" id="follow-toggle" title="Auto-scroll to the newest scene. Scrolling up manually turns this off.">
|
|
631
|
-
<input type="checkbox" id="follow-checkbox" checked>
|
|
632
|
-
<span>Follow output</span>
|
|
633
|
-
</label>
|
|
634
|
-
<div class="status-bar">
|
|
635
|
-
<div class="stat scenes">
|
|
636
|
-
<span class="label">Scenes:</span>
|
|
637
|
-
<span class="value" id="scene-count">0</span>
|
|
638
|
-
</div>
|
|
639
|
-
<div class="stat pass">
|
|
640
|
-
<span class="label">Pass:</span>
|
|
641
|
-
<span class="value" id="pass-count">0</span>
|
|
642
|
-
</div>
|
|
643
|
-
<div class="stat fail">
|
|
644
|
-
<span class="label">Fail:</span>
|
|
645
|
-
<span class="value" id="fail-count">0</span>
|
|
646
|
-
</div>
|
|
647
|
-
<div class="stat">
|
|
648
|
-
<span class="label">Time:</span>
|
|
649
|
-
<span class="value" id="elapsed">-</span>
|
|
650
|
-
</div>
|
|
651
|
-
<div class="connection" id="connection" title="SSE connection"></div>
|
|
652
|
-
</div>
|
|
653
|
-
<div class="progress-bar" id="progress-bar"><div class="progress-fill" id="progress-fill"></div></div>
|
|
654
|
-
</header>
|
|
655
|
-
|
|
656
|
-
<main id="main">
|
|
657
|
-
<div class="waiting" id="waiting">
|
|
658
|
-
<h2>Waiting for scene run...</h2>
|
|
659
|
-
<p>Run <code>scenetest</code> to see the live timeline here.</p>
|
|
660
|
-
</div>
|
|
661
|
-
<div id="scenes"></div>
|
|
662
|
-
</main>
|
|
663
|
-
|
|
664
|
-
<script>
|
|
665
|
-
// ─── State ────────────────────────────────────────
|
|
666
|
-
const state = {
|
|
667
|
-
scenes: [], // { name, file, actors, actions: Map<actor, []>, assertions: [], startTime, endTime, status, team, teamIndex }
|
|
668
|
-
currentScene: null,
|
|
669
|
-
runStartTime: null,
|
|
670
|
-
passCount: 0,
|
|
671
|
-
failCount: 0,
|
|
672
|
-
sceneCount: 0,
|
|
673
|
-
followOutput: true,
|
|
674
|
-
teams: new Set(), // team names seen in scene:start events
|
|
675
|
-
replayTeam: '', // currently selected team to replay against ('' = all teams)
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
// ─── Follow-output toggle ────────────────────────
|
|
679
|
-
var followToggleEl = document.getElementById('follow-toggle')
|
|
680
|
-
var followCheckboxEl = document.getElementById('follow-checkbox')
|
|
681
|
-
|
|
682
|
-
function setFollowOutput(enabled, opts) {
|
|
683
|
-
state.followOutput = enabled
|
|
684
|
-
followCheckboxEl.checked = enabled
|
|
685
|
-
followToggleEl.classList.toggle('active', enabled)
|
|
686
|
-
if (enabled && !(opts && opts.skipScroll)) {
|
|
687
|
-
scrollToLatestScene()
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
followToggleEl.addEventListener('click', function(e) {
|
|
692
|
-
// The label's default click also toggles the checkbox; intercept both paths.
|
|
693
|
-
if (e.target !== followCheckboxEl) {
|
|
694
|
-
e.preventDefault()
|
|
695
|
-
setFollowOutput(!state.followOutput)
|
|
696
|
-
} else {
|
|
697
|
-
// checkbox was clicked directly; sync state after browser toggles it
|
|
698
|
-
setTimeout(function() { setFollowOutput(followCheckboxEl.checked) }, 0)
|
|
699
|
-
}
|
|
34
|
+
<div id="root"></div>
|
|
35
|
+
<script type="module">
|
|
36
|
+
import { mountDashboard, createDevTransport } from '@scenetest/dashboard'
|
|
37
|
+
mountDashboard(document.getElementById('root'), {
|
|
38
|
+
transport: createDevTransport(),
|
|
700
39
|
})
|
|
701
|
-
|
|
702
|
-
function scrollToLatestScene() {
|
|
703
|
-
if (state.scenes.length === 0) return
|
|
704
|
-
var idx = state.scenes.length - 1
|
|
705
|
-
var el = document.getElementById('scene-' + idx)
|
|
706
|
-
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
// User-initiated scroll disables follow. We only listen to events that
|
|
710
|
-
// come from direct user input (wheel, touch, scroll-keys) — programmatic
|
|
711
|
-
// scrollIntoView() does not fire these, so it won't accidentally toggle.
|
|
712
|
-
function onUserScrollIntent() {
|
|
713
|
-
if (state.followOutput) setFollowOutput(false, { skipScroll: true })
|
|
714
|
-
}
|
|
715
|
-
window.addEventListener('wheel', onUserScrollIntent, { passive: true })
|
|
716
|
-
window.addEventListener('touchmove', onUserScrollIntent, { passive: true })
|
|
717
|
-
window.addEventListener('keydown', function(e) {
|
|
718
|
-
var scrollKeys = ['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' ']
|
|
719
|
-
if (scrollKeys.indexOf(e.key) !== -1) onUserScrollIntent()
|
|
720
|
-
})
|
|
721
|
-
|
|
722
|
-
// ─── SSE Connection ───────────────────────────────
|
|
723
|
-
const evtSource = new EventSource('/__scenetest/events')
|
|
724
|
-
const connEl = document.getElementById('connection')
|
|
725
|
-
|
|
726
|
-
evtSource.onopen = () => {
|
|
727
|
-
connEl.classList.add('connected')
|
|
728
|
-
connEl.classList.remove('disconnected')
|
|
729
|
-
connEl.title = 'Connected'
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
evtSource.onerror = () => {
|
|
733
|
-
connEl.classList.add('disconnected')
|
|
734
|
-
connEl.classList.remove('connected')
|
|
735
|
-
connEl.title = 'Disconnected — retrying...'
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
evtSource.onmessage = (e) => {
|
|
739
|
-
try {
|
|
740
|
-
const event = JSON.parse(e.data)
|
|
741
|
-
handleEvent(event)
|
|
742
|
-
} catch {}
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
// ─── Event handling ───────────────────────────────
|
|
746
|
-
function handleEvent(event) {
|
|
747
|
-
switch (event.type) {
|
|
748
|
-
case 'run:start':
|
|
749
|
-
// Clear previous run
|
|
750
|
-
state.scenes = []
|
|
751
|
-
state.currentScene = null
|
|
752
|
-
state.runStartTime = event.timestamp
|
|
753
|
-
state.passCount = 0
|
|
754
|
-
state.failCount = 0
|
|
755
|
-
state.sceneCount = event.sceneCount
|
|
756
|
-
document.getElementById('waiting').style.display = 'none'
|
|
757
|
-
document.getElementById('scenes').innerHTML = ''
|
|
758
|
-
setRunning(true)
|
|
759
|
-
updateStats()
|
|
760
|
-
break
|
|
761
|
-
|
|
762
|
-
case 'scene:start': {
|
|
763
|
-
const scene = {
|
|
764
|
-
name: event.name,
|
|
765
|
-
file: event.file,
|
|
766
|
-
actors: event.actors || [],
|
|
767
|
-
actions: new Map(),
|
|
768
|
-
assertions: [],
|
|
769
|
-
startTime: event.timestamp,
|
|
770
|
-
endTime: null,
|
|
771
|
-
status: 'running',
|
|
772
|
-
team: event.team || {},
|
|
773
|
-
teamIndex: event.teamIndex == null ? 0 : event.teamIndex,
|
|
774
|
-
}
|
|
775
|
-
// Initialize lanes for declared actors
|
|
776
|
-
for (const actor of scene.actors) {
|
|
777
|
-
scene.actions.set(actor, [])
|
|
778
|
-
}
|
|
779
|
-
state.scenes.push(scene)
|
|
780
|
-
state.currentScene = scene
|
|
781
|
-
if (scene.team && scene.team.name && !state.teams.has(scene.team.name)) {
|
|
782
|
-
state.teams.add(scene.team.name)
|
|
783
|
-
renderTeamSelect()
|
|
784
|
-
}
|
|
785
|
-
renderScene(scene)
|
|
786
|
-
// Auto-scroll to keep the running scene visible, only when the
|
|
787
|
-
// user has follow-output enabled. Scrolling up manually turns it off.
|
|
788
|
-
if (state.followOutput) {
|
|
789
|
-
var sceneEl = document.getElementById('scene-' + (state.scenes.length - 1))
|
|
790
|
-
if (sceneEl) sceneEl.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
791
|
-
}
|
|
792
|
-
break
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
case 'action:start': {
|
|
796
|
-
const scene = state.currentScene
|
|
797
|
-
if (!scene) break
|
|
798
|
-
// Ensure lane exists for this actor
|
|
799
|
-
if (!scene.actions.has(event.actor)) {
|
|
800
|
-
scene.actions.set(event.actor, [])
|
|
801
|
-
scene.actors.push(event.actor)
|
|
802
|
-
}
|
|
803
|
-
const actions = scene.actions.get(event.actor)
|
|
804
|
-
actions.push({
|
|
805
|
-
action: event.action,
|
|
806
|
-
target: event.target,
|
|
807
|
-
startTime: event.timestamp,
|
|
808
|
-
endTime: null,
|
|
809
|
-
duration: null,
|
|
810
|
-
error: null,
|
|
811
|
-
status: 'running',
|
|
812
|
-
})
|
|
813
|
-
renderScene(scene)
|
|
814
|
-
break
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
case 'action:end': {
|
|
818
|
-
const scene = state.currentScene
|
|
819
|
-
if (!scene) break
|
|
820
|
-
const actions = scene.actions.get(event.actor)
|
|
821
|
-
if (!actions) break
|
|
822
|
-
// Find the running action (last one that matches)
|
|
823
|
-
for (let i = actions.length - 1; i >= 0; i--) {
|
|
824
|
-
if (actions[i].status === 'running' && actions[i].action === event.action) {
|
|
825
|
-
actions[i].endTime = event.timestamp
|
|
826
|
-
actions[i].duration = event.duration
|
|
827
|
-
actions[i].error = event.error || null
|
|
828
|
-
actions[i].status = event.error ? 'error' : (event.duration > 500 ? 'slow' : 'success')
|
|
829
|
-
break
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
renderScene(scene)
|
|
833
|
-
break
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
case 'assertion': {
|
|
837
|
-
const scene = state.currentScene
|
|
838
|
-
if (!scene) break
|
|
839
|
-
scene.assertions.push({
|
|
840
|
-
actor: event.actor,
|
|
841
|
-
description: event.description,
|
|
842
|
-
result: event.result,
|
|
843
|
-
timestamp: event.timestamp,
|
|
844
|
-
})
|
|
845
|
-
renderScene(scene)
|
|
846
|
-
break
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
case 'scene:end': {
|
|
850
|
-
const scene = state.currentScene
|
|
851
|
-
if (!scene) break
|
|
852
|
-
scene.endTime = event.timestamp
|
|
853
|
-
scene.status = event.status
|
|
854
|
-
scene.duration = event.duration
|
|
855
|
-
scene.error = event.error
|
|
856
|
-
if (event.status === 'completed') state.passCount++
|
|
857
|
-
else state.failCount++
|
|
858
|
-
state.currentScene = null
|
|
859
|
-
renderScene(scene)
|
|
860
|
-
updateStats()
|
|
861
|
-
break
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
case 'run:end':
|
|
865
|
-
state.sceneCount = event.summary?.scenes || state.sceneCount
|
|
866
|
-
if (event.summary) {
|
|
867
|
-
state.passCount = event.summary.completed ?? state.passCount
|
|
868
|
-
state.failCount = event.summary.failed ?? state.failCount
|
|
869
|
-
}
|
|
870
|
-
document.getElementById('elapsed').textContent = event.duration + 'ms'
|
|
871
|
-
setRunning(false)
|
|
872
|
-
updateStats()
|
|
873
|
-
break
|
|
874
|
-
}
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
// ─── Stats ────────────────────────────────────────
|
|
878
|
-
function updateStats() {
|
|
879
|
-
const completed = state.scenes.filter(s => s.status !== 'running').length
|
|
880
|
-
document.getElementById('scene-count').textContent =
|
|
881
|
-
completed + '/' + state.sceneCount
|
|
882
|
-
document.getElementById('pass-count').textContent = state.passCount
|
|
883
|
-
document.getElementById('fail-count').textContent = state.failCount
|
|
884
|
-
|
|
885
|
-
if (state.runStartTime && state.scenes.some(s => s.status === 'running')) {
|
|
886
|
-
document.getElementById('elapsed').textContent =
|
|
887
|
-
(Date.now() - state.runStartTime) + 'ms'
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
// Progress bar
|
|
891
|
-
var bar = document.getElementById('progress-bar')
|
|
892
|
-
var fill = document.getElementById('progress-fill')
|
|
893
|
-
if (state.sceneCount > 0) {
|
|
894
|
-
bar.classList.add('visible')
|
|
895
|
-
var pct = Math.round((completed / state.sceneCount) * 100)
|
|
896
|
-
fill.style.width = pct + '%'
|
|
897
|
-
bar.classList.toggle('done', completed === state.sceneCount && state.failCount === 0)
|
|
898
|
-
bar.classList.toggle('has-failures', state.failCount > 0)
|
|
899
|
-
}
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
// Update elapsed timer
|
|
903
|
-
setInterval(() => {
|
|
904
|
-
if (state.runStartTime && state.scenes.some(s => s.status === 'running')) {
|
|
905
|
-
document.getElementById('elapsed').textContent =
|
|
906
|
-
(Date.now() - state.runStartTime) + 'ms'
|
|
907
|
-
}
|
|
908
|
-
}, 200)
|
|
909
|
-
|
|
910
|
-
// ─── Rendering ────────────────────────────────────
|
|
911
|
-
function renderScene(scene) {
|
|
912
|
-
const idx = state.scenes.indexOf(scene)
|
|
913
|
-
let el = document.getElementById('scene-' + idx)
|
|
914
|
-
if (!el) {
|
|
915
|
-
el = document.createElement('div')
|
|
916
|
-
el.id = 'scene-' + idx
|
|
917
|
-
el.className = 'scene-section'
|
|
918
|
-
document.getElementById('scenes').appendChild(el)
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
const statusIcon = scene.status === 'completed' ? '\\u2713'
|
|
922
|
-
: scene.status === 'failed' ? '\\u2717'
|
|
923
|
-
: scene.status === 'timeout' ? '\\u23F1'
|
|
924
|
-
: '\\u25B6'
|
|
925
|
-
|
|
926
|
-
const statusColor = scene.status === 'completed' ? 'var(--green)'
|
|
927
|
-
: scene.status === 'failed' || scene.status === 'timeout' ? 'var(--red)'
|
|
928
|
-
: 'var(--blue)'
|
|
929
|
-
|
|
930
|
-
const durationStr = scene.duration ? scene.duration + 'ms' : 'running...'
|
|
931
|
-
|
|
932
|
-
// Build swim lanes
|
|
933
|
-
let lanesHtml = ''
|
|
934
|
-
const actors = Array.from(scene.actions.keys())
|
|
935
|
-
|
|
936
|
-
// Calculate time range for positioning
|
|
937
|
-
let minTime = scene.startTime
|
|
938
|
-
let maxTime = scene.endTime || Date.now()
|
|
939
|
-
for (const actions of scene.actions.values()) {
|
|
940
|
-
for (const a of actions) {
|
|
941
|
-
if (a.endTime && a.endTime > maxTime) maxTime = a.endTime
|
|
942
|
-
}
|
|
943
|
-
}
|
|
944
|
-
const timeSpan = Math.max(maxTime - minTime, 1)
|
|
945
|
-
|
|
946
|
-
// Time ruler
|
|
947
|
-
const tickCount = 5
|
|
948
|
-
let rulerHtml = ''
|
|
949
|
-
for (let i = 0; i <= tickCount; i++) {
|
|
950
|
-
const pct = (i / tickCount) * 100
|
|
951
|
-
const ms = Math.round((i / tickCount) * timeSpan)
|
|
952
|
-
rulerHtml += '<div class="tick" style="left: ' + pct + '%">' + formatMs(ms) + '</div>'
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
for (const actor of actors) {
|
|
956
|
-
const actions = scene.actions.get(actor) || []
|
|
957
|
-
let barsHtml = ''
|
|
958
|
-
|
|
959
|
-
for (const a of actions) {
|
|
960
|
-
const offsetPct = ((a.startTime - minTime) / timeSpan) * 100
|
|
961
|
-
const durMs = a.duration || (Date.now() - a.startTime)
|
|
962
|
-
const widthPct = Math.max((durMs / timeSpan) * 100, 2)
|
|
963
|
-
|
|
964
|
-
const cls = a.status
|
|
965
|
-
const label = a.action + (a.target ? '(' + escapeHtml(a.target) + ')' : '')
|
|
966
|
-
const durLabel = a.duration ? formatMs(a.duration) : '...'
|
|
967
|
-
const tooltip = label + ' — ' + durLabel + (a.error ? ' — ' + escapeHtml(a.error) : '')
|
|
968
|
-
|
|
969
|
-
barsHtml += '<div class="action-bar ' + cls + '" ' +
|
|
970
|
-
'style="position:absolute; left:' + offsetPct + '%; width:' + widthPct + '%;" ' +
|
|
971
|
-
'data-tooltip="' + escapeHtml(tooltip) + '">' +
|
|
972
|
-
'<span class="name">' + escapeHtml(a.action) + '</span>' +
|
|
973
|
-
(a.duration ? '<span class="duration">' + formatMs(a.duration) + '</span>' : '') +
|
|
974
|
-
'</div>'
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
// Assertion markers for this actor
|
|
978
|
-
const actorAssertions = scene.assertions.filter(a => a.actor === actor)
|
|
979
|
-
for (const a of actorAssertions) {
|
|
980
|
-
const offsetPct = ((a.timestamp - minTime) / timeSpan) * 100
|
|
981
|
-
const cls = a.result ? 'pass' : 'fail'
|
|
982
|
-
const icon = a.result ? '\\u2713' : '\\u2717'
|
|
983
|
-
barsHtml += '<div class="assertion-marker ' + cls + '" ' +
|
|
984
|
-
'style="position:absolute; left:' + offsetPct + '%; top:50%; transform:translateY(-50%);" ' +
|
|
985
|
-
'title="' + escapeHtml(a.description) + '">' + icon + '</div>'
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
lanesHtml += '<div class="lane">' +
|
|
989
|
-
'<div class="lane-label">' + escapeHtml(actor) + '</div>' +
|
|
990
|
-
'<div class="lane-track" style="position:relative;">' + barsHtml + '</div>' +
|
|
991
|
-
'</div>'
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
const replayDisabled = state.scenes.some(s => s.status === 'running') ? ' disabled' : ''
|
|
995
|
-
const replayFileAttr = scene.file ? ' data-file="' + escapeHtml(scene.file) + '"' : ''
|
|
996
|
-
|
|
997
|
-
// Collect errors from failed actions and scene-level error
|
|
998
|
-
var errors = []
|
|
999
|
-
for (var _a of scene.actions.values()) {
|
|
1000
|
-
for (var _act of _a) {
|
|
1001
|
-
if (_act.error) {
|
|
1002
|
-
errors.push({ action: _act.action, target: _act.target, msg: _act.error })
|
|
1003
|
-
}
|
|
1004
|
-
}
|
|
1005
|
-
}
|
|
1006
|
-
if (scene.error && !errors.some(function(e) { return e.msg === scene.error })) {
|
|
1007
|
-
errors.push({ action: null, target: null, msg: scene.error })
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
var errorsHtml = ''
|
|
1011
|
-
if (errors.length > 0) {
|
|
1012
|
-
var lines = ''
|
|
1013
|
-
for (var _e of errors) {
|
|
1014
|
-
var actionLabel = _e.action
|
|
1015
|
-
? '<span class="error-action">' + escapeHtml(_e.action + (_e.target ? '(' + _e.target + ')' : '')) + '</span> '
|
|
1016
|
-
: ''
|
|
1017
|
-
lines += '<div class="scene-error-line" onclick="toggleErrorExpand(this)" title="Click to expand/collapse">' +
|
|
1018
|
-
'<span class="error-icon">\\u2717</span> ' +
|
|
1019
|
-
actionLabel +
|
|
1020
|
-
'<span class="error-msg">' + escapeHtml(_e.msg) + '</span>' +
|
|
1021
|
-
'</div>'
|
|
1022
|
-
}
|
|
1023
|
-
errorsHtml = '<div class="scene-errors" style="border-radius: 0 0 8px 8px;">' + lines + '</div>'
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
var lanesRadius = errors.length > 0 ? '' : ' style="border-radius: 0 0 8px 8px;"'
|
|
1027
|
-
|
|
1028
|
-
var teamLabel = (scene.team && scene.team.name) || ('team ' + scene.teamIndex)
|
|
1029
|
-
var teamHtml = '<span class="team" title="Team running this scene">' + escapeHtml(teamLabel) + '</span>'
|
|
1030
|
-
|
|
1031
|
-
el.innerHTML =
|
|
1032
|
-
'<div class="scene-header">' +
|
|
1033
|
-
'<span class="icon" style="color:' + statusColor + '">' + statusIcon + '</span>' +
|
|
1034
|
-
'<span class="name">' + escapeHtml(scene.name) + '</span>' +
|
|
1035
|
-
teamHtml +
|
|
1036
|
-
'<span class="file">' + escapeHtml(scene.file || '') + '</span>' +
|
|
1037
|
-
'<span class="duration">' + durationStr + '</span>' +
|
|
1038
|
-
'<button class="replay-btn scene-replay-btn"' + replayFileAttr + replayDisabled +
|
|
1039
|
-
' onclick="replayScene(this)">' +
|
|
1040
|
-
'<span class="play-icon">▶</span> Replay' +
|
|
1041
|
-
'</button>' +
|
|
1042
|
-
'<button class="copy-btn scene-copy-btn" data-scene-idx="' + idx + '"' +
|
|
1043
|
-
' onclick="copyScene(this)" title="Copy scene name, file, and errors">' +
|
|
1044
|
-
'<span class="copy-icon">📋</span>' +
|
|
1045
|
-
'</button>' +
|
|
1046
|
-
'</div>' +
|
|
1047
|
-
'<div class="swim-lanes"' + lanesRadius + '>' +
|
|
1048
|
-
'<div class="time-ruler">' +
|
|
1049
|
-
'<div class="lane-label" style="font-size:10px;color:var(--text2)">time</div>' +
|
|
1050
|
-
'<div class="ruler-track">' + rulerHtml + '</div>' +
|
|
1051
|
-
'</div>' +
|
|
1052
|
-
lanesHtml +
|
|
1053
|
-
'</div>' +
|
|
1054
|
-
errorsHtml
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
function formatMs(ms) {
|
|
1058
|
-
if (ms < 1000) return ms + 'ms'
|
|
1059
|
-
return (ms / 1000).toFixed(1) + 's'
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
function escapeHtml(str) {
|
|
1063
|
-
if (!str) return ''
|
|
1064
|
-
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
// ─── Run controls ─────────────────────────────────
|
|
1068
|
-
var isPaused = false
|
|
1069
|
-
|
|
1070
|
-
function setRunning(running) {
|
|
1071
|
-
document.querySelectorAll('.replay-btn').forEach(function(btn) {
|
|
1072
|
-
btn.disabled = running
|
|
1073
|
-
})
|
|
1074
|
-
if (running) {
|
|
1075
|
-
document.querySelector('header').classList.add('running')
|
|
1076
|
-
} else {
|
|
1077
|
-
document.querySelector('header').classList.remove('running')
|
|
1078
|
-
isPaused = false
|
|
1079
|
-
updatePauseButton()
|
|
1080
|
-
}
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
function updatePauseButton() {
|
|
1084
|
-
document.getElementById('pause-icon').innerHTML = isPaused ? '▶' : '▮▮'
|
|
1085
|
-
document.getElementById('pause-label').textContent = isPaused ? 'Resume' : 'Pause'
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
function replayBody(extra) {
|
|
1089
|
-
var body = extra || {}
|
|
1090
|
-
if (state.replayTeam) body.team = state.replayTeam
|
|
1091
|
-
return JSON.stringify(body)
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
function replayAll() {
|
|
1095
|
-
setRunning(true)
|
|
1096
|
-
fetch('/__scenetest/replay', {
|
|
1097
|
-
method: 'POST',
|
|
1098
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1099
|
-
body: replayBody(),
|
|
1100
|
-
}).catch(function() {
|
|
1101
|
-
setRunning(false)
|
|
1102
|
-
})
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
function replayScene(btn) {
|
|
1106
|
-
var file = btn.getAttribute('data-file')
|
|
1107
|
-
if (!file) return
|
|
1108
|
-
setRunning(true)
|
|
1109
|
-
fetch('/__scenetest/replay', {
|
|
1110
|
-
method: 'POST',
|
|
1111
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1112
|
-
body: replayBody({ file: file }),
|
|
1113
|
-
}).catch(function() {
|
|
1114
|
-
setRunning(false)
|
|
1115
|
-
})
|
|
1116
|
-
}
|
|
1117
|
-
|
|
1118
|
-
function onTeamSelectChange(e) {
|
|
1119
|
-
state.replayTeam = e.target.value || ''
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
function renderTeamSelect() {
|
|
1123
|
-
var sel = document.getElementById('team-select')
|
|
1124
|
-
if (!sel) return
|
|
1125
|
-
var current = state.replayTeam
|
|
1126
|
-
var teams = Array.from(state.teams).sort()
|
|
1127
|
-
var html = '<option value="">all teams</option>'
|
|
1128
|
-
for (var i = 0; i < teams.length; i++) {
|
|
1129
|
-
var name = teams[i]
|
|
1130
|
-
var safe = name.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
1131
|
-
html += '<option value="' + safe + '"' + (name === current ? ' selected' : '') + '>' + safe + '</option>'
|
|
1132
|
-
}
|
|
1133
|
-
sel.innerHTML = html
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
function stopRun() {
|
|
1137
|
-
fetch('/__scenetest/stop', { method: 'POST' })
|
|
1138
|
-
.then(function() { setRunning(false) })
|
|
1139
|
-
.catch(function() { setRunning(false) })
|
|
1140
|
-
}
|
|
1141
|
-
|
|
1142
|
-
function toggleErrorExpand(el) {
|
|
1143
|
-
el.classList.toggle('expanded')
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
function copyScene(btn) {
|
|
1147
|
-
var idx = parseInt(btn.getAttribute('data-scene-idx'), 10)
|
|
1148
|
-
var scene = state.scenes[idx]
|
|
1149
|
-
if (!scene) return
|
|
1150
|
-
|
|
1151
|
-
var lines = []
|
|
1152
|
-
lines.push('Scene: ' + scene.name)
|
|
1153
|
-
if (scene.file) lines.push('File: ' + scene.file)
|
|
1154
|
-
if (scene.status) lines.push('Status: ' + scene.status)
|
|
1155
|
-
if (scene.duration != null) lines.push('Duration: ' + scene.duration + 'ms')
|
|
1156
|
-
|
|
1157
|
-
var errs = []
|
|
1158
|
-
for (var actionsArr of scene.actions.values()) {
|
|
1159
|
-
for (var a of actionsArr) {
|
|
1160
|
-
if (a.error) {
|
|
1161
|
-
errs.push(' \\u2717 ' + a.action + (a.target ? '(' + a.target + ')' : '') + ' \\u2014 ' + a.error)
|
|
1162
|
-
}
|
|
1163
|
-
}
|
|
1164
|
-
}
|
|
1165
|
-
if (scene.error && !errs.some(function(l) { return l.indexOf(scene.error) !== -1 })) {
|
|
1166
|
-
errs.push(' \\u2717 ' + scene.error)
|
|
1167
|
-
}
|
|
1168
|
-
if (errs.length > 0) {
|
|
1169
|
-
lines.push('')
|
|
1170
|
-
lines.push('Errors:')
|
|
1171
|
-
for (var line of errs) lines.push(line)
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
var failedAssertions = scene.assertions.filter(function(a) { return !a.result })
|
|
1175
|
-
if (failedAssertions.length > 0) {
|
|
1176
|
-
lines.push('')
|
|
1177
|
-
lines.push('Failed assertions:')
|
|
1178
|
-
for (var fa of failedAssertions) {
|
|
1179
|
-
lines.push(' \\u2717 [' + fa.actor + '] ' + fa.description)
|
|
1180
|
-
}
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
var text = lines.join('\\n')
|
|
1184
|
-
var done = function() {
|
|
1185
|
-
btn.classList.add('copied')
|
|
1186
|
-
var icon = btn.querySelector('.copy-icon')
|
|
1187
|
-
var prev = icon.innerHTML
|
|
1188
|
-
icon.innerHTML = '✓'
|
|
1189
|
-
setTimeout(function() {
|
|
1190
|
-
btn.classList.remove('copied')
|
|
1191
|
-
icon.innerHTML = prev
|
|
1192
|
-
}, 1200)
|
|
1193
|
-
}
|
|
1194
|
-
|
|
1195
|
-
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
1196
|
-
navigator.clipboard.writeText(text).then(done, function() {
|
|
1197
|
-
fallbackCopy(text, done)
|
|
1198
|
-
})
|
|
1199
|
-
} else {
|
|
1200
|
-
fallbackCopy(text, done)
|
|
1201
|
-
}
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
|
-
function fallbackCopy(text, done) {
|
|
1205
|
-
var ta = document.createElement('textarea')
|
|
1206
|
-
ta.value = text
|
|
1207
|
-
ta.style.position = 'fixed'
|
|
1208
|
-
ta.style.opacity = '0'
|
|
1209
|
-
document.body.appendChild(ta)
|
|
1210
|
-
ta.select()
|
|
1211
|
-
try { document.execCommand('copy') } catch (_) {}
|
|
1212
|
-
document.body.removeChild(ta)
|
|
1213
|
-
done()
|
|
1214
|
-
}
|
|
1215
|
-
|
|
1216
|
-
function togglePause() {
|
|
1217
|
-
fetch('/__scenetest/pause', { method: 'POST' })
|
|
1218
|
-
.then(function(r) { return r.json() })
|
|
1219
|
-
.then(function(data) {
|
|
1220
|
-
isPaused = data.paused
|
|
1221
|
-
updatePauseButton()
|
|
1222
|
-
})
|
|
1223
|
-
.catch(function() {})
|
|
1224
|
-
}
|
|
1225
40
|
</script>
|
|
1226
41
|
</body>
|
|
1227
42
|
</html>`;
|