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.
- package/README.md +325 -0
- package/bin/sessionsnap.js +333 -0
- package/package.json +29 -0
- package/src/core/heuristics.js +53 -0
- package/src/core/recorder.js +576 -0
- package/src/core/replayer.js +886 -0
- package/src/core/snapshot.js +58 -0
- package/src/playwright/capture.js +65 -0
- package/src/playwright/open.js +65 -0
- package/src/playwright/replay.js +43 -0
- package/src/puppeteer/capture.js +69 -0
- package/src/puppeteer/open.js +65 -0
- package/src/puppeteer/replay.js +44 -0
- package/src/store.js +123 -0
- package/test/cli.test.js +56 -0
- package/test/heuristics.test.js +105 -0
- package/test/snapshot.test.js +147 -0
- package/test/store.test.js +71 -0
|
@@ -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
|
+
}
|