@dynamicu/chromedebug-mcp 2.6.6 → 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.
- package/CLAUDE.md +1 -1
- package/README.md +1 -1
- package/chrome-extension/activation-manager.js +18 -4
- package/chrome-extension/background.js +1044 -552
- package/chrome-extension/browser-recording-manager.js +256 -0
- package/chrome-extension/chrome-debug-logger.js +168 -0
- package/chrome-extension/console-interception-library.js +430 -0
- package/chrome-extension/content.css +16 -16
- package/chrome-extension/content.js +617 -215
- package/chrome-extension/data-buffer.js +206 -17
- package/chrome-extension/extension-config.js +1 -1
- package/chrome-extension/frame-capture.js +52 -15
- package/chrome-extension/license-helper.js +26 -0
- package/chrome-extension/manifest.free.json +3 -6
- package/chrome-extension/options.js +1 -1
- package/chrome-extension/popup.html +315 -181
- package/chrome-extension/popup.js +673 -526
- package/chrome-extension/pro/enhanced-capture.js +406 -0
- package/chrome-extension/pro/frame-editor.html +410 -0
- package/chrome-extension/pro/frame-editor.js +1496 -0
- package/chrome-extension/pro/function-tracker.js +843 -0
- package/chrome-extension/pro/jszip.min.js +13 -0
- package/config/chromedebug-config.json +101 -0
- package/dist/chromedebug-extension-free.zip +0 -0
- package/package.json +3 -1
- package/scripts/package-pro-extension.js +1 -1
- package/scripts/webpack.config.free.cjs +11 -8
- package/scripts/webpack.config.pro.cjs +5 -0
- package/src/chrome-controller.js +7 -7
- package/src/cli.js +2 -2
- package/src/database.js +61 -9
- package/src/http-server.js +3 -2
- package/src/index.js +9 -6
- package/src/mcp/server.js +2 -2
- package/src/services/process-manager.js +10 -6
- package/src/services/process-tracker.js +10 -5
- package/src/services/profile-manager.js +17 -2
- package/src/validation/schemas.js +36 -6
- package/src/index-direct.js +0 -157
- package/src/index-modular.js +0 -219
- package/src/index-monolithic-backup.js +0 -2230
- package/src/legacy/chrome-controller-old.js +0 -1406
- package/src/legacy/index-express.js +0 -625
- package/src/legacy/index-old.js +0 -977
- package/src/legacy/routes.js +0 -260
- 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
|
+
});
|