@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/dist/dashboard.js CHANGED
@@ -1,6 +1,12 @@
1
1
  /**
2
- * Self-contained HTML dashboard page with swim-lane timeline.
3
- * Connects to /__scenetest/events via SSE for real-time updates.
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
- :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
- .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
- <header>
614
- <h1><span class="logo">S</span> Scenetest Dashboard</h1>
615
- <button class="replay-btn replay-all-btn" id="replay-all" onclick="replayAll()">
616
- <span class="play-icon">&#9654;</span> Replay All
617
- </button>
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">&#9646;&#9646;</span> <span id="pause-label">Pause</span>
626
- </button>
627
- <button class="stop-btn" id="stop-btn" onclick="stopRun()">
628
- <span class="btn-icon">&#9632;</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">&#9654;</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">&#128203;</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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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 ? '&#9654;' : '&#9646;&#9646;'
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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 = '&#10003;'
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>`;