@dynamicu/chromedebug-mcp 2.6.7 → 2.7.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.
Files changed (49) hide show
  1. package/CLAUDE.md +17 -1
  2. package/README.md +1 -1
  3. package/chrome-extension/activation-manager.js +10 -10
  4. package/chrome-extension/background.js +1045 -736
  5. package/chrome-extension/browser-recording-manager.js +1 -1
  6. package/chrome-extension/chrome-debug-logger.js +168 -0
  7. package/chrome-extension/chrome-session-manager.js +5 -5
  8. package/chrome-extension/console-interception-library.js +430 -0
  9. package/chrome-extension/content.css +16 -16
  10. package/chrome-extension/content.js +739 -221
  11. package/chrome-extension/data-buffer.js +5 -5
  12. package/chrome-extension/dom-tracker.js +9 -9
  13. package/chrome-extension/extension-config.js +1 -1
  14. package/chrome-extension/firebase-client.js +13 -13
  15. package/chrome-extension/frame-capture.js +20 -38
  16. package/chrome-extension/license-helper.js +33 -7
  17. package/chrome-extension/manifest.free.json +3 -6
  18. package/chrome-extension/network-tracker.js +9 -9
  19. package/chrome-extension/options.html +10 -0
  20. package/chrome-extension/options.js +21 -8
  21. package/chrome-extension/performance-monitor.js +17 -17
  22. package/chrome-extension/popup.html +230 -193
  23. package/chrome-extension/popup.js +146 -458
  24. package/chrome-extension/pro/enhanced-capture.js +406 -0
  25. package/chrome-extension/pro/frame-editor.html +433 -0
  26. package/chrome-extension/pro/frame-editor.js +1567 -0
  27. package/chrome-extension/pro/function-tracker.js +843 -0
  28. package/chrome-extension/pro/jszip.min.js +13 -0
  29. package/chrome-extension/upload-manager.js +7 -7
  30. package/dist/chromedebug-extension-free.zip +0 -0
  31. package/package.json +3 -1
  32. package/scripts/webpack.config.free.cjs +8 -8
  33. package/scripts/webpack.config.pro.cjs +2 -0
  34. package/src/cli.js +2 -2
  35. package/src/database.js +55 -7
  36. package/src/index.js +9 -6
  37. package/src/mcp/server.js +2 -2
  38. package/src/services/process-manager.js +10 -6
  39. package/src/services/process-tracker.js +10 -5
  40. package/src/services/profile-manager.js +17 -2
  41. package/src/validation/schemas.js +12 -11
  42. package/src/index-direct.js +0 -157
  43. package/src/index-modular.js +0 -219
  44. package/src/index-monolithic-backup.js +0 -2230
  45. package/src/legacy/chrome-controller-old.js +0 -1406
  46. package/src/legacy/index-express.js +0 -625
  47. package/src/legacy/index-old.js +0 -977
  48. package/src/legacy/routes.js +0 -260
  49. package/src/legacy/shared-storage.js +0 -101
@@ -0,0 +1,1567 @@
1
+ // Frame Editor Logic
2
+ class FrameEditor {
3
+ constructor() {
4
+ this.sessionId = null;
5
+ this.frames = [];
6
+ this.selectedFrames = new Set();
7
+ this.loadedImages = new Map();
8
+ this.lastClickedIndex = -1;
9
+ this.isWorkflow = false; // NEW: Track if viewing workflow
10
+ this.workflowData = null; // NEW: Store workflow data
11
+
12
+ this.init();
13
+ }
14
+
15
+ // Helper method to get item count (works for both frames and workflows)
16
+ getItemCount() {
17
+ return this.isWorkflow
18
+ ? (this.workflowData?.actions?.length || 0)
19
+ : this.frames.length;
20
+ }
21
+
22
+ // Helper method to get items array (works for both frames and workflows)
23
+ getItems() {
24
+ return this.isWorkflow
25
+ ? (this.workflowData?.actions || [])
26
+ : this.frames;
27
+ }
28
+
29
+ // Get actions to export based on selection
30
+ // Selection is REQUIRED - returns empty array if nothing selected
31
+ // Returns array of { action, originalIndex } for proper correlation
32
+ getActionsToExport() {
33
+ if (!this.workflowData?.actions) return [];
34
+ if (this.selectedFrames.size === 0) return []; // Selection required
35
+
36
+ // Export only selected actions, preserving original indices
37
+ const selectedIndices = Array.from(this.selectedFrames).sort((a, b) => a - b);
38
+ return selectedIndices.map(index => ({
39
+ action: this.workflowData.actions[index],
40
+ originalIndex: index
41
+ }));
42
+ }
43
+
44
+ // Strip screenshot data from action and add file reference instead
45
+ // This prevents duplicate data (images are saved as separate files)
46
+ stripScreenshotData(action, originalIndex, hasScreenshot) {
47
+ const stripped = { ...action };
48
+
49
+ // Remove both possible screenshot field names
50
+ delete stripped.screenshot;
51
+ delete stripped.screenshot_data;
52
+
53
+ // Add reference to external file if screenshot exists
54
+ if (hasScreenshot) {
55
+ const paddedIndex = String(originalIndex).padStart(3, '0');
56
+ stripped.screenshotFile = `screenshots/action_${paddedIndex}.jpg`;
57
+ }
58
+
59
+ return stripped;
60
+ }
61
+
62
+ // Filter logs for exported actions using non-overlapping windows
63
+ // Same algorithm as getLogsForAction() but for a subset of actions
64
+ filterLogsForExport(actionsToExport) {
65
+ if (!this.workflowData?.logs || actionsToExport.length === 0) return [];
66
+
67
+ const allActions = this.workflowData.actions;
68
+ const logs = this.workflowData.logs;
69
+ const exportedIndices = new Set(actionsToExport.map(a => a.originalIndex));
70
+
71
+ // For each exported action, calculate its window and collect logs
72
+ const includedLogs = [];
73
+
74
+ actionsToExport.forEach(({ action, originalIndex }, exportOrder) => {
75
+ // Window start: 500ms before first action, or this action's timestamp
76
+ const windowStart = originalIndex === 0
77
+ ? action.timestamp - 500
78
+ : action.timestamp;
79
+
80
+ // Window end: next action's timestamp, or 5000ms after last action
81
+ let windowEnd;
82
+ if (originalIndex < allActions.length - 1) {
83
+ // Find next action's timestamp
84
+ windowEnd = allActions[originalIndex + 1].timestamp;
85
+ } else {
86
+ // Last action in original recording
87
+ windowEnd = action.timestamp + 5000;
88
+ }
89
+
90
+ // Collect logs in this window
91
+ logs.forEach(log => {
92
+ const logTime = typeof log.timestamp === 'number'
93
+ ? log.timestamp
94
+ : new Date(log.timestamp).getTime();
95
+
96
+ if (logTime >= windowStart && logTime < windowEnd) {
97
+ // Only include if not already included (handles edge cases)
98
+ if (!includedLogs.some(l => l.timestamp === log.timestamp && l.message === log.message)) {
99
+ includedLogs.push(log);
100
+ }
101
+ }
102
+ });
103
+ });
104
+
105
+ // Sort by timestamp
106
+ return includedLogs.sort((a, b) => a.timestamp - b.timestamp);
107
+ }
108
+
109
+ // ========== FRAME RECORDING HELPERS ==========
110
+ // These are separate from workflow helpers because frames have different semantics:
111
+ // - Frames have logs already attached (no time-window filtering needed)
112
+ // - Frames use imageData field (not screenshot/screenshot_data)
113
+ // - Frames are snapshots, not replayable actions
114
+
115
+ // Get frames to export based on selection
116
+ // Selection is REQUIRED - returns empty array if nothing selected
117
+ getFramesToExport() {
118
+ if (this.selectedFrames.size === 0) return [];
119
+
120
+ // Export only selected frames, preserving original indices
121
+ const selectedIndices = Array.from(this.selectedFrames).sort((a, b) => a - b);
122
+ return selectedIndices.map(index => ({
123
+ frame: this.frames[index],
124
+ originalIndex: index
125
+ }));
126
+ }
127
+
128
+ // Strip imageData from frame and add file reference instead
129
+ // Frames have logs already attached - no filtering needed
130
+ stripFrameImageData(frame, originalIndex) {
131
+ const stripped = { ...frame };
132
+
133
+ // Remove imageData field (base64 image)
134
+ delete stripped.imageData;
135
+
136
+ // Add reference to external file and frame index for clarity
137
+ const paddedIndex = String(originalIndex).padStart(3, '0');
138
+ stripped.screenshotFile = `screenshots/frame_${paddedIndex}.jpg`;
139
+ stripped.frameIndex = originalIndex;
140
+
141
+ return stripped;
142
+ }
143
+
144
+ async init() {
145
+ // Get session ID and browserOnly flag from URL params
146
+ const params = new URLSearchParams(window.location.search);
147
+ this.sessionId = params.get('sessionId');
148
+ this.browserOnly = params.get('browserOnly') === 'true';
149
+ this.isWorkflow = params.get('type') === 'workflow'; // NEW: Detect workflow type
150
+
151
+ if (!this.sessionId) {
152
+ // Try to get the most recent recording
153
+ const result = await chrome.storage.local.get(['recordings']);
154
+ const recordings = result.recordings || [];
155
+ if (recordings.length > 0 && recordings[0].isFrameCapture) {
156
+ this.sessionId = recordings[0].id;
157
+ }
158
+ }
159
+
160
+ if (!this.sessionId) {
161
+ this.showEmpty();
162
+ return;
163
+ }
164
+
165
+ // Load the session data
166
+ if (this.isWorkflow) {
167
+ await this.loadWorkflowSession(); // NEW: Load workflow data
168
+ } else {
169
+ await this.loadSession();
170
+ }
171
+
172
+ // Setup event listeners
173
+ this.setupEventListeners();
174
+
175
+ // Setup Intersection Observer for lazy loading
176
+ this.setupLazyLoading();
177
+ }
178
+
179
+ async loadSession() {
180
+ try {
181
+ // If in browser-only mode, load from IndexedDB directly
182
+ if (this.browserOnly) {
183
+ // console.log(`[INFO] Frame Editor: Loading browser-only recording from IndexedDB...`);
184
+ const result = await chrome.runtime.sendMessage({
185
+ action: 'getBrowserRecordingFrames',
186
+ sessionId: this.sessionId
187
+ });
188
+
189
+ if (result && result.success && result.frames) {
190
+ this.frames = result.frames;
191
+ // console.log(`[INFO] Frame Editor: Loaded ${this.frames.length} frames from IndexedDB`);
192
+ } else {
193
+ throw new Error('Failed to load browser-only recording from IndexedDB');
194
+ }
195
+
196
+ // Update UI and render frames - don't try HTTP server
197
+ this.updateSessionInfo();
198
+ this.renderFrames();
199
+
200
+ // Show the frames container and hide loading
201
+ document.getElementById('loadingContainer').style.display = 'none';
202
+ document.getElementById('framesContainer').style.display = 'block';
203
+ return;
204
+ }
205
+
206
+ // Server mode: Try to fetch from HTTP server first (includes console logs from database)
207
+ const ports = CHROMEDEBUG_CONFIG?.ports?.slice(0, 5) || [3001, 3000, 3028]; // Use configured ports
208
+ let found = false;
209
+
210
+ // console.log(`[INFO] Frame Editor: Fetching session ${this.sessionId} from HTTP server...`);
211
+
212
+ for (const port of ports) {
213
+ try {
214
+ const response = await fetch(`http://localhost:${port}/chromedebug/frame-session/${this.sessionId}`);
215
+ if (response.ok) {
216
+ const data = await response.json();
217
+ this.frames = data.frames || [];
218
+ found = true;
219
+ // console.log(`[INFO] Frame Editor: Loaded ${this.frames.length} frames from HTTP server on port ${port}`);
220
+ break;
221
+ }
222
+ } catch (e) {
223
+ // Try next port
224
+ // console.log(`[DEBUG] Frame Editor: Failed to fetch from port ${port}:`, e.message);
225
+ }
226
+ }
227
+
228
+ // Fall back to Chrome storage if HTTP server unavailable
229
+ if (!found) {
230
+ // console.log(`[INFO] Frame Editor: HTTP server unavailable, falling back to Chrome storage...`);
231
+ const storageKey = this.sessionId;
232
+ const result = await chrome.storage.local.get([storageKey]);
233
+ const sessionData = result[storageKey];
234
+
235
+ if (sessionData) {
236
+ this.frames = sessionData.frames || [];
237
+ // console.log(`[INFO] Frame Editor: Loaded ${this.frames.length} frames from Chrome storage (logs may not be available)`);
238
+ } else {
239
+ throw new Error('Session not found in HTTP server or Chrome storage');
240
+ }
241
+ }
242
+
243
+ // Update UI
244
+ this.updateSessionInfo();
245
+ this.renderFrames();
246
+
247
+ document.getElementById('loadingContainer').style.display = 'none';
248
+ document.getElementById('framesContainer').style.display = 'block';
249
+
250
+ } catch (error) {
251
+ this.showError('Failed to load session: ' + error.message);
252
+ }
253
+ }
254
+
255
+ // NEW: Load workflow recording data
256
+ async loadWorkflowSession() {
257
+ try {
258
+ const ports = CHROMEDEBUG_CONFIG?.ports?.slice(0, 5) || [3001, 3000, 3028];
259
+ let found = false;
260
+
261
+ // console.log(`[INFO] Frame Editor: Fetching workflow ${this.sessionId} from HTTP server...`);
262
+
263
+ for (const port of ports) {
264
+ try {
265
+ const response = await fetch(`http://localhost:${port}/chromedebug/workflow-recording/${this.sessionId}`);
266
+ if (response.ok) {
267
+ this.workflowData = await response.json();
268
+ found = true;
269
+ // console.log(`[INFO] Frame Editor: Loaded workflow with ${this.workflowData.actions?.length || 0} actions from port ${port}`);
270
+ break;
271
+ }
272
+ } catch (e) {
273
+ // console.log(`[DEBUG] Frame Editor: Failed to fetch workflow from port ${port}:`, e.message);
274
+ }
275
+ }
276
+
277
+ if (!found) {
278
+ throw new Error('Workflow recording not found on HTTP server');
279
+ }
280
+
281
+ // Update UI
282
+ this.updateWorkflowInfo();
283
+ this.renderWorkflow();
284
+
285
+ document.getElementById('loadingContainer').style.display = 'none';
286
+ document.getElementById('framesContainer').style.display = 'block';
287
+
288
+ } catch (error) {
289
+ this.showError('Failed to load workflow: ' + error.message);
290
+ }
291
+ }
292
+
293
+ updateSessionInfo() {
294
+ if (this.isWorkflow) {
295
+ this.updateWorkflowInfo();
296
+ return;
297
+ }
298
+
299
+ const infoEl = document.getElementById('sessionInfo');
300
+ const totalTime = this.frames.length > 0 ?
301
+ Math.round(this.frames[this.frames.length - 1].timestamp / 1000) : 0;
302
+
303
+ infoEl.textContent = `Session: ${this.sessionId} | ${this.frames.length} frames | ${totalTime}s`;
304
+
305
+ // Update button text to "Download ZIP" (matches workflow behavior)
306
+ const saveBtn = document.getElementById('saveEditedBtn');
307
+ if (saveBtn) {
308
+ saveBtn.textContent = 'Download ZIP';
309
+ saveBtn.disabled = true; // Disabled until items are selected
310
+ }
311
+ }
312
+
313
+ // NEW: Update workflow session info
314
+ updateWorkflowInfo() {
315
+ // Update title
316
+ const titleEl = document.getElementById('editorTitle');
317
+ if (titleEl) titleEl.textContent = 'Workflow Editor';
318
+
319
+ // Update selection type
320
+ const selectionTypeEl = document.getElementById('selectionType');
321
+ if (selectionTypeEl) selectionTypeEl.textContent = 'actions';
322
+
323
+ const infoEl = document.getElementById('sessionInfo');
324
+ const actionCount = this.workflowData?.actions?.length || 0;
325
+ const logCount = this.workflowData?.logs?.length || 0;
326
+
327
+ infoEl.textContent = `Workflow: ${this.sessionId} | ${actionCount} actions | ${logCount} console logs`;
328
+
329
+ // Update button text for workflow mode
330
+ const saveBtn = document.getElementById('saveEditedBtn');
331
+ if (saveBtn) {
332
+ saveBtn.textContent = 'Download ZIP';
333
+ saveBtn.disabled = true; // Disabled until items are selected
334
+ }
335
+
336
+ // Hide delete button for workflows (not supported)
337
+ const deleteBtn = document.getElementById('deleteSelectedBtn');
338
+ if (deleteBtn) {
339
+ deleteBtn.style.display = 'none';
340
+ }
341
+ }
342
+
343
+ renderFrames() {
344
+ const container = document.getElementById('framesContainer');
345
+ container.innerHTML = '';
346
+
347
+ this.frames.forEach((frame, index) => {
348
+ const frameEl = this.createFrameElement(frame, index);
349
+ container.appendChild(frameEl);
350
+ });
351
+ }
352
+
353
+ // NEW: Render workflow actions
354
+ renderWorkflow() {
355
+ const container = document.getElementById('framesContainer');
356
+ container.innerHTML = '';
357
+
358
+ const actions = this.workflowData?.actions || [];
359
+ actions.forEach((action, index) => {
360
+ const actionEl = this.createActionElement(action, index);
361
+ container.appendChild(actionEl);
362
+ });
363
+ }
364
+
365
+ createFrameElement(frame, index) {
366
+ // Debug frame structure
367
+ if (index === 0) {
368
+ const debugInfo = {
369
+ frameIndex: index,
370
+ hasImageData: !!frame.imageData,
371
+ imageDataPrefix: frame.imageData ? frame.imageData.substring(0, 30) : 'N/A',
372
+ hasLogs: !!frame.logs,
373
+ logsCount: frame.logs ? frame.logs.length : 0,
374
+ timestamp: frame.timestamp,
375
+ allKeys: Object.keys(frame)
376
+ };
377
+ // console.log('[DEBUG] createFrameElement - First frame:', JSON.stringify(debugInfo, null, 2));
378
+ }
379
+
380
+ const div = document.createElement('div');
381
+ div.className = 'frame-item';
382
+ div.dataset.index = index;
383
+
384
+ const checkbox = document.createElement('input');
385
+ checkbox.type = 'checkbox';
386
+ checkbox.className = 'frame-checkbox';
387
+ checkbox.checked = this.selectedFrames.has(index);
388
+ checkbox.addEventListener('change', () => this.toggleFrameSelection(index));
389
+ checkbox.addEventListener('click', (e) => this.handleCheckboxClick(e, index));
390
+
391
+ const thumbnail = document.createElement('div');
392
+ thumbnail.className = 'frame-thumbnail';
393
+ thumbnail.dataset.index = index;
394
+
395
+ const placeholder = document.createElement('div');
396
+ placeholder.className = 'frame-placeholder';
397
+ placeholder.textContent = 'Loading...';
398
+ thumbnail.appendChild(placeholder);
399
+
400
+ const info = document.createElement('div');
401
+ info.className = 'frame-info';
402
+
403
+ const header = document.createElement('div');
404
+ header.className = 'frame-header';
405
+
406
+ const title = document.createElement('div');
407
+ title.className = 'frame-title';
408
+ title.textContent = `Frame ${index}`;
409
+
410
+ const timestamp = document.createElement('div');
411
+ timestamp.className = 'frame-timestamp';
412
+ timestamp.textContent = `${(frame.timestamp / 1000).toFixed(1)}s`;
413
+
414
+ header.appendChild(title);
415
+ header.appendChild(timestamp);
416
+
417
+ const logs = document.createElement('div');
418
+ logs.className = 'frame-logs';
419
+
420
+ const logSummary = document.createElement('div');
421
+ logSummary.className = 'frame-log-summary';
422
+
423
+ // Debug log count
424
+ const logCount = frame.logs ? frame.logs.length : 0;
425
+ if (index === 0) {
426
+ // console.log(`[DEBUG] Frame ${index} log count: ${logCount}`);
427
+ }
428
+
429
+ logSummary.innerHTML = `
430
+ <span>Console Logs</span>
431
+ <span class="log-count">${logCount}</span>
432
+ <span>${logCount > 0 ? '▼' : ''}</span>
433
+ `;
434
+
435
+ if (frame.logs && frame.logs.length > 0) {
436
+ logSummary.style.cursor = 'pointer';
437
+ logSummary.addEventListener('click', () => this.toggleLogs(index));
438
+ }
439
+
440
+ logs.appendChild(logSummary);
441
+
442
+ info.appendChild(header);
443
+ info.appendChild(logs);
444
+
445
+ div.appendChild(checkbox);
446
+ div.appendChild(thumbnail);
447
+ div.appendChild(info);
448
+
449
+ return div;
450
+ }
451
+
452
+ // NEW: Create action element for workflow
453
+ createActionElement(action, index) {
454
+ const div = document.createElement('div');
455
+ div.className = 'frame-item';
456
+ div.dataset.index = index;
457
+
458
+ const checkbox = document.createElement('input');
459
+ checkbox.type = 'checkbox';
460
+ checkbox.className = 'frame-checkbox';
461
+ checkbox.checked = this.selectedFrames.has(index);
462
+ checkbox.addEventListener('change', () => this.toggleFrameSelection(index));
463
+ checkbox.addEventListener('click', (e) => this.handleCheckboxClick(e, index));
464
+
465
+ const thumbnail = document.createElement('div');
466
+ thumbnail.className = 'frame-thumbnail';
467
+ thumbnail.dataset.index = index;
468
+
469
+ // Check if action has screenshot
470
+ if (action.screenshot) {
471
+ const placeholder = document.createElement('div');
472
+ placeholder.className = 'frame-placeholder';
473
+ placeholder.textContent = 'Loading...';
474
+ thumbnail.appendChild(placeholder);
475
+ } else {
476
+ const placeholder = document.createElement('div');
477
+ placeholder.className = 'frame-placeholder';
478
+ placeholder.textContent = 'No screenshot';
479
+ thumbnail.appendChild(placeholder);
480
+ }
481
+
482
+ const info = document.createElement('div');
483
+ info.className = 'frame-info';
484
+
485
+ const header = document.createElement('div');
486
+ header.className = 'frame-header';
487
+
488
+ const title = document.createElement('div');
489
+ title.className = 'frame-title';
490
+
491
+ // Format action title based on type
492
+ let actionTitle = `Action ${index}: ${action.type || 'Unknown'}`;
493
+ if (action.type === 'click' && action.target) {
494
+ actionTitle += ` on ${action.target}`;
495
+ } else if (action.type === 'input' && action.target) {
496
+ actionTitle += ` in ${action.target}`;
497
+ } else if (action.type === 'navigate') {
498
+ actionTitle += ` to ${action.url || ''}`;
499
+ }
500
+ title.textContent = actionTitle;
501
+
502
+ const timestamp = document.createElement('div');
503
+ timestamp.className = 'frame-timestamp';
504
+ timestamp.textContent = new Date(action.timestamp).toLocaleTimeString();
505
+
506
+ header.appendChild(title);
507
+ header.appendChild(timestamp);
508
+
509
+ // Add action details
510
+ const actionDetails = document.createElement('div');
511
+ actionDetails.style.cssText = 'margin-top: 8px; font-size: 13px; color: #666;';
512
+
513
+ let detailsHtml = `<strong>Type:</strong> ${action.type || 'N/A'}`;
514
+ if (action.target) detailsHtml += ` | <strong>Target:</strong> ${action.target}`;
515
+ if (action.value) detailsHtml += ` | <strong>Value:</strong> ${action.value}`;
516
+ if (action.url) detailsHtml += ` | <strong>URL:</strong> ${action.url}`;
517
+
518
+ actionDetails.innerHTML = detailsHtml;
519
+
520
+ // Get logs for this action (logs that occurred between prev/next action timestamps)
521
+ const actionLogs = this.getLogsForAction(action, index);
522
+
523
+ const logs = document.createElement('div');
524
+ logs.className = 'frame-logs';
525
+
526
+ const logSummary = document.createElement('div');
527
+ logSummary.className = 'frame-log-summary';
528
+
529
+ const logCount = actionLogs.length;
530
+ logSummary.innerHTML = `
531
+ <span>Console Logs</span>
532
+ <span class="log-count">${logCount}</span>
533
+ <span>${logCount > 0 ? '▼' : ''}</span>
534
+ `;
535
+
536
+ if (logCount > 0) {
537
+ logSummary.style.cursor = 'pointer';
538
+ logSummary.addEventListener('click', () => this.toggleWorkflowLogs(index, actionLogs));
539
+ }
540
+
541
+ logs.appendChild(logSummary);
542
+
543
+ info.appendChild(header);
544
+ info.appendChild(actionDetails);
545
+ info.appendChild(logs);
546
+
547
+ div.appendChild(checkbox);
548
+ div.appendChild(thumbnail);
549
+ div.appendChild(info);
550
+
551
+ return div;
552
+ }
553
+
554
+ // Get logs associated with an action using NON-OVERLAPPING time windows
555
+ // Each log appears in exactly ONE action to avoid duplicates
556
+ // This mirrors the algorithm in background.js stopWorkflowRecording()
557
+ getLogsForAction(action, actionIndex) {
558
+ if (!this.workflowData?.logs || !this.workflowData?.actions) return [];
559
+
560
+ const actions = this.workflowData.actions;
561
+ const logs = this.workflowData.logs;
562
+
563
+ // Calculate NON-OVERLAPPING time window:
564
+ // - Each action owns logs from ITS timestamp until the NEXT action's timestamp
565
+ // - First action also includes 500ms before it
566
+ // - Last action includes 5000ms after it
567
+ const windowStart = actionIndex === 0
568
+ ? action.timestamp - 500 // First action: include 500ms before
569
+ : action.timestamp; // Other actions: start at this action's timestamp
570
+
571
+ const windowEnd = actionIndex < actions.length - 1
572
+ ? actions[actionIndex + 1].timestamp // Until next action's timestamp
573
+ : action.timestamp + 5000; // Last action: 5000ms after
574
+
575
+ // Use >= for start and < for end to ensure non-overlapping windows
576
+ return logs.filter(log => {
577
+ // Log timestamp should already be a number, but handle string format just in case
578
+ const logTime = typeof log.timestamp === 'number'
579
+ ? log.timestamp
580
+ : new Date(log.timestamp).getTime();
581
+ return logTime >= windowStart && logTime < windowEnd;
582
+ });
583
+ }
584
+
585
+ // NEW: Toggle logs for workflow actions
586
+ toggleWorkflowLogs(index, logs) {
587
+ const frameItem = document.querySelector(`.frame-item[data-index="${index}"]`);
588
+ if (!frameItem) return;
589
+
590
+ let expanded = frameItem.querySelector('.frame-logs-expanded');
591
+
592
+ if (expanded) {
593
+ expanded.remove();
594
+ return;
595
+ }
596
+
597
+ expanded = document.createElement('div');
598
+ expanded.className = 'frame-logs-expanded';
599
+
600
+ logs.forEach(log => {
601
+ const entry = document.createElement('div');
602
+ entry.className = 'log-entry';
603
+
604
+ const level = document.createElement('span');
605
+ level.className = `log-level ${log.level || 'log'}`;
606
+ level.textContent = log.level || 'log';
607
+
608
+ const time = document.createElement('span');
609
+ time.className = 'log-time';
610
+ time.textContent = new Date(log.timestamp).toLocaleTimeString();
611
+
612
+ const message = document.createElement('span');
613
+ message.className = 'log-message';
614
+ message.textContent = log.message || JSON.stringify(log.args || []);
615
+
616
+ entry.appendChild(level);
617
+ entry.appendChild(time);
618
+ entry.appendChild(message);
619
+
620
+ expanded.appendChild(entry);
621
+ });
622
+
623
+ const logsContainer = frameItem.querySelector('.frame-logs');
624
+ logsContainer.appendChild(expanded);
625
+ }
626
+
627
+ setupLazyLoading() {
628
+ const options = {
629
+ root: null,
630
+ rootMargin: '100px',
631
+ threshold: 0.01
632
+ };
633
+
634
+ this.observer = new IntersectionObserver((entries) => {
635
+ entries.forEach(entry => {
636
+ if (entry.isIntersecting) {
637
+ const index = parseInt(entry.target.dataset.index);
638
+ this.loadFrameImage(index);
639
+ }
640
+ });
641
+ }, options);
642
+
643
+ // Observe all thumbnails
644
+ document.querySelectorAll('.frame-thumbnail').forEach(thumbnail => {
645
+ this.observer.observe(thumbnail);
646
+ });
647
+ }
648
+
649
+ async loadFrameImage(index) {
650
+ // console.log(`[DEBUG] loadFrameImage called for ${this.isWorkflow ? 'action' : 'frame'} ${index}`);
651
+
652
+ if (this.loadedImages.has(index)) {
653
+ // console.log(`[DEBUG] ${this.isWorkflow ? 'Action' : 'Frame'} ${index} already loaded, skipping`);
654
+ return;
655
+ }
656
+
657
+ // Handle workflow actions
658
+ if (this.isWorkflow) {
659
+ const action = this.workflowData?.actions?.[index];
660
+ if (!action || !action.screenshot) {
661
+ // console.log(`[DEBUG] Action ${index} has no screenshot`);
662
+ return;
663
+ }
664
+
665
+ const thumbnail = document.querySelector(`.frame-thumbnail[data-index="${index}"]`);
666
+ if (!thumbnail) return;
667
+
668
+ const img = document.createElement('img');
669
+ img.src = action.screenshot; // Screenshot should be base64 data URL
670
+ img.onload = () => {
671
+ thumbnail.innerHTML = '';
672
+ thumbnail.appendChild(img);
673
+ this.loadedImages.set(index, true);
674
+
675
+ // Add click handler to open modal (same as screen recordings)
676
+ thumbnail.addEventListener('click', () => this.openFrameModal(index));
677
+ };
678
+ return;
679
+ }
680
+
681
+ // Handle regular frames
682
+ const frame = this.frames[index];
683
+ if (!frame) {
684
+ console.error(`[DEBUG] Frame ${index} not found in this.frames`);
685
+ return;
686
+ }
687
+
688
+ if (!frame.imageData) {
689
+ const debugInfo = {
690
+ hasScreenshot: !!frame.screenshot,
691
+ screenshotPrefix: frame.screenshot ? frame.screenshot.substring(0, 30) : 'N/A',
692
+ allKeys: Object.keys(frame),
693
+ frameIndex: frame.frameIndex,
694
+ timestamp: frame.timestamp
695
+ };
696
+ console.error(`[DEBUG] Frame ${index} has no imageData:`, JSON.stringify(debugInfo, null, 2));
697
+ return;
698
+ }
699
+
700
+ // console.log(`[DEBUG] Frame ${index} loading image, imageData prefix:`, frame.imageData.substring(0, 50));
701
+
702
+ const thumbnail = document.querySelector(`.frame-thumbnail[data-index="${index}"]`);
703
+ if (!thumbnail) {
704
+ console.error(`[DEBUG] No thumbnail element found for frame ${index}`);
705
+ return;
706
+ }
707
+
708
+ const img = document.createElement('img');
709
+ img.src = frame.imageData;
710
+
711
+ img.onerror = (error) => {
712
+ console.error(`[DEBUG] Failed to load image for frame ${index}:`, error);
713
+ };
714
+
715
+ img.onload = () => {
716
+ // console.log(`[DEBUG] Image loaded successfully for frame ${index}`);
717
+ thumbnail.innerHTML = '';
718
+ thumbnail.appendChild(img);
719
+ this.loadedImages.set(index, true);
720
+
721
+ // Add click handler to open modal
722
+ thumbnail.addEventListener('click', () => this.openFrameModal(index));
723
+ };
724
+ }
725
+
726
+ toggleFrameSelection(index) {
727
+ const frameEl = document.querySelector(`.frame-item[data-index="${index}"]`);
728
+
729
+ if (this.selectedFrames.has(index)) {
730
+ this.selectedFrames.delete(index);
731
+ frameEl.classList.remove('selected');
732
+ } else {
733
+ this.selectedFrames.add(index);
734
+ frameEl.classList.add('selected');
735
+ }
736
+
737
+ this.updateSelectionInfo();
738
+ }
739
+
740
+ handleCheckboxClick(event, index) {
741
+ if (event.shiftKey && this.lastClickedIndex !== -1) {
742
+ event.preventDefault();
743
+ const start = Math.min(this.lastClickedIndex, index);
744
+ const end = Math.max(this.lastClickedIndex, index);
745
+
746
+ for (let i = start; i <= end; i++) {
747
+ this.selectedFrames.add(i);
748
+ const frameEl = document.querySelector(`.frame-item[data-index="${i}"]`);
749
+ const checkbox = frameEl.querySelector('.frame-checkbox');
750
+ frameEl.classList.add('selected');
751
+ checkbox.checked = true;
752
+ }
753
+
754
+ this.updateSelectionInfo();
755
+ }
756
+
757
+ this.lastClickedIndex = index;
758
+ }
759
+
760
+ toggleLogs(index) {
761
+ const frameEl = document.querySelector(`.frame-item[data-index="${index}"]`);
762
+ const logsContainer = frameEl.querySelector('.frame-logs');
763
+
764
+ const existingExpanded = logsContainer.querySelector('.frame-logs-expanded');
765
+ if (existingExpanded) {
766
+ existingExpanded.remove();
767
+ return;
768
+ }
769
+
770
+ const frame = this.frames[index];
771
+ if (!frame.logs || frame.logs.length === 0) return;
772
+
773
+ const expanded = document.createElement('div');
774
+ expanded.className = 'frame-logs-expanded';
775
+
776
+ frame.logs.forEach(log => {
777
+ const entry = document.createElement('div');
778
+ entry.className = 'log-entry';
779
+
780
+ const level = document.createElement('span');
781
+ level.className = `log-level ${log.level}`;
782
+ level.textContent = log.level;
783
+
784
+ const time = document.createElement('span');
785
+ time.className = 'log-time';
786
+ // Handle both camelCase (relativeTime) and snake_case (relative_time) for compatibility
787
+ const relativeTime = log.relativeTime || log.relative_time || 0;
788
+ time.textContent = `${(relativeTime / 1000).toFixed(3)}s`;
789
+
790
+ const message = document.createElement('span');
791
+ message.className = 'log-message';
792
+ message.textContent = log.message;
793
+
794
+ entry.appendChild(level);
795
+ entry.appendChild(time);
796
+ entry.appendChild(message);
797
+ expanded.appendChild(entry);
798
+ });
799
+
800
+ logsContainer.appendChild(expanded);
801
+ }
802
+
803
+ updateSelectionInfo() {
804
+ const count = this.selectedFrames.size;
805
+ const selectionInfo = document.getElementById('selectionInfo');
806
+ const selectedCount = document.getElementById('selectedCount');
807
+ const deleteBtn = document.getElementById('deleteSelectedBtn');
808
+ const saveBtn = document.getElementById('saveEditedBtn');
809
+
810
+ if (count > 0) {
811
+ selectionInfo.style.display = 'block';
812
+ selectedCount.textContent = count;
813
+ // Enable Download ZIP when items are selected (both frames and workflows)
814
+ saveBtn.disabled = false;
815
+ // Delete only supported for frames, not workflows
816
+ deleteBtn.disabled = this.isWorkflow;
817
+ } else {
818
+ // Nothing selected - disable both buttons
819
+ selectionInfo.style.display = 'none';
820
+ deleteBtn.disabled = true;
821
+ saveBtn.disabled = true; // Must select items to download
822
+ }
823
+ }
824
+
825
+ openFrameModal(index) {
826
+ const modal = document.getElementById('frameModal');
827
+ const modalTitle = document.getElementById('modalTitle');
828
+ const modalImage = document.getElementById('modalImage');
829
+
830
+ if (this.isWorkflow) {
831
+ // Handle workflow action
832
+ const action = this.workflowData?.actions?.[index];
833
+ if (!action || !action.screenshot) return;
834
+
835
+ modalTitle.textContent = `Action ${index} - ${action.type || 'Unknown'}`;
836
+ modalImage.src = action.screenshot;
837
+ } else {
838
+ // Handle regular frame
839
+ const frame = this.frames[index];
840
+ if (!frame || !frame.imageData) return;
841
+
842
+ modalTitle.textContent = `Frame ${index} - ${(frame.timestamp / 1000).toFixed(1)}s`;
843
+ modalImage.src = frame.imageData;
844
+ }
845
+
846
+ modal.classList.add('visible');
847
+
848
+ // Add keyboard listener for ESC key
849
+ document.addEventListener('keydown', this.handleModalKeydown);
850
+ }
851
+
852
+ closeFrameModal() {
853
+ const modal = document.getElementById('frameModal');
854
+ modal.classList.remove('visible');
855
+
856
+ // Remove keyboard listener
857
+ document.removeEventListener('keydown', this.handleModalKeydown);
858
+ }
859
+
860
+ handleModalKeydown = (event) => {
861
+ if (event.key === 'Escape') {
862
+ this.closeFrameModal();
863
+ }
864
+ }
865
+
866
+ // Copy text to clipboard with toast notification
867
+ async copyToClipboard(text, successMessage) {
868
+ try {
869
+ await navigator.clipboard.writeText(text);
870
+ this.showToast(successMessage);
871
+ } catch (err) {
872
+ // Fallback for older browsers or permission issues
873
+ this.showToast('Failed to copy - check permissions');
874
+ }
875
+ }
876
+
877
+ // Show toast notification
878
+ showToast(message) {
879
+ // Remove existing toast if any
880
+ const existing = document.querySelector('.toast');
881
+ if (existing) existing.remove();
882
+
883
+ const toast = document.createElement('div');
884
+ toast.className = 'toast';
885
+ toast.textContent = message;
886
+ document.body.appendChild(toast);
887
+
888
+ // Remove after animation completes
889
+ setTimeout(() => toast.remove(), 2000);
890
+ }
891
+
892
+ // Generate AI prompt based on recording type
893
+ generateAIPrompt() {
894
+ if (this.isWorkflow) {
895
+ const actionCount = this.workflowData?.actions?.length || 0;
896
+ const logCount = this.workflowData?.logs?.length || 0;
897
+ return `Analyze workflow recording: ${this.sessionId}
898
+ - Actions: ${actionCount}, Console Logs: ${logCount}
899
+ - Use MCP tool: get_workflow_recording or get_workflow_summary`;
900
+ } else {
901
+ const frameCount = this.frames.length;
902
+ return `Analyze screen recording: ${this.sessionId}
903
+ - Frames: ${frameCount}
904
+ - Use MCP tool: get_frame_session_info`;
905
+ }
906
+ }
907
+
908
+ setupEventListeners() {
909
+ // Copy ID button
910
+ document.getElementById('copyIdBtn').addEventListener('click', () => {
911
+ if (this.sessionId) {
912
+ this.copyToClipboard(this.sessionId, 'Session ID copied!');
913
+ } else {
914
+ this.showToast('No session ID available');
915
+ }
916
+ });
917
+
918
+ // Copy Prompt button
919
+ document.getElementById('copyPromptBtn').addEventListener('click', () => {
920
+ if (this.sessionId) {
921
+ const prompt = this.generateAIPrompt();
922
+ this.copyToClipboard(prompt, 'Prompt copied!');
923
+ } else {
924
+ this.showToast('No session available');
925
+ }
926
+ });
927
+
928
+ document.getElementById('selectAllBtn').addEventListener('click', () => {
929
+ const itemCount = this.getItemCount();
930
+ for (let i = 0; i < itemCount; i++) {
931
+ this.selectedFrames.add(i);
932
+ const frameEl = document.querySelector(`.frame-item[data-index="${i}"]`);
933
+ if (frameEl) {
934
+ const checkbox = frameEl.querySelector('.frame-checkbox');
935
+ frameEl.classList.add('selected');
936
+ if (checkbox) checkbox.checked = true;
937
+ }
938
+ }
939
+ this.updateSelectionInfo();
940
+ });
941
+
942
+ document.getElementById('selectNoneBtn').addEventListener('click', () => {
943
+ this.selectedFrames.clear();
944
+ document.querySelectorAll('.frame-item').forEach(el => {
945
+ el.classList.remove('selected');
946
+ el.querySelector('.frame-checkbox').checked = false;
947
+ });
948
+ this.updateSelectionInfo();
949
+ });
950
+
951
+ document.getElementById('invertSelectionBtn').addEventListener('click', () => {
952
+ const itemCount = this.getItemCount();
953
+ for (let i = 0; i < itemCount; i++) {
954
+ if (this.selectedFrames.has(i)) {
955
+ this.selectedFrames.delete(i);
956
+ } else {
957
+ this.selectedFrames.add(i);
958
+ }
959
+
960
+ const frameEl = document.querySelector(`.frame-item[data-index="${i}"]`);
961
+ if (frameEl) {
962
+ const checkbox = frameEl.querySelector('.frame-checkbox');
963
+ frameEl.classList.toggle('selected');
964
+ if (checkbox) checkbox.checked = !checkbox.checked;
965
+ }
966
+ }
967
+ this.updateSelectionInfo();
968
+ });
969
+
970
+ document.getElementById('deleteSelectedBtn').addEventListener('click', () => {
971
+ this.deleteSelectedFrames();
972
+ });
973
+
974
+ document.getElementById('saveEditedBtn').addEventListener('click', () => {
975
+ this.saveEditedRecording();
976
+ });
977
+
978
+ // Modal event listeners
979
+ document.getElementById('modalClose').addEventListener('click', () => {
980
+ this.closeFrameModal();
981
+ });
982
+
983
+ document.getElementById('frameModal').addEventListener('click', (event) => {
984
+ // Close modal when clicking on overlay (not on modal content)
985
+ if (event.target.id === 'frameModal') {
986
+ this.closeFrameModal();
987
+ }
988
+ });
989
+ }
990
+
991
+ async deleteSelectedFrames() {
992
+ if (this.selectedFrames.size === 0) return;
993
+
994
+ const confirmMsg = `Delete ${this.selectedFrames.size} frame${this.selectedFrames.size > 1 ? 's' : ''}?`;
995
+ if (!confirm(confirmMsg)) return;
996
+
997
+ // Temporarily disable buttons
998
+ this.setButtonsEnabled(false);
999
+
1000
+ // Show loading state
1001
+ const deleteBtn = document.getElementById('deleteSelectedBtn');
1002
+ deleteBtn.textContent = 'Deleting...';
1003
+
1004
+ // Remove selected frames
1005
+ const selectedIndices = Array.from(this.selectedFrames).sort((a, b) => b - a);
1006
+ selectedIndices.forEach(index => {
1007
+ const frameEl = document.querySelector(`.frame-item[data-index="${index}"]`);
1008
+ frameEl.remove();
1009
+ });
1010
+
1011
+ // Update UI
1012
+ this.selectedFrames.clear();
1013
+ this.updateSelectionInfo();
1014
+
1015
+ // Re-enable buttons
1016
+ this.setButtonsEnabled(true);
1017
+ deleteBtn.textContent = 'Delete Selected';
1018
+
1019
+ // Check if we need to save immediately or wait for save button
1020
+ if (this.getItemCount() === selectedIndices.length) {
1021
+ this.showEmpty();
1022
+ }
1023
+ }
1024
+
1025
+ async saveEditedRecording() {
1026
+ // Both frame and workflow recordings now use ZIP download
1027
+ if (this.isWorkflow) {
1028
+ this.downloadWorkflowAsZip();
1029
+ } else {
1030
+ this.downloadFramesAsZip();
1031
+ }
1032
+ }
1033
+
1034
+ // Legacy method - kept for reference but no longer used
1035
+ async _legacySaveEditedRecording() {
1036
+ if (this.selectedFrames.size === this.getItemCount()) {
1037
+ alert('Cannot save - all frames are selected for deletion');
1038
+ return;
1039
+ }
1040
+
1041
+ // Disable buttons and show loading
1042
+ this.setButtonsEnabled(false);
1043
+ const saveBtn = document.getElementById('saveEditedBtn');
1044
+ const originalText = saveBtn.textContent;
1045
+ saveBtn.textContent = 'Saving...';
1046
+
1047
+ try {
1048
+ // Create new frame array excluding selected frames
1049
+ const editedFrames = this.frames.filter((_, index) => !this.selectedFrames.has(index));
1050
+
1051
+ // Generate new session ID
1052
+ const newSessionId = `frame_${Date.now()}_edited`;
1053
+
1054
+ // Create new session object
1055
+ const newSession = {
1056
+ type: 'frame_capture',
1057
+ sessionId: newSessionId,
1058
+ timestamp: Date.now(),
1059
+ frames: editedFrames,
1060
+ originalSessionId: this.sessionId
1061
+ };
1062
+
1063
+ // Save to storage
1064
+ const storageKey = newSessionId;
1065
+ await chrome.storage.local.set({ [storageKey]: newSession });
1066
+
1067
+ // Add to recordings list
1068
+ const result = await chrome.storage.local.get(['recordings']);
1069
+ const recordings = result.recordings || [];
1070
+ recordings.unshift({
1071
+ id: newSessionId,
1072
+ timestamp: Date.now(),
1073
+ isFrameCapture: true,
1074
+ edited: true,
1075
+ originalId: this.sessionId
1076
+ });
1077
+ await chrome.storage.local.set({ recordings });
1078
+
1079
+ // Notify server about the new recording
1080
+ await this.notifyServerOfNewRecording(newSessionId, newSession);
1081
+
1082
+ // Show success and redirect
1083
+ alert(`Edited recording saved successfully!\nNew session ID: ${newSessionId}`);
1084
+ window.location.href = `frame-editor.html?sessionId=${newSessionId}`;
1085
+
1086
+ } catch (error) {
1087
+ console.error('Error saving edited recording:', error);
1088
+ alert('Failed to save edited recording: ' + error.message);
1089
+
1090
+ // Re-enable buttons
1091
+ this.setButtonsEnabled(true);
1092
+ saveBtn.textContent = originalText;
1093
+ }
1094
+ }
1095
+
1096
+ async notifyServerOfNewRecording(sessionId, sessionData) {
1097
+ const ports = CHROMEDEBUG_CONFIG?.ports?.slice(0, 5) || [3001, 3000, 3028]; // Use configured ports
1098
+
1099
+ for (const port of ports) {
1100
+ try {
1101
+ const response = await fetch(`http://localhost:${port}/chromedebug/save-edited-session`, {
1102
+ method: 'POST',
1103
+ headers: {
1104
+ 'Content-Type': 'application/json'
1105
+ },
1106
+ body: JSON.stringify({
1107
+ sessionId: sessionId,
1108
+ sessionData: sessionData
1109
+ })
1110
+ });
1111
+
1112
+ if (response.ok) {
1113
+ // console.log(`Server notified on port ${port}`);
1114
+ break;
1115
+ }
1116
+ } catch (e) {
1117
+ // Try next port
1118
+ }
1119
+ }
1120
+ }
1121
+
1122
+ // Download workflow as ZIP file for sharing
1123
+ // Only exports SELECTED actions - requires items to be selected
1124
+ // Strips base64 image data from JSON since images are saved separately
1125
+ async downloadWorkflowAsZip() {
1126
+ if (!window.JSZip) {
1127
+ alert('JSZip library not loaded. Cannot create ZIP file.');
1128
+ return;
1129
+ }
1130
+
1131
+ // Guard clause: require selection (defense-in-depth, button should be disabled)
1132
+ if (this.selectedFrames.size === 0) {
1133
+ alert('Please select items to download');
1134
+ return;
1135
+ }
1136
+
1137
+ try {
1138
+ const saveBtn = document.getElementById('saveEditedBtn');
1139
+ const originalText = saveBtn.textContent;
1140
+ saveBtn.textContent = 'Creating ZIP...';
1141
+ saveBtn.disabled = true;
1142
+
1143
+ const zip = new JSZip();
1144
+
1145
+ // Get actions to export (respects selection)
1146
+ const actionsToExport = this.getActionsToExport();
1147
+ const isPartialExport = this.selectedFrames.size > 0;
1148
+ const totalActions = this.workflowData?.actions?.length || 0;
1149
+
1150
+ // Create cleaned workflow data with stripped screenshot data
1151
+ const cleanedActions = actionsToExport.map(({ action, originalIndex }) => {
1152
+ const hasScreenshot = !!(action.screenshot || action.screenshot_data);
1153
+ return this.stripScreenshotData(action, originalIndex, hasScreenshot);
1154
+ });
1155
+
1156
+ // Filter logs to only include those for exported actions
1157
+ const filteredLogs = this.filterLogsForExport(actionsToExport);
1158
+
1159
+ // Create export workflow object
1160
+ const exportWorkflowData = {
1161
+ ...this.workflowData,
1162
+ actions: cleanedActions,
1163
+ logs: filteredLogs,
1164
+ exportMetadata: {
1165
+ version: '2.0',
1166
+ format: 'workflow-export',
1167
+ screenshotsExtracted: true,
1168
+ isPartialExport,
1169
+ exportedActionCount: actionsToExport.length,
1170
+ totalActionCount: totalActions,
1171
+ exportedAt: new Date().toISOString()
1172
+ }
1173
+ };
1174
+
1175
+ // Add workflow data as JSON (formatted for readability)
1176
+ const workflowJson = JSON.stringify(exportWorkflowData, null, 2);
1177
+ zip.file('workflow.json', workflowJson);
1178
+
1179
+ // Add README with metadata
1180
+ const readme = this.generateWorkflowReadme(actionsToExport, filteredLogs, isPartialExport, totalActions);
1181
+ zip.file('README.txt', readme);
1182
+
1183
+ // Extract and add screenshots using ORIGINAL action indices
1184
+ let screenshotCount = 0;
1185
+ actionsToExport.forEach(({ action, originalIndex }) => {
1186
+ const screenshotData = action.screenshot || action.screenshot_data;
1187
+ if (!screenshotData) return;
1188
+
1189
+ const paddedIndex = String(originalIndex).padStart(3, '0');
1190
+ // Handle both data URL format and raw base64
1191
+ let imageData = screenshotData;
1192
+ let extension = 'jpg'; // Default to jpg
1193
+
1194
+ if (screenshotData.startsWith('data:image/')) {
1195
+ // Extract format and data from data URL
1196
+ const match = screenshotData.match(/^data:image\/(\w+);base64,(.+)$/);
1197
+ if (match) {
1198
+ extension = match[1] === 'jpeg' ? 'jpg' : match[1];
1199
+ imageData = match[2];
1200
+ }
1201
+ }
1202
+
1203
+ const filename = `screenshots/action_${paddedIndex}.${extension}`;
1204
+ zip.file(filename, imageData, { base64: true });
1205
+ screenshotCount++;
1206
+ });
1207
+
1208
+ // Generate ZIP file
1209
+ const blob = await zip.generateAsync({
1210
+ type: 'blob',
1211
+ compression: 'DEFLATE',
1212
+ compressionOptions: { level: 6 }
1213
+ });
1214
+
1215
+ // Trigger download
1216
+ const url = URL.createObjectURL(blob);
1217
+ const a = document.createElement('a');
1218
+ a.href = url;
1219
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
1220
+ const suffix = isPartialExport ? `_${actionsToExport.length}of${totalActions}` : '';
1221
+ a.download = `workflow_${this.sessionId}${suffix}_${timestamp}.zip`;
1222
+ document.body.appendChild(a);
1223
+ a.click();
1224
+ document.body.removeChild(a);
1225
+ URL.revokeObjectURL(url);
1226
+
1227
+ saveBtn.textContent = originalText;
1228
+ saveBtn.disabled = false;
1229
+
1230
+ // console.log(`Workflow downloaded successfully: ${actionsToExport.length} actions, ${screenshotCount} screenshots, ${filteredLogs.length} logs`);
1231
+
1232
+ } catch (error) {
1233
+ console.error('Error creating ZIP:', error);
1234
+ alert('Failed to create ZIP file: ' + error.message);
1235
+
1236
+ const saveBtn = document.getElementById('saveEditedBtn');
1237
+ saveBtn.textContent = 'Download ZIP';
1238
+ saveBtn.disabled = false;
1239
+ }
1240
+ }
1241
+
1242
+ // Generate README content for the ZIP file
1243
+ generateWorkflowReadme(actionsToExport, filteredLogs, isPartialExport, totalActions) {
1244
+ const actionCount = actionsToExport.length;
1245
+ const logCount = filteredLogs.length;
1246
+
1247
+ // Count screenshots in exported actions
1248
+ const screenshotCount = actionsToExport.filter(({ action }) =>
1249
+ action.screenshot || action.screenshot_data
1250
+ ).length;
1251
+
1252
+ // Get action summary with original indices
1253
+ const actionSummary = actionsToExport
1254
+ .map(({ action, originalIndex }) =>
1255
+ ` ${originalIndex}. ${action.type || 'unknown'}${action.target ? ` on ${action.target}` : ''}`
1256
+ )
1257
+ .join('\n');
1258
+
1259
+ // Partial export warning
1260
+ const partialExportNote = isPartialExport
1261
+ ? `
1262
+ PARTIAL EXPORT WARNING
1263
+ ======================
1264
+ This export contains ${actionCount} of ${totalActions} total actions.
1265
+ Selected action indices: ${actionsToExport.map(a => a.originalIndex).join(', ')}
1266
+
1267
+ If replaying this workflow, earlier actions may be required to establish
1268
+ the correct page state (navigation, login, form data, etc.).
1269
+
1270
+ `
1271
+ : '';
1272
+
1273
+ return `Chrome Debug Workflow Recording
1274
+ ================================
1275
+
1276
+ Session ID: ${this.sessionId}
1277
+ Exported: ${new Date().toISOString()}
1278
+ Export Type: ${isPartialExport ? 'PARTIAL (selected actions only)' : 'COMPLETE'}
1279
+ ${partialExportNote}
1280
+ Contents:
1281
+ ---------
1282
+ - workflow.json: Workflow data with actions and console logs (screenshots stripped)
1283
+ - screenshots/: ${screenshotCount} screenshot files (action_XXX.jpg format)
1284
+
1285
+ Note: Screenshot data has been extracted from workflow.json to reduce file size.
1286
+ Each action's "screenshotFile" field references its corresponding image file.
1287
+
1288
+ Statistics:
1289
+ -----------
1290
+ - Exported Actions: ${actionCount}${isPartialExport ? ` of ${totalActions} total` : ''}
1291
+ - Console Logs: ${logCount}
1292
+ - Screenshots: ${screenshotCount}
1293
+
1294
+ Actions Summary:
1295
+ ----------------
1296
+ ${actionSummary || ' (no actions recorded)'}
1297
+
1298
+ Screenshot Mapping:
1299
+ -------------------
1300
+ Screenshots use ORIGINAL action indices for correlation:
1301
+ - action_000.jpg corresponds to action at original index 0
1302
+ - action_005.jpg corresponds to action at original index 5
1303
+ - etc.
1304
+
1305
+ This preserves correlation even in partial exports.
1306
+
1307
+ Usage:
1308
+ ------
1309
+ 1. Open workflow.json to view all recorded actions and logs
1310
+ 2. Match screenshotFile references to files in screenshots/ folder
1311
+ 3. This data can be imported into Chrome Debug for replay
1312
+ 4. Share with developers or Claude Code for debugging assistance
1313
+
1314
+ Generated by Chrome Debug MCP
1315
+ https://github.com/anthropics/chrome-debug
1316
+ `;
1317
+ }
1318
+
1319
+ // Extract screenshots from workflow actions (respects selection)
1320
+ // Returns array of { screenshotData, originalIndex } for proper correlation
1321
+ extractWorkflowScreenshots() {
1322
+ const actionsToExport = this.getActionsToExport();
1323
+
1324
+ return actionsToExport
1325
+ .filter(({ action }) => action.screenshot || action.screenshot_data)
1326
+ .map(({ action, originalIndex }) => ({
1327
+ screenshotData: action.screenshot || action.screenshot_data,
1328
+ originalIndex
1329
+ }));
1330
+ }
1331
+
1332
+ // ========== FRAME RECORDING DOWNLOAD ==========
1333
+ // Download frame recording as ZIP file for sharing
1334
+ // Only exports SELECTED frames - requires frames to be selected
1335
+ // Simpler than workflow export: frames already have logs attached
1336
+ async downloadFramesAsZip() {
1337
+ if (!window.JSZip) {
1338
+ alert('JSZip library not loaded. Cannot create ZIP file.');
1339
+ return;
1340
+ }
1341
+
1342
+ // Guard clause: require selection
1343
+ if (this.selectedFrames.size === 0) {
1344
+ alert('Please select frames to download');
1345
+ return;
1346
+ }
1347
+
1348
+ try {
1349
+ const saveBtn = document.getElementById('saveEditedBtn');
1350
+ const originalText = saveBtn.textContent;
1351
+ saveBtn.textContent = 'Creating ZIP...';
1352
+ saveBtn.disabled = true;
1353
+
1354
+ const zip = new JSZip();
1355
+
1356
+ // Get frames to export (respects selection)
1357
+ const framesToExport = this.getFramesToExport();
1358
+ const isPartialExport = this.selectedFrames.size < this.frames.length;
1359
+ const totalFrames = this.frames.length;
1360
+
1361
+ // Create cleaned frame data with stripped image data
1362
+ // Frames already have logs attached - no filtering needed
1363
+ const cleanedFrames = framesToExport.map(({ frame, originalIndex }) =>
1364
+ this.stripFrameImageData(frame, originalIndex)
1365
+ );
1366
+
1367
+ // Create export object
1368
+ const exportData = {
1369
+ sessionId: this.sessionId,
1370
+ type: 'frame-capture-export',
1371
+ frames: cleanedFrames,
1372
+ exportMetadata: {
1373
+ version: '2.0',
1374
+ format: 'frame-capture',
1375
+ screenshotsExtracted: true,
1376
+ isPartialExport,
1377
+ exportedFrameCount: framesToExport.length,
1378
+ totalFrameCount: totalFrames,
1379
+ exportedAt: new Date().toISOString(),
1380
+ browserOnly: this.browserOnly || false
1381
+ }
1382
+ };
1383
+
1384
+ // Add frames data as JSON
1385
+ const framesJson = JSON.stringify(exportData, null, 2);
1386
+ zip.file('frames.json', framesJson);
1387
+
1388
+ // Add README with metadata
1389
+ const readme = this.generateFramesReadme(framesToExport, isPartialExport, totalFrames);
1390
+ zip.file('README.txt', readme);
1391
+
1392
+ // Extract and add screenshots using ORIGINAL frame indices
1393
+ let screenshotCount = 0;
1394
+ framesToExport.forEach(({ frame, originalIndex }) => {
1395
+ const imageData = frame.imageData;
1396
+ if (!imageData) return;
1397
+
1398
+ const paddedIndex = String(originalIndex).padStart(3, '0');
1399
+
1400
+ // Handle both data URL format and raw base64
1401
+ let base64Data = imageData;
1402
+ let extension = 'jpg'; // Default to jpg
1403
+
1404
+ if (imageData.startsWith('data:image/')) {
1405
+ const match = imageData.match(/^data:image\/(\w+);base64,(.+)$/);
1406
+ if (match) {
1407
+ extension = match[1] === 'jpeg' ? 'jpg' : match[1];
1408
+ base64Data = match[2];
1409
+ }
1410
+ }
1411
+
1412
+ const filename = `screenshots/frame_${paddedIndex}.${extension}`;
1413
+ zip.file(filename, base64Data, { base64: true });
1414
+ screenshotCount++;
1415
+ });
1416
+
1417
+ // Generate ZIP file
1418
+ const blob = await zip.generateAsync({
1419
+ type: 'blob',
1420
+ compression: 'DEFLATE',
1421
+ compressionOptions: { level: 6 }
1422
+ });
1423
+
1424
+ // Trigger download
1425
+ const url = URL.createObjectURL(blob);
1426
+ const a = document.createElement('a');
1427
+ a.href = url;
1428
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
1429
+ const suffix = isPartialExport ? `_${framesToExport.length}of${totalFrames}` : '';
1430
+ a.download = `frames_${this.sessionId}${suffix}_${timestamp}.zip`;
1431
+ document.body.appendChild(a);
1432
+ a.click();
1433
+ document.body.removeChild(a);
1434
+ URL.revokeObjectURL(url);
1435
+
1436
+ saveBtn.textContent = originalText;
1437
+ saveBtn.disabled = false;
1438
+
1439
+ // console.log(`Frames downloaded successfully: ${framesToExport.length} frames, ${screenshotCount} screenshots`);
1440
+
1441
+ } catch (error) {
1442
+ console.error('Error creating ZIP:', error);
1443
+ alert('Failed to create ZIP file: ' + error.message);
1444
+
1445
+ const saveBtn = document.getElementById('saveEditedBtn');
1446
+ saveBtn.textContent = 'Download ZIP';
1447
+ saveBtn.disabled = false;
1448
+ }
1449
+ }
1450
+
1451
+ // Generate README content for frame export ZIP file
1452
+ // Simpler than workflow README - frames are snapshots, not replayable actions
1453
+ generateFramesReadme(framesToExport, isPartialExport, totalFrames) {
1454
+ const frameCount = framesToExport.length;
1455
+
1456
+ // Count total logs across all exported frames
1457
+ const totalLogs = framesToExport.reduce((sum, { frame }) =>
1458
+ sum + (frame.logs?.length || 0), 0
1459
+ );
1460
+
1461
+ // Calculate duration
1462
+ const firstTimestamp = framesToExport[0]?.frame.timestamp || 0;
1463
+ const lastTimestamp = framesToExport[framesToExport.length - 1]?.frame.timestamp || 0;
1464
+ const durationSec = ((lastTimestamp - firstTimestamp) / 1000).toFixed(1);
1465
+
1466
+ // Get frame summary (first 20)
1467
+ const frameSummary = framesToExport
1468
+ .slice(0, 20)
1469
+ .map(({ frame, originalIndex }) => {
1470
+ const timeStr = (frame.timestamp / 1000).toFixed(1);
1471
+ const logCount = frame.logs?.length || 0;
1472
+ return ` ${originalIndex}. ${timeStr}s (${logCount} logs)`;
1473
+ })
1474
+ .join('\n');
1475
+
1476
+ const moreSummary = framesToExport.length > 20
1477
+ ? `\n ... and ${framesToExport.length - 20} more frames`
1478
+ : '';
1479
+
1480
+ // Partial export note
1481
+ const partialExportNote = isPartialExport
1482
+ ? `
1483
+ PARTIAL EXPORT
1484
+ ==============
1485
+ This export contains ${frameCount} of ${totalFrames} total frames.
1486
+ Selected frame indices: ${framesToExport.map(f => f.originalIndex).join(', ')}
1487
+
1488
+ `
1489
+ : '';
1490
+
1491
+ return `Chrome Debug Frame Capture Recording
1492
+ =====================================
1493
+
1494
+ Session ID: ${this.sessionId}
1495
+ Exported: ${new Date().toISOString()}
1496
+ Export Type: ${isPartialExport ? 'PARTIAL (selected frames only)' : 'COMPLETE'}
1497
+ Mode: ${this.browserOnly ? 'Browser-Only (IndexedDB)' : 'Server Mode'}
1498
+ ${partialExportNote}
1499
+ Contents:
1500
+ ---------
1501
+ - frames.json: Frame metadata with timestamps and console logs (images stripped)
1502
+ - screenshots/: ${frameCount} screenshot files (frame_XXX.jpg format)
1503
+ - README.txt: This file
1504
+
1505
+ Note: Screenshot data has been extracted from frames.json to reduce file size.
1506
+ Each frame's "screenshotFile" field references its corresponding image file.
1507
+
1508
+ Statistics:
1509
+ -----------
1510
+ - Exported Frames: ${frameCount}${isPartialExport ? ` of ${totalFrames} total` : ''}
1511
+ - Duration: ${durationSec}s
1512
+ - Total Console Logs: ${totalLogs}
1513
+
1514
+ Frames Summary:
1515
+ ---------------
1516
+ ${frameSummary}${moreSummary}
1517
+
1518
+ Screenshot Mapping:
1519
+ -------------------
1520
+ Screenshots use ORIGINAL frame indices for correlation:
1521
+ - frame_000.jpg corresponds to frame at original index 0
1522
+ - frame_005.jpg corresponds to frame at original index 5
1523
+ - etc.
1524
+
1525
+ Frame Data Structure:
1526
+ ---------------------
1527
+ Each frame in frames.json contains:
1528
+ - frameIndex: Original index in the recording
1529
+ - timestamp: Time in milliseconds from recording start
1530
+ - logs: Array of console log entries captured during this frame
1531
+ - screenshotFile: Path to screenshot image in screenshots/ folder
1532
+
1533
+ Usage:
1534
+ ------
1535
+ 1. Open frames.json to view all frame metadata and console logs
1536
+ 2. Match screenshotFile references to files in screenshots/ folder
1537
+ 3. Use for debugging, documentation, or sharing with developers
1538
+ 4. Share with Claude Code for AI-assisted debugging
1539
+
1540
+ Generated by Chrome Debug MCP
1541
+ https://github.com/anthropics/chrome-debug
1542
+ `;
1543
+ }
1544
+
1545
+ setButtonsEnabled(enabled) {
1546
+ document.querySelectorAll('.btn').forEach(btn => {
1547
+ btn.disabled = !enabled;
1548
+ });
1549
+ }
1550
+
1551
+ showEmpty() {
1552
+ document.getElementById('loadingContainer').style.display = 'none';
1553
+ document.getElementById('emptyContainer').style.display = 'block';
1554
+ }
1555
+
1556
+ showError(message) {
1557
+ document.getElementById('loadingContainer').style.display = 'none';
1558
+ const errorContainer = document.getElementById('errorContainer');
1559
+ errorContainer.textContent = message;
1560
+ errorContainer.style.display = 'block';
1561
+ }
1562
+ }
1563
+
1564
+ // Initialize when DOM is ready
1565
+ document.addEventListener('DOMContentLoaded', () => {
1566
+ new FrameEditor();
1567
+ });