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,53 @@
1
+ const LOGIN_URL_PATTERN = /login|signin|sign-in|auth/i;
2
+ const POLL_INTERVAL_MS = 2000;
3
+ const DEFAULT_TIMEOUT_MIN = 5;
4
+
5
+ function isLoginUrl(url) {
6
+ return LOGIN_URL_PATTERN.test(url);
7
+ }
8
+
9
+ export async function getCookieCount(page, runner) {
10
+ if (runner === 'puppeteer') {
11
+ const client = await page.createCDPSession();
12
+ const { cookies } = await client.send('Network.getAllCookies');
13
+ await client.detach();
14
+ return cookies.length;
15
+ }
16
+ // playwright
17
+ const cookies = await page.context().cookies();
18
+ return cookies.length;
19
+ }
20
+
21
+ export async function waitForLoginComplete(page, { runner, waitMinutes } = {}) {
22
+ const timeout = (waitMinutes ?? DEFAULT_TIMEOUT_MIN) * 60 * 1000;
23
+ const start = Date.now();
24
+ const initialCookieCount = await getCookieCount(page, runner);
25
+
26
+ return new Promise((resolve) => {
27
+ const timer = setInterval(async () => {
28
+ const elapsed = Date.now() - start;
29
+
30
+ // timeout reached — resolve anyway
31
+ if (elapsed >= timeout) {
32
+ clearInterval(timer);
33
+ console.log('[sessionsnap] Timeout reached, capturing current state...');
34
+ resolve(false);
35
+ return;
36
+ }
37
+
38
+ const currentUrl = page.url();
39
+ const urlClear = !isLoginUrl(currentUrl);
40
+
41
+ let cookieIncreased = false;
42
+ try {
43
+ const count = await getCookieCount(page, runner);
44
+ cookieIncreased = count > initialCookieCount;
45
+ } catch { /* page may have navigated */ }
46
+
47
+ if (urlClear || cookieIncreased) {
48
+ clearInterval(timer);
49
+ resolve(true);
50
+ }
51
+ }, POLL_INTERVAL_MS);
52
+ });
53
+ }
@@ -0,0 +1,576 @@
1
+ /**
2
+ * Action Recorder — injects a script into the page that captures user actions
3
+ * and sends them immediately to Node.js via page.exposeFunction.
4
+ *
5
+ * Uses css-selector-generator (https://github.com/fczbkk/css-selector-generator)
6
+ * for robust, unique CSS selector generation.
7
+ *
8
+ * CRITICAL: Actions are written to disk IMMEDIATELY using writeFileSync.
9
+ * This ensures no data loss even on Ctrl+C, SIGINT, or abrupt process exit.
10
+ */
11
+
12
+ import { writeFileSync, mkdirSync, readFileSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { createRequire } from 'node:module';
15
+
16
+ // Load the UMD bundle of css-selector-generator to inject into pages.
17
+ // createRequire uses the "require" condition in package.json exports,
18
+ // which resolves to the UMD build (build/index.js).
19
+ const require = createRequire(import.meta.url);
20
+ const cssSelectorGeneratorPath = require.resolve('css-selector-generator');
21
+ const CSS_SELECTOR_GENERATOR_LIB = readFileSync(cssSelectorGeneratorPath, 'utf-8');
22
+
23
+ // The script injected into every page document.
24
+ const INJECTED_SCRIPT = `
25
+ (function() {
26
+ if (window.__sessionsnap_recorder_active) return;
27
+ window.__sessionsnap_recorder_active = true;
28
+
29
+ // css-selector-generator is available as window.CssSelectorGenerator.getCssSelector
30
+
31
+ // Walk up from the raw target to the nearest interactive/identifiable element
32
+ var INTERACTIVE = ['A','BUTTON','INPUT','TEXTAREA','SELECT','LABEL','FORM','DETAILS','SUMMARY'];
33
+ function closestUseful(el) {
34
+ var cur = el;
35
+ for (var i = 0; i < 5 && cur && cur !== document.body; i++) {
36
+ if (INTERACTIVE.indexOf(cur.tagName) !== -1) return cur;
37
+ if (cur.id) return cur;
38
+ if (cur.getAttribute && (cur.getAttribute('data-testid') || cur.getAttribute('data-test') || cur.getAttribute('data-cy'))) return cur;
39
+ if (cur.getAttribute && cur.getAttribute('role')) return cur;
40
+ cur = cur.parentElement;
41
+ }
42
+ return el;
43
+ }
44
+
45
+ // CSS-in-JS blacklist patterns
46
+ var cssInJsBlacklist = [
47
+ /^\\.(css|sc|emotion|styled|tw|svelte)-/,
48
+ /^\\.[a-z]{1,3}[A-Z][a-zA-Z]{2,}$/,
49
+ /^\\.[a-f0-9]{5,}$/i,
50
+ /^\\._/,
51
+ ];
52
+
53
+ // Hover-state class blacklist — classes added dynamically on hover/focus/active
54
+ // that won't exist before the interaction, making selectors unreplayable.
55
+ var hoverStateBlacklist = [
56
+ // CSS-in-JS (same as above)
57
+ /^\\.(css|sc|emotion|styled|tw|svelte)-/,
58
+ /^\\.[a-z]{1,3}[A-Z][a-zA-Z]{2,}$/,
59
+ /^\\.[a-f0-9]{5,}$/i,
60
+ /^\\._/,
61
+ // State classes: *-open, *-active, *-hover, *-visible, *-show, etc.
62
+ /-(open|opened|active|hover|hovered|focus|focused|visible|show|shown|selected|highlighted|pressed|entered|expanded|collapsed)$/i,
63
+ // Ant Design state classes
64
+ /^\\.(ant|antd)-.*-(open|active|focused|selected|checked|disabled|loading)$/,
65
+ /^\\.ant-dropdown-open$/,
66
+ // Bootstrap state classes
67
+ /^\\.(show|open|active|hover|focus|collapse|collapsing|fade)$/,
68
+ // Material UI state classes
69
+ /^\\.Mui-(active|focused|selected|expanded|checked|disabled|error)/,
70
+ // Element Plus / Vuetify / general frameworks
71
+ /^\\.(is|el|v)-[a-z]+-?(open|active|focus|hover|visible|show|selected|expanded)/i,
72
+ // Generic state patterns
73
+ /^\\.(active|open|show|shown|visible|hover|focus|selected|expanded|collapsed|pressed|entered)$/i,
74
+ // aria-state driven class toggles (common pattern)
75
+ /^\\.\\[aria-/,
76
+ ];
77
+
78
+ var selectorWhitelist = [
79
+ /^#/, // IDs
80
+ /data-testid/, // test IDs
81
+ /data-test/,
82
+ /data-cy/,
83
+ /\\[name=/, // name attributes
84
+ /\\[aria-label=/, // accessibility
85
+ /\\[role=/,
86
+ /\\[href=/, // links
87
+ /\\[placeholder=/, // inputs
88
+ ];
89
+
90
+ function getSelector(el) {
91
+ if (!el || !el.tagName) return 'unknown';
92
+
93
+ // Walk up to a meaningful element
94
+ var useful = closestUseful(el);
95
+
96
+ try {
97
+ if (typeof CssSelectorGenerator !== 'undefined' && CssSelectorGenerator.getCssSelector) {
98
+ return CssSelectorGenerator.getCssSelector(useful, {
99
+ selectors: ['id', 'attribute', 'class', 'tag', 'nthoftype'],
100
+ blacklist: cssInJsBlacklist,
101
+ whitelist: selectorWhitelist,
102
+ combineWithinSelector: true,
103
+ combineBetweenSelectors: true,
104
+ includeTag: false,
105
+ maxCombinations: 50,
106
+ maxCandidates: 10,
107
+ });
108
+ }
109
+ } catch (e) {
110
+ // library failed — use basic fallback
111
+ }
112
+
113
+ // Fallback: tag name
114
+ return useful.tagName.toLowerCase();
115
+ }
116
+
117
+ // Hover-specific selector: excludes dynamically-added state classes
118
+ // so the selector stays valid BEFORE the hover interaction.
119
+ function getHoverSelector(el) {
120
+ if (!el || !el.tagName) return 'unknown';
121
+
122
+ try {
123
+ if (typeof CssSelectorGenerator !== 'undefined' && CssSelectorGenerator.getCssSelector) {
124
+ return CssSelectorGenerator.getCssSelector(el, {
125
+ selectors: ['id', 'attribute', 'class', 'tag', 'nthoftype'],
126
+ blacklist: hoverStateBlacklist,
127
+ whitelist: selectorWhitelist,
128
+ combineWithinSelector: true,
129
+ combineBetweenSelectors: true,
130
+ includeTag: false,
131
+ maxCombinations: 50,
132
+ maxCandidates: 10,
133
+ });
134
+ }
135
+ } catch (e) {
136
+ // library failed — use basic fallback
137
+ }
138
+
139
+ return el.tagName.toLowerCase();
140
+ }
141
+
142
+ function send(action) {
143
+ action.timestamp = Date.now();
144
+ action.url = location.href;
145
+ try {
146
+ if (typeof window.__sessionsnap_push === 'function') {
147
+ window.__sessionsnap_push(JSON.stringify(action));
148
+ }
149
+ } catch (e) {
150
+ // silently ignore if binding is not yet available
151
+ }
152
+ }
153
+
154
+ // --- Click ---
155
+ document.addEventListener('click', function(e) {
156
+ var el = e.target;
157
+ send({
158
+ type: 'click',
159
+ selector: getSelector(el),
160
+ tag: el.tagName.toLowerCase(),
161
+ text: (el.innerText || '').slice(0, 100).trim(),
162
+ x: e.clientX,
163
+ y: e.clientY,
164
+ });
165
+ }, true);
166
+
167
+ // --- Double click ---
168
+ document.addEventListener('dblclick', function(e) {
169
+ var el = e.target;
170
+ send({
171
+ type: 'dblclick',
172
+ selector: getSelector(el),
173
+ tag: el.tagName.toLowerCase(),
174
+ text: (el.innerText || '').slice(0, 100).trim(),
175
+ x: e.clientX,
176
+ y: e.clientY,
177
+ });
178
+ }, true);
179
+
180
+ // --- Hover (debounced 200ms, interactive/hoverable elements only) ---
181
+ // mouseenter does NOT bubble — use mouseover which bubbles up to document.
182
+ // Dedicated closestHoverable: walks further up (8 levels), considers
183
+ // nav items, menu triggers, roles, aria attributes — not just INTERACTIVE tags.
184
+ var HOVERABLE = ['A','BUTTON','INPUT','TEXTAREA','SELECT','LABEL','FORM','DETAILS','SUMMARY','LI','NAV','HEADER','FOOTER'];
185
+ var HOVER_ROLES = ['menuitem','tab','option','treeitem','listbox','menu','tablist','toolbar','tooltip'];
186
+ function closestHoverable(el) {
187
+ var cur = el;
188
+ for (var i = 0; i < 8 && cur && cur !== document.body; i++) {
189
+ if (HOVERABLE.indexOf(cur.tagName) !== -1) return cur;
190
+ if (cur.id) return cur;
191
+ if (cur.getAttribute) {
192
+ if (cur.getAttribute('data-testid') || cur.getAttribute('data-test') || cur.getAttribute('data-cy')) return cur;
193
+ var role = cur.getAttribute('role');
194
+ if (role && HOVER_ROLES.indexOf(role) !== -1) return cur;
195
+ if (cur.getAttribute('aria-haspopup') || cur.getAttribute('aria-expanded') !== null) return cur;
196
+ if (cur.getAttribute('data-toggle') || cur.getAttribute('data-bs-toggle')) return cur;
197
+ }
198
+ cur = cur.parentElement;
199
+ }
200
+ return el;
201
+ }
202
+
203
+ var hoverQueue = [];
204
+ var hoverFlushTimer = null;
205
+ var lastHoverEl = null;
206
+ var HOVER_DEBOUNCE = 150;
207
+ var HOVER_FLUSH_INTERVAL = 300;
208
+
209
+ function flushHoverQueue() {
210
+ if (hoverQueue.length === 0) return;
211
+ // Send all queued hovers (deduplicated by element reference)
212
+ var seen = [];
213
+ for (var i = 0; i < hoverQueue.length; i++) {
214
+ var item = hoverQueue[i];
215
+ if (seen.indexOf(item.el) === -1) {
216
+ seen.push(item.el);
217
+ send({
218
+ type: 'hover',
219
+ selector: getHoverSelector(item.el),
220
+ tag: item.el.tagName.toLowerCase(),
221
+ text: (item.el.innerText || '').slice(0, 100).trim(),
222
+ x: item.x,
223
+ y: item.y,
224
+ });
225
+ }
226
+ }
227
+ hoverQueue = [];
228
+ }
229
+
230
+ var hoverTimer = null;
231
+ document.addEventListener('mouseover', function(e) {
232
+ var el = e.target;
233
+ if (!el || !el.tagName) return;
234
+ var useful = closestHoverable(el);
235
+ // Only record if we found a meaningful parent or the element itself is hoverable
236
+ if (useful === el && HOVERABLE.indexOf(el.tagName) === -1 && !(el.getAttribute && el.getAttribute('role'))) return;
237
+ // Skip if same element as last hover (unless element was detached from DOM)
238
+ if (useful === lastHoverEl && useful.isConnected !== false) return;
239
+
240
+ // Record mouse's actual position for hover (e.clientX, e.clientY)
241
+ // This captures where the user actually moved the mouse
242
+ var x = Math.round(e.clientX);
243
+ var y = Math.round(e.clientY);
244
+
245
+ var coords = { el: useful, x: x, y: y };
246
+
247
+ // Debounce per transition: wait HOVER_DEBOUNCE ms, then queue
248
+ if (hoverTimer) clearTimeout(hoverTimer);
249
+ hoverTimer = setTimeout(function() {
250
+ lastHoverEl = useful;
251
+ hoverQueue.push(coords);
252
+ // Schedule a flush if not already pending
253
+ if (!hoverFlushTimer) {
254
+ hoverFlushTimer = setTimeout(function() {
255
+ hoverFlushTimer = null;
256
+ flushHoverQueue();
257
+ }, HOVER_FLUSH_INTERVAL);
258
+ }
259
+ }, HOVER_DEBOUNCE);
260
+ }, true);
261
+
262
+ // Also flush on click/navigation so queued hovers are not lost
263
+ document.addEventListener('click', function() {
264
+ if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; }
265
+ flushHoverQueue();
266
+ }, true);
267
+ window.addEventListener('beforeunload', function() {
268
+ flushHoverQueue();
269
+ });
270
+
271
+ // --- Mouse Movement (strict tracking for precise replay) ---
272
+ // Record all mouse movements with debouncing to capture precise path
273
+ var mouseMoveQueue = [];
274
+ var mouseMoveFlushTimer = null;
275
+ var lastMouseMoveTime = 0;
276
+ var MOUSE_MOVE_DEBOUNCE = 16; // ~60fps (16ms between moves)
277
+ var MOUSE_MOVE_FLUSH_INTERVAL = 100; // Flush queue every 100ms
278
+
279
+ function flushMouseMoveQueue() {
280
+ if (mouseMoveQueue.length === 0) return;
281
+ // Send all queued mouse movements
282
+ for (var i = 0; i < mouseMoveQueue.length; i++) {
283
+ var move = mouseMoveQueue[i];
284
+ send({
285
+ type: 'mousemove',
286
+ x: move.x,
287
+ y: move.y,
288
+ });
289
+ }
290
+ mouseMoveQueue = [];
291
+ }
292
+
293
+ var mouseMoveTimer = null;
294
+ document.addEventListener('mousemove', function(e) {
295
+ var now = Date.now();
296
+ // Throttle to ~60fps (16ms minimum between moves)
297
+ if (now - lastMouseMoveTime < MOUSE_MOVE_DEBOUNCE) return;
298
+ lastMouseMoveTime = now;
299
+
300
+ var x = Math.round(e.clientX);
301
+ var y = Math.round(e.clientY);
302
+
303
+ mouseMoveQueue.push({ x: x, y: y });
304
+
305
+ // Schedule flush if not already pending
306
+ if (!mouseMoveFlushTimer) {
307
+ mouseMoveFlushTimer = setTimeout(function() {
308
+ mouseMoveFlushTimer = null;
309
+ flushMouseMoveQueue();
310
+ }, MOUSE_MOVE_FLUSH_INTERVAL);
311
+ }
312
+ }, true);
313
+
314
+ // Flush mouse moves on click/navigation/unload
315
+ document.addEventListener('click', function() {
316
+ if (mouseMoveTimer) { clearTimeout(mouseMoveTimer); mouseMoveTimer = null; }
317
+ flushMouseMoveQueue();
318
+ }, true);
319
+ window.addEventListener('beforeunload', function() {
320
+ flushMouseMoveQueue();
321
+ });
322
+
323
+ // --- Input (debounced 500ms per element) ---
324
+ var inputTimers = new WeakMap();
325
+ document.addEventListener('input', function(e) {
326
+ var el = e.target;
327
+ if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT') {
328
+ if (inputTimers.has(el)) clearTimeout(inputTimers.get(el));
329
+ inputTimers.set(el, setTimeout(function() {
330
+ send({
331
+ type: 'input',
332
+ selector: getSelector(el),
333
+ tag: el.tagName.toLowerCase(),
334
+ inputType: el.type || '',
335
+ value: el.type === 'password' ? '***' : el.value,
336
+ });
337
+ }, 500));
338
+ }
339
+ }, true);
340
+
341
+ // --- Select / Checkbox / Radio change ---
342
+ document.addEventListener('change', function(e) {
343
+ var el = e.target;
344
+ if (el.tagName === 'SELECT') {
345
+ send({
346
+ type: 'select',
347
+ selector: getSelector(el),
348
+ value: el.value,
349
+ text: (el.options[el.selectedIndex] || {}).text || '',
350
+ });
351
+ } else if (el.tagName === 'INPUT' && (el.type === 'checkbox' || el.type === 'radio')) {
352
+ send({
353
+ type: 'checkbox',
354
+ selector: getSelector(el),
355
+ inputType: el.type,
356
+ checked: el.checked,
357
+ name: el.name || '',
358
+ value: el.value || '',
359
+ });
360
+ } else if (el.tagName === 'INPUT' && el.type === 'file') {
361
+ var names = [];
362
+ if (el.files) {
363
+ for (var i = 0; i < el.files.length; i++) names.push(el.files[i].name);
364
+ }
365
+ send({
366
+ type: 'file',
367
+ selector: getSelector(el),
368
+ fileNames: names,
369
+ fileCount: names.length,
370
+ });
371
+ }
372
+ }, true);
373
+
374
+ // --- Keydown (special keys & modifier combos only) ---
375
+ var SPECIAL_KEYS = ['Enter','Escape','Tab','Backspace','Delete','ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Home','End','PageUp','PageDown','F1','F2','F3','F4','F5','F6','F7','F8','F9','F10','F11','F12'];
376
+ document.addEventListener('keydown', function(e) {
377
+ var isSpecial = SPECIAL_KEYS.indexOf(e.key) !== -1;
378
+ var hasModifier = e.ctrlKey || e.metaKey || e.altKey;
379
+ if (!isSpecial && !hasModifier) return;
380
+ // Skip bare modifier key presses (Control, Meta, Alt, Shift alone)
381
+ if (['Control','Meta','Alt','Shift'].indexOf(e.key) !== -1) return;
382
+ var combo = '';
383
+ if (e.ctrlKey) combo += 'Ctrl+';
384
+ if (e.metaKey) combo += 'Meta+';
385
+ if (e.altKey) combo += 'Alt+';
386
+ if (e.shiftKey && !isSpecial) combo += 'Shift+';
387
+ combo += e.key;
388
+ var target = e.target;
389
+ send({
390
+ type: 'keydown',
391
+ key: e.key,
392
+ combo: combo,
393
+ selector: getSelector(target),
394
+ tag: target.tagName.toLowerCase(),
395
+ });
396
+ }, true);
397
+
398
+ // --- Form submit ---
399
+ document.addEventListener('submit', function(e) {
400
+ var form = e.target;
401
+ send({
402
+ type: 'submit',
403
+ selector: getSelector(form),
404
+ action: form.action || '',
405
+ method: form.method || 'get',
406
+ });
407
+ }, true);
408
+
409
+ // --- Scroll (debounced 400ms, supports both window and inner element scrolls) ---
410
+ // scroll events do NOT bubble, but capture phase on document catches them all.
411
+ var scrollTimers = new WeakMap();
412
+ var windowScrollTimer = null;
413
+ document.addEventListener('scroll', function(e) {
414
+ var target = e.target;
415
+
416
+ // Window / document-level scroll
417
+ if (target === document || target === document.documentElement) {
418
+ if (windowScrollTimer) clearTimeout(windowScrollTimer);
419
+ windowScrollTimer = setTimeout(function() {
420
+ send({
421
+ type: 'scroll',
422
+ target: 'window',
423
+ scrollX: Math.round(window.scrollX || window.pageXOffset || 0),
424
+ scrollY: Math.round(window.scrollY || window.pageYOffset || 0),
425
+ scrollHeight: document.documentElement.scrollHeight,
426
+ viewportHeight: window.innerHeight,
427
+ });
428
+ }, 400);
429
+ return;
430
+ }
431
+
432
+ // Inner element scroll — must be an element with overflow
433
+ if (target && target.nodeType === 1) {
434
+ if (scrollTimers.has(target)) clearTimeout(scrollTimers.get(target));
435
+ scrollTimers.set(target, setTimeout(function() {
436
+ send({
437
+ type: 'scroll',
438
+ target: 'element',
439
+ selector: getSelector(target),
440
+ tag: target.tagName.toLowerCase(),
441
+ scrollLeft: Math.round(target.scrollLeft),
442
+ scrollTop: Math.round(target.scrollTop),
443
+ scrollWidth: target.scrollWidth,
444
+ scrollHeight: target.scrollHeight,
445
+ clientWidth: target.clientWidth,
446
+ clientHeight: target.clientHeight,
447
+ });
448
+ }, 400));
449
+ }
450
+ }, true);
451
+
452
+ // --- SPA navigation tracking ---
453
+ var lastUrl = location.href;
454
+ function checkNav() {
455
+ if (location.href !== lastUrl) {
456
+ send({ type: 'navigation', from: lastUrl, to: location.href });
457
+ lastUrl = location.href;
458
+ }
459
+ }
460
+ var target = document.body || document.documentElement;
461
+ if (target) {
462
+ var obs = new MutationObserver(checkNav);
463
+ obs.observe(target, { childList: true, subtree: true });
464
+ }
465
+ window.addEventListener('popstate', checkNav);
466
+ window.addEventListener('hashchange', checkNav);
467
+
468
+ console.log('[sessionsnap] Action recorder active (12 action types).');
469
+ })();
470
+ `;
471
+
472
+ // Full script to inject: library + recorder
473
+ const FULL_INJECT_SCRIPT = CSS_SELECTOR_GENERATOR_LIB + ';\n' + INJECTED_SCRIPT;
474
+
475
+ export function getInjectedScript() {
476
+ return FULL_INJECT_SCRIPT;
477
+ }
478
+
479
+ function describeAction(action) {
480
+ switch (action.type) {
481
+ case 'click':
482
+ return `click ${action.selector}${action.text ? ' "' + action.text.slice(0, 40) + '"' : ''}`;
483
+ case 'dblclick':
484
+ return `dblclick ${action.selector}${action.text ? ' "' + action.text.slice(0, 40) + '"' : ''}`;
485
+ case 'hover':
486
+ return `hover ${action.selector}${action.text ? ' "' + action.text.slice(0, 40) + '"' : ''} (${action.x}, ${action.y})`;
487
+ case 'mousemove':
488
+ return `mousemove → (${action.x}, ${action.y})`;
489
+ case 'input':
490
+ return `input ${action.selector} → ${action.value === '***' ? '(password)' : '"' + String(action.value).slice(0, 30) + '"'}`;
491
+ case 'select':
492
+ return `select ${action.selector} → "${action.text || action.value}"`;
493
+ case 'checkbox':
494
+ return `checkbox ${action.selector} ${action.checked ? '☑' : '☐'} (${action.inputType})`;
495
+ case 'keydown':
496
+ return `keydown [${action.combo}] on ${action.selector}`;
497
+ case 'submit':
498
+ return `submit ${action.selector}`;
499
+ case 'scroll':
500
+ if (action.target === 'element') return `scroll ${action.selector} → (${action.scrollLeft}, ${action.scrollTop})`;
501
+ return `scroll window → (${action.scrollX}, ${action.scrollY})`;
502
+ case 'file':
503
+ return `file ${action.selector} → ${action.fileCount} file(s): ${(action.fileNames || []).join(', ')}`;
504
+ case 'navigation':
505
+ return `navigate → ${action.to}`;
506
+ default:
507
+ return `${action.type}`;
508
+ }
509
+ }
510
+
511
+ /**
512
+ * Set up action recording on a page.
513
+ *
514
+ * Actions are immediately:
515
+ * 1. Pushed to an in-memory array
516
+ * 2. Written to disk with writeFileSync (crash-safe)
517
+ * 3. Logged to the console in real-time
518
+ *
519
+ * @param {object} page - Puppeteer or Playwright page
520
+ * @param {'puppeteer'|'playwright'} runner
521
+ * @param {string} profileDir - Absolute path to the profile directory
522
+ * @param {string} [label='capture'] - Label for the actions file
523
+ * @returns {Promise<{ getActions: () => Array, filePath: string }>}
524
+ */
525
+ export async function setupRecorder(page, runner, profileDir, label = 'capture') {
526
+ const actions = [];
527
+
528
+ // Create the actions file path upfront
529
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
530
+ const filePath = join(profileDir, `actions-${label}-${ts}.json`);
531
+
532
+ // Ensure profile dir exists (sync for safety)
533
+ mkdirSync(profileDir, { recursive: true });
534
+
535
+ // Write initial empty file so it exists even if no actions are recorded
536
+ writeFileSync(filePath, '[]', 'utf-8');
537
+
538
+ // 1. Expose the push callback — survives navigations
539
+ await page.exposeFunction('__sessionsnap_push', (jsonStr) => {
540
+ try {
541
+ const action = JSON.parse(jsonStr);
542
+ actions.push(action);
543
+
544
+ // IMMEDIATELY write to disk — survives any crash/SIGINT/kill
545
+ try {
546
+ writeFileSync(filePath, JSON.stringify(actions, null, 2), 'utf-8');
547
+ } catch { /* disk write failed — at least we have in-memory */ }
548
+
549
+ // Real-time log
550
+ const desc = describeAction(action);
551
+ console.log(`[sessionsnap] 🔴 REC [${actions.length}] ${desc}`);
552
+ } catch { /* malformed JSON — skip */ }
553
+ });
554
+
555
+ // 2. Register script for all future new-document events
556
+ if (runner === 'puppeteer') {
557
+ await page.evaluateOnNewDocument(FULL_INJECT_SCRIPT);
558
+ } else {
559
+ await page.context().addInitScript(FULL_INJECT_SCRIPT);
560
+ }
561
+
562
+ // 3. Inject into the current document right now
563
+ await page.evaluate(FULL_INJECT_SCRIPT).catch(() => {});
564
+
565
+ console.log(`[sessionsnap] Action recorder enabled. File: ${filePath}`);
566
+
567
+ return {
568
+ getActions() {
569
+ return actions;
570
+ },
571
+ get count() {
572
+ return actions.length;
573
+ },
574
+ filePath,
575
+ };
576
+ }