@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.
- package/dist/analyze-app.d.ts +24 -0
- package/dist/analyze-app.d.ts.map +1 -0
- package/dist/analyze-app.js +807 -0
- package/dist/analyze-app.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -2
- package/dist/index.js.map +1 -1
- package/dist/middleware.d.ts +3 -1
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +280 -1
- package/dist/middleware.js.map +1 -1
- package/package.json +6 -4
|
@@ -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
|