@scenetest/vite-plugin 0.8.3 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,807 @@
1
+ /**
2
+ * Self-contained interactive analyze app served at `/__scenetest/`.
3
+ *
4
+ * Uses Preact + htm via a `<script type="importmap">` that points each
5
+ * bare specifier ("preact", "preact/hooks", "htm") at a middleware route
6
+ * (see `middleware.ts`, VENDOR_MODULES) which serves the matching ESM
7
+ * bundle out of the plugin's own node_modules. No build step, no CDN.
8
+ *
9
+ * Two views over the same RunReport shape:
10
+ * - "Log" — filterable / groupable scene list with copy-failures
11
+ * and a spec-snippet panel for reproducing manually.
12
+ * - "Waterfall" — links out to /__scenetest/dashboard (existing page).
13
+ *
14
+ * Data sources (chosen via the run-picker in the header):
15
+ * - "live" — subscribes to /__scenetest/events (SSE) and folds
16
+ * the DashboardEvent stream into a RunReport-shaped
17
+ * in-memory model.
18
+ * - <timestamped run id> — fetched from /__scenetest/runs/<id> (a JSON
19
+ * file written by the CLI runner).
20
+ *
21
+ * Spec snippet for a selected scene is fetched lazily from /__scenetest/source.
22
+ */
23
+ export function generateAnalyzeAppHtml() {
24
+ return `<!DOCTYPE html>
25
+ <html lang="en">
26
+ <head>
27
+ <meta charset="UTF-8" />
28
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
29
+ <title>Scenetest</title>
30
+ <style>${STYLES}</style>
31
+ <script type="importmap">
32
+ {
33
+ "imports": {
34
+ "preact": "/__scenetest/vendor/preact.js",
35
+ "preact/hooks": "/__scenetest/vendor/preact-hooks.js",
36
+ "htm": "/__scenetest/vendor/htm.js"
37
+ }
38
+ }
39
+ </script>
40
+ </head>
41
+ <body>
42
+ <div id="root"></div>
43
+ <script type="module">${CLIENT_SCRIPT}</script>
44
+ </body>
45
+ </html>`;
46
+ }
47
+ // ─── Styles ─────────────────────────────────────────────────────────
48
+ const STYLES = `
49
+ :root {
50
+ --bg: #0f1117;
51
+ --bg2: #1a1d27;
52
+ --bg3: #252833;
53
+ --border: #2e3140;
54
+ --text: #e1e4ed;
55
+ --text2: #8b8fa3;
56
+ --text3: #5a5e72;
57
+ --green: #22c55e;
58
+ --red: #ef4444;
59
+ --amber: #f59e0b;
60
+ --blue: #3b82f6;
61
+ --purple: #8b5cf6;
62
+ }
63
+ * { margin: 0; padding: 0; box-sizing: border-box; }
64
+ html, body, #root { height: 100%; }
65
+ body {
66
+ font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', ui-monospace, monospace;
67
+ background: var(--bg);
68
+ color: var(--text);
69
+ }
70
+ .app { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
71
+ header {
72
+ display: flex; align-items: center; gap: 16px;
73
+ padding: 10px 16px; background: var(--bg2);
74
+ border-bottom: 1px solid var(--border); flex-shrink: 0;
75
+ }
76
+ header h1 { font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
77
+ .logo {
78
+ width: 26px; height: 26px; border-radius: 6px;
79
+ background: rgba(80, 70, 229, 0.15);
80
+ box-shadow: inset 0 1px 4px rgba(80, 70, 229, 0.3);
81
+ display: inline-flex; align-items: center; justify-content: center;
82
+ font-size: 13px;
83
+ }
84
+ .tabs { display: flex; gap: 4px; }
85
+ .tab {
86
+ padding: 5px 12px; background: transparent; color: var(--text2);
87
+ border: 1px solid var(--border); border-radius: 4px;
88
+ font: inherit; cursor: pointer; text-decoration: none; font-size: 12px;
89
+ }
90
+ .tab:hover { color: var(--text); border-color: var(--text2); }
91
+ .tab.active { background: var(--bg3); color: var(--text); border-color: var(--text2); }
92
+ .run-picker { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text2); }
93
+ .run-picker select {
94
+ background: var(--bg3); color: var(--text);
95
+ border: 1px solid var(--border); border-radius: 4px;
96
+ padding: 4px 8px; font: inherit; font-size: 12px; min-width: 200px;
97
+ }
98
+ .conn { width: 8px; height: 8px; border-radius: 50%; background: var(--text3); }
99
+ .conn.connected { background: var(--green); }
100
+ .conn.disconnected { background: var(--red); }
101
+ .status-bar { margin-left: auto; font-size: 12px; color: var(--text2); display: flex; gap: 14px; }
102
+ .status-bar .ok { color: var(--green); }
103
+ .status-bar .fail { color: var(--red); }
104
+
105
+ main {
106
+ display: grid;
107
+ grid-template-columns: 240px minmax(0, 1fr) minmax(320px, 420px);
108
+ flex: 1; min-height: 0;
109
+ }
110
+ aside.tree, aside.detail {
111
+ background: var(--bg2);
112
+ border-right: 1px solid var(--border);
113
+ overflow: auto;
114
+ }
115
+ aside.detail { border-right: none; border-left: 1px solid var(--border); padding: 14px; }
116
+ .list-pane { display: flex; flex-direction: column; min-width: 0; }
117
+ .filters {
118
+ display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
119
+ padding: 10px 14px; border-bottom: 1px solid var(--border); background: var(--bg2);
120
+ }
121
+ .filters input[type=search] {
122
+ flex: 1; min-width: 180px;
123
+ background: var(--bg3); color: var(--text);
124
+ border: 1px solid var(--border); border-radius: 4px;
125
+ padding: 5px 10px; font: inherit; font-size: 12px;
126
+ }
127
+ .chips { display: flex; gap: 4px; }
128
+ .chip {
129
+ border: 1px solid var(--border); background: transparent;
130
+ color: var(--text3); padding: 4px 10px; border-radius: 12px;
131
+ font: inherit; font-size: 11px; cursor: pointer;
132
+ }
133
+ .chip.on { color: var(--text); border-color: var(--text2); background: var(--bg3); }
134
+ .chip[data-status=failed].on { color: var(--red); border-color: var(--red); }
135
+ .chip[data-status=completed].on { color: var(--green); border-color: var(--green); }
136
+ .chip[data-status=running].on { color: var(--blue); border-color: var(--blue); }
137
+ .chip[data-status=timeout].on { color: var(--amber); border-color: var(--amber); }
138
+ select.group-by {
139
+ background: var(--bg3); color: var(--text);
140
+ border: 1px solid var(--border); border-radius: 4px;
141
+ padding: 4px 8px; font: inherit; font-size: 12px;
142
+ }
143
+ .btn {
144
+ background: var(--bg3); color: var(--text);
145
+ border: 1px solid var(--border); border-radius: 4px;
146
+ padding: 4px 10px; font: inherit; font-size: 12px; cursor: pointer;
147
+ }
148
+ .btn:hover { border-color: var(--blue); color: var(--blue); }
149
+ .btn.subtle { color: var(--text2); }
150
+ .btn.copied { color: var(--green); border-color: var(--green); }
151
+ .list { flex: 1; overflow: auto; }
152
+ .group-header {
153
+ padding: 8px 14px; font-size: 11px; text-transform: uppercase;
154
+ color: var(--text2); background: var(--bg);
155
+ border-bottom: 1px solid var(--border); position: sticky; top: 0;
156
+ letter-spacing: 0.04em;
157
+ }
158
+ .row {
159
+ display: grid; grid-template-columns: 22px minmax(0, 1fr) auto auto;
160
+ gap: 10px; align-items: center;
161
+ padding: 6px 14px; border-bottom: 1px solid rgba(46, 49, 64, 0.4);
162
+ cursor: pointer;
163
+ }
164
+ .row:hover { background: rgba(255, 255, 255, 0.02); }
165
+ .row.selected { background: rgba(59, 130, 246, 0.08); }
166
+ .row .icon { font-weight: 700; }
167
+ .row .icon.completed { color: var(--green); }
168
+ .row .icon.failed { color: var(--red); }
169
+ .row .icon.timeout { color: var(--amber); }
170
+ .row .icon.running { color: var(--blue); }
171
+ .row .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
172
+ .row .meta { color: var(--text2); font-size: 11px; white-space: nowrap; }
173
+ .row .file {
174
+ color: var(--text3); font-size: 11px; white-space: nowrap;
175
+ overflow: hidden; text-overflow: ellipsis; max-width: 240px;
176
+ }
177
+
178
+ .tree { padding: 10px 0; font-size: 12px; }
179
+ .tree-file {
180
+ padding: 4px 14px; color: var(--text2);
181
+ display: flex; justify-content: space-between; gap: 8px;
182
+ }
183
+ .tree-file .fail { color: var(--red); }
184
+ .tree-scene {
185
+ padding: 3px 14px 3px 28px; color: var(--text);
186
+ display: flex; justify-content: space-between; gap: 8px; cursor: pointer;
187
+ }
188
+ .tree-scene:hover { background: rgba(255, 255, 255, 0.03); }
189
+ .tree-scene.failed, .tree-scene.timeout { color: var(--red); }
190
+ .tree-scene.running { color: var(--blue); }
191
+ .tree-scene.selected { background: rgba(59, 130, 246, 0.08); }
192
+
193
+ .detail h3 { font-size: 13px; margin-bottom: 8px; word-break: break-word; }
194
+ .detail .meta-row {
195
+ color: var(--text2); font-size: 11px; margin-bottom: 12px;
196
+ display: flex; flex-wrap: wrap; gap: 8px;
197
+ }
198
+ .detail .meta-row .pill {
199
+ border: 1px solid var(--border); border-radius: 10px; padding: 1px 8px;
200
+ }
201
+ .detail .err {
202
+ background: rgba(239, 68, 68, 0.08); border: 1px solid rgba(239, 68, 68, 0.4);
203
+ color: var(--red); padding: 8px 10px; border-radius: 4px;
204
+ font-size: 12px; white-space: pre-wrap; word-break: break-word;
205
+ margin-bottom: 12px;
206
+ }
207
+ .detail h4 {
208
+ font-size: 11px; text-transform: uppercase; color: var(--text2);
209
+ margin: 14px 0 6px; letter-spacing: 0.04em;
210
+ }
211
+ .detail ul { list-style: none; }
212
+ .detail .alist li { font-size: 12px; padding: 2px 0; }
213
+ .detail .alist .pass { color: var(--green); }
214
+ .detail .alist .fail { color: var(--red); }
215
+ .detail .timeline li { font-size: 11px; color: var(--text2); padding: 2px 0; }
216
+ .detail .timeline .err-step { color: var(--red); }
217
+ .detail .actions { display: flex; gap: 6px; margin-bottom: 10px; flex-wrap: wrap; }
218
+ .detail .empty { color: var(--text2); font-size: 12px; }
219
+
220
+ pre.snippet {
221
+ background: var(--bg); border: 1px solid var(--border);
222
+ border-radius: 4px; padding: 8px 0; font-size: 11px;
223
+ overflow: auto; max-height: 240px; line-height: 1.45;
224
+ }
225
+ pre.snippet .ln {
226
+ display: inline-block; width: 38px; text-align: right;
227
+ color: var(--text3); padding-right: 10px; user-select: none;
228
+ }
229
+ pre.snippet .row-line { padding: 0 8px; white-space: pre; }
230
+ pre.snippet .row-line.hl { background: rgba(239, 68, 68, 0.12); }
231
+ `;
232
+ // ─── Client (Preact + htm) ──────────────────────────────────────────
233
+ //
234
+ // Imports resolve via the importmap above. The whole app fits in a single
235
+ // module — store + components — but is broken into clearly scoped pieces.
236
+ const CLIENT_SCRIPT = `
237
+ import { h, render } from 'preact'
238
+ import { useState, useEffect, useMemo, useCallback, useRef } from 'preact/hooks'
239
+ import htm from 'htm'
240
+
241
+ const html = htm.bind(h)
242
+
243
+ // ── Helpers ───────────────────────────────────────────────────────
244
+ const STATUSES = ['failed', 'timeout', 'running', 'completed']
245
+
246
+ function emptySummary() {
247
+ return {
248
+ scenes: 0, completed: 0, failed: 0,
249
+ assertions: { total: 0, passed: 0, failed: 0 },
250
+ warnings: 0, consoleErrors: 0,
251
+ }
252
+ }
253
+
254
+ function shortFile(f) {
255
+ if (!f) return ''
256
+ const parts = f.replace(/\\\\/g, '/').split('/')
257
+ return parts.length > 2 ? '…/' + parts.slice(-2).join('/') : f
258
+ }
259
+
260
+ function statusIcon(s) {
261
+ return s === 'completed' ? '✓' : s === 'running' ? '◐' : s === 'timeout' ? '⏱' : '✗'
262
+ }
263
+
264
+ // ── Live SSE store ────────────────────────────────────────────────
265
+ //
266
+ // Folds DashboardEvent stream into a RunReport-shaped value. Mutations
267
+ // happen through dispatch so React re-renders are predictable. Each
268
+ // dispatch returns a *new* state object — Preact diffs from there.
269
+
270
+ function applyEvent(state, ev) {
271
+ switch (ev.type) {
272
+ case 'run:start':
273
+ return {
274
+ ...state,
275
+ scenes: [],
276
+ sceneActions: new Map(),
277
+ summary: { ...emptySummary(), scenes: ev.sceneCount || 0 },
278
+ }
279
+ case 'scene:start': {
280
+ const scene = {
281
+ name: ev.name, file: ev.file || '',
282
+ line: ev.line, status: 'running',
283
+ assertions: [], warnings: [], consoleErrors: [], timeline: [],
284
+ actors: Object.fromEntries((ev.actors || []).map(r => [r, { key: r }])),
285
+ team: {}, teamIndex: 0, duration: 0, error: undefined,
286
+ }
287
+ const sceneActions = new Map(state.sceneActions)
288
+ sceneActions.set(ev.name, [])
289
+ return { ...state, scenes: [...state.scenes, scene], sceneActions }
290
+ }
291
+ case 'action:start':
292
+ case 'action:end': {
293
+ // Append to the most recent running scene's action log
294
+ const last = lastRunningSceneIndex(state.scenes)
295
+ if (last < 0) return state
296
+ const sceneName = state.scenes[last].name
297
+ const sceneActions = new Map(state.sceneActions)
298
+ const arr = sceneActions.get(sceneName) || []
299
+ sceneActions.set(sceneName, [...arr, ev])
300
+ return { ...state, sceneActions }
301
+ }
302
+ case 'assertion': {
303
+ const last = lastRunningSceneIndex(state.scenes)
304
+ if (last < 0) return state
305
+ const scenes = state.scenes.slice()
306
+ const sc = { ...scenes[last] }
307
+ sc.assertions = [...sc.assertions, {
308
+ type: ev.result ? 'pass' : 'fail',
309
+ description: ev.description, result: ev.result, timestamp: ev.timestamp,
310
+ }]
311
+ scenes[last] = sc
312
+ const summary = {
313
+ ...state.summary,
314
+ assertions: {
315
+ total: state.summary.assertions.total + 1,
316
+ passed: state.summary.assertions.passed + (ev.result ? 1 : 0),
317
+ failed: state.summary.assertions.failed + (ev.result ? 0 : 1),
318
+ },
319
+ }
320
+ return { ...state, scenes, summary }
321
+ }
322
+ case 'warning': {
323
+ const last = lastRunningSceneIndex(state.scenes)
324
+ if (last < 0) return state
325
+ const scenes = state.scenes.slice()
326
+ const sc = { ...scenes[last] }
327
+ sc.warnings = [...sc.warnings, {
328
+ actor: ev.actor, selector: ev.selector,
329
+ message: ev.message, timestamp: ev.timestamp,
330
+ }]
331
+ scenes[last] = sc
332
+ return { ...state, scenes, summary: { ...state.summary, warnings: state.summary.warnings + 1 } }
333
+ }
334
+ case 'scene:end': {
335
+ const idx = state.scenes.findIndex(s => s.name === ev.name && s.status === 'running')
336
+ if (idx < 0) return state
337
+ const scenes = state.scenes.slice()
338
+ scenes[idx] = { ...scenes[idx], status: ev.status, duration: ev.duration, error: ev.error }
339
+ const summary = { ...state.summary }
340
+ if (ev.status === 'completed') summary.completed = (summary.completed || 0) + 1
341
+ else summary.failed = (summary.failed || 0) + 1
342
+ return { ...state, scenes, summary }
343
+ }
344
+ case 'run:end':
345
+ return ev.summary ? { ...state, summary: ev.summary } : state
346
+ default:
347
+ return state
348
+ }
349
+ }
350
+
351
+ function lastRunningSceneIndex(scenes) {
352
+ for (let i = scenes.length - 1; i >= 0; i--) {
353
+ if (scenes[i].status === 'running') return i
354
+ }
355
+ return -1
356
+ }
357
+
358
+ // ── App root ──────────────────────────────────────────────────────
359
+ function App() {
360
+ const [runs, setRuns] = useState([])
361
+ const [runId, setRunId] = useState(() =>
362
+ new URLSearchParams(location.search).get('run') || 'live'
363
+ )
364
+ const [report, setReport] = useState({
365
+ scenes: [], summary: emptySummary(), sceneActions: new Map(),
366
+ })
367
+ const [connection, setConnection] = useState('idle')
368
+ const [filters, setFilters] = useState({
369
+ text: '', statuses: new Set(STATUSES), groupBy: 'status',
370
+ })
371
+ const [selected, setSelected] = useState(null)
372
+
373
+ // Fetch list of past runs once on mount
374
+ useEffect(() => {
375
+ fetch('/__scenetest/runs')
376
+ .then(r => r.ok ? r.json() : null)
377
+ .then(data => { if (data) setRuns(data.runs || []) })
378
+ .catch(() => {})
379
+ }, [])
380
+
381
+ // Sync URL when run changes
382
+ useEffect(() => {
383
+ const p = new URLSearchParams(location.search)
384
+ p.set('run', runId)
385
+ history.replaceState(null, '', '?' + p.toString())
386
+ }, [runId])
387
+
388
+ // Load past run / connect to live SSE depending on selected run id
389
+ useEffect(() => {
390
+ setReport({ scenes: [], summary: emptySummary(), sceneActions: new Map() })
391
+ setSelected(null)
392
+
393
+ if (runId === 'live') {
394
+ const es = new EventSource('/__scenetest/events')
395
+ es.onopen = () => setConnection('connected')
396
+ es.onerror = () => setConnection('disconnected')
397
+ es.onmessage = e => {
398
+ try {
399
+ const ev = JSON.parse(e.data)
400
+ setReport(prev => applyEvent(prev, ev))
401
+ } catch {}
402
+ }
403
+ return () => { es.close(); setConnection('idle') }
404
+ } else {
405
+ setConnection('idle')
406
+ fetch('/__scenetest/runs/' + encodeURIComponent(runId))
407
+ .then(r => r.ok ? r.json() : null)
408
+ .then(data => {
409
+ if (!data) return
410
+ setReport({
411
+ scenes: data.scenes || [],
412
+ summary: data.summary || emptySummary(),
413
+ sceneActions: new Map(),
414
+ })
415
+ })
416
+ .catch(() => {})
417
+ }
418
+ }, [runId])
419
+
420
+ return html\`
421
+ <div class="app">
422
+ <\${Header}
423
+ runs=\${runs} runId=\${runId} onRunChange=\${setRunId}
424
+ connection=\${connection} summary=\${report.summary}
425
+ scenes=\${report.scenes}
426
+ />
427
+ <main>
428
+ <\${Tree} scenes=\${report.scenes} selected=\${selected} onSelect=\${setSelected} />
429
+ <\${ListPane}
430
+ scenes=\${report.scenes} filters=\${filters} onFilters=\${setFilters}
431
+ selected=\${selected} onSelect=\${setSelected}
432
+ sceneActions=\${report.sceneActions} runId=\${runId}
433
+ />
434
+ <\${Detail}
435
+ scene=\${report.scenes.find(s => s.name === selected)}
436
+ sceneActions=\${report.sceneActions}
437
+ />
438
+ </main>
439
+ </div>
440
+ \`
441
+ }
442
+
443
+ // ── Header ────────────────────────────────────────────────────────
444
+ function Header({ runs, runId, onRunChange, connection, summary, scenes }) {
445
+ const completed = scenes.filter(s => s.status === 'completed').length
446
+ const a = summary.assertions || { total: 0, passed: 0, failed: 0 }
447
+ return html\`
448
+ <header>
449
+ <h1><span class="logo">🎬</span> Scenetest</h1>
450
+ <nav class="tabs">
451
+ <span class="tab active">Log</span>
452
+ <a class="tab" href="/__scenetest/dashboard">Waterfall</a>
453
+ </nav>
454
+ <div class="run-picker">
455
+ <label for="run-select">Run</label>
456
+ <select id="run-select" value=\${runId} onChange=\${e => onRunChange(e.target.value)}>
457
+ <option value="live">Live (current run)</option>
458
+ \${runs.map(r => html\`
459
+ <option value=\${r.id}>\${new Date(r.mtime).toLocaleString()} — \${r.id}</option>
460
+ \`)}
461
+ </select>
462
+ <span class=\${'conn ' + connection} title=\${connection}></span>
463
+ </div>
464
+ <div class="status-bar">
465
+ <span>scenes \${completed}/\${summary.scenes || scenes.length}</span>
466
+ <span class="ok">✓ \${a.passed}</span>
467
+ <span class="fail">✗ \${a.failed}</span>
468
+ \${summary.warnings ? html\`<span>⚡ \${summary.warnings}</span>\` : null}
469
+ </div>
470
+ </header>
471
+ \`
472
+ }
473
+
474
+ // ── Tree ──────────────────────────────────────────────────────────
475
+ function Tree({ scenes, selected, onSelect }) {
476
+ const byFile = useMemo(() => {
477
+ const m = new Map()
478
+ for (const s of scenes) {
479
+ const f = s.file || '(no file)'
480
+ if (!m.has(f)) m.set(f, [])
481
+ m.get(f).push(s)
482
+ }
483
+ return m
484
+ }, [scenes])
485
+
486
+ return html\`
487
+ <aside class="tree">
488
+ \${[...byFile.entries()].map(([file, group]) => {
489
+ const fails = group.filter(s => s.status !== 'completed' && s.status !== 'running').length
490
+ return html\`
491
+ <div class="tree-file" title=\${file}>
492
+ <span>\${shortFile(file)}</span>
493
+ \${fails ? html\`<span class="fail">\${fails}</span>\` : null}
494
+ </div>
495
+ \${group.map(s => html\`
496
+ <div
497
+ class=\${'tree-scene ' + s.status + (s.name === selected ? ' selected' : '')}
498
+ onClick=\${() => onSelect(s.name)}
499
+ >\${s.name}</div>
500
+ \`)}
501
+ \`
502
+ })}
503
+ </aside>
504
+ \`
505
+ }
506
+
507
+ // ── List pane (filters + grouped list) ───────────────────────────
508
+ function ListPane({ scenes, filters, onFilters, selected, onSelect, sceneActions, runId }) {
509
+ const filtered = useMemo(() => {
510
+ const text = filters.text.toLowerCase()
511
+ return scenes.filter(s => {
512
+ if (!filters.statuses.has(s.status)) return false
513
+ if (!text) return true
514
+ const hay = [
515
+ s.name, s.file,
516
+ ...(s.assertions || []).map(a => a.description),
517
+ s.error || '',
518
+ ].join(' ').toLowerCase()
519
+ return hay.includes(text)
520
+ })
521
+ }, [scenes, filters])
522
+
523
+ const groups = useMemo(() => groupScenes(filtered, filters.groupBy), [filtered, filters.groupBy])
524
+
525
+ const toggleStatus = useCallback(s => {
526
+ const next = new Set(filters.statuses)
527
+ next.has(s) ? next.delete(s) : next.add(s)
528
+ onFilters({ ...filters, statuses: next })
529
+ }, [filters, onFilters])
530
+
531
+ return html\`
532
+ <section class="list-pane">
533
+ <div class="filters">
534
+ <input type="search" placeholder="Filter by scene, file, or assertion…"
535
+ value=\${filters.text}
536
+ onInput=\${e => onFilters({ ...filters, text: e.target.value })}
537
+ />
538
+ <div class="chips">
539
+ \${STATUSES.map(s => html\`
540
+ <button
541
+ class=\${'chip' + (filters.statuses.has(s) ? ' on' : '')}
542
+ data-status=\${s} onClick=\${() => toggleStatus(s)}
543
+ >\${s[0].toUpperCase() + s.slice(1)}</button>
544
+ \`)}
545
+ </div>
546
+ <select class="group-by" value=\${filters.groupBy}
547
+ onChange=\${e => onFilters({ ...filters, groupBy: e.target.value })}>
548
+ <option value="none">No grouping</option>
549
+ <option value="file">Group by file</option>
550
+ <option value="status">Group by status</option>
551
+ <option value="team">Group by team</option>
552
+ </select>
553
+ <\${CopyButton}
554
+ label="Copy all failures"
555
+ getText=\${() => formatFailureReport(scenes.filter(s => s.status !== 'completed'), runId, sceneActions)}
556
+ />
557
+ <\${CopyButton}
558
+ label="Copy all" subtle
559
+ getText=\${() => formatFailureReport(scenes, runId, sceneActions)}
560
+ />
561
+ </div>
562
+ <div class="list">
563
+ \${groups.length === 0
564
+ ? html\`<div class="group-header">No scenes match the current filters.</div>\`
565
+ : groups.map(g => html\`
566
+ \${filters.groupBy !== 'none' ? html\`
567
+ <div class="group-header">\${g.key || ''} · \${g.items.length}</div>
568
+ \` : null}
569
+ \${g.items.map(s => html\`
570
+ <div
571
+ class=\${'row' + (s.name === selected ? ' selected' : '')}
572
+ onClick=\${() => onSelect(s.name)}
573
+ >
574
+ <span class=\${'icon ' + s.status}>\${statusIcon(s.status)}</span>
575
+ <span class="name">\${s.name}</span>
576
+ <span class="meta">
577
+ \${(s.assertions || []).filter(a => !a.result).length > 0
578
+ ? html\`<span class="icon failed">✗</span> \`
579
+ : null}
580
+ \${(s.assertions || []).length} check\${(s.assertions || []).length === 1 ? '' : 's'}
581
+ \${s.duration ? ' · ' + s.duration + 'ms' : ''}
582
+ </span>
583
+ <span class="file">\${shortFile(s.file)}</span>
584
+ </div>
585
+ \`)}
586
+ \`)}
587
+ </div>
588
+ </section>
589
+ \`
590
+ }
591
+
592
+ function groupScenes(scenes, groupBy) {
593
+ if (groupBy === 'none') return [{ key: '', items: scenes }]
594
+ const m = new Map()
595
+ for (const s of scenes) {
596
+ let key = ''
597
+ if (groupBy === 'file') key = s.file || '(no file)'
598
+ else if (groupBy === 'status') key = s.status
599
+ else if (groupBy === 'team') key = (s.team && s.team.name) || ('team#' + s.teamIndex)
600
+ if (!m.has(key)) m.set(key, [])
601
+ m.get(key).push(s)
602
+ }
603
+ const keys = [...m.keys()]
604
+ if (groupBy === 'status') {
605
+ const order = { failed: 0, timeout: 1, running: 2, completed: 3 }
606
+ keys.sort((a, b) => (order[a] ?? 99) - (order[b] ?? 99))
607
+ } else {
608
+ keys.sort()
609
+ }
610
+ return keys.map(k => ({ key: k, items: m.get(k) }))
611
+ }
612
+
613
+ // ── Detail pane (scene + spec snippet) ───────────────────────────
614
+ function Detail({ scene, sceneActions }) {
615
+ if (!scene) {
616
+ return html\`
617
+ <aside class="detail">
618
+ <div class="empty">
619
+ Select a scene on the left to see error details, timeline,
620
+ and the spec snippet for reproducing it manually.
621
+ </div>
622
+ </aside>
623
+ \`
624
+ }
625
+
626
+ const failed = scene.status !== 'completed' && scene.status !== 'running'
627
+ const liveActions = sceneActions.get(scene.name) || []
628
+ const timeline = (scene.timeline && scene.timeline.length)
629
+ ? scene.timeline
630
+ : liveActionsToTimeline(liveActions)
631
+
632
+ const pills = [
633
+ 'team ' + ((scene.team && scene.team.name) || scene.teamIndex),
634
+ scene.duration ? scene.duration + 'ms' : '',
635
+ scene.status,
636
+ ].filter(Boolean)
637
+
638
+ return html\`
639
+ <aside class="detail">
640
+ <div class="actions">
641
+ <\${CopyButton} label="Copy" getText=\${() => formatScene(scene, sceneActions)} />
642
+ \${scene.file ? html\`
643
+ <a class="btn subtle"
644
+ href=\${'/__open-in-editor?file=' + encodeURIComponent(scene.file) +
645
+ (scene.line ? '&line=' + scene.line : '')}
646
+ >Open in editor</a>
647
+ \` : null}
648
+ </div>
649
+ <h3>
650
+ \${failed ? html\`<span class="icon failed">✗</span> \` : null}
651
+ \${scene.name}
652
+ </h3>
653
+ <div class="meta-row">
654
+ \${pills.map(t => html\`<span class="pill">\${t}</span>\`)}
655
+ \${scene.file ? html\`
656
+ <span class="pill" title=\${scene.file}>
657
+ \${shortFile(scene.file)}\${scene.line ? ':' + scene.line : ''}
658
+ </span>
659
+ \` : null}
660
+ </div>
661
+ \${scene.error ? html\`<div class="err">\${scene.error}</div>\` : null}
662
+ <h4>Assertions</h4>
663
+ <ul class="alist">
664
+ \${(scene.assertions || []).length === 0
665
+ ? html\`<li>No assertions recorded.</li>\`
666
+ : scene.assertions.map(a => html\`
667
+ <li class=\${a.result ? 'pass' : 'fail'}>
668
+ \${a.result ? '✓' : '✗'} \${a.description}
669
+ </li>
670
+ \`)}
671
+ </ul>
672
+ <h4>Timeline</h4>
673
+ <ul class="timeline">
674
+ \${timeline.length === 0
675
+ ? html\`<li>(no timeline)</li>\`
676
+ : timeline.map(t => html\`
677
+ <li class=\${t.error ? 'err-step' : ''}>
678
+ \${t.actor}: \${t.action}\${t.target ? ' ' + t.target : ''}
679
+ \${t.duration != null ? ' (' + t.duration + 'ms)' : ''}
680
+ \${t.error ? ' — ' + t.error : ''}
681
+ </li>
682
+ \`)}
683
+ </ul>
684
+ <h4>Spec snippet</h4>
685
+ <\${SpecSnippet} file=\${scene.file} line=\${scene.line} />
686
+ </aside>
687
+ \`
688
+ }
689
+
690
+ function liveActionsToTimeline(events) {
691
+ const open = new Map()
692
+ const out = []
693
+ for (const ev of events) {
694
+ const key = ev.actor + ':' + ev.action
695
+ if (ev.type === 'action:start') open.set(key, ev)
696
+ else if (ev.type === 'action:end') {
697
+ out.push({
698
+ actor: ev.actor, action: ev.action, target: ev.target,
699
+ timestamp: ev.timestamp, duration: ev.duration, error: ev.error,
700
+ })
701
+ open.delete(key)
702
+ }
703
+ }
704
+ for (const ev of open.values()) {
705
+ out.push({
706
+ actor: ev.actor, action: ev.action + ' (in flight)',
707
+ target: ev.target, timestamp: ev.timestamp,
708
+ })
709
+ }
710
+ return out
711
+ }
712
+
713
+ // ── Spec snippet (lazy fetch) ─────────────────────────────────────
714
+ function SpecSnippet({ file, line }) {
715
+ const [state, setState] = useState({ status: 'idle' })
716
+ // Re-fetch when file or line changes; aborts on unmount
717
+ useEffect(() => {
718
+ if (!file) { setState({ status: 'no-file' }); return }
719
+ const ctrl = new AbortController()
720
+ setState({ status: 'loading' })
721
+ const url = '/__scenetest/source?file=' + encodeURIComponent(file) +
722
+ '&line=' + (line || 1) + '&context=20'
723
+ fetch(url, { signal: ctrl.signal })
724
+ .then(r => r.ok ? r.json().then(d => ({ ok: true, d })) : { ok: false, status: r.status })
725
+ .then(res => {
726
+ if (res.ok) setState({ status: 'loaded', data: res.d })
727
+ else setState({ status: 'missing', code: res.status })
728
+ })
729
+ .catch(err => {
730
+ if (err.name !== 'AbortError') setState({ status: 'error' })
731
+ })
732
+ return () => ctrl.abort()
733
+ }, [file, line])
734
+
735
+ if (state.status === 'no-file') return html\`<div class="empty">No source file recorded.</div>\`
736
+ if (state.status === 'loading') return html\`<div class="empty">Loading…</div>\`
737
+ if (state.status === 'missing') return html\`<div class="empty">Source not available (\${state.code}).</div>\`
738
+ if (state.status === 'error') return html\`<div class="empty">Could not load source.</div>\`
739
+ if (state.status !== 'loaded') return null
740
+
741
+ const { start, lines } = state.data
742
+ const target = line || start
743
+ return html\`
744
+ <pre class="snippet">\${lines.map((ln, i) => {
745
+ const n = start + i
746
+ const cls = 'row-line' + (n === target ? ' hl' : '')
747
+ return html\`<div class=\${cls}><span class="ln">\${n}</span>\${ln}</div>\`
748
+ })}</pre>
749
+ \`
750
+ }
751
+
752
+ // ── Copy button (shared) ──────────────────────────────────────────
753
+ function CopyButton({ label, getText, subtle }) {
754
+ const [copied, setCopied] = useState(false)
755
+ const tRef = useRef(null)
756
+ useEffect(() => () => { if (tRef.current) clearTimeout(tRef.current) }, [])
757
+ const onClick = () => {
758
+ navigator.clipboard.writeText(getText()).then(() => {
759
+ setCopied(true)
760
+ tRef.current = setTimeout(() => setCopied(false), 1500)
761
+ })
762
+ }
763
+ const cls = 'btn' + (subtle ? ' subtle' : '') + (copied ? ' copied' : '')
764
+ return html\`<button class=\${cls} onClick=\${onClick}>\${copied ? 'Copied!' : label}</button>\`
765
+ }
766
+
767
+ // ── Plain-text formatters (clipboard payload) ────────────────────
768
+ function formatScene(s, sceneActions) {
769
+ const status = s.status === 'completed' ? 'PASSED'
770
+ : s.status === 'timeout' ? 'TIMEOUT'
771
+ : s.status === 'running' ? 'RUNNING' : 'FAILED'
772
+ const lines = []
773
+ lines.push(statusIcon(s.status) + ' ' + s.name + ' — ' + status)
774
+ lines.push(' File: ' + s.file + (s.line ? ':' + s.line : ''))
775
+ lines.push(' Team: ' + ((s.team && s.team.name) || ('team#' + s.teamIndex)) +
776
+ (s.duration ? ' · ' + s.duration + 'ms' : ''))
777
+ if (s.error) lines.push(' Error: ' + s.error)
778
+ if ((s.assertions || []).length) {
779
+ lines.push(' Assertions:')
780
+ for (const a of s.assertions) {
781
+ lines.push(' ' + (a.result ? '✓' : '✗') + ' ' + a.description)
782
+ }
783
+ }
784
+ const tl = (s.timeline && s.timeline.length)
785
+ ? s.timeline
786
+ : liveActionsToTimeline(sceneActions.get(s.name) || [])
787
+ if (tl.length) {
788
+ lines.push(' Timeline:')
789
+ for (const t of tl) {
790
+ lines.push(' ' + t.actor + ': ' + t.action + (t.target ? ' ' + t.target : '') +
791
+ (t.duration != null ? ' (' + t.duration + 'ms)' : '') + (t.error ? ' — ' + t.error : ''))
792
+ }
793
+ }
794
+ return lines.join('\\n')
795
+ }
796
+
797
+ function formatFailureReport(scenes, runId, sceneActions) {
798
+ const failures = scenes.filter(s => s.status !== 'completed' && s.status !== 'running')
799
+ const target = failures.length ? failures : scenes
800
+ const header = ['Scenetest — ' + (runId || 'live'), failures.length + ' failing scene(s)', '']
801
+ return header.join('\\n') + '\\n' + target.map(s => formatScene(s, sceneActions)).join('\\n\\n')
802
+ }
803
+
804
+ // ── Bootstrap ─────────────────────────────────────────────────────
805
+ render(h(App, {}), document.getElementById('root'))
806
+ `;
807
+ //# sourceMappingURL=analyze-app.js.map