sessionsnap 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,886 @@
1
+ /**
2
+ * Replay engine — executes recorded actions on a live page.
3
+ * Works with both Puppeteer and Playwright through a unified interface.
4
+ */
5
+
6
+ const DEFAULT_ACTION_DELAY = 500; // ms between actions
7
+
8
+ /**
9
+ * Inject a visual mouse cursor overlay into the page.
10
+ * Returns a function to move the cursor to (x, y) with smooth animation.
11
+ */
12
+ async function injectVisualCursor(page) {
13
+ await page.evaluate(() => {
14
+ if (document.getElementById('__sessionsnap_cursor')) return;
15
+ const cursor = document.createElement('div');
16
+ cursor.id = '__sessionsnap_cursor';
17
+ cursor.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
18
+ <path d="M5 3L19 12L12 13L9 20L5 3Z" fill="#4F46E5" stroke="#fff" stroke-width="1.2" stroke-linejoin="round"/>
19
+ </svg>`;
20
+ cursor.style.cssText = `
21
+ position: fixed; top: 0; left: 0; z-index: 2147483647;
22
+ pointer-events: none; width: 24px; height: 24px;
23
+ transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
24
+ transform: translate(-105px, -103px);
25
+ filter: drop-shadow(1px 2px 2px rgba(0,0,0,0.3));
26
+ `;
27
+ document.body.appendChild(cursor);
28
+
29
+ // Click ripple effect container
30
+ const ripple = document.createElement('div');
31
+ ripple.id = '__sessionsnap_ripple';
32
+ ripple.style.cssText = `
33
+ position: fixed; top: 0; left: 0; z-index: 2147483646;
34
+ pointer-events: none; width: 20px; height: 20px;
35
+ border-radius: 50%; border: 2px solid #4F46E5;
36
+ transform: translate(-100px, -100px) scale(0);
37
+ opacity: 0;
38
+ `;
39
+ document.body.appendChild(ripple);
40
+ });
41
+ }
42
+
43
+ /**
44
+ * Move the visual cursor to (x, y) and optionally show a click ripple.
45
+ */
46
+ async function moveVisualCursor(page, x, y, showRipple = false) {
47
+ await page.evaluate(({ x, y, showRipple }) => {
48
+ const cursor = document.getElementById('__sessionsnap_cursor');
49
+ if (cursor) {
50
+ // SVG path starts at (5, 3) which is the cursor tip, so offset to align tip with (x, y)
51
+ cursor.style.transform = `translate(${x - 5}px, ${y - 3}px)`;
52
+ }
53
+ if (showRipple) {
54
+ const ripple = document.getElementById('__sessionsnap_ripple');
55
+ if (ripple) {
56
+ // Reset animation (ripple is 20px, so center at -10, -10)
57
+ ripple.style.transition = 'none';
58
+ ripple.style.transform = `translate(${x - 10}px, ${y - 10}px) scale(0)`;
59
+ ripple.style.opacity = '1';
60
+ ripple.offsetHeight; // force reflow
61
+ ripple.style.transition = 'transform 0.4s ease-out, opacity 0.4s ease-out';
62
+ ripple.style.transform = `translate(${x - 10}px, ${y - 10}px) scale(2.5)`;
63
+ ripple.style.opacity = '0';
64
+ }
65
+ }
66
+ }, { x, y, showRipple });
67
+ // Wait for cursor animation to settle
68
+ await sleep(280);
69
+ }
70
+
71
+ /**
72
+ * Resolve the target coordinates for a given action by querying the element on the page.
73
+ * Returns { x, y } or null if the element cannot be found.
74
+ */
75
+ async function resolveActionCoords(page, action) {
76
+ // If action already has coordinates, use them
77
+ if (action.x != null && action.y != null) return { x: action.x, y: action.y };
78
+
79
+ // Try to find the element by selector
80
+ if (action.selector && action.selector !== 'unknown') {
81
+ try {
82
+ const coords = await page.evaluate((sel) => {
83
+ const el = document.querySelector(sel);
84
+ if (!el) return null;
85
+ const rect = el.getBoundingClientRect();
86
+ if (rect.width === 0 && rect.height === 0) return null;
87
+ return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
88
+ }, action.selector);
89
+ if (coords) return coords;
90
+ } catch { /* ignore */ }
91
+ }
92
+
93
+ // Try to find by text content
94
+ if (action.text && action.text.trim()) {
95
+ try {
96
+ const coords = await page.evaluate((text) => {
97
+ const candidates = document.querySelectorAll('a, button, div, span, li, td, th, label, input, select, textarea, summary, details');
98
+ for (const el of candidates) {
99
+ const elText = (el.innerText || el.value || '').trim();
100
+ if (elText === text || elText.startsWith(text.slice(0, 30))) {
101
+ const rect = el.getBoundingClientRect();
102
+ if (rect.width > 0 && rect.height > 0) {
103
+ return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
104
+ }
105
+ }
106
+ }
107
+ return null;
108
+ }, action.text.trim());
109
+ if (coords) return coords;
110
+ } catch { /* ignore */ }
111
+ }
112
+
113
+ return null;
114
+ }
115
+
116
+ /**
117
+ * Replay a list of recorded actions on the given page.
118
+ *
119
+ * @param {object} page - Puppeteer or Playwright page instance
120
+ * @param {Array} actions - Array of recorded action objects
121
+ * @param {object} options
122
+ * @param {'puppeteer'|'playwright'} options.runner
123
+ * @param {number} [options.speed=1] - Playback speed multiplier (2 = 2x faster)
124
+ * @param {boolean} [options.visual=false] - Show a visual mouse cursor during replay
125
+ * @param {function} [options.onAction] - Callback after each action: (action, index, total) => void
126
+ */
127
+ export async function replayActions(page, actions, { runner, speed = 1, bail = false, visual = false, onAction } = {}) {
128
+ if (!actions || actions.length === 0) {
129
+ console.log('[sessionsnap] No actions to replay.');
130
+ return;
131
+ }
132
+
133
+ const baseDelay = DEFAULT_ACTION_DELAY / speed;
134
+ console.log(`[sessionsnap] Replaying ${actions.length} actions (speed: ${speed}x${bail ? ', bail on failure' : ''}${visual ? ', visual cursor' : ''})...`);
135
+
136
+ // Inject visual cursor if enabled
137
+ if (visual) {
138
+ await injectVisualCursor(page);
139
+ }
140
+
141
+ let failed = 0;
142
+ let mousemoveBatchCount = 0;
143
+ let mousemoveBatchStart = -1;
144
+
145
+ for (let i = 0; i < actions.length; i++) {
146
+ const action = actions[i];
147
+
148
+ // Batch mousemove actions: execute immediately without delay for smooth movement
149
+ if (action.type === 'mousemove') {
150
+ if (mousemoveBatchStart === -1) {
151
+ mousemoveBatchStart = i;
152
+ }
153
+ mousemoveBatchCount++;
154
+
155
+ // Execute mousemove immediately (no delay, no visual cursor update for each move)
156
+ try {
157
+ await executeAction(page, action, runner);
158
+ if (onAction) onAction(action, i, actions.length);
159
+
160
+ // Update visual cursor only every 5 mousemoves to reduce lag
161
+ if (visual && mousemoveBatchCount % 5 === 0) {
162
+ try {
163
+ const coords = await resolveActionCoords(page, action);
164
+ if (coords) {
165
+ // Use direct DOM update instead of moveVisualCursor to avoid sleep delay
166
+ await page.evaluate(({ x, y }) => {
167
+ const cursor = document.getElementById('__sessionsnap_cursor');
168
+ if (cursor) {
169
+ cursor.style.transform = `translate(${x - 5}px, ${y - 3}px)`;
170
+ }
171
+ }, { x: coords.x, y: coords.y });
172
+ }
173
+ } catch { /* ignore */ }
174
+ }
175
+
176
+ // Log every 10 mousemoves
177
+ if (mousemoveBatchCount % 10 === 0) {
178
+ console.log(`[sessionsnap] [${i + 1}/${actions.length}] mousemove batch: ${mousemoveBatchCount} moves`);
179
+ }
180
+ } catch (err) {
181
+ failed++;
182
+ console.log(`[sessionsnap] [${i + 1}/${actions.length}] mousemove FAILED: ${err.message}`);
183
+ if (bail) {
184
+ console.log(`[sessionsnap] Bail: stopping replay due to failure at action ${i + 1}.`);
185
+ return { completed: i, failed, total: actions.length, bailedAt: i + 1 };
186
+ }
187
+ }
188
+
189
+ // Continue to next action (no delay for mousemove)
190
+ continue;
191
+ }
192
+
193
+ // Non-mousemove action: log mousemove batch if we just finished one
194
+ if (mousemoveBatchCount > 0) {
195
+ // Update visual cursor to final position of mousemove batch
196
+ if (visual && i > 0) {
197
+ try {
198
+ const lastMousemove = actions[i - 1];
199
+ if (lastMousemove && lastMousemove.type === 'mousemove') {
200
+ const coords = await resolveActionCoords(page, lastMousemove);
201
+ if (coords) {
202
+ await page.evaluate(({ x, y }) => {
203
+ const cursor = document.getElementById('__sessionsnap_cursor');
204
+ if (cursor) {
205
+ cursor.style.transform = `translate(${x - 5}px, ${y - 3}px)`;
206
+ }
207
+ }, { x: coords.x, y: coords.y });
208
+ }
209
+ }
210
+ } catch { /* ignore */ }
211
+ }
212
+
213
+ if (mousemoveBatchCount % 10 !== 0) {
214
+ // Log remaining mousemoves if batch wasn't already logged
215
+ console.log(`[sessionsnap] [${mousemoveBatchStart + 1}-${i}/${actions.length}] mousemove batch: ${mousemoveBatchCount} moves`);
216
+ }
217
+ mousemoveBatchCount = 0;
218
+ mousemoveBatchStart = -1;
219
+ }
220
+
221
+ // Calculate delay from timestamps if available
222
+ let delay = baseDelay;
223
+ if (i > 0 && actions[i].timestamp && actions[i - 1].timestamp) {
224
+ const realDelay = actions[i].timestamp - actions[i - 1].timestamp;
225
+ delay = Math.min(Math.max(realDelay / speed, 50), 5000); // clamp 50ms–5s
226
+ }
227
+ await sleep(delay);
228
+
229
+ // Move visual cursor to target position before executing the action
230
+ if (visual && action.type !== 'navigation' && action.type !== 'keydown') {
231
+ try {
232
+ const coords = await resolveActionCoords(page, action);
233
+ if (coords) {
234
+ const isClick = ['click', 'dblclick', 'checkbox', 'submit'].includes(action.type);
235
+ // For hover, use direct DOM update (no sleep delay) for smooth movement
236
+ if (action.type === 'hover') {
237
+ await page.evaluate(({ x, y }) => {
238
+ const cursor = document.getElementById('__sessionsnap_cursor');
239
+ if (cursor) {
240
+ cursor.style.transform = `translate(${x - 5}px, ${y - 3}px)`;
241
+ }
242
+ }, { x: coords.x, y: coords.y });
243
+ } else {
244
+ // For clicks, use moveVisualCursor to show ripple effect
245
+ const showRipple = isClick;
246
+ await moveVisualCursor(page, coords.x, coords.y, showRipple);
247
+ }
248
+ }
249
+ } catch { /* visual cursor is best-effort, ignore errors */ }
250
+ }
251
+
252
+ try {
253
+ await executeAction(page, action, runner);
254
+ if (onAction) onAction(action, i, actions.length);
255
+ console.log(`[sessionsnap] [${i + 1}/${actions.length}] ${action.type}: ${describeAction(action)}`);
256
+ } catch (err) {
257
+ failed++;
258
+ console.log(`[sessionsnap] [${i + 1}/${actions.length}] ${action.type} FAILED: ${err.message}`);
259
+ if (bail) {
260
+ console.log(`[sessionsnap] Bail: stopping replay due to failure at action ${i + 1}.`);
261
+ return { completed: i, failed, total: actions.length, bailedAt: i + 1 };
262
+ }
263
+ }
264
+ }
265
+
266
+ // Log final mousemove batch if any and update visual cursor
267
+ if (mousemoveBatchCount > 0) {
268
+ // Update visual cursor to final position of mousemove batch
269
+ if (visual && actions.length > 0) {
270
+ try {
271
+ const lastMousemove = actions[actions.length - 1];
272
+ if (lastMousemove && lastMousemove.type === 'mousemove') {
273
+ const coords = await resolveActionCoords(page, lastMousemove);
274
+ if (coords) {
275
+ await page.evaluate(({ x, y }) => {
276
+ const cursor = document.getElementById('__sessionsnap_cursor');
277
+ if (cursor) {
278
+ cursor.style.transform = `translate(${x - 5}px, ${y - 3}px)`;
279
+ }
280
+ }, { x: coords.x, y: coords.y });
281
+ }
282
+ }
283
+ } catch { /* ignore */ }
284
+ }
285
+
286
+ if (mousemoveBatchCount % 10 !== 0) {
287
+ console.log(`[sessionsnap] [${mousemoveBatchStart + 1}-${actions.length}/${actions.length}] mousemove batch: ${mousemoveBatchCount} moves`);
288
+ }
289
+ }
290
+
291
+ // Remove visual cursor
292
+ if (visual) {
293
+ await page.evaluate(() => {
294
+ document.getElementById('__sessionsnap_cursor')?.remove();
295
+ document.getElementById('__sessionsnap_ripple')?.remove();
296
+ }).catch(() => {});
297
+ }
298
+
299
+ console.log(`[sessionsnap] Replay complete. ${failed > 0 ? `(${failed} failed)` : ''}`);
300
+ return { completed: actions.length, failed, total: actions.length, bailedAt: null };
301
+ }
302
+
303
+ async function executeAction(page, action, runner) {
304
+ switch (action.type) {
305
+ case 'click':
306
+ await executeClick(page, action, runner);
307
+ break;
308
+ case 'dblclick':
309
+ await executeDblclick(page, action, runner);
310
+ break;
311
+ case 'hover':
312
+ await executeHover(page, action, runner);
313
+ break;
314
+ case 'mousemove':
315
+ await executeMousemove(page, action, runner);
316
+ break;
317
+ case 'input':
318
+ await executeInput(page, action, runner);
319
+ break;
320
+ case 'select':
321
+ await executeSelect(page, action, runner);
322
+ break;
323
+ case 'checkbox':
324
+ await executeCheckbox(page, action, runner);
325
+ break;
326
+ case 'keydown':
327
+ await executeKeydown(page, action, runner);
328
+ break;
329
+ case 'submit':
330
+ await executeSubmit(page, action, runner);
331
+ break;
332
+ case 'scroll':
333
+ await executeScroll(page, action, runner);
334
+ break;
335
+ case 'file':
336
+ console.log(`[sessionsnap] Skipping file input: ${action.selector} (${action.fileCount} file(s): ${(action.fileNames || []).join(', ')}) — file selection cannot be replayed`);
337
+ break;
338
+ case 'navigation':
339
+ await executeNavigation(page, action, runner);
340
+ break;
341
+ default:
342
+ console.log(`[sessionsnap] Unknown action type: ${action.type}`);
343
+ }
344
+ }
345
+
346
+ async function executeClick(page, action, runner) {
347
+ // Strategy 1: click by recorded coordinates (most accurate, simulates real user click)
348
+ if (action.x != null && action.y != null) {
349
+ // Move mouse to recorded position first, then click
350
+ await page.mouse.move(action.x, action.y, { steps: 1 });
351
+ await page.mouse.click(action.x, action.y);
352
+ return;
353
+ }
354
+
355
+ // Strategy 2: CSS selector + move to element center if coordinates not available
356
+ try {
357
+ if (runner === 'puppeteer') {
358
+ await page.waitForSelector(action.selector, { timeout: 1000 });
359
+ // Get element center coordinates for more accurate click
360
+ const coords = await page.evaluate((sel) => {
361
+ const el = document.querySelector(sel);
362
+ if (!el) return null;
363
+ const rect = el.getBoundingClientRect();
364
+ if (rect.width === 0 && rect.height === 0) return null;
365
+ return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
366
+ }, action.selector);
367
+ if (coords) {
368
+ await page.mouse.move(coords.x, coords.y, { steps: 1 });
369
+ await page.mouse.click(coords.x, coords.y);
370
+ } else {
371
+ await page.click(action.selector);
372
+ }
373
+ return;
374
+ } else {
375
+ const locator = page.locator(action.selector).first();
376
+ await locator.waitFor({ timeout: 1000, state: 'visible' });
377
+ // Get element center coordinates for more accurate click
378
+ const coords = await page.evaluate((sel) => {
379
+ const el = document.querySelector(sel);
380
+ if (!el) return null;
381
+ const rect = el.getBoundingClientRect();
382
+ if (rect.width === 0 && rect.height === 0) return null;
383
+ return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
384
+ }, action.selector);
385
+ if (coords) {
386
+ await page.mouse.move(coords.x, coords.y, { steps: 1 });
387
+ await page.mouse.click(coords.x, coords.y);
388
+ } else {
389
+ await locator.click({ timeout: 1000 });
390
+ }
391
+ return;
392
+ }
393
+ } catch { /* selector failed, try next strategy */ }
394
+
395
+ // Strategy 3: find element by text content
396
+ if (action.text && action.text.trim()) {
397
+ try {
398
+ const result = await page.evaluate((text) => {
399
+ // Search all visible elements for matching text
400
+ const candidates = document.querySelectorAll('a, button, div, span, li, td, th, label, p, h1, h2, h3, h4, h5, h6');
401
+ for (const el of candidates) {
402
+ const elText = (el.innerText || '').trim();
403
+ if (elText === text || elText.startsWith(text.slice(0, 30))) {
404
+ // Verify element is visible
405
+ const rect = el.getBoundingClientRect();
406
+ if (rect.width > 0 && rect.height > 0) {
407
+ return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
408
+ }
409
+ }
410
+ }
411
+ return null;
412
+ }, action.text.trim());
413
+ if (result) {
414
+ await page.mouse.move(result.x, result.y, { steps: 1 });
415
+ await page.mouse.click(result.x, result.y);
416
+ return;
417
+ }
418
+ } catch { /* text search failed */ }
419
+ }
420
+
421
+ throw new Error(`Element not found: ${action.selector} (text: "${action.text || ''}")`);
422
+ }
423
+
424
+ async function executeDblclick(page, action, runner) {
425
+ // Strategy 1: double-click by recorded coordinates (most accurate, simulates real user dblclick)
426
+ if (action.x != null && action.y != null) {
427
+ // Move mouse to recorded position first, then double-click
428
+ await page.mouse.move(action.x, action.y, { steps: 1 });
429
+ await page.mouse.click(action.x, action.y, { clickCount: 2 });
430
+ return;
431
+ }
432
+
433
+ // Strategy 2: CSS selector + move to element center if coordinates not available
434
+ try {
435
+ if (runner === 'puppeteer') {
436
+ await page.waitForSelector(action.selector, { timeout: 1000 });
437
+ // Get element center coordinates for more accurate click
438
+ const coords = await page.evaluate((sel) => {
439
+ const el = document.querySelector(sel);
440
+ if (!el) return null;
441
+ const rect = el.getBoundingClientRect();
442
+ if (rect.width === 0 && rect.height === 0) return null;
443
+ return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
444
+ }, action.selector);
445
+ if (coords) {
446
+ await page.mouse.move(coords.x, coords.y, { steps: 1 });
447
+ await page.mouse.click(coords.x, coords.y, { clickCount: 2 });
448
+ } else {
449
+ await page.click(action.selector, { clickCount: 2 });
450
+ }
451
+ return;
452
+ } else {
453
+ const locator = page.locator(action.selector).first();
454
+ await locator.waitFor({ timeout: 1000, state: 'visible' });
455
+ // Get element center coordinates for more accurate click
456
+ const coords = await page.evaluate((sel) => {
457
+ const el = document.querySelector(sel);
458
+ if (!el) return null;
459
+ const rect = el.getBoundingClientRect();
460
+ if (rect.width === 0 && rect.height === 0) return null;
461
+ return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
462
+ }, action.selector);
463
+ if (coords) {
464
+ await page.mouse.move(coords.x, coords.y, { steps: 1 });
465
+ await page.mouse.click(coords.x, coords.y, { clickCount: 2 });
466
+ } else {
467
+ await locator.dblclick({ timeout: 1000 });
468
+ }
469
+ return;
470
+ }
471
+ } catch { /* selector failed, try next strategy */ }
472
+
473
+ // Strategy 3: find element by text content
474
+ if (action.text && action.text.trim()) {
475
+ try {
476
+ const result = await page.evaluate((text) => {
477
+ const candidates = document.querySelectorAll('a, button, div, span, li, td, th, label, p, h1, h2, h3, h4, h5, h6');
478
+ for (const el of candidates) {
479
+ const elText = (el.innerText || '').trim();
480
+ if (elText === text || elText.startsWith(text.slice(0, 30))) {
481
+ const rect = el.getBoundingClientRect();
482
+ if (rect.width > 0 && rect.height > 0) {
483
+ return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
484
+ }
485
+ }
486
+ }
487
+ return null;
488
+ }, action.text.trim());
489
+ if (result) {
490
+ await page.mouse.move(result.x, result.y, { steps: 1 });
491
+ await page.mouse.click(result.x, result.y, { clickCount: 2 });
492
+ return;
493
+ }
494
+ } catch { /* text search failed */ }
495
+ }
496
+
497
+ throw new Error(`Element not found for dblclick: ${action.selector}`);
498
+ }
499
+
500
+ async function executeHover(page, action, runner) {
501
+ // Strategy 1: CSS selector + mouse position
502
+ try {
503
+ if (runner === 'puppeteer') {
504
+ await page.waitForSelector(action.selector, { timeout: 1000 });
505
+ // Move mouse to recorded position first for accuracy, then hover element
506
+ if (action.x != null && action.y != null) {
507
+ await page.mouse.move(action.x, action.y, { steps: 1 }); // steps: 1 for instant movement
508
+ }
509
+ await page.hover(action.selector);
510
+ return;
511
+ } else {
512
+ const locator = page.locator(action.selector).first();
513
+ await locator.waitFor({ timeout: 1000, state: 'visible' });
514
+ // Move mouse to recorded position first for accuracy, then hover element
515
+ if (action.x != null && action.y != null) {
516
+ await page.mouse.move(action.x, action.y, { steps: 1 }); // steps: 1 for instant movement
517
+ }
518
+ await locator.hover({ timeout: 1000 });
519
+ return;
520
+ }
521
+ } catch { /* selector failed, try text fallback */ }
522
+
523
+ // Strategy 2: find element by text content and hover via mouse move
524
+ if (action.text && action.text.trim()) {
525
+ try {
526
+ const result = await page.evaluate((text) => {
527
+ const candidates = document.querySelectorAll('a, button, div, span, li, td, th, label, summary, details');
528
+ for (const el of candidates) {
529
+ const elText = (el.innerText || '').trim();
530
+ if (elText === text || elText.startsWith(text.slice(0, 30))) {
531
+ const rect = el.getBoundingClientRect();
532
+ if (rect.width > 0 && rect.height > 0) {
533
+ return {
534
+ x: rect.x + rect.width / 2,
535
+ y: rect.y + rect.height / 2,
536
+ selector: el.id ? '#' + el.id : null
537
+ };
538
+ }
539
+ }
540
+ }
541
+ return null;
542
+ }, action.text.trim());
543
+ if (result) {
544
+ // Use recorded position if available, otherwise use element center
545
+ const x = action.x != null ? action.x : result.x;
546
+ const y = action.y != null ? action.y : result.y;
547
+ await page.mouse.move(x, y, { steps: 1 }); // steps: 1 for instant movement
548
+ // Also trigger hover event on element
549
+ if (result.selector) {
550
+ try {
551
+ if (runner === 'puppeteer') {
552
+ await page.hover(result.selector);
553
+ } else {
554
+ await page.locator(result.selector).first().hover();
555
+ }
556
+ } catch { /* ignore if hover fails */ }
557
+ }
558
+ return;
559
+ }
560
+ } catch { /* text search failed */ }
561
+ }
562
+
563
+ // Strategy 3: hover by recorded coordinates only (fallback)
564
+ if (action.x != null && action.y != null) {
565
+ await page.mouse.move(action.x, action.y, { steps: 1 }); // steps: 1 for instant movement
566
+ // Try to trigger hover event by dispatching mouseover on element at that position
567
+ await page.evaluate(({ x, y }) => {
568
+ const el = document.elementFromPoint(x, y);
569
+ if (el) {
570
+ const event = new MouseEvent('mouseover', {
571
+ bubbles: true,
572
+ cancelable: true,
573
+ clientX: x,
574
+ clientY: y,
575
+ view: window
576
+ });
577
+ el.dispatchEvent(event);
578
+ }
579
+ }, { x: action.x, y: action.y });
580
+ return;
581
+ }
582
+
583
+ throw new Error(`Element not found for hover: ${action.selector}`);
584
+ }
585
+
586
+ async function executeMousemove(page, action, runner) {
587
+ // Strict mouse movement: move to exact recorded coordinates
588
+ if (action.x != null && action.y != null) {
589
+ await page.mouse.move(action.x, action.y);
590
+ return;
591
+ }
592
+ // Should not happen if recording is correct, but handle gracefully
593
+ console.log(`[sessionsnap] Warning: mousemove action missing coordinates`);
594
+ }
595
+
596
+ async function executeInput(page, action, runner) {
597
+ if (action.value === '***') {
598
+ console.log(`[sessionsnap] Skipping password field: ${action.selector}`);
599
+ return;
600
+ }
601
+
602
+ if (runner === 'puppeteer') {
603
+ await page.waitForSelector(action.selector, { timeout: 3000 });
604
+ // Clear existing value then type
605
+ await page.click(action.selector, { clickCount: 3 });
606
+ await page.type(action.selector, action.value);
607
+ } else {
608
+ const locator = page.locator(action.selector).first();
609
+ await locator.fill(action.value, { timeout: 3000 });
610
+ }
611
+ }
612
+
613
+ async function executeSelect(page, action, runner) {
614
+ if (runner === 'puppeteer') {
615
+ await page.waitForSelector(action.selector, { timeout: 3000 });
616
+ await page.select(action.selector, action.value);
617
+ } else {
618
+ await page.locator(action.selector).first().selectOption(action.value, { timeout: 3000 });
619
+ }
620
+ }
621
+
622
+ async function executeCheckbox(page, action, runner) {
623
+ // Use recorded coordinates if available for accurate click simulation
624
+ if (action.x != null && action.y != null) {
625
+ if (runner === 'puppeteer') {
626
+ // Check current state before clicking
627
+ const currentChecked = await page.evaluate(({ x, y }) => {
628
+ const el = document.elementFromPoint(x, y);
629
+ if (el && (el.type === 'checkbox' || el.type === 'radio')) {
630
+ return el.checked;
631
+ }
632
+ return null;
633
+ }, { x: action.x, y: action.y });
634
+
635
+ if (currentChecked !== null && currentChecked !== action.checked) {
636
+ await page.mouse.move(action.x, action.y, { steps: 1 });
637
+ await page.mouse.click(action.x, action.y);
638
+ }
639
+ return;
640
+ } else {
641
+ // For Playwright, check state and click if needed
642
+ const currentChecked = await page.evaluate(({ x, y }) => {
643
+ const el = document.elementFromPoint(x, y);
644
+ if (el && (el.type === 'checkbox' || el.type === 'radio')) {
645
+ return el.checked;
646
+ }
647
+ return null;
648
+ }, { x: action.x, y: action.y });
649
+
650
+ if (currentChecked !== null && currentChecked !== action.checked) {
651
+ await page.mouse.move(action.x, action.y, { steps: 1 });
652
+ await page.mouse.click(action.x, action.y);
653
+ }
654
+ return;
655
+ }
656
+ }
657
+
658
+ // Fallback to selector-based approach
659
+ if (runner === 'puppeteer') {
660
+ await page.waitForSelector(action.selector, { timeout: 1000 });
661
+ // Get element center coordinates for more accurate click
662
+ const coords = await page.evaluate((sel) => {
663
+ const el = document.querySelector(sel);
664
+ if (!el) return null;
665
+ const rect = el.getBoundingClientRect();
666
+ if (rect.width === 0 && rect.height === 0) return null;
667
+ return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
668
+ }, action.selector);
669
+
670
+ const currentChecked = await page.$eval(action.selector, (el) => el.checked);
671
+ if (currentChecked !== action.checked) {
672
+ if (coords) {
673
+ await page.mouse.move(coords.x, coords.y, { steps: 1 });
674
+ await page.mouse.click(coords.x, coords.y);
675
+ } else {
676
+ await page.click(action.selector);
677
+ }
678
+ }
679
+ } else {
680
+ const locator = page.locator(action.selector).first();
681
+ await locator.waitFor({ timeout: 1000, state: 'visible' });
682
+ // Get element center coordinates for more accurate click
683
+ const coords = await page.evaluate((sel) => {
684
+ const el = document.querySelector(sel);
685
+ if (!el) return null;
686
+ const rect = el.getBoundingClientRect();
687
+ if (rect.width === 0 && rect.height === 0) return null;
688
+ return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
689
+ }, action.selector);
690
+
691
+ if (coords) {
692
+ const currentChecked = await page.evaluate((sel) => {
693
+ const el = document.querySelector(sel);
694
+ return el ? el.checked : null;
695
+ }, action.selector);
696
+
697
+ if (currentChecked !== null && currentChecked !== action.checked) {
698
+ await page.mouse.move(coords.x, coords.y, { steps: 1 });
699
+ await page.mouse.click(coords.x, coords.y);
700
+ }
701
+ } else {
702
+ if (action.checked) {
703
+ await locator.check({ timeout: 1000 });
704
+ } else {
705
+ await locator.uncheck({ timeout: 1000 });
706
+ }
707
+ }
708
+ }
709
+ }
710
+
711
+ async function executeKeydown(page, action, runner) {
712
+ // Reconstruct key combo for Puppeteer/Playwright keyboard API
713
+ const modifiers = [];
714
+ if (action.combo) {
715
+ if (action.combo.includes('Ctrl+')) modifiers.push('Control');
716
+ if (action.combo.includes('Meta+')) modifiers.push('Meta');
717
+ if (action.combo.includes('Alt+')) modifiers.push('Alt');
718
+ if (action.combo.includes('Shift+')) modifiers.push('Shift');
719
+ }
720
+
721
+ // Press modifiers down
722
+ for (const mod of modifiers) {
723
+ await page.keyboard.down(mod);
724
+ }
725
+
726
+ await page.keyboard.press(action.key);
727
+
728
+ // Release modifiers
729
+ for (const mod of modifiers.reverse()) {
730
+ await page.keyboard.up(mod);
731
+ }
732
+ }
733
+
734
+ async function executeScroll(page, action) {
735
+ if (action.target === 'element' && action.selector) {
736
+ // Inner element scroll
737
+ await page.evaluate(({ sel, left, top }) => {
738
+ const el = document.querySelector(sel);
739
+ if (el) el.scrollTo(left, top);
740
+ }, { sel: action.selector, left: action.scrollLeft || 0, top: action.scrollTop || 0 });
741
+ await sleep(200);
742
+ } else if (action.scrollX != null && action.scrollY != null) {
743
+ // Window scroll
744
+ await page.evaluate(({ x, y }) => {
745
+ window.scrollTo(x, y);
746
+ }, { x: action.scrollX, y: action.scrollY });
747
+ await sleep(200);
748
+ }
749
+ }
750
+
751
+ async function executeSubmit(page, action, runner) {
752
+ // Try to submit the form by clicking a submit button or dispatching submit event
753
+ try {
754
+ if (runner === 'puppeteer') {
755
+ await page.evaluate((sel) => {
756
+ const form = document.querySelector(sel);
757
+ if (form) form.requestSubmit();
758
+ }, action.selector);
759
+ } else {
760
+ await page.evaluate((sel) => {
761
+ const form = document.querySelector(sel);
762
+ if (form) form.requestSubmit();
763
+ }, action.selector);
764
+ }
765
+ } catch {
766
+ // Try pressing Enter on the last focused element as fallback
767
+ await page.keyboard.press('Enter');
768
+ }
769
+ }
770
+
771
+ async function executeNavigation(page, action, runner) {
772
+ if (action.to) {
773
+ try {
774
+ if (runner === 'puppeteer') {
775
+ await page.goto(action.to, { waitUntil: 'domcontentloaded', timeout: 10000 });
776
+ } else {
777
+ await page.goto(action.to, { waitUntil: 'domcontentloaded', timeout: 10000 });
778
+ }
779
+ } catch {
780
+ console.log(`[sessionsnap] Navigation to ${action.to} timed out, continuing...`);
781
+ }
782
+ }
783
+ }
784
+
785
+ function describeAction(action) {
786
+ switch (action.type) {
787
+ case 'click':
788
+ return `${action.selector}${action.text ? ' "' + action.text.slice(0, 30) + '"' : ''}`;
789
+ case 'dblclick':
790
+ return `${action.selector}${action.text ? ' "' + action.text.slice(0, 30) + '"' : ''}`;
791
+ case 'hover':
792
+ return `${action.selector}${action.text ? ' "' + action.text.slice(0, 30) + '"' : ''} (${action.x ?? '?'}, ${action.y ?? '?'})`;
793
+ case 'mousemove':
794
+ return `(${action.x ?? '?'}, ${action.y ?? '?'})`;
795
+ case 'input':
796
+ return `${action.selector} → ${action.value === '***' ? '(password)' : '"' + action.value.slice(0, 30) + '"'}`;
797
+ case 'select':
798
+ return `${action.selector} → "${action.text || action.value}"`;
799
+ case 'checkbox':
800
+ return `${action.selector} ${action.checked ? '☑' : '☐'} (${action.inputType})`;
801
+ case 'keydown':
802
+ return `[${action.combo}] on ${action.selector}`;
803
+ case 'submit':
804
+ return `${action.selector} [${action.method}]`;
805
+ case 'scroll':
806
+ if (action.target === 'element') return `${action.selector} → (${action.scrollLeft}, ${action.scrollTop})`;
807
+ return `window → (${action.scrollX}, ${action.scrollY})`;
808
+ case 'file':
809
+ return `${action.selector} — skipped`;
810
+ case 'navigation':
811
+ return `${action.from} → ${action.to}`;
812
+ default:
813
+ return JSON.stringify(action);
814
+ }
815
+ }
816
+
817
+ /**
818
+ * Wait for the page to stabilize after an action:
819
+ * - Network requests to settle
820
+ * - DOM mutations to stop
821
+ * - CSS transitions/animations to complete
822
+ */
823
+ async function waitForStabilization(page, runner, actionType) {
824
+ try {
825
+ // Wait for network idle (short timeout to avoid blocking too long)
826
+ if (runner === 'puppeteer') {
827
+ await page.waitForNetworkIdle({ idleTime: 200, timeout: 1000 }).catch(() => {});
828
+ } else {
829
+ await page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => {});
830
+ }
831
+ } catch { /* ignore network idle timeout */ }
832
+
833
+ // Wait for DOM to stabilize (no mutations for 100ms)
834
+ try {
835
+ await page.evaluate(() => {
836
+ return new Promise((resolve) => {
837
+ let timeout;
838
+ const observer = new MutationObserver(() => {
839
+ clearTimeout(timeout);
840
+ timeout = setTimeout(resolve, 100);
841
+ });
842
+ observer.observe(document.body, {
843
+ childList: true,
844
+ subtree: true,
845
+ attributes: true,
846
+ attributeOldValue: false,
847
+ });
848
+ timeout = setTimeout(() => {
849
+ observer.disconnect();
850
+ resolve();
851
+ }, 100);
852
+ });
853
+ });
854
+ } catch { /* ignore DOM stabilization errors */ }
855
+
856
+ // Wait for CSS transitions/animations to complete
857
+ // Simple approach: wait a few animation frames to let transitions/animations settle
858
+ try {
859
+ await page.evaluate(() => {
860
+ return new Promise((resolve) => {
861
+ let framesWaited = 0;
862
+ const maxFrames = 3; // Wait up to 3 frames (~50ms at 60fps)
863
+ const checkFrame = () => {
864
+ framesWaited++;
865
+ if (framesWaited >= maxFrames) {
866
+ resolve();
867
+ } else {
868
+ requestAnimationFrame(checkFrame);
869
+ }
870
+ };
871
+ requestAnimationFrame(checkFrame);
872
+ // Max wait time (300ms) as fallback
873
+ setTimeout(resolve, 300);
874
+ });
875
+ });
876
+ } catch { /* ignore animation check errors */ }
877
+
878
+ // Extra wait for specific action types that typically trigger UI changes
879
+ if (['click', 'dblclick', 'hover', 'navigation', 'submit'].includes(actionType)) {
880
+ await sleep(100); // Additional 100ms for UI to respond
881
+ }
882
+ }
883
+
884
+ function sleep(ms) {
885
+ return new Promise((resolve) => setTimeout(resolve, ms));
886
+ }