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