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,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
|
+
}
|