@dynamicu/chromedebug-mcp 2.6.7 → 2.7.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 (39) hide show
  1. package/CLAUDE.md +1 -1
  2. package/README.md +1 -1
  3. package/chrome-extension/background.js +611 -505
  4. package/chrome-extension/browser-recording-manager.js +1 -1
  5. package/chrome-extension/chrome-debug-logger.js +168 -0
  6. package/chrome-extension/console-interception-library.js +430 -0
  7. package/chrome-extension/content.css +16 -16
  8. package/chrome-extension/content.js +458 -126
  9. package/chrome-extension/extension-config.js +1 -1
  10. package/chrome-extension/license-helper.js +26 -0
  11. package/chrome-extension/manifest.free.json +0 -3
  12. package/chrome-extension/options.js +1 -1
  13. package/chrome-extension/popup.html +221 -191
  14. package/chrome-extension/popup.js +88 -379
  15. package/chrome-extension/pro/enhanced-capture.js +406 -0
  16. package/chrome-extension/pro/frame-editor.html +410 -0
  17. package/chrome-extension/pro/frame-editor.js +1496 -0
  18. package/chrome-extension/pro/function-tracker.js +843 -0
  19. package/chrome-extension/pro/jszip.min.js +13 -0
  20. package/dist/chromedebug-extension-free.zip +0 -0
  21. package/package.json +3 -1
  22. package/scripts/webpack.config.free.cjs +8 -8
  23. package/scripts/webpack.config.pro.cjs +2 -0
  24. package/src/cli.js +2 -2
  25. package/src/database.js +55 -7
  26. package/src/index.js +9 -6
  27. package/src/mcp/server.js +2 -2
  28. package/src/services/process-manager.js +10 -6
  29. package/src/services/process-tracker.js +10 -5
  30. package/src/services/profile-manager.js +17 -2
  31. package/src/validation/schemas.js +2 -2
  32. package/src/index-direct.js +0 -157
  33. package/src/index-modular.js +0 -219
  34. package/src/index-monolithic-backup.js +0 -2230
  35. package/src/legacy/chrome-controller-old.js +0 -1406
  36. package/src/legacy/index-express.js +0 -625
  37. package/src/legacy/index-old.js +0 -977
  38. package/src/legacy/routes.js +0 -260
  39. package/src/legacy/shared-storage.js +0 -101
@@ -0,0 +1,1496 @@
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
+ // Enable click to enlarge
676
+ thumbnail.addEventListener('click', () => this.showEnlargedImage(action.screenshot, `Action ${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 frame = this.frames[index];
827
+ if (!frame || !frame.imageData) return;
828
+
829
+ const modal = document.getElementById('frameModal');
830
+ const modalTitle = document.getElementById('modalTitle');
831
+ const modalImage = document.getElementById('modalImage');
832
+
833
+ modalTitle.textContent = `Frame ${index} - ${(frame.timestamp / 1000).toFixed(1)}s`;
834
+ modalImage.src = frame.imageData;
835
+
836
+ modal.classList.add('visible');
837
+
838
+ // Add keyboard listener for ESC key
839
+ document.addEventListener('keydown', this.handleModalKeydown);
840
+ }
841
+
842
+ closeFrameModal() {
843
+ const modal = document.getElementById('frameModal');
844
+ modal.classList.remove('visible');
845
+
846
+ // Remove keyboard listener
847
+ document.removeEventListener('keydown', this.handleModalKeydown);
848
+ }
849
+
850
+ handleModalKeydown = (event) => {
851
+ if (event.key === 'Escape') {
852
+ this.closeFrameModal();
853
+ }
854
+ }
855
+
856
+ setupEventListeners() {
857
+ document.getElementById('selectAllBtn').addEventListener('click', () => {
858
+ const itemCount = this.getItemCount();
859
+ for (let i = 0; i < itemCount; i++) {
860
+ this.selectedFrames.add(i);
861
+ const frameEl = document.querySelector(`.frame-item[data-index="${i}"]`);
862
+ if (frameEl) {
863
+ const checkbox = frameEl.querySelector('.frame-checkbox');
864
+ frameEl.classList.add('selected');
865
+ if (checkbox) checkbox.checked = true;
866
+ }
867
+ }
868
+ this.updateSelectionInfo();
869
+ });
870
+
871
+ document.getElementById('selectNoneBtn').addEventListener('click', () => {
872
+ this.selectedFrames.clear();
873
+ document.querySelectorAll('.frame-item').forEach(el => {
874
+ el.classList.remove('selected');
875
+ el.querySelector('.frame-checkbox').checked = false;
876
+ });
877
+ this.updateSelectionInfo();
878
+ });
879
+
880
+ document.getElementById('invertSelectionBtn').addEventListener('click', () => {
881
+ const itemCount = this.getItemCount();
882
+ for (let i = 0; i < itemCount; i++) {
883
+ if (this.selectedFrames.has(i)) {
884
+ this.selectedFrames.delete(i);
885
+ } else {
886
+ this.selectedFrames.add(i);
887
+ }
888
+
889
+ const frameEl = document.querySelector(`.frame-item[data-index="${i}"]`);
890
+ if (frameEl) {
891
+ const checkbox = frameEl.querySelector('.frame-checkbox');
892
+ frameEl.classList.toggle('selected');
893
+ if (checkbox) checkbox.checked = !checkbox.checked;
894
+ }
895
+ }
896
+ this.updateSelectionInfo();
897
+ });
898
+
899
+ document.getElementById('deleteSelectedBtn').addEventListener('click', () => {
900
+ this.deleteSelectedFrames();
901
+ });
902
+
903
+ document.getElementById('saveEditedBtn').addEventListener('click', () => {
904
+ this.saveEditedRecording();
905
+ });
906
+
907
+ // Modal event listeners
908
+ document.getElementById('modalClose').addEventListener('click', () => {
909
+ this.closeFrameModal();
910
+ });
911
+
912
+ document.getElementById('frameModal').addEventListener('click', (event) => {
913
+ // Close modal when clicking on overlay (not on modal content)
914
+ if (event.target.id === 'frameModal') {
915
+ this.closeFrameModal();
916
+ }
917
+ });
918
+ }
919
+
920
+ async deleteSelectedFrames() {
921
+ if (this.selectedFrames.size === 0) return;
922
+
923
+ const confirmMsg = `Delete ${this.selectedFrames.size} frame${this.selectedFrames.size > 1 ? 's' : ''}?`;
924
+ if (!confirm(confirmMsg)) return;
925
+
926
+ // Temporarily disable buttons
927
+ this.setButtonsEnabled(false);
928
+
929
+ // Show loading state
930
+ const deleteBtn = document.getElementById('deleteSelectedBtn');
931
+ deleteBtn.textContent = 'Deleting...';
932
+
933
+ // Remove selected frames
934
+ const selectedIndices = Array.from(this.selectedFrames).sort((a, b) => b - a);
935
+ selectedIndices.forEach(index => {
936
+ const frameEl = document.querySelector(`.frame-item[data-index="${index}"]`);
937
+ frameEl.remove();
938
+ });
939
+
940
+ // Update UI
941
+ this.selectedFrames.clear();
942
+ this.updateSelectionInfo();
943
+
944
+ // Re-enable buttons
945
+ this.setButtonsEnabled(true);
946
+ deleteBtn.textContent = 'Delete Selected';
947
+
948
+ // Check if we need to save immediately or wait for save button
949
+ if (this.getItemCount() === selectedIndices.length) {
950
+ this.showEmpty();
951
+ }
952
+ }
953
+
954
+ async saveEditedRecording() {
955
+ // Both frame and workflow recordings now use ZIP download
956
+ if (this.isWorkflow) {
957
+ this.downloadWorkflowAsZip();
958
+ } else {
959
+ this.downloadFramesAsZip();
960
+ }
961
+ }
962
+
963
+ // Legacy method - kept for reference but no longer used
964
+ async _legacySaveEditedRecording() {
965
+ if (this.selectedFrames.size === this.getItemCount()) {
966
+ alert('Cannot save - all frames are selected for deletion');
967
+ return;
968
+ }
969
+
970
+ // Disable buttons and show loading
971
+ this.setButtonsEnabled(false);
972
+ const saveBtn = document.getElementById('saveEditedBtn');
973
+ const originalText = saveBtn.textContent;
974
+ saveBtn.textContent = 'Saving...';
975
+
976
+ try {
977
+ // Create new frame array excluding selected frames
978
+ const editedFrames = this.frames.filter((_, index) => !this.selectedFrames.has(index));
979
+
980
+ // Generate new session ID
981
+ const newSessionId = `frame_${Date.now()}_edited`;
982
+
983
+ // Create new session object
984
+ const newSession = {
985
+ type: 'frame_capture',
986
+ sessionId: newSessionId,
987
+ timestamp: Date.now(),
988
+ frames: editedFrames,
989
+ originalSessionId: this.sessionId
990
+ };
991
+
992
+ // Save to storage
993
+ const storageKey = newSessionId;
994
+ await chrome.storage.local.set({ [storageKey]: newSession });
995
+
996
+ // Add to recordings list
997
+ const result = await chrome.storage.local.get(['recordings']);
998
+ const recordings = result.recordings || [];
999
+ recordings.unshift({
1000
+ id: newSessionId,
1001
+ timestamp: Date.now(),
1002
+ isFrameCapture: true,
1003
+ edited: true,
1004
+ originalId: this.sessionId
1005
+ });
1006
+ await chrome.storage.local.set({ recordings });
1007
+
1008
+ // Notify server about the new recording
1009
+ await this.notifyServerOfNewRecording(newSessionId, newSession);
1010
+
1011
+ // Show success and redirect
1012
+ alert(`Edited recording saved successfully!\nNew session ID: ${newSessionId}`);
1013
+ window.location.href = `frame-editor.html?sessionId=${newSessionId}`;
1014
+
1015
+ } catch (error) {
1016
+ console.error('Error saving edited recording:', error);
1017
+ alert('Failed to save edited recording: ' + error.message);
1018
+
1019
+ // Re-enable buttons
1020
+ this.setButtonsEnabled(true);
1021
+ saveBtn.textContent = originalText;
1022
+ }
1023
+ }
1024
+
1025
+ async notifyServerOfNewRecording(sessionId, sessionData) {
1026
+ const ports = CHROMEDEBUG_CONFIG?.ports?.slice(0, 5) || [3001, 3000, 3028]; // Use configured ports
1027
+
1028
+ for (const port of ports) {
1029
+ try {
1030
+ const response = await fetch(`http://localhost:${port}/chromedebug/save-edited-session`, {
1031
+ method: 'POST',
1032
+ headers: {
1033
+ 'Content-Type': 'application/json'
1034
+ },
1035
+ body: JSON.stringify({
1036
+ sessionId: sessionId,
1037
+ sessionData: sessionData
1038
+ })
1039
+ });
1040
+
1041
+ if (response.ok) {
1042
+ console.log(`Server notified on port ${port}`);
1043
+ break;
1044
+ }
1045
+ } catch (e) {
1046
+ // Try next port
1047
+ }
1048
+ }
1049
+ }
1050
+
1051
+ // Download workflow as ZIP file for sharing
1052
+ // Only exports SELECTED actions - requires items to be selected
1053
+ // Strips base64 image data from JSON since images are saved separately
1054
+ async downloadWorkflowAsZip() {
1055
+ if (!window.JSZip) {
1056
+ alert('JSZip library not loaded. Cannot create ZIP file.');
1057
+ return;
1058
+ }
1059
+
1060
+ // Guard clause: require selection (defense-in-depth, button should be disabled)
1061
+ if (this.selectedFrames.size === 0) {
1062
+ alert('Please select items to download');
1063
+ return;
1064
+ }
1065
+
1066
+ try {
1067
+ const saveBtn = document.getElementById('saveEditedBtn');
1068
+ const originalText = saveBtn.textContent;
1069
+ saveBtn.textContent = 'Creating ZIP...';
1070
+ saveBtn.disabled = true;
1071
+
1072
+ const zip = new JSZip();
1073
+
1074
+ // Get actions to export (respects selection)
1075
+ const actionsToExport = this.getActionsToExport();
1076
+ const isPartialExport = this.selectedFrames.size > 0;
1077
+ const totalActions = this.workflowData?.actions?.length || 0;
1078
+
1079
+ // Create cleaned workflow data with stripped screenshot data
1080
+ const cleanedActions = actionsToExport.map(({ action, originalIndex }) => {
1081
+ const hasScreenshot = !!(action.screenshot || action.screenshot_data);
1082
+ return this.stripScreenshotData(action, originalIndex, hasScreenshot);
1083
+ });
1084
+
1085
+ // Filter logs to only include those for exported actions
1086
+ const filteredLogs = this.filterLogsForExport(actionsToExport);
1087
+
1088
+ // Create export workflow object
1089
+ const exportWorkflowData = {
1090
+ ...this.workflowData,
1091
+ actions: cleanedActions,
1092
+ logs: filteredLogs,
1093
+ exportMetadata: {
1094
+ version: '2.0',
1095
+ format: 'workflow-export',
1096
+ screenshotsExtracted: true,
1097
+ isPartialExport,
1098
+ exportedActionCount: actionsToExport.length,
1099
+ totalActionCount: totalActions,
1100
+ exportedAt: new Date().toISOString()
1101
+ }
1102
+ };
1103
+
1104
+ // Add workflow data as JSON (formatted for readability)
1105
+ const workflowJson = JSON.stringify(exportWorkflowData, null, 2);
1106
+ zip.file('workflow.json', workflowJson);
1107
+
1108
+ // Add README with metadata
1109
+ const readme = this.generateWorkflowReadme(actionsToExport, filteredLogs, isPartialExport, totalActions);
1110
+ zip.file('README.txt', readme);
1111
+
1112
+ // Extract and add screenshots using ORIGINAL action indices
1113
+ let screenshotCount = 0;
1114
+ actionsToExport.forEach(({ action, originalIndex }) => {
1115
+ const screenshotData = action.screenshot || action.screenshot_data;
1116
+ if (!screenshotData) return;
1117
+
1118
+ const paddedIndex = String(originalIndex).padStart(3, '0');
1119
+ // Handle both data URL format and raw base64
1120
+ let imageData = screenshotData;
1121
+ let extension = 'jpg'; // Default to jpg
1122
+
1123
+ if (screenshotData.startsWith('data:image/')) {
1124
+ // Extract format and data from data URL
1125
+ const match = screenshotData.match(/^data:image\/(\w+);base64,(.+)$/);
1126
+ if (match) {
1127
+ extension = match[1] === 'jpeg' ? 'jpg' : match[1];
1128
+ imageData = match[2];
1129
+ }
1130
+ }
1131
+
1132
+ const filename = `screenshots/action_${paddedIndex}.${extension}`;
1133
+ zip.file(filename, imageData, { base64: true });
1134
+ screenshotCount++;
1135
+ });
1136
+
1137
+ // Generate ZIP file
1138
+ const blob = await zip.generateAsync({
1139
+ type: 'blob',
1140
+ compression: 'DEFLATE',
1141
+ compressionOptions: { level: 6 }
1142
+ });
1143
+
1144
+ // Trigger download
1145
+ const url = URL.createObjectURL(blob);
1146
+ const a = document.createElement('a');
1147
+ a.href = url;
1148
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
1149
+ const suffix = isPartialExport ? `_${actionsToExport.length}of${totalActions}` : '';
1150
+ a.download = `workflow_${this.sessionId}${suffix}_${timestamp}.zip`;
1151
+ document.body.appendChild(a);
1152
+ a.click();
1153
+ document.body.removeChild(a);
1154
+ URL.revokeObjectURL(url);
1155
+
1156
+ saveBtn.textContent = originalText;
1157
+ saveBtn.disabled = false;
1158
+
1159
+ console.log(`Workflow downloaded successfully: ${actionsToExport.length} actions, ${screenshotCount} screenshots, ${filteredLogs.length} logs`);
1160
+
1161
+ } catch (error) {
1162
+ console.error('Error creating ZIP:', error);
1163
+ alert('Failed to create ZIP file: ' + error.message);
1164
+
1165
+ const saveBtn = document.getElementById('saveEditedBtn');
1166
+ saveBtn.textContent = 'Download ZIP';
1167
+ saveBtn.disabled = false;
1168
+ }
1169
+ }
1170
+
1171
+ // Generate README content for the ZIP file
1172
+ generateWorkflowReadme(actionsToExport, filteredLogs, isPartialExport, totalActions) {
1173
+ const actionCount = actionsToExport.length;
1174
+ const logCount = filteredLogs.length;
1175
+
1176
+ // Count screenshots in exported actions
1177
+ const screenshotCount = actionsToExport.filter(({ action }) =>
1178
+ action.screenshot || action.screenshot_data
1179
+ ).length;
1180
+
1181
+ // Get action summary with original indices
1182
+ const actionSummary = actionsToExport
1183
+ .map(({ action, originalIndex }) =>
1184
+ ` ${originalIndex}. ${action.type || 'unknown'}${action.target ? ` on ${action.target}` : ''}`
1185
+ )
1186
+ .join('\n');
1187
+
1188
+ // Partial export warning
1189
+ const partialExportNote = isPartialExport
1190
+ ? `
1191
+ PARTIAL EXPORT WARNING
1192
+ ======================
1193
+ This export contains ${actionCount} of ${totalActions} total actions.
1194
+ Selected action indices: ${actionsToExport.map(a => a.originalIndex).join(', ')}
1195
+
1196
+ If replaying this workflow, earlier actions may be required to establish
1197
+ the correct page state (navigation, login, form data, etc.).
1198
+
1199
+ `
1200
+ : '';
1201
+
1202
+ return `Chrome Debug Workflow Recording
1203
+ ================================
1204
+
1205
+ Session ID: ${this.sessionId}
1206
+ Exported: ${new Date().toISOString()}
1207
+ Export Type: ${isPartialExport ? 'PARTIAL (selected actions only)' : 'COMPLETE'}
1208
+ ${partialExportNote}
1209
+ Contents:
1210
+ ---------
1211
+ - workflow.json: Workflow data with actions and console logs (screenshots stripped)
1212
+ - screenshots/: ${screenshotCount} screenshot files (action_XXX.jpg format)
1213
+
1214
+ Note: Screenshot data has been extracted from workflow.json to reduce file size.
1215
+ Each action's "screenshotFile" field references its corresponding image file.
1216
+
1217
+ Statistics:
1218
+ -----------
1219
+ - Exported Actions: ${actionCount}${isPartialExport ? ` of ${totalActions} total` : ''}
1220
+ - Console Logs: ${logCount}
1221
+ - Screenshots: ${screenshotCount}
1222
+
1223
+ Actions Summary:
1224
+ ----------------
1225
+ ${actionSummary || ' (no actions recorded)'}
1226
+
1227
+ Screenshot Mapping:
1228
+ -------------------
1229
+ Screenshots use ORIGINAL action indices for correlation:
1230
+ - action_000.jpg corresponds to action at original index 0
1231
+ - action_005.jpg corresponds to action at original index 5
1232
+ - etc.
1233
+
1234
+ This preserves correlation even in partial exports.
1235
+
1236
+ Usage:
1237
+ ------
1238
+ 1. Open workflow.json to view all recorded actions and logs
1239
+ 2. Match screenshotFile references to files in screenshots/ folder
1240
+ 3. This data can be imported into Chrome Debug for replay
1241
+ 4. Share with developers or Claude Code for debugging assistance
1242
+
1243
+ Generated by Chrome Debug MCP
1244
+ https://github.com/anthropics/chrome-debug
1245
+ `;
1246
+ }
1247
+
1248
+ // Extract screenshots from workflow actions (respects selection)
1249
+ // Returns array of { screenshotData, originalIndex } for proper correlation
1250
+ extractWorkflowScreenshots() {
1251
+ const actionsToExport = this.getActionsToExport();
1252
+
1253
+ return actionsToExport
1254
+ .filter(({ action }) => action.screenshot || action.screenshot_data)
1255
+ .map(({ action, originalIndex }) => ({
1256
+ screenshotData: action.screenshot || action.screenshot_data,
1257
+ originalIndex
1258
+ }));
1259
+ }
1260
+
1261
+ // ========== FRAME RECORDING DOWNLOAD ==========
1262
+ // Download frame recording as ZIP file for sharing
1263
+ // Only exports SELECTED frames - requires frames to be selected
1264
+ // Simpler than workflow export: frames already have logs attached
1265
+ async downloadFramesAsZip() {
1266
+ if (!window.JSZip) {
1267
+ alert('JSZip library not loaded. Cannot create ZIP file.');
1268
+ return;
1269
+ }
1270
+
1271
+ // Guard clause: require selection
1272
+ if (this.selectedFrames.size === 0) {
1273
+ alert('Please select frames to download');
1274
+ return;
1275
+ }
1276
+
1277
+ try {
1278
+ const saveBtn = document.getElementById('saveEditedBtn');
1279
+ const originalText = saveBtn.textContent;
1280
+ saveBtn.textContent = 'Creating ZIP...';
1281
+ saveBtn.disabled = true;
1282
+
1283
+ const zip = new JSZip();
1284
+
1285
+ // Get frames to export (respects selection)
1286
+ const framesToExport = this.getFramesToExport();
1287
+ const isPartialExport = this.selectedFrames.size < this.frames.length;
1288
+ const totalFrames = this.frames.length;
1289
+
1290
+ // Create cleaned frame data with stripped image data
1291
+ // Frames already have logs attached - no filtering needed
1292
+ const cleanedFrames = framesToExport.map(({ frame, originalIndex }) =>
1293
+ this.stripFrameImageData(frame, originalIndex)
1294
+ );
1295
+
1296
+ // Create export object
1297
+ const exportData = {
1298
+ sessionId: this.sessionId,
1299
+ type: 'frame-capture-export',
1300
+ frames: cleanedFrames,
1301
+ exportMetadata: {
1302
+ version: '2.0',
1303
+ format: 'frame-capture',
1304
+ screenshotsExtracted: true,
1305
+ isPartialExport,
1306
+ exportedFrameCount: framesToExport.length,
1307
+ totalFrameCount: totalFrames,
1308
+ exportedAt: new Date().toISOString(),
1309
+ browserOnly: this.browserOnly || false
1310
+ }
1311
+ };
1312
+
1313
+ // Add frames data as JSON
1314
+ const framesJson = JSON.stringify(exportData, null, 2);
1315
+ zip.file('frames.json', framesJson);
1316
+
1317
+ // Add README with metadata
1318
+ const readme = this.generateFramesReadme(framesToExport, isPartialExport, totalFrames);
1319
+ zip.file('README.txt', readme);
1320
+
1321
+ // Extract and add screenshots using ORIGINAL frame indices
1322
+ let screenshotCount = 0;
1323
+ framesToExport.forEach(({ frame, originalIndex }) => {
1324
+ const imageData = frame.imageData;
1325
+ if (!imageData) return;
1326
+
1327
+ const paddedIndex = String(originalIndex).padStart(3, '0');
1328
+
1329
+ // Handle both data URL format and raw base64
1330
+ let base64Data = imageData;
1331
+ let extension = 'jpg'; // Default to jpg
1332
+
1333
+ if (imageData.startsWith('data:image/')) {
1334
+ const match = imageData.match(/^data:image\/(\w+);base64,(.+)$/);
1335
+ if (match) {
1336
+ extension = match[1] === 'jpeg' ? 'jpg' : match[1];
1337
+ base64Data = match[2];
1338
+ }
1339
+ }
1340
+
1341
+ const filename = `screenshots/frame_${paddedIndex}.${extension}`;
1342
+ zip.file(filename, base64Data, { base64: true });
1343
+ screenshotCount++;
1344
+ });
1345
+
1346
+ // Generate ZIP file
1347
+ const blob = await zip.generateAsync({
1348
+ type: 'blob',
1349
+ compression: 'DEFLATE',
1350
+ compressionOptions: { level: 6 }
1351
+ });
1352
+
1353
+ // Trigger download
1354
+ const url = URL.createObjectURL(blob);
1355
+ const a = document.createElement('a');
1356
+ a.href = url;
1357
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
1358
+ const suffix = isPartialExport ? `_${framesToExport.length}of${totalFrames}` : '';
1359
+ a.download = `frames_${this.sessionId}${suffix}_${timestamp}.zip`;
1360
+ document.body.appendChild(a);
1361
+ a.click();
1362
+ document.body.removeChild(a);
1363
+ URL.revokeObjectURL(url);
1364
+
1365
+ saveBtn.textContent = originalText;
1366
+ saveBtn.disabled = false;
1367
+
1368
+ console.log(`Frames downloaded successfully: ${framesToExport.length} frames, ${screenshotCount} screenshots`);
1369
+
1370
+ } catch (error) {
1371
+ console.error('Error creating ZIP:', error);
1372
+ alert('Failed to create ZIP file: ' + error.message);
1373
+
1374
+ const saveBtn = document.getElementById('saveEditedBtn');
1375
+ saveBtn.textContent = 'Download ZIP';
1376
+ saveBtn.disabled = false;
1377
+ }
1378
+ }
1379
+
1380
+ // Generate README content for frame export ZIP file
1381
+ // Simpler than workflow README - frames are snapshots, not replayable actions
1382
+ generateFramesReadme(framesToExport, isPartialExport, totalFrames) {
1383
+ const frameCount = framesToExport.length;
1384
+
1385
+ // Count total logs across all exported frames
1386
+ const totalLogs = framesToExport.reduce((sum, { frame }) =>
1387
+ sum + (frame.logs?.length || 0), 0
1388
+ );
1389
+
1390
+ // Calculate duration
1391
+ const firstTimestamp = framesToExport[0]?.frame.timestamp || 0;
1392
+ const lastTimestamp = framesToExport[framesToExport.length - 1]?.frame.timestamp || 0;
1393
+ const durationSec = ((lastTimestamp - firstTimestamp) / 1000).toFixed(1);
1394
+
1395
+ // Get frame summary (first 20)
1396
+ const frameSummary = framesToExport
1397
+ .slice(0, 20)
1398
+ .map(({ frame, originalIndex }) => {
1399
+ const timeStr = (frame.timestamp / 1000).toFixed(1);
1400
+ const logCount = frame.logs?.length || 0;
1401
+ return ` ${originalIndex}. ${timeStr}s (${logCount} logs)`;
1402
+ })
1403
+ .join('\n');
1404
+
1405
+ const moreSummary = framesToExport.length > 20
1406
+ ? `\n ... and ${framesToExport.length - 20} more frames`
1407
+ : '';
1408
+
1409
+ // Partial export note
1410
+ const partialExportNote = isPartialExport
1411
+ ? `
1412
+ PARTIAL EXPORT
1413
+ ==============
1414
+ This export contains ${frameCount} of ${totalFrames} total frames.
1415
+ Selected frame indices: ${framesToExport.map(f => f.originalIndex).join(', ')}
1416
+
1417
+ `
1418
+ : '';
1419
+
1420
+ return `Chrome Debug Frame Capture Recording
1421
+ =====================================
1422
+
1423
+ Session ID: ${this.sessionId}
1424
+ Exported: ${new Date().toISOString()}
1425
+ Export Type: ${isPartialExport ? 'PARTIAL (selected frames only)' : 'COMPLETE'}
1426
+ Mode: ${this.browserOnly ? 'Browser-Only (IndexedDB)' : 'Server Mode'}
1427
+ ${partialExportNote}
1428
+ Contents:
1429
+ ---------
1430
+ - frames.json: Frame metadata with timestamps and console logs (images stripped)
1431
+ - screenshots/: ${frameCount} screenshot files (frame_XXX.jpg format)
1432
+ - README.txt: This file
1433
+
1434
+ Note: Screenshot data has been extracted from frames.json to reduce file size.
1435
+ Each frame's "screenshotFile" field references its corresponding image file.
1436
+
1437
+ Statistics:
1438
+ -----------
1439
+ - Exported Frames: ${frameCount}${isPartialExport ? ` of ${totalFrames} total` : ''}
1440
+ - Duration: ${durationSec}s
1441
+ - Total Console Logs: ${totalLogs}
1442
+
1443
+ Frames Summary:
1444
+ ---------------
1445
+ ${frameSummary}${moreSummary}
1446
+
1447
+ Screenshot Mapping:
1448
+ -------------------
1449
+ Screenshots use ORIGINAL frame indices for correlation:
1450
+ - frame_000.jpg corresponds to frame at original index 0
1451
+ - frame_005.jpg corresponds to frame at original index 5
1452
+ - etc.
1453
+
1454
+ Frame Data Structure:
1455
+ ---------------------
1456
+ Each frame in frames.json contains:
1457
+ - frameIndex: Original index in the recording
1458
+ - timestamp: Time in milliseconds from recording start
1459
+ - logs: Array of console log entries captured during this frame
1460
+ - screenshotFile: Path to screenshot image in screenshots/ folder
1461
+
1462
+ Usage:
1463
+ ------
1464
+ 1. Open frames.json to view all frame metadata and console logs
1465
+ 2. Match screenshotFile references to files in screenshots/ folder
1466
+ 3. Use for debugging, documentation, or sharing with developers
1467
+ 4. Share with Claude Code for AI-assisted debugging
1468
+
1469
+ Generated by Chrome Debug MCP
1470
+ https://github.com/anthropics/chrome-debug
1471
+ `;
1472
+ }
1473
+
1474
+ setButtonsEnabled(enabled) {
1475
+ document.querySelectorAll('.btn').forEach(btn => {
1476
+ btn.disabled = !enabled;
1477
+ });
1478
+ }
1479
+
1480
+ showEmpty() {
1481
+ document.getElementById('loadingContainer').style.display = 'none';
1482
+ document.getElementById('emptyContainer').style.display = 'block';
1483
+ }
1484
+
1485
+ showError(message) {
1486
+ document.getElementById('loadingContainer').style.display = 'none';
1487
+ const errorContainer = document.getElementById('errorContainer');
1488
+ errorContainer.textContent = message;
1489
+ errorContainer.style.display = 'block';
1490
+ }
1491
+ }
1492
+
1493
+ // Initialize when DOM is ready
1494
+ document.addEventListener('DOMContentLoaded', () => {
1495
+ new FrameEditor();
1496
+ });