@scenetest/vite-plugin 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/index.d.ts.map +1 -1
  2. package/dist/index.js +11 -5
  3. package/dist/index.js.map +1 -1
  4. package/dist/panels/observer/audio.d.ts +81 -0
  5. package/dist/panels/observer/audio.d.ts.map +1 -0
  6. package/dist/panels/observer/audio.js +296 -0
  7. package/dist/panels/observer/audio.js.map +1 -0
  8. package/dist/panels/observer/auto.d.ts +10 -0
  9. package/dist/panels/observer/auto.d.ts.map +1 -0
  10. package/dist/panels/observer/auto.js +11 -0
  11. package/dist/panels/observer/auto.js.map +1 -0
  12. package/dist/panels/observer/fs-viewer.d.ts +20 -0
  13. package/dist/panels/observer/fs-viewer.d.ts.map +1 -0
  14. package/dist/panels/observer/fs-viewer.js +536 -0
  15. package/dist/panels/observer/fs-viewer.js.map +1 -0
  16. package/dist/panels/observer/fullscreen.d.ts +24 -0
  17. package/dist/panels/observer/fullscreen.d.ts.map +1 -0
  18. package/dist/panels/observer/fullscreen.js +701 -0
  19. package/dist/panels/observer/fullscreen.js.map +1 -0
  20. package/dist/panels/observer/history.d.ts +41 -0
  21. package/dist/panels/observer/history.d.ts.map +1 -0
  22. package/dist/panels/observer/history.js +307 -0
  23. package/dist/panels/observer/history.js.map +1 -0
  24. package/dist/panels/observer/index.d.ts +33 -0
  25. package/dist/panels/observer/index.d.ts.map +1 -0
  26. package/dist/panels/observer/index.js +128 -0
  27. package/dist/panels/observer/index.js.map +1 -0
  28. package/dist/panels/observer/panel.d.ts +12 -0
  29. package/dist/panels/observer/panel.d.ts.map +1 -0
  30. package/dist/panels/observer/panel.js +461 -0
  31. package/dist/panels/observer/panel.js.map +1 -0
  32. package/dist/panels/observer/render.d.ts +109 -0
  33. package/dist/panels/observer/render.d.ts.map +1 -0
  34. package/dist/panels/observer/render.js +760 -0
  35. package/dist/panels/observer/render.js.map +1 -0
  36. package/dist/panels/observer/state.d.ts +57 -0
  37. package/dist/panels/observer/state.d.ts.map +1 -0
  38. package/dist/panels/observer/state.js +187 -0
  39. package/dist/panels/observer/state.js.map +1 -0
  40. package/dist/panels/observer/styles.d.ts +6 -0
  41. package/dist/panels/observer/styles.d.ts.map +1 -0
  42. package/dist/panels/observer/styles.js +1706 -0
  43. package/dist/panels/observer/styles.js.map +1 -0
  44. package/dist/panels/observer/types.d.ts +102 -0
  45. package/dist/panels/observer/types.d.ts.map +1 -0
  46. package/dist/panels/observer/types.js +5 -0
  47. package/dist/panels/observer/types.js.map +1 -0
  48. package/dist/panels/observer/utils.d.ts +45 -0
  49. package/dist/panels/observer/utils.d.ts.map +1 -0
  50. package/dist/panels/observer/utils.js +101 -0
  51. package/dist/panels/observer/utils.js.map +1 -0
  52. package/dist/panels/recorder/auto.d.ts +10 -0
  53. package/dist/panels/recorder/auto.d.ts.map +1 -0
  54. package/dist/panels/recorder/auto.js +11 -0
  55. package/dist/panels/recorder/auto.js.map +1 -0
  56. package/dist/panels/recorder/capture.d.ts +18 -0
  57. package/dist/panels/recorder/capture.d.ts.map +1 -0
  58. package/dist/panels/recorder/capture.js +218 -0
  59. package/dist/panels/recorder/capture.js.map +1 -0
  60. package/dist/panels/recorder/index.d.ts +41 -0
  61. package/dist/panels/recorder/index.d.ts.map +1 -0
  62. package/dist/panels/recorder/index.js +208 -0
  63. package/dist/panels/recorder/index.js.map +1 -0
  64. package/dist/panels/recorder/panel.d.ts +55 -0
  65. package/dist/panels/recorder/panel.d.ts.map +1 -0
  66. package/dist/panels/recorder/panel.js +284 -0
  67. package/dist/panels/recorder/panel.js.map +1 -0
  68. package/dist/panels/recorder/reverse-selector.d.ts +31 -0
  69. package/dist/panels/recorder/reverse-selector.d.ts.map +1 -0
  70. package/dist/panels/recorder/reverse-selector.js +116 -0
  71. package/dist/panels/recorder/reverse-selector.js.map +1 -0
  72. package/dist/panels/recorder/styles.d.ts +5 -0
  73. package/dist/panels/recorder/styles.d.ts.map +1 -0
  74. package/dist/panels/recorder/styles.js +300 -0
  75. package/dist/panels/recorder/styles.js.map +1 -0
  76. package/dist/panels/recorder/types.d.ts +51 -0
  77. package/dist/panels/recorder/types.d.ts.map +1 -0
  78. package/dist/panels/recorder/types.js +15 -0
  79. package/dist/panels/recorder/types.js.map +1 -0
  80. package/dist/strip.d.ts.map +1 -1
  81. package/dist/strip.js +6 -3
  82. package/dist/strip.js.map +1 -1
  83. package/dist/transform.d.ts.map +1 -1
  84. package/dist/transform.js +5 -2
  85. package/dist/transform.js.map +1 -1
  86. package/package.json +8 -6
@@ -0,0 +1,760 @@
1
+ /**
2
+ * Rendering functions for the dev panel
3
+ *
4
+ * SECURITY NOTE: These functions use data attributes instead of inline onclick
5
+ * handlers to prevent XSS. Event listeners are attached via delegation in
6
+ * attachEventListeners() after rendering.
7
+ */
8
+ import { getHistoryStats, formatHistorySummary, computeFlakyStats, computeResolutionStats, formatResolutionSummary, formatFlakyStatus } from './history.js';
9
+ import { escapeHtml, escapeHtmlAttr, formatContext, formatLocation, formatLocationShort, formatTime, getGroupStats } from './utils.js';
10
+ import { getNoteInfo } from './audio.js';
11
+ import { fileTreeCollapsed, FILE_TREE_GLOW_MS } from './state.js';
12
+ /**
13
+ * Render a single assertion item for the main panel
14
+ * Click opens fullscreen view, no history shown (history only in fullscreen)
15
+ */
16
+ export function renderPanelItem(a, groupId) {
17
+ const titleAttr = a.context
18
+ ? escapeHtml(formatContext(a.context))
19
+ : a.location
20
+ ? escapeHtml(formatLocation(a.location))
21
+ : '';
22
+ const noteInfo = getNoteInfo(a.description);
23
+ const isServer = !!a.assertionId;
24
+ return `
25
+ <div class="scenetest-item ${a.result ? 'pass' : 'fail'}${isServer ? ' server' : ''}"
26
+ data-action="openFullscreenToGroup"
27
+ data-group-id="${groupId}"
28
+ title="${titleAttr}">
29
+ <span class="scenetest-icon">${a.result ? '\u2713' : '\u2717'}</span>
30
+ <div class="scenetest-content">
31
+ <div class="scenetest-desc${a.type === 'fail' && a.result ? ' negated' : ''}">${isServer ? '<span class="scenetest-server-badge">server</span>' : ''}${escapeHtml(a.description)}</div>
32
+ ${a.location ? `<div class="scenetest-location">${escapeHtml(formatLocation(a.location))}</div>` : ''}
33
+ </div>
34
+ <span class="scenetest-note ${a.result ? 'pass' : 'fail'}" title="Musical note for this assertion">\u266A${noteInfo.noteName}</span>
35
+ </div>
36
+ `;
37
+ }
38
+ /**
39
+ * Render a single assertion item for the main panel (ungrouped, with time)
40
+ */
41
+ export function renderPanelItemWithTime(a) {
42
+ const histStats = getHistoryStats(a.description, a._index ?? 0);
43
+ const histSummary = formatHistorySummary(histStats);
44
+ const locJson = a.location ? escapeHtmlAttr(JSON.stringify(a.location)) : 'null';
45
+ const titleAttr = a.context
46
+ ? escapeHtml(formatContext(a.context))
47
+ : a.location
48
+ ? escapeHtml(formatLocation(a.location))
49
+ : '';
50
+ return `
51
+ <div class="scenetest-item ${a.result ? 'pass' : 'fail'}"
52
+ data-action="openInEditor"
53
+ data-location="${locJson}"
54
+ title="${titleAttr}">
55
+ <span class="scenetest-icon">${a.result ? '\u2713' : '\u2717'}</span>
56
+ <div class="scenetest-content">
57
+ <div class="scenetest-desc${a.type === 'fail' && a.result ? ' negated' : ''}">${escapeHtml(a.description)}</div>
58
+ ${a.location ? `<div class="scenetest-location">${escapeHtml(formatLocation(a.location))}</div>` : ''}
59
+ ${histSummary ? `<div class="scenetest-history">${histSummary}</div>` : ''}
60
+ </div>
61
+ <span class="scenetest-time">${formatTime(a.timestamp)}</span>
62
+ </div>
63
+ `;
64
+ }
65
+ /**
66
+ * Render a group of assertions for the main panel
67
+ */
68
+ export function renderPanelGroup(g) {
69
+ const stats = getGroupStats(g.items);
70
+ return `
71
+ <div class="scenetest-group${g.collapsed ? ' collapsed' : ''}" data-group-id="${g.id}">
72
+ <div class="scenetest-group-header" data-action="toggleCollapsed">
73
+ <div class="scenetest-group-summary">
74
+ <span class="scenetest-group-time">${formatTime(g.timestamp)}</span>
75
+ <div class="scenetest-group-stats">
76
+ <span class="scenetest-group-stat pass">\u2713${stats.passCount}</span>
77
+ <span class="scenetest-group-stat ${stats.failCount > 0 ? 'fail' : 'zero'}">\u2717${stats.failCount}</span>
78
+ </div>
79
+ </div>
80
+ <span class="scenetest-group-toggle">\u25BC</span>
81
+ </div>
82
+ <div class="scenetest-group-items">
83
+ ${g.items.map(a => renderPanelItem(a, g.id)).join('')}
84
+ </div>
85
+ </div>
86
+ `;
87
+ }
88
+ /**
89
+ * Render a single assertion item for fullscreen view
90
+ */
91
+ export function renderFullscreenItem(a) {
92
+ const histStats = getHistoryStats(a.description, a._index ?? 0);
93
+ const histSummary = formatHistorySummary(histStats);
94
+ const locJson = a.location ? escapeHtmlAttr(JSON.stringify(a.location)) : 'null';
95
+ const noteInfo = getNoteInfo(a.description);
96
+ const isServer = !!a.assertionId;
97
+ return `
98
+ <div class="item ${a.result ? 'pass' : 'fail'}${isServer ? ' server' : ''}">
99
+ <span class="icon">${a.result ? '\u2713' : '\u2717'}</span>
100
+ <div class="content">
101
+ <div class="desc${a.type === 'fail' && a.result ? ' negated' : ''}" data-action="showSequence" data-key="${escapeHtmlAttr(JSON.stringify(a.description))}">${isServer ? '<span class="server-badge">server</span>' : ''}${escapeHtml(a.description)}</div>
102
+ ${a.location ? `<div class="location" data-action="openInEditorFullscreen" data-location="${locJson}">${escapeHtml(formatLocation(a.location))}</div>` : ''}
103
+ ${histSummary ? `<div class="history">${histSummary}</div>` : ''}
104
+ ${a.context ? `<div class="context">${escapeHtml(formatContext(a.context))}</div>` : ''}
105
+ ${a.stack && !a.context ? `<div class="stack">${escapeHtml(a.stack.split('\n').slice(0, 3).join('\n'))}</div>` : ''}
106
+ </div>
107
+ <span class="note-badge ${a.result ? 'pass' : 'fail'}" title="Musical note: ${noteInfo.noteName}">\u266A${noteInfo.noteName}</span>
108
+ </div>
109
+ `;
110
+ }
111
+ /**
112
+ * Render a group of assertions for fullscreen view
113
+ */
114
+ export function renderFullscreenGroup(g) {
115
+ const stats = getGroupStats(g.items);
116
+ return `
117
+ <div class="group" data-group-id="${g.id}">
118
+ <div class="group-header" data-action="toggleCollapsed">
119
+ <div class="group-info">
120
+ <span class="group-time">${formatTime(g.timestamp)}</span>
121
+ <div class="group-stats">
122
+ ${stats.passCount > 0 ? `<span class="group-stat pass">\u2713 ${stats.passCount}</span>` : ''}
123
+ ${stats.failCount > 0 ? `<span class="group-stat fail">\u2717 ${stats.failCount}</span>` : ''}
124
+ </div>
125
+ <span style="color: #6a6a8a">${g.items.length} assertion${g.items.length === 1 ? '' : 's'}</span>
126
+ </div>
127
+ <span class="group-toggle">\u25BC</span>
128
+ </div>
129
+ <div class="group-items">
130
+ ${g.items.map(a => renderFullscreenItem(a)).join('')}
131
+ </div>
132
+ </div>
133
+ `;
134
+ }
135
+ /**
136
+ * Render a location row for the "by location" view
137
+ * Shows one row per unique code location with status dots
138
+ */
139
+ export function renderLocationRow(group) {
140
+ const passCount = group.entries.filter(e => e.result).length;
141
+ const failCount = group.entries.filter(e => !e.result).length;
142
+ const total = group.entries.length;
143
+ const keyJson = escapeHtmlAttr(JSON.stringify(group.key));
144
+ const locJson = escapeHtmlAttr(JSON.stringify(group.location));
145
+ const noteInfo = getNoteInfo(group.description);
146
+ // Generate status dots (most recent 10 runs) with ✗ marker for failures
147
+ const recentEntries = group.entries.slice(-10);
148
+ const dots = recentEntries
149
+ .map(e => {
150
+ if (e.result) {
151
+ return `<span class="status-dot pass" title="${formatTime(e.timestamp)}"></span>`;
152
+ }
153
+ else {
154
+ return `<span class="status-dot fail" title="${formatTime(e.timestamp)}"><span class="dot-x">\u2717</span></span>`;
155
+ }
156
+ })
157
+ .join('');
158
+ // Determine overall status class and icon
159
+ const hasAnyFails = failCount > 0;
160
+ const lastFailed = !group.lastResult;
161
+ const statusClass = lastFailed ? 'last-fail' : hasAnyFails ? 'has-fails' : 'all-pass';
162
+ // Note badge class based on current state
163
+ const noteBadgeClass = lastFailed ? 'fail' : hasAnyFails ? 'warn' : 'pass';
164
+ // Current state icon - prominent indicator of current status
165
+ const stateIcon = lastFailed
166
+ ? '<span class="state-icon fail">\u2717</span>'
167
+ : hasAnyFails
168
+ ? '<span class="state-icon warn">\u26A0</span>'
169
+ : '<span class="state-icon pass">\u2713</span>';
170
+ // Compute flaky and resolution stats
171
+ const flakyStats = computeFlakyStats(group.description);
172
+ const resolutionStats = computeResolutionStats(group.description);
173
+ // Build flaky info line
174
+ let flakyInfo = '';
175
+ if (flakyStats && flakyStats.isCurrentlyFailing) {
176
+ // Currently failing - show failure rate
177
+ flakyInfo = formatFlakyStatus(flakyStats);
178
+ }
179
+ else if (resolutionStats && resolutionStats.resolvedFailures.length > 0) {
180
+ // Has resolved failures - show resolution summary
181
+ flakyInfo = formatResolutionSummary(resolutionStats);
182
+ }
183
+ return `
184
+ <div class="location-row ${statusClass}" data-action="showSequence" data-key="${keyJson}" data-location-key="${keyJson}" data-description="${escapeHtmlAttr(group.description)}" data-last-result="${group.lastResult}">
185
+ ${stateIcon}
186
+ <div class="location-main">
187
+ <div class="location-info">
188
+ <span class="location-file">${escapeHtml(formatLocationShort(group.location))}</span>
189
+ <span class="location-desc">${escapeHtml(group.description)}</span>
190
+ ${flakyInfo ? `<span class="flaky-info ${lastFailed ? 'failing' : 'resolved'}">${escapeHtml(flakyInfo)}</span>` : ''}
191
+ </div>
192
+ <div class="location-stats">
193
+ <div class="status-dots">${dots}</div>
194
+ <span class="location-count">${total} run${total === 1 ? '' : 's'}</span>
195
+ <div class="location-summary">
196
+ ${passCount > 0 ? `<span class="stat pass">\u2713${passCount}</span>` : ''}
197
+ ${failCount > 0 ? `<span class="stat fail">\u2717${failCount}</span>` : ''}
198
+ </div>
199
+ </div>
200
+ </div>
201
+ <div class="location-actions">
202
+ <span class="note-badge clickable ${noteBadgeClass}" data-description="${escapeHtml(group.description)}" data-result="${group.lastResult}" title="Click to hear this note">\u266A${noteInfo.noteName}</span>
203
+ <button class="loc-btn" data-action="openInEditorFullscreen" data-location="${locJson}" title="Open in editor">\u270E</button>
204
+ </div>
205
+ </div>
206
+ `;
207
+ }
208
+ /**
209
+ * Render a piano roll visualization showing notes over time (chronological grid)
210
+ * X axis = time (chords), Y axis = notes (pitch)
211
+ */
212
+ export function renderPianoRoll(locations, allAssertions) {
213
+ if (locations.length === 0 || allAssertions.length === 0)
214
+ return '';
215
+ // Group assertions by timestamp (within 50ms threshold)
216
+ const GROUP_THRESHOLD = 50;
217
+ const chords = [];
218
+ const sortedAssertions = [...allAssertions].sort((a, b) => a.timestamp - b.timestamp);
219
+ sortedAssertions.forEach(assertion => {
220
+ const lastChord = chords[chords.length - 1];
221
+ if (lastChord && assertion.timestamp - lastChord.timestamp < GROUP_THRESHOLD) {
222
+ lastChord.assertions.push(assertion);
223
+ }
224
+ else {
225
+ chords.push({ timestamp: assertion.timestamp, assertions: [assertion] });
226
+ }
227
+ });
228
+ // Get unique notes sorted by pitch
229
+ const noteMap = new Map();
230
+ locations.forEach(loc => {
231
+ const noteInfo = getNoteInfo(loc.description);
232
+ const failCount = loc.entries.filter(e => !e.result).length;
233
+ noteMap.set(loc.description, {
234
+ noteInfo,
235
+ description: loc.description,
236
+ hasAnyFails: failCount > 0,
237
+ lastResult: loc.lastResult,
238
+ });
239
+ });
240
+ const notes = Array.from(noteMap.values()).sort((a, b) => a.noteInfo.noteIndex - b.noteInfo.noteIndex);
241
+ // Limit to last N chords to fit reasonably
242
+ const maxChords = 30;
243
+ const displayChords = chords.slice(-maxChords);
244
+ // Build the grid
245
+ const rows = notes.map(note => {
246
+ const statusClass = !note.lastResult ? 'fail' : note.hasAnyFails ? 'warn' : 'pass';
247
+ const cells = displayChords.map((chord, colIdx) => {
248
+ const match = chord.assertions.find(a => a.description === note.description);
249
+ if (match) {
250
+ const cellClass = match.result ? 'pass' : 'fail';
251
+ return `<span class="piano-cell ${cellClass}" data-col="${colIdx}"></span>`;
252
+ }
253
+ return `<span class="piano-cell empty" data-col="${colIdx}"></span>`;
254
+ }).join('');
255
+ return `
256
+ <div class="piano-row ${statusClass}" data-description="${escapeHtmlAttr(note.description)}" data-result="${note.lastResult}">
257
+ <span class="piano-note">${note.noteInfo.noteName}</span>
258
+ <div class="piano-cells">${cells}</div>
259
+ </div>
260
+ `;
261
+ }).join('');
262
+ // Build chord data for click handlers (JSON encoded)
263
+ const chordData = displayChords.map(chord => ({
264
+ timestamp: chord.timestamp,
265
+ notes: chord.assertions.map(a => ({
266
+ description: a.description,
267
+ result: a.result,
268
+ })),
269
+ }));
270
+ return `
271
+ <div class="piano-roll" data-chords="${escapeHtmlAttr(JSON.stringify(chordData))}">
272
+ <div class="piano-roll-header">
273
+ <span class="piano-roll-title">\uD83C\uDFB9 Piano Roll</span>
274
+ <span class="piano-roll-hint">Click a column to hear that chord</span>
275
+ </div>
276
+ <div class="piano-grid">
277
+ ${rows}
278
+ </div>
279
+ <div class="piano-timeline">
280
+ <span class="piano-note"></span>
281
+ <div class="piano-time-markers">
282
+ ${displayChords.map((_, i) => `<span class="piano-time-marker" data-col="${i}"></span>`).join('')}
283
+ </div>
284
+ </div>
285
+ </div>
286
+ `;
287
+ }
288
+ /**
289
+ * Render a sequence entry for the "sequence" view
290
+ * Shows a single run of an assertion at a specific location
291
+ * isFirst/isLast are used to show timeline labels
292
+ */
293
+ export function renderSequenceEntry(entry, _location, isFirst = false, isLast = false) {
294
+ const noteInfo = getNoteInfo(entry.description);
295
+ return `
296
+ <div class="sequence-entry ${entry.result ? 'pass' : 'fail'}" data-timestamp="${entry.timestamp}">
297
+ <div class="timeline-track">
298
+ <div class="timeline-line ${isFirst ? 'first' : ''} ${isLast ? 'last' : ''}"></div>
299
+ <div class="timeline-dot ${entry.result ? 'pass' : 'fail'}">
300
+ ${entry.result ? '' : '<span class="dot-x">\u2717</span>'}
301
+ </div>
302
+ </div>
303
+ <div class="content">
304
+ <div class="sequence-time chord-trigger" data-timestamp="${entry.timestamp}" title="Hover to hear this chord">
305
+ ${formatTime(entry.timestamp)}
306
+ <span class="chord-icon">\uD83C\uDFB5</span>
307
+ </div>
308
+ <div class="desc">${escapeHtml(entry.description)}</div>
309
+ ${entry.context ? `<div class="context">${escapeHtml(formatContext(entry.context))}</div>` : ''}
310
+ </div>
311
+ <span class="note-badge ${entry.result ? 'pass' : 'fail'}" title="Musical note: ${noteInfo.noteName}">\u266A${noteInfo.noteName}</span>
312
+ </div>
313
+ `;
314
+ }
315
+ /**
316
+ * Render a chord tooltip showing all assertions that fired together
317
+ */
318
+ export function renderChordTooltip(assertions) {
319
+ if (assertions.length === 0)
320
+ return '';
321
+ const passes = assertions.filter(a => a.result).length;
322
+ const fails = assertions.length - passes;
323
+ return `
324
+ <div class="chord-tooltip">
325
+ <div class="chord-header">
326
+ <span class="chord-title">\uD83C\uDFB5 Chord (${assertions.length} note${assertions.length === 1 ? '' : 's'})</span>
327
+ <span class="chord-stats">
328
+ ${passes > 0 ? `<span class="pass">\u2713${passes}</span>` : ''}
329
+ ${fails > 0 ? `<span class="fail">\u2717${fails}</span>` : ''}
330
+ </span>
331
+ </div>
332
+ <div class="chord-notes">
333
+ ${assertions.map(a => {
334
+ const noteInfo = getNoteInfo(a.description);
335
+ return `
336
+ <div class="chord-note ${a.result ? 'pass' : 'fail'}">
337
+ <span class="note-indicator">\u266A${noteInfo.noteName}</span>
338
+ <span class="note-desc">${escapeHtml(a.description)}</span>
339
+ <span class="note-status">${a.result ? '\u2713' : '\u2717'}</span>
340
+ </div>
341
+ `;
342
+ }).join('')}
343
+ </div>
344
+ </div>
345
+ `;
346
+ }
347
+ /**
348
+ * Render the sequence view header showing the location being tracked
349
+ */
350
+ export function renderSequenceHeader(group) {
351
+ const locJson = escapeHtmlAttr(JSON.stringify(group.location));
352
+ const passCount = group.entries.filter(e => e.result).length;
353
+ const failCount = group.entries.filter(e => !e.result).length;
354
+ const noteInfo = getNoteInfo(group.description);
355
+ // Determine status: flaky (has both), fail (only fails), or pass
356
+ const isFlaky = passCount > 0 && failCount > 0;
357
+ const lastFailed = !group.lastResult;
358
+ const statusClass = lastFailed ? 'fail' : isFlaky ? 'warn' : 'pass';
359
+ // State icon like in location rows
360
+ const stateIcon = lastFailed
361
+ ? '<span class="state-icon fail">\u2717</span>'
362
+ : isFlaky
363
+ ? '<span class="state-icon warn">\u26A0</span>'
364
+ : '<span class="state-icon pass">\u2713</span>';
365
+ // Compute flaky and resolution stats
366
+ const flakyStats = computeFlakyStats(group.description);
367
+ const resolutionStats = computeResolutionStats(group.description);
368
+ // Build detailed flaky info lines
369
+ let flakyStatusLine = '';
370
+ let resolutionLine = '';
371
+ if (flakyStats && flakyStats.isCurrentlyFailing) {
372
+ flakyStatusLine = formatFlakyStatus(flakyStats);
373
+ }
374
+ if (resolutionStats && resolutionStats.resolvedFailures.length > 0) {
375
+ resolutionLine = formatResolutionSummary(resolutionStats);
376
+ }
377
+ return `
378
+ <div class="sequence-header ${statusClass}">
379
+ ${stateIcon}
380
+ <div class="sequence-info">
381
+ <div class="sequence-location">
382
+ <span class="sequence-file" data-action="openInEditorFullscreen" data-location="${locJson}">${escapeHtml(formatLocation(group.location))}</span>
383
+ </div>
384
+ <div class="sequence-desc">${escapeHtml(group.description)}</div>
385
+ <div class="sequence-summary">
386
+ <span class="sequence-total">${group.entries.length} run${group.entries.length === 1 ? '' : 's'}</span>
387
+ <div class="sequence-stats">
388
+ ${passCount > 0 ? `<span class="stat pass">\u2713 ${passCount}</span>` : ''}
389
+ ${failCount > 0 ? `<span class="stat fail">\u2717 ${failCount}</span>` : ''}
390
+ </div>
391
+ </div>
392
+ ${flakyStatusLine ? `<div class="flaky-status">${escapeHtml(flakyStatusLine)}</div>` : ''}
393
+ ${resolutionLine ? `<div class="resolution-info">${escapeHtml(resolutionLine)}</div>` : ''}
394
+ </div>
395
+ <span class="note-badge clickable ${statusClass}" data-description="${escapeHtml(group.description)}" data-result="${group.lastResult}" title="Click to hear this note">\u266A${noteInfo.noteName}</span>
396
+ </div>
397
+ `;
398
+ }
399
+ /**
400
+ * Render the back button for sequence view
401
+ */
402
+ export function renderBackButton(fromView = 'byLocation') {
403
+ const label = fromView === 'grouped' ? 'Back to timeline' : 'Back to all locations';
404
+ return `<div class="back-btn" data-action="backToLocationView">\u2190 ${label}</div>`;
405
+ }
406
+ /**
407
+ * Shorten a file path for display: strip everything up to and including /src/,
408
+ * or everything before the last three segments.
409
+ */
410
+ function shortenPath(file) {
411
+ const srcIdx = file.indexOf('/src/');
412
+ if (srcIdx !== -1)
413
+ return file.slice(srcIdx + 1); // "src/..."
414
+ // Fallback: last 3 path segments
415
+ const parts = file.split('/');
416
+ return parts.slice(Math.max(0, parts.length - 3)).join('/');
417
+ }
418
+ /**
419
+ * Build a file tree from location groups.
420
+ *
421
+ * Groups assertions by their `location.file`, constructs a trie from the path
422
+ * segments, and compacts single-child directory chains (e.g. "src/components").
423
+ */
424
+ export function buildFileTree(locations) {
425
+ // Group by file path
426
+ const byFile = new Map();
427
+ for (const loc of locations) {
428
+ const file = loc.location ? shortenPath(loc.location.file) : '(unknown)';
429
+ let group = byFile.get(file);
430
+ if (!group) {
431
+ group = [];
432
+ byFile.set(file, group);
433
+ }
434
+ group.push(loc);
435
+ }
436
+ const root = { name: '', path: '', children: new Map(), assertions: [] };
437
+ for (const [filePath, locs] of byFile) {
438
+ const parts = filePath.split('/');
439
+ let current = root;
440
+ let pathSoFar = '';
441
+ for (let i = 0; i < parts.length; i++) {
442
+ const part = parts[i];
443
+ pathSoFar = pathSoFar ? pathSoFar + '/' + part : part;
444
+ let child = current.children.get(part);
445
+ if (!child) {
446
+ child = { name: part, path: pathSoFar, children: new Map(), assertions: [] };
447
+ current.children.set(part, child);
448
+ }
449
+ current = child;
450
+ }
451
+ // Leaf node: attach assertions
452
+ current.assertions = locs;
453
+ }
454
+ // Convert trie to FileTreeNode array, compacting single-child dirs
455
+ function convert(trie) {
456
+ const isFile = trie.children.size === 0;
457
+ const children = [];
458
+ for (const child of trie.children.values()) {
459
+ children.push(convert(child));
460
+ }
461
+ // Sort: directories first (alphabetical), then files (alphabetical)
462
+ children.sort((a, b) => {
463
+ if (a.isFile !== b.isFile)
464
+ return a.isFile ? 1 : -1;
465
+ return a.name.localeCompare(b.name);
466
+ });
467
+ // Compute aggregate stats
468
+ let totalAssertions = 0;
469
+ let totalFailures = 0;
470
+ let lastTimestamp = 0;
471
+ if (isFile) {
472
+ for (const loc of trie.assertions) {
473
+ totalAssertions += loc.entries.length;
474
+ totalFailures += loc.entries.filter(e => !e.result).length;
475
+ if (loc.lastTimestamp > lastTimestamp)
476
+ lastTimestamp = loc.lastTimestamp;
477
+ }
478
+ }
479
+ else {
480
+ for (const child of children) {
481
+ totalAssertions += child.totalAssertions;
482
+ totalFailures += child.totalFailures;
483
+ if (child.lastTimestamp > lastTimestamp)
484
+ lastTimestamp = child.lastTimestamp;
485
+ }
486
+ }
487
+ const hasAnyFails = totalFailures > 0;
488
+ // Determine if any assertion is currently failing
489
+ const anyCurrentFail = isFile
490
+ ? trie.assertions.some(a => !a.lastResult)
491
+ : children.some(c => c.status === 'fail');
492
+ const status = anyCurrentFail ? 'fail' : hasAnyFails ? 'warn' : 'pass';
493
+ return {
494
+ name: trie.name,
495
+ path: trie.path,
496
+ isFile,
497
+ children,
498
+ assertions: trie.assertions,
499
+ status,
500
+ lastTimestamp,
501
+ totalAssertions,
502
+ totalFailures,
503
+ };
504
+ }
505
+ // Convert root's children (skip the empty root node)
506
+ const topLevel = [];
507
+ for (const child of root.children.values()) {
508
+ topLevel.push(convert(child));
509
+ }
510
+ // Compact: if a directory has a single child directory, merge names
511
+ function compact(node) {
512
+ if (!node.isFile && node.children.length === 1 && !node.children[0].isFile) {
513
+ const child = node.children[0];
514
+ return compact({
515
+ ...child,
516
+ name: node.name + '/' + child.name,
517
+ // Keep the child's path since it's the full path
518
+ });
519
+ }
520
+ return {
521
+ ...node,
522
+ children: node.children.map(compact),
523
+ };
524
+ }
525
+ return topLevel.map(compact).sort((a, b) => {
526
+ if (a.isFile !== b.isFile)
527
+ return a.isFile ? 1 : -1;
528
+ return a.name.localeCompare(b.name);
529
+ });
530
+ }
531
+ /**
532
+ * Render the full file tree view.
533
+ */
534
+ export function renderFileTree(tree) {
535
+ if (tree.length === 0)
536
+ return '';
537
+ // Compute totals for header
538
+ let totalFiles = 0;
539
+ let totalAssertions = 0;
540
+ function countFiles(nodes) {
541
+ for (const n of nodes) {
542
+ if (n.isFile) {
543
+ totalFiles++;
544
+ totalAssertions += n.totalAssertions;
545
+ }
546
+ else
547
+ countFiles(n.children);
548
+ }
549
+ }
550
+ countFiles(tree);
551
+ return `
552
+ <div class="file-tree">
553
+ <div class="file-tree-header">
554
+ <span class="file-tree-title">\uD83D\uDCC2 File Tree</span>
555
+ <span class="file-tree-hint">${totalFiles} file${totalFiles === 1 ? '' : 's'}, ${totalAssertions} assertion${totalAssertions === 1 ? '' : 's'}</span>
556
+ </div>
557
+ <div class="file-tree-content">
558
+ ${tree.map(node => renderFileTreeNode(node, 0)).join('')}
559
+ </div>
560
+ </div>
561
+ `;
562
+ }
563
+ /**
564
+ * Render a single file tree node (directory or file) recursively.
565
+ */
566
+ function renderFileTreeNode(node, depth) {
567
+ const now = Date.now();
568
+ const isActive = now - node.lastTimestamp < FILE_TREE_GLOW_MS;
569
+ const isCollapsed = fileTreeCollapsed.has(node.path);
570
+ const pathJson = escapeHtmlAttr(JSON.stringify(node.path));
571
+ if (node.isFile) {
572
+ // File node with assertions underneath
573
+ const fileStatusIcon = node.status === 'fail'
574
+ ? '<span class="ft-status-icon fail">\u2717</span>'
575
+ : node.status === 'warn'
576
+ ? '<span class="ft-status-icon warn">\u26A0</span>'
577
+ : '<span class="ft-status-icon pass">\u2713</span>';
578
+ const assertionCount = node.assertions.reduce((sum, a) => sum + a.entries.length, 0);
579
+ return `
580
+ <div class="ft-node ft-file ${node.status}${isActive ? ' ft-active' : ''}${isCollapsed ? ' ft-collapsed' : ''}"
581
+ style="--depth: ${depth}">
582
+ <div class="ft-file-header" data-action="toggleFileTree" data-tree-path="${pathJson}">
583
+ <span class="ft-indent" style="width: ${depth * 16}px"></span>
584
+ <span class="ft-toggle">${isCollapsed ? '\u25B6' : '\u25BC'}</span>
585
+ <span class="ft-file-icon">\uD83D\uDCC4</span>
586
+ ${fileStatusIcon}
587
+ <span class="ft-name">${escapeHtml(node.name)}</span>
588
+ <span class="ft-badge">${assertionCount}</span>
589
+ </div>
590
+ ${!isCollapsed ? `
591
+ <div class="ft-assertions">
592
+ ${node.assertions
593
+ .sort((a, b) => b.lastTimestamp - a.lastTimestamp)
594
+ .map(loc => renderFileTreeAssertion(loc, depth + 1))
595
+ .join('')}
596
+ </div>
597
+ ` : ''}
598
+ </div>
599
+ `;
600
+ }
601
+ // Directory node
602
+ const dirStatusIcon = node.status === 'fail'
603
+ ? '<span class="ft-status-icon fail">\u2717</span>'
604
+ : node.status === 'warn'
605
+ ? '<span class="ft-status-icon warn">\u26A0</span>'
606
+ : '<span class="ft-status-icon pass">\u2713</span>';
607
+ return `
608
+ <div class="ft-node ft-dir ${node.status}${isActive ? ' ft-active' : ''}${isCollapsed ? ' ft-collapsed' : ''}"
609
+ style="--depth: ${depth}">
610
+ <div class="ft-dir-header" data-action="toggleFileTree" data-tree-path="${pathJson}">
611
+ <span class="ft-indent" style="width: ${depth * 16}px"></span>
612
+ <span class="ft-toggle">${isCollapsed ? '\u25B6' : '\u25BC'}</span>
613
+ <span class="ft-dir-icon">${isCollapsed ? '\uD83D\uDCC1' : '\uD83D\uDCC2'}</span>
614
+ ${dirStatusIcon}
615
+ <span class="ft-name">${escapeHtml(node.name)}</span>
616
+ <span class="ft-badge">${node.totalAssertions}</span>
617
+ ${node.totalFailures > 0 ? `<span class="ft-fail-badge">${node.totalFailures} \u2717</span>` : ''}
618
+ </div>
619
+ ${!isCollapsed ? `
620
+ <div class="ft-children">
621
+ ${node.children.map(child => renderFileTreeNode(child, depth + 1)).join('')}
622
+ </div>
623
+ ` : ''}
624
+ </div>
625
+ `;
626
+ }
627
+ /**
628
+ * Render a single assertion row inside a file tree file node.
629
+ */
630
+ function renderFileTreeAssertion(group, depth) {
631
+ const now = Date.now();
632
+ const isActive = now - group.lastTimestamp < FILE_TREE_GLOW_MS;
633
+ const keyJson = escapeHtmlAttr(JSON.stringify(group.key));
634
+ const lastFailed = !group.lastResult;
635
+ const hasAnyFails = group.entries.some(e => !e.result);
636
+ const statusClass = lastFailed ? 'fail' : hasAnyFails ? 'warn' : 'pass';
637
+ const total = group.entries.length;
638
+ const line = group.location?.line;
639
+ const statusIcon = lastFailed
640
+ ? '<span class="ft-assert-icon fail">\u2717</span>'
641
+ : hasAnyFails
642
+ ? '<span class="ft-assert-icon warn">\u26A0</span>'
643
+ : '<span class="ft-assert-icon pass">\u2713</span>';
644
+ // Recent status dots (last 5)
645
+ const recentEntries = group.entries.slice(-5);
646
+ const dots = recentEntries
647
+ .map(e => `<span class="ft-dot ${e.result ? 'pass' : 'fail'}"></span>`)
648
+ .join('');
649
+ return `
650
+ <div class="ft-assertion ${statusClass}${isActive ? ' ft-active' : ''}"
651
+ data-action="showSequence" data-key="${keyJson}"
652
+ style="--depth: ${depth}">
653
+ <span class="ft-indent" style="width: ${depth * 16}px"></span>
654
+ ${statusIcon}
655
+ <span class="ft-assert-desc">${escapeHtml(group.description)}</span>
656
+ ${line ? `<span class="ft-assert-line">:${line}</span>` : ''}
657
+ <span class="ft-dots">${dots}</span>
658
+ <span class="ft-assert-count">${total}x</span>
659
+ </div>
660
+ `;
661
+ }
662
+ /**
663
+ * Attach event listeners to rendered elements using event delegation.
664
+ * Call this after rendering HTML to a container.
665
+ *
666
+ * @param container - The container element (or document)
667
+ * @param handlers - Object with handler functions
668
+ */
669
+ export function attachEventListeners(container, handlers) {
670
+ const root = container instanceof Document ? container.body : container;
671
+ root.addEventListener('click', (e) => {
672
+ const target = e.target;
673
+ // Find the element with the data-action attribute
674
+ const actionEl = target.closest('[data-action]');
675
+ if (!actionEl)
676
+ return;
677
+ const action = actionEl.dataset.action;
678
+ switch (action) {
679
+ case 'openFullscreenToGroup': {
680
+ const groupId = parseInt(actionEl.dataset.groupId || '', 10);
681
+ if (!isNaN(groupId) && handlers.openFullscreenToGroup) {
682
+ handlers.openFullscreenToGroup(groupId);
683
+ }
684
+ break;
685
+ }
686
+ case 'openInEditor': {
687
+ const locStr = actionEl.dataset.location;
688
+ if (locStr && locStr !== 'null' && handlers.openInEditor) {
689
+ try {
690
+ const location = JSON.parse(locStr);
691
+ handlers.openInEditor(location);
692
+ }
693
+ catch {
694
+ // Invalid JSON, ignore
695
+ }
696
+ }
697
+ break;
698
+ }
699
+ case 'openInEditorFullscreen': {
700
+ e.stopPropagation();
701
+ const locStr = actionEl.dataset.location;
702
+ if (locStr && locStr !== 'null' && handlers.openInEditorFullscreen) {
703
+ try {
704
+ const location = JSON.parse(locStr);
705
+ handlers.openInEditorFullscreen(location);
706
+ }
707
+ catch {
708
+ // Invalid JSON, ignore
709
+ }
710
+ }
711
+ break;
712
+ }
713
+ case 'showSequence': {
714
+ const key = actionEl.dataset.key;
715
+ if (key && handlers.showSequence) {
716
+ try {
717
+ const keyParsed = JSON.parse(key);
718
+ handlers.showSequence(keyParsed);
719
+ }
720
+ catch {
721
+ // Invalid JSON, ignore
722
+ }
723
+ }
724
+ break;
725
+ }
726
+ case 'backToLocationView': {
727
+ if (handlers.backToLocationView) {
728
+ handlers.backToLocationView();
729
+ }
730
+ break;
731
+ }
732
+ case 'toggleCollapsed': {
733
+ const parent = actionEl.parentElement;
734
+ if (parent) {
735
+ parent.classList.toggle('collapsed');
736
+ // Also update the state if a callback is provided
737
+ const groupId = parseInt(parent.dataset.groupId || '', 10);
738
+ if (!isNaN(groupId) && handlers.toggleCollapsed) {
739
+ handlers.toggleCollapsed(groupId);
740
+ }
741
+ }
742
+ break;
743
+ }
744
+ case 'toggleFileTree': {
745
+ const treePath = actionEl.dataset.treePath;
746
+ if (treePath && handlers.toggleFileTree) {
747
+ try {
748
+ const pathParsed = JSON.parse(treePath);
749
+ handlers.toggleFileTree(pathParsed);
750
+ }
751
+ catch {
752
+ // Invalid JSON, ignore
753
+ }
754
+ }
755
+ break;
756
+ }
757
+ }
758
+ });
759
+ }
760
+ //# sourceMappingURL=render.js.map