screenci 0.0.4 → 0.0.5
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 +227 -0
- package/cli.ts +1111 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +896 -0
- package/dist/cli.js.map +1 -0
- package/dist/e2e/instrument.e2e.d.ts +2 -0
- package/dist/e2e/instrument.e2e.d.ts.map +1 -0
- package/dist/e2e/instrument.e2e.js +661 -0
- package/dist/e2e/instrument.e2e.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/playwright.config.d.ts +3 -0
- package/dist/playwright.config.d.ts.map +1 -0
- package/dist/playwright.config.js +21 -0
- package/dist/playwright.config.js.map +1 -0
- package/dist/reporter.d.ts +9 -0
- package/dist/reporter.d.ts.map +1 -0
- package/dist/reporter.js +49 -0
- package/dist/reporter.js.map +1 -0
- package/dist/src/asset.d.ts +90 -0
- package/dist/src/asset.d.ts.map +1 -0
- package/dist/src/asset.js +74 -0
- package/dist/src/asset.js.map +1 -0
- package/dist/src/autoZoom.d.ts +40 -0
- package/dist/src/autoZoom.d.ts.map +1 -0
- package/dist/src/autoZoom.js +88 -0
- package/dist/src/autoZoom.js.map +1 -0
- package/dist/src/caption.d.ts +152 -0
- package/dist/src/caption.d.ts.map +1 -0
- package/dist/src/caption.js +240 -0
- package/dist/src/caption.js.map +1 -0
- package/dist/src/caption.test-d.d.ts +2 -0
- package/dist/src/caption.test-d.d.ts.map +1 -0
- package/dist/src/caption.test-d.js +50 -0
- package/dist/src/caption.test-d.js.map +1 -0
- package/dist/src/config.d.ts +42 -0
- package/dist/src/config.d.ts.map +1 -0
- package/dist/src/config.js +147 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/defaults.d.ts +63 -0
- package/dist/src/defaults.d.ts.map +1 -0
- package/dist/src/defaults.js +66 -0
- package/dist/src/defaults.js.map +1 -0
- package/dist/src/dimensions.d.ts +29 -0
- package/dist/src/dimensions.d.ts.map +1 -0
- package/dist/src/dimensions.js +47 -0
- package/dist/src/dimensions.js.map +1 -0
- package/dist/src/events.d.ts +203 -0
- package/dist/src/events.d.ts.map +1 -0
- package/dist/src/events.js +227 -0
- package/dist/src/events.js.map +1 -0
- package/dist/src/hide.d.ts +27 -0
- package/dist/src/hide.d.ts.map +1 -0
- package/dist/src/hide.js +49 -0
- package/dist/src/hide.js.map +1 -0
- package/dist/src/instrument.d.ts +15 -0
- package/dist/src/instrument.d.ts.map +1 -0
- package/dist/src/instrument.js +910 -0
- package/dist/src/instrument.js.map +1 -0
- package/dist/src/logger.d.ts +7 -0
- package/dist/src/logger.d.ts.map +1 -0
- package/dist/src/logger.js +13 -0
- package/dist/src/logger.js.map +1 -0
- package/dist/src/reporter.d.ts +9 -0
- package/dist/src/reporter.d.ts.map +1 -0
- package/dist/src/reporter.js +50 -0
- package/dist/src/reporter.js.map +1 -0
- package/dist/src/sanitize.d.ts +5 -0
- package/dist/src/sanitize.d.ts.map +1 -0
- package/dist/src/sanitize.js +11 -0
- package/dist/src/sanitize.js.map +1 -0
- package/dist/src/types.d.ts +544 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +2 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/video.d.ts +138 -0
- package/dist/src/video.d.ts.map +1 -0
- package/dist/src/video.js +415 -0
- package/dist/src/video.js.map +1 -0
- package/dist/src/voices.d.ts +60 -0
- package/dist/src/voices.d.ts.map +1 -0
- package/dist/src/voices.js +42 -0
- package/dist/src/voices.js.map +1 -0
- package/dist/src/xvfb.d.ts +22 -0
- package/dist/src/xvfb.d.ts.map +1 -0
- package/dist/src/xvfb.js +87 -0
- package/dist/src/xvfb.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +45 -4
- package/bin/index.js +0 -3
- package/index.js +0 -1
|
@@ -0,0 +1,910 @@
|
|
|
1
|
+
import { logger } from './logger.js';
|
|
2
|
+
import { isInsideAutoZoom, getZoomDuration, getPostZoomInOutDelay, getLastZoomLocation, setLastZoomLocation, } from './autoZoom.js';
|
|
3
|
+
let activeClickRecorder = null;
|
|
4
|
+
export function setActiveClickRecorder(recorder) {
|
|
5
|
+
activeClickRecorder = recorder;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Evaluate a polynomial easing function at normalized time t ∈ [0, 1].
|
|
9
|
+
* Returns the eased progress value (0–1).
|
|
10
|
+
*/
|
|
11
|
+
function evaluateEasingAtT(t, easing) {
|
|
12
|
+
if (t <= 0)
|
|
13
|
+
return 0;
|
|
14
|
+
if (t >= 1)
|
|
15
|
+
return 1;
|
|
16
|
+
switch (easing) {
|
|
17
|
+
case 'linear':
|
|
18
|
+
return t;
|
|
19
|
+
case 'ease-in':
|
|
20
|
+
return t * t * t;
|
|
21
|
+
case 'ease-out':
|
|
22
|
+
return 1 - (1 - t) * (1 - t) * (1 - t);
|
|
23
|
+
case 'ease-in-out':
|
|
24
|
+
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
25
|
+
case 'ease-in-strong':
|
|
26
|
+
return t * t * t * t;
|
|
27
|
+
case 'ease-out-strong':
|
|
28
|
+
return 1 - (1 - t) * (1 - t) * (1 - t) * (1 - t);
|
|
29
|
+
case 'ease-in-out-strong':
|
|
30
|
+
return t < 0.5 ? 8 * t * t * t * t : 1 - Math.pow(-2 * t + 2, 4) / 2;
|
|
31
|
+
default: {
|
|
32
|
+
const _ = easing;
|
|
33
|
+
throw new Error(`Unknown easing: ${_}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Tracked cursor position per page, updated after each animated move. */
|
|
38
|
+
const mousePositions = new WeakMap();
|
|
39
|
+
/** Tracks cursor visibility per page (true = visible). Defaults to true. */
|
|
40
|
+
const mouseVisibilities = new WeakMap();
|
|
41
|
+
/** Stores the original (un-instrumented) mouse.move per page so internal
|
|
42
|
+
* cursor animations don't emit addInput recorder events. */
|
|
43
|
+
const originalMouseMoves = new WeakMap();
|
|
44
|
+
const instrumented = new WeakSet();
|
|
45
|
+
function sleep(ms) {
|
|
46
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
47
|
+
}
|
|
48
|
+
const CLICK_DURATION_MS = 200;
|
|
49
|
+
const LOCATOR_RETURN_METHODS = [
|
|
50
|
+
'locator',
|
|
51
|
+
'getByAltText',
|
|
52
|
+
'getByLabel',
|
|
53
|
+
'getByPlaceholder',
|
|
54
|
+
'getByRole',
|
|
55
|
+
'getByTestId',
|
|
56
|
+
'getByText',
|
|
57
|
+
'getByTitle',
|
|
58
|
+
];
|
|
59
|
+
const LOCATOR_ONLY_SYNC_RETURN_METHODS = [
|
|
60
|
+
'and',
|
|
61
|
+
'describe',
|
|
62
|
+
'filter',
|
|
63
|
+
'first',
|
|
64
|
+
'last',
|
|
65
|
+
'nth',
|
|
66
|
+
'or',
|
|
67
|
+
];
|
|
68
|
+
const FRAME_LOCATOR_LOCATOR_RETURN_METHODS = [
|
|
69
|
+
'locator',
|
|
70
|
+
'getByAltText',
|
|
71
|
+
'getByLabel',
|
|
72
|
+
'getByPlaceholder',
|
|
73
|
+
'getByRole',
|
|
74
|
+
'getByTestId',
|
|
75
|
+
'getByText',
|
|
76
|
+
'getByTitle',
|
|
77
|
+
'owner',
|
|
78
|
+
];
|
|
79
|
+
const FRAME_LOCATOR_SELF_RETURN_METHODS = [
|
|
80
|
+
'frameLocator',
|
|
81
|
+
'first',
|
|
82
|
+
'last',
|
|
83
|
+
'nth',
|
|
84
|
+
];
|
|
85
|
+
// Per-page storage for the most recently captured DOM click event data.
|
|
86
|
+
// Reset to null before each instrumented click; set by the exposeFunction callback.
|
|
87
|
+
const pendingClickData = new WeakMap();
|
|
88
|
+
export function scrollIntoViewAsync(locator, options = {}) {
|
|
89
|
+
const { behavior = 'smooth', block = 'center', timeout = 5000, postScrollTimeout = 500, } = options;
|
|
90
|
+
return locator.evaluate((element, opts) => new Promise((resolve) => {
|
|
91
|
+
let settled = false;
|
|
92
|
+
const finish = () => {
|
|
93
|
+
if (settled)
|
|
94
|
+
return;
|
|
95
|
+
settled = true;
|
|
96
|
+
clearTimeout(fallback);
|
|
97
|
+
setTimeout(resolve, opts.postScrollTimeout);
|
|
98
|
+
};
|
|
99
|
+
const fallback = setTimeout(finish, opts.timeout);
|
|
100
|
+
// scrollend fires once the smooth-scroll animation completes (Chrome 114+).
|
|
101
|
+
// It captures scroll on any ancestor element via the capture phase.
|
|
102
|
+
window.addEventListener('scrollend', finish, {
|
|
103
|
+
capture: true,
|
|
104
|
+
once: true,
|
|
105
|
+
});
|
|
106
|
+
element.scrollIntoView({ behavior: opts.behavior, block: opts.block });
|
|
107
|
+
// If the element is already in the target position no scroll occurs and
|
|
108
|
+
// scrollend never fires. Detect this: if no scroll event appears within
|
|
109
|
+
// two animation frames the element was already in place.
|
|
110
|
+
let didScroll = false;
|
|
111
|
+
window.addEventListener('scroll', () => {
|
|
112
|
+
didScroll = true;
|
|
113
|
+
}, {
|
|
114
|
+
capture: true,
|
|
115
|
+
passive: true,
|
|
116
|
+
once: true,
|
|
117
|
+
});
|
|
118
|
+
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
119
|
+
if (!didScroll)
|
|
120
|
+
finish();
|
|
121
|
+
}));
|
|
122
|
+
}), { behavior, block, timeout, postScrollTimeout });
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Scrolls the locator into view only if it is at least partially outside the
|
|
126
|
+
* viewport, then returns the (possibly updated) bounding box as an ElementRect.
|
|
127
|
+
* Returns undefined if the bounding box cannot be determined.
|
|
128
|
+
*/
|
|
129
|
+
async function scrollIntoViewIfNeeded(locator) {
|
|
130
|
+
const page = locator.page();
|
|
131
|
+
const bb = await locator.boundingBox();
|
|
132
|
+
if (!bb)
|
|
133
|
+
return undefined;
|
|
134
|
+
const viewportSize = page.viewportSize();
|
|
135
|
+
if (viewportSize) {
|
|
136
|
+
const isFullyInViewport = bb.x >= 0 &&
|
|
137
|
+
bb.y >= 0 &&
|
|
138
|
+
bb.x + bb.width <= viewportSize.width &&
|
|
139
|
+
bb.y + bb.height <= viewportSize.height;
|
|
140
|
+
if (!isFullyInViewport) {
|
|
141
|
+
await scrollIntoViewAsync(locator);
|
|
142
|
+
const newBb = await locator.boundingBox();
|
|
143
|
+
if (newBb)
|
|
144
|
+
return {
|
|
145
|
+
x: newBb.x,
|
|
146
|
+
y: newBb.y,
|
|
147
|
+
width: newBb.width,
|
|
148
|
+
height: newBb.height,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
logger.warn('[screenci] Unable to determine viewport size; skipping auto-scroll check.');
|
|
154
|
+
}
|
|
155
|
+
return { x: bb.x, y: bb.y, width: bb.width, height: bb.height };
|
|
156
|
+
}
|
|
157
|
+
const CURSOR_STEP_MS = 1000 / 60;
|
|
158
|
+
/**
|
|
159
|
+
* Physically moves the mouse from its current tracked position to (targetX,
|
|
160
|
+
* targetY) over `duration` ms using `easing`, then returns a MouseMoveEvent
|
|
161
|
+
* whose startMs is `eventStartMs` (which may predate this call, e.g. when a
|
|
162
|
+
* scroll consumed part of moveDuration). When duration ≤ 0 the cursor is
|
|
163
|
+
* snapped directly to the target with a single move call.
|
|
164
|
+
* Always updates the internal mousePositions tracker.
|
|
165
|
+
*/
|
|
166
|
+
async function animateMouseMove(page, mouseMoveInternal, targetX, targetY, duration, easing, eventStartMs, elementRect) {
|
|
167
|
+
if (duration > 0) {
|
|
168
|
+
const startPos = mousePositions.get(page) ?? { x: 0, y: 0 };
|
|
169
|
+
const steps = Math.max(1, Math.floor(duration / CURSOR_STEP_MS));
|
|
170
|
+
const stepMs = duration / steps;
|
|
171
|
+
for (let i = 0; i <= steps; i++) {
|
|
172
|
+
const t = i / steps;
|
|
173
|
+
const easedT = evaluateEasingAtT(t, easing);
|
|
174
|
+
const x = startPos.x + easedT * (targetX - startPos.x);
|
|
175
|
+
const y = startPos.y + easedT * (targetY - startPos.y);
|
|
176
|
+
await mouseMoveInternal(x, y);
|
|
177
|
+
if (i < steps) {
|
|
178
|
+
await new Promise((resolve) => setTimeout(resolve, stepMs));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
await mouseMoveInternal(targetX, targetY);
|
|
184
|
+
}
|
|
185
|
+
mousePositions.set(page, { x: targetX, y: targetY });
|
|
186
|
+
return {
|
|
187
|
+
type: 'mouseMove',
|
|
188
|
+
startMs: eventStartMs,
|
|
189
|
+
endMs: Date.now(),
|
|
190
|
+
x: targetX,
|
|
191
|
+
y: targetY,
|
|
192
|
+
easing,
|
|
193
|
+
...(elementRect !== undefined ? { elementRect } : {}),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Performs all click mechanics (scroll-check, zoom handling, cursor animation,
|
|
198
|
+
* click, post-click move) and returns the collected timing/position data.
|
|
199
|
+
* Returns null if coordinates could not be determined (no DOM event and no
|
|
200
|
+
* locator bounding box).
|
|
201
|
+
*/
|
|
202
|
+
async function performClickActions(locator, doClick, clickOptions, position, moveDuration = 1000, beforeClickPause = CLICK_DURATION_MS / 2, moveEasing = 'ease-in-out', postClickPause = CLICK_DURATION_MS / 2, postClickMove) {
|
|
203
|
+
const page = locator.page();
|
|
204
|
+
pendingClickData.set(page, null);
|
|
205
|
+
const halfClickDuration = CLICK_DURATION_MS / 2;
|
|
206
|
+
const mouseMoveInternal = originalMouseMoves.get(page) ?? page.mouse.move.bind(page.mouse);
|
|
207
|
+
// Capture before any setLastZoomLocation call changes the state.
|
|
208
|
+
const isFirstAutoZoomEvent = isInsideAutoZoom() && getLastZoomLocation() === null;
|
|
209
|
+
const moveStartTime = Date.now();
|
|
210
|
+
const locatorRect = await scrollIntoViewIfNeeded(locator);
|
|
211
|
+
const scrollElapsedMs = Date.now() - moveStartTime;
|
|
212
|
+
if (!locatorRect) {
|
|
213
|
+
logger.warn('[screenci] Unable to get locator bounding box; skipping auto-scroll check.');
|
|
214
|
+
}
|
|
215
|
+
const innerEvents = [];
|
|
216
|
+
// If inside autoZoom: optionally await zoom duration for camera pan then
|
|
217
|
+
// update the zoom location tracker.
|
|
218
|
+
if (isInsideAutoZoom() && locatorRect) {
|
|
219
|
+
const targetX = position
|
|
220
|
+
? locatorRect.x + position.x
|
|
221
|
+
: locatorRect.x + locatorRect.width / 2;
|
|
222
|
+
const targetY = position
|
|
223
|
+
? locatorRect.y + position.y
|
|
224
|
+
: locatorRect.y + locatorRect.height / 2;
|
|
225
|
+
const lastLoc = getLastZoomLocation();
|
|
226
|
+
if (lastLoc !== null) {
|
|
227
|
+
const zoomDur = getZoomDuration() ?? 0;
|
|
228
|
+
if (zoomDur > 0) {
|
|
229
|
+
await sleep(zoomDur);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
setLastZoomLocation({
|
|
233
|
+
x: targetX,
|
|
234
|
+
y: targetY,
|
|
235
|
+
elementRect: locatorRect,
|
|
236
|
+
eventType: 'click',
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
const targetPos = position
|
|
240
|
+
? position
|
|
241
|
+
: locatorRect
|
|
242
|
+
? {
|
|
243
|
+
x: locatorRect.width / 2,
|
|
244
|
+
y: locatorRect.height / 2,
|
|
245
|
+
}
|
|
246
|
+
: undefined;
|
|
247
|
+
// No physical mouse movement happens during the scroll. moveStartTime is
|
|
248
|
+
// captured before the scroll so the recorded event spans scroll-start →
|
|
249
|
+
// animation-end, making the cursor appear to move during the scroll in the
|
|
250
|
+
// video. After the scroll, the remaining duration drives the physical easing
|
|
251
|
+
// animation via the shared helper.
|
|
252
|
+
if (targetPos && locatorRect) {
|
|
253
|
+
const targetX = locatorRect.x + targetPos.x;
|
|
254
|
+
const targetY = locatorRect.y + targetPos.y;
|
|
255
|
+
const effectiveDuration = Math.max(0, moveDuration - scrollElapsedMs);
|
|
256
|
+
innerEvents.push(await animateMouseMove(page, mouseMoveInternal, targetX, targetY, effectiveDuration, moveEasing, moveStartTime, locatorRect));
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
const remainingMs = Math.max(0, moveDuration - scrollElapsedMs);
|
|
260
|
+
if (remainingMs > 0) {
|
|
261
|
+
await new Promise((resolve) => setTimeout(resolve, remainingMs));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const effectiveBeforeClickPause = isFirstAutoZoomEvent
|
|
265
|
+
? Math.max(beforeClickPause, getPostZoomInOutDelay() ?? 0)
|
|
266
|
+
: beforeClickPause;
|
|
267
|
+
await new Promise((resolve) => setTimeout(resolve, effectiveBeforeClickPause));
|
|
268
|
+
await new Promise((resolve) => setTimeout(resolve, halfClickDuration));
|
|
269
|
+
// Note click can take some time, but better to show it before than after
|
|
270
|
+
const clickTime = Date.now();
|
|
271
|
+
const mouseDownStart = clickTime - halfClickDuration;
|
|
272
|
+
innerEvents.push({
|
|
273
|
+
type: 'mouseDown',
|
|
274
|
+
startMs: mouseDownStart,
|
|
275
|
+
endMs: clickTime,
|
|
276
|
+
easing: 'ease-in-out',
|
|
277
|
+
});
|
|
278
|
+
await doClick({
|
|
279
|
+
...clickOptions,
|
|
280
|
+
...(targetPos ? { position: targetPos } : {}),
|
|
281
|
+
});
|
|
282
|
+
const domClickData = pendingClickData.get(page);
|
|
283
|
+
const mouseUpEnd = Date.now() + halfClickDuration;
|
|
284
|
+
innerEvents.push({
|
|
285
|
+
type: 'mouseUp',
|
|
286
|
+
startMs: clickTime,
|
|
287
|
+
endMs: mouseUpEnd,
|
|
288
|
+
easing: 'ease-in-out',
|
|
289
|
+
});
|
|
290
|
+
await new Promise((resolve) => setTimeout(resolve, halfClickDuration));
|
|
291
|
+
await new Promise((resolve) => setTimeout(resolve, postClickPause));
|
|
292
|
+
// Animate mouse cursor in the specified direction after the click completes,
|
|
293
|
+
// capturing start/end times and final position for the recorded event.
|
|
294
|
+
if (postClickMove !== undefined) {
|
|
295
|
+
const currentPos = mousePositions.get(page) ?? { x: 0, y: 0 };
|
|
296
|
+
let targetX;
|
|
297
|
+
let targetY;
|
|
298
|
+
if ('direction' in postClickMove) {
|
|
299
|
+
if (locatorRect === undefined) {
|
|
300
|
+
logger.warn('[screenci] postClickMove with direction requires a locator rect; skipping mouse move.');
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
const padding = postClickMove.padding ?? 0;
|
|
304
|
+
switch (postClickMove.direction) {
|
|
305
|
+
case 'up':
|
|
306
|
+
targetX = currentPos.x;
|
|
307
|
+
targetY = locatorRect.y - padding;
|
|
308
|
+
break;
|
|
309
|
+
case 'down':
|
|
310
|
+
targetX = currentPos.x;
|
|
311
|
+
targetY = locatorRect.y + locatorRect.height + padding;
|
|
312
|
+
break;
|
|
313
|
+
case 'left':
|
|
314
|
+
targetX = locatorRect.x - padding;
|
|
315
|
+
targetY = currentPos.y;
|
|
316
|
+
break;
|
|
317
|
+
case 'right':
|
|
318
|
+
targetX = locatorRect.x + locatorRect.width + padding;
|
|
319
|
+
targetY = currentPos.y;
|
|
320
|
+
break;
|
|
321
|
+
default: {
|
|
322
|
+
const _ = postClickMove.direction;
|
|
323
|
+
throw new Error(`Unknown postClickMove direction: ${_}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
targetX = currentPos.x + postClickMove.x;
|
|
330
|
+
targetY = currentPos.y + postClickMove.y;
|
|
331
|
+
}
|
|
332
|
+
if (targetX !== undefined && targetY !== undefined) {
|
|
333
|
+
const easing = postClickMove.easing ?? 'ease-in-out';
|
|
334
|
+
const { duration } = postClickMove;
|
|
335
|
+
const steps = Math.max(1, Math.floor(duration / CURSOR_STEP_MS));
|
|
336
|
+
const stepMs = duration / steps;
|
|
337
|
+
const postClickMoveStartMs = Date.now();
|
|
338
|
+
const startPos = { ...currentPos };
|
|
339
|
+
for (let i = 0; i <= steps; i++) {
|
|
340
|
+
const t = i / steps;
|
|
341
|
+
const easedT = evaluateEasingAtT(t, easing);
|
|
342
|
+
const x = startPos.x + easedT * (targetX - startPos.x);
|
|
343
|
+
const y = startPos.y + easedT * (targetY - startPos.y);
|
|
344
|
+
await mouseMoveInternal(x, y);
|
|
345
|
+
if (i < steps) {
|
|
346
|
+
await new Promise((resolve) => setTimeout(resolve, stepMs));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
innerEvents.push({
|
|
350
|
+
type: 'mouseMove',
|
|
351
|
+
startMs: postClickMoveStartMs,
|
|
352
|
+
endMs: Date.now(),
|
|
353
|
+
x: targetX,
|
|
354
|
+
y: targetY,
|
|
355
|
+
easing,
|
|
356
|
+
zoomFollow: false,
|
|
357
|
+
});
|
|
358
|
+
mousePositions.set(page, { x: targetX, y: targetY });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
let elementRect;
|
|
362
|
+
if (locatorRect) {
|
|
363
|
+
elementRect = locatorRect;
|
|
364
|
+
}
|
|
365
|
+
else if (domClickData) {
|
|
366
|
+
console.warn('[screenci] using DOM click data as fallback for elementRect');
|
|
367
|
+
elementRect = domClickData.targetRect;
|
|
368
|
+
}
|
|
369
|
+
if (elementRect) {
|
|
370
|
+
return {
|
|
371
|
+
elementRect,
|
|
372
|
+
innerEvents,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
logger.warn('[screenci] Failed to capture click coordinates from both DOM event and locator bounding box.');
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
async function performSimpleAction(locator, doAction, options, subType, clickOpt) {
|
|
381
|
+
let innerEvents = [];
|
|
382
|
+
let elementRect;
|
|
383
|
+
if (clickOpt !== undefined) {
|
|
384
|
+
const { moveDuration, beforeClickPause, moveEasing, postClickPause, postClickMove, position, } = clickOpt;
|
|
385
|
+
const clickActionResult = await performClickActions(locator, doAction, {}, position, moveDuration, beforeClickPause, moveEasing, postClickPause, postClickMove);
|
|
386
|
+
innerEvents = clickActionResult?.innerEvents ?? [];
|
|
387
|
+
elementRect = clickActionResult?.elementRect;
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
const isFirstAutoZoomEvent = isInsideAutoZoom() && getLastZoomLocation() === null;
|
|
391
|
+
const locatorRect = await scrollIntoViewIfNeeded(locator);
|
|
392
|
+
if (isInsideAutoZoom() && locatorRect) {
|
|
393
|
+
const targetX = locatorRect.x + locatorRect.width / 2;
|
|
394
|
+
const targetY = locatorRect.y + locatorRect.height / 2;
|
|
395
|
+
const lastLoc = getLastZoomLocation();
|
|
396
|
+
if (lastLoc !== null) {
|
|
397
|
+
const zoomDur = getZoomDuration() ?? 0;
|
|
398
|
+
if (zoomDur > 0) {
|
|
399
|
+
await sleep(zoomDur);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
setLastZoomLocation({
|
|
403
|
+
x: targetX,
|
|
404
|
+
y: targetY,
|
|
405
|
+
elementRect: locatorRect,
|
|
406
|
+
eventType: 'fill',
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
if (isFirstAutoZoomEvent) {
|
|
410
|
+
const postDelay = getPostZoomInOutDelay() ?? 0;
|
|
411
|
+
if (postDelay > 0)
|
|
412
|
+
await sleep(postDelay);
|
|
413
|
+
}
|
|
414
|
+
const targetPosition = locatorRect
|
|
415
|
+
? {
|
|
416
|
+
x: locatorRect.width / 2,
|
|
417
|
+
y: locatorRect.height / 2,
|
|
418
|
+
}
|
|
419
|
+
: undefined;
|
|
420
|
+
const startTime = Date.now();
|
|
421
|
+
await doAction({
|
|
422
|
+
...options,
|
|
423
|
+
...(targetPosition ? { position: targetPosition } : {}),
|
|
424
|
+
});
|
|
425
|
+
const endTime = Date.now();
|
|
426
|
+
const midTime = (startTime + endTime) / 2;
|
|
427
|
+
innerEvents.push({
|
|
428
|
+
type: 'mouseDown',
|
|
429
|
+
startMs: startTime,
|
|
430
|
+
endMs: midTime,
|
|
431
|
+
});
|
|
432
|
+
innerEvents.push({
|
|
433
|
+
type: 'mouseUp',
|
|
434
|
+
startMs: midTime,
|
|
435
|
+
endMs: endTime,
|
|
436
|
+
});
|
|
437
|
+
elementRect = locatorRect;
|
|
438
|
+
}
|
|
439
|
+
if (activeClickRecorder && innerEvents.length > 0) {
|
|
440
|
+
activeClickRecorder.addInput(subType, elementRect, innerEvents);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
async function recordedClick(locator, doClick, clickOptions, position, moveDuration = 1000, beforeClickPause = CLICK_DURATION_MS / 2, moveEasing = 'ease-in-out', postClickPause = CLICK_DURATION_MS / 2, postClickMove) {
|
|
444
|
+
const result = await performClickActions(locator, doClick, clickOptions, position, moveDuration, beforeClickPause, moveEasing, postClickPause, postClickMove);
|
|
445
|
+
if (activeClickRecorder && result) {
|
|
446
|
+
activeClickRecorder.addInput('click', result.elementRect, result.innerEvents);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
function instrumentLocatorMethods(obj) {
|
|
450
|
+
for (const method of LOCATOR_RETURN_METHODS) {
|
|
451
|
+
const original = obj[method].bind(obj);
|
|
452
|
+
obj[method] = (...args) => instrumentLocator(original(...args));
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
export function instrumentFrameLocator(frameLocator) {
|
|
456
|
+
if (instrumented.has(frameLocator))
|
|
457
|
+
return frameLocator;
|
|
458
|
+
instrumented.add(frameLocator);
|
|
459
|
+
for (const method of FRAME_LOCATOR_LOCATOR_RETURN_METHODS) {
|
|
460
|
+
const original = frameLocator[method].bind(frameLocator);
|
|
461
|
+
frameLocator[method] = (...args) => instrumentLocator(original(...args));
|
|
462
|
+
}
|
|
463
|
+
for (const method of FRAME_LOCATOR_SELF_RETURN_METHODS) {
|
|
464
|
+
const original = frameLocator[method].bind(frameLocator);
|
|
465
|
+
frameLocator[method] =
|
|
466
|
+
(...args) => instrumentFrameLocator(original(...args));
|
|
467
|
+
}
|
|
468
|
+
return frameLocator;
|
|
469
|
+
}
|
|
470
|
+
export function instrumentLocator(locator) {
|
|
471
|
+
if (instrumented.has(locator))
|
|
472
|
+
return locator;
|
|
473
|
+
instrumented.add(locator);
|
|
474
|
+
const originalClick = locator.click.bind(locator);
|
|
475
|
+
locator.click = async (options) => {
|
|
476
|
+
const { moveDuration, beforeClickPause, moveEasing, postClickPause, postClickMove, position, steps: _steps, ...clickOptions } = options ?? {};
|
|
477
|
+
return recordedClick(locator, (options) => originalClick(options), clickOptions, position, moveDuration, beforeClickPause, moveEasing, postClickPause, postClickMove);
|
|
478
|
+
};
|
|
479
|
+
const originalPressSequentially = locator.pressSequentially.bind(locator);
|
|
480
|
+
locator.pressSequentially = async (text, options) => {
|
|
481
|
+
const innerEvents = [];
|
|
482
|
+
let elementRect;
|
|
483
|
+
if (options?.click !== undefined) {
|
|
484
|
+
// Click before fill: performClickActions handles scrolling and bounding box.
|
|
485
|
+
const clickOpt = options.click;
|
|
486
|
+
const { moveDuration, beforeClickPause, moveEasing, postClickPause, postClickMove, position, ...clickOptions } = clickOpt;
|
|
487
|
+
const clickActionResult = await performClickActions(locator, (options) => originalClick(options), clickOptions, position, moveDuration, beforeClickPause, moveEasing, postClickPause, postClickMove);
|
|
488
|
+
innerEvents.push(...(clickActionResult?.innerEvents ?? []));
|
|
489
|
+
elementRect = clickActionResult?.elementRect;
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
const isFirstAutoZoomEvent = isInsideAutoZoom() && getLastZoomLocation() === null;
|
|
493
|
+
const locatorRect = await scrollIntoViewIfNeeded(locator);
|
|
494
|
+
// If inside autoZoom: await zoom duration for camera pan, then update zoom tracker
|
|
495
|
+
if (isInsideAutoZoom() && locatorRect) {
|
|
496
|
+
const targetX = locatorRect.x + locatorRect.width / 2;
|
|
497
|
+
const targetY = locatorRect.y + locatorRect.height / 2;
|
|
498
|
+
const lastLoc = getLastZoomLocation();
|
|
499
|
+
if (lastLoc !== null) {
|
|
500
|
+
const zoomDur = getZoomDuration() ?? 0;
|
|
501
|
+
if (zoomDur > 0) {
|
|
502
|
+
await sleep(zoomDur);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
setLastZoomLocation({
|
|
506
|
+
x: targetX,
|
|
507
|
+
y: targetY,
|
|
508
|
+
elementRect: locatorRect,
|
|
509
|
+
eventType: 'fill',
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
if (isFirstAutoZoomEvent) {
|
|
513
|
+
const postDelay = getPostZoomInOutDelay() ?? 0;
|
|
514
|
+
if (postDelay > 0)
|
|
515
|
+
await sleep(postDelay);
|
|
516
|
+
}
|
|
517
|
+
elementRect = locatorRect;
|
|
518
|
+
}
|
|
519
|
+
// Hide cursor while typing (will be shown again on next mouse move)
|
|
520
|
+
const page = locator.page();
|
|
521
|
+
const shouldHideMouse = options?.hideMouse === true;
|
|
522
|
+
if (shouldHideMouse) {
|
|
523
|
+
const cursorVisible = mouseVisibilities.get(page) ?? true;
|
|
524
|
+
if (cursorVisible) {
|
|
525
|
+
mouseVisibilities.set(page, false);
|
|
526
|
+
const hideMs = Date.now();
|
|
527
|
+
innerEvents.push({
|
|
528
|
+
type: 'mouseHide',
|
|
529
|
+
startMs: hideMs,
|
|
530
|
+
endMs: hideMs,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
// Strip the click and hideMouse options before forwarding to the original Playwright method
|
|
535
|
+
const { click: _click, hideMouse: _hideMouse, ...pressOptions } = options ?? {};
|
|
536
|
+
const typingStart = Date.now();
|
|
537
|
+
await originalPressSequentially(text, pressOptions);
|
|
538
|
+
const typingEnd = Date.now();
|
|
539
|
+
if (activeClickRecorder) {
|
|
540
|
+
// addInput requires at least one inner event; use typing start/end as a
|
|
541
|
+
// fallback span when no other inner events (click, mouseHide) were collected.
|
|
542
|
+
const eventsToRecord = innerEvents.length > 0
|
|
543
|
+
? innerEvents
|
|
544
|
+
: [
|
|
545
|
+
{ type: 'mouseDown', startMs: typingStart, endMs: typingStart },
|
|
546
|
+
{ type: 'mouseUp', startMs: typingStart, endMs: typingEnd },
|
|
547
|
+
];
|
|
548
|
+
activeClickRecorder.addInput('pressSequentially', elementRect, eventsToRecord);
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
locator.fill = async (value, options) => {
|
|
552
|
+
const duration = options?.duration ?? 1000;
|
|
553
|
+
const delay = value.length > 0 ? duration / value.length : 0;
|
|
554
|
+
const pressOptions = { delay };
|
|
555
|
+
if (options?.timeout !== undefined)
|
|
556
|
+
pressOptions.timeout = options.timeout;
|
|
557
|
+
if (options?.click !== undefined)
|
|
558
|
+
pressOptions.click = options.click;
|
|
559
|
+
if (options?.hideMouse !== undefined)
|
|
560
|
+
pressOptions.hideMouse = options.hideMouse;
|
|
561
|
+
return locator.pressSequentially(value, pressOptions);
|
|
562
|
+
};
|
|
563
|
+
const originalTap = locator.tap.bind(locator);
|
|
564
|
+
locator.tap = async (options) => {
|
|
565
|
+
const clickOpt = options?.click;
|
|
566
|
+
const { click: _click, ...tapOpts } = options ?? {};
|
|
567
|
+
return performSimpleAction(locator, (options) => originalTap(options), tapOpts, 'tap', clickOpt);
|
|
568
|
+
};
|
|
569
|
+
const originalCheck = locator.check.bind(locator);
|
|
570
|
+
locator.check = async (options) => {
|
|
571
|
+
const clickOpt = options?.click;
|
|
572
|
+
const { click: _click, position, ...checkOpts } = options ?? {};
|
|
573
|
+
const effectiveClickOpt = clickOpt !== undefined && position !== undefined
|
|
574
|
+
? { ...clickOpt, position }
|
|
575
|
+
: clickOpt;
|
|
576
|
+
return performSimpleAction(locator, (options) => originalCheck(options), checkOpts, 'check', effectiveClickOpt);
|
|
577
|
+
};
|
|
578
|
+
const originalUncheck = locator.uncheck.bind(locator);
|
|
579
|
+
locator.uncheck = async (options) => {
|
|
580
|
+
const clickOpt = options?.click;
|
|
581
|
+
const { click: _click, position, ...uncheckOpts } = options ?? {};
|
|
582
|
+
const effectiveClickOpt = clickOpt !== undefined && position !== undefined
|
|
583
|
+
? { ...clickOpt, position }
|
|
584
|
+
: clickOpt;
|
|
585
|
+
return performSimpleAction(locator, (options) => originalUncheck(options), uncheckOpts, 'uncheck', effectiveClickOpt);
|
|
586
|
+
};
|
|
587
|
+
locator.setChecked = async (checked, options) => {
|
|
588
|
+
if (checked) {
|
|
589
|
+
return locator.check(options);
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
return locator.uncheck(options);
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
const originalSelectOption = locator.selectOption.bind(locator);
|
|
596
|
+
locator.selectOption = async (values, options) => {
|
|
597
|
+
const clickOpt = options?.click;
|
|
598
|
+
const { click: _click, ...selectOpts } = options ?? {};
|
|
599
|
+
let result = [];
|
|
600
|
+
await performSimpleAction(locator, (options) => originalSelectOption(values, options).then((res) => {
|
|
601
|
+
result = res;
|
|
602
|
+
}), selectOpts, 'select', clickOpt);
|
|
603
|
+
return result;
|
|
604
|
+
};
|
|
605
|
+
const originalHover = locator.hover.bind(locator);
|
|
606
|
+
locator.hover = async (options) => {
|
|
607
|
+
const { moveDuration = 1000, easing: moveEasing = 'ease-in-out', hoverDuration = 1000, position, ...hoverOptions } = options ?? {};
|
|
608
|
+
const page = locator.page();
|
|
609
|
+
const mouseMoveInternal = originalMouseMoves.get(page) ?? page.mouse.move.bind(page.mouse);
|
|
610
|
+
const moveStartTime = Date.now();
|
|
611
|
+
const locatorRect = await scrollIntoViewIfNeeded(locator);
|
|
612
|
+
const scrollElapsedMs = Date.now() - moveStartTime;
|
|
613
|
+
const innerEvents = [];
|
|
614
|
+
const targetPos = position ??
|
|
615
|
+
(locatorRect
|
|
616
|
+
? { x: locatorRect.width / 2, y: locatorRect.height / 2 }
|
|
617
|
+
: undefined);
|
|
618
|
+
if (targetPos && locatorRect) {
|
|
619
|
+
const targetX = locatorRect.x + targetPos.x;
|
|
620
|
+
const targetY = locatorRect.y + targetPos.y;
|
|
621
|
+
const effectiveDuration = Math.max(0, moveDuration - scrollElapsedMs);
|
|
622
|
+
innerEvents.push(await animateMouseMove(page, mouseMoveInternal, targetX, targetY, effectiveDuration, moveEasing, moveStartTime, locatorRect));
|
|
623
|
+
}
|
|
624
|
+
const waitStartMs = Date.now();
|
|
625
|
+
await originalHover({
|
|
626
|
+
...hoverOptions,
|
|
627
|
+
...(targetPos ? { position: targetPos } : {}),
|
|
628
|
+
});
|
|
629
|
+
if (hoverDuration > 0) {
|
|
630
|
+
await sleep(hoverDuration);
|
|
631
|
+
}
|
|
632
|
+
const waitEndMs = Date.now();
|
|
633
|
+
innerEvents.push({
|
|
634
|
+
type: 'mouseWait',
|
|
635
|
+
startMs: waitStartMs,
|
|
636
|
+
endMs: waitEndMs,
|
|
637
|
+
});
|
|
638
|
+
if (activeClickRecorder && innerEvents.length > 0) {
|
|
639
|
+
activeClickRecorder.addInput('hover', locatorRect, innerEvents);
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
const originalSelectText = locator.selectText.bind(locator);
|
|
643
|
+
locator.selectText = async (options) => {
|
|
644
|
+
const { moveDuration = 1000, easing: moveEasing = 'ease-in-out', beforeClickPause = CLICK_DURATION_MS / 2, selectDuration = 600, ...selectOpts } = options ?? {};
|
|
645
|
+
const page = locator.page();
|
|
646
|
+
const mouseMoveInternal = originalMouseMoves.get(page) ?? page.mouse.move.bind(page.mouse);
|
|
647
|
+
const moveStartTime = Date.now();
|
|
648
|
+
const locatorRect = await scrollIntoViewIfNeeded(locator);
|
|
649
|
+
const scrollElapsedMs = Date.now() - moveStartTime;
|
|
650
|
+
const innerEvents = [];
|
|
651
|
+
const targetPos = locatorRect
|
|
652
|
+
? { x: locatorRect.width / 2, y: locatorRect.height / 2 }
|
|
653
|
+
: undefined;
|
|
654
|
+
if (targetPos && locatorRect) {
|
|
655
|
+
const targetX = locatorRect.x + targetPos.x;
|
|
656
|
+
const targetY = locatorRect.y + targetPos.y;
|
|
657
|
+
const effectiveDuration = Math.max(0, moveDuration - scrollElapsedMs);
|
|
658
|
+
innerEvents.push(await animateMouseMove(page, mouseMoveInternal, targetX, targetY, effectiveDuration, moveEasing, moveStartTime, locatorRect));
|
|
659
|
+
}
|
|
660
|
+
await sleep(beforeClickPause);
|
|
661
|
+
await originalSelectText(selectOpts);
|
|
662
|
+
// Backtrack triple-click events from the moment originalSelectText resolves.
|
|
663
|
+
// Clamp start so events don't precede the prior animation (produces a visible
|
|
664
|
+
// pre-click pause in the recording, which is acceptable).
|
|
665
|
+
// All timestamps use a single base + integer * segmentMs to avoid FP drift.
|
|
666
|
+
const selectEndMs = Date.now();
|
|
667
|
+
const lastEventEndMs = innerEvents.at(-1)?.endMs ?? 0;
|
|
668
|
+
const tripleClickStartMs = Math.max(lastEventEndMs, selectEndMs - selectDuration);
|
|
669
|
+
const segmentMs = selectDuration / 6;
|
|
670
|
+
for (let i = 0; i < 3; i++) {
|
|
671
|
+
const seg = i * 2;
|
|
672
|
+
innerEvents.push({
|
|
673
|
+
type: 'mouseDown',
|
|
674
|
+
startMs: tripleClickStartMs + seg * segmentMs,
|
|
675
|
+
endMs: tripleClickStartMs + (seg + 1) * segmentMs,
|
|
676
|
+
easing: 'ease-in-out',
|
|
677
|
+
});
|
|
678
|
+
innerEvents.push({
|
|
679
|
+
type: 'mouseUp',
|
|
680
|
+
startMs: tripleClickStartMs + (seg + 1) * segmentMs,
|
|
681
|
+
endMs: tripleClickStartMs + (seg + 2) * segmentMs,
|
|
682
|
+
easing: 'ease-in-out',
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
if (activeClickRecorder && innerEvents.length > 0) {
|
|
686
|
+
activeClickRecorder.addInput('selectText', locatorRect, innerEvents);
|
|
687
|
+
}
|
|
688
|
+
};
|
|
689
|
+
locator.dragTo = async (target, options) => {
|
|
690
|
+
const { moveDuration = 1000, moveEasing = 'ease-in-out', preDragPause = CLICK_DURATION_MS / 2, dragDuration = 1000, dragEasing = 'ease-in-out', sourcePosition, targetPosition, ...dragOpts } = options ?? {};
|
|
691
|
+
const page = locator.page();
|
|
692
|
+
const mouseMoveInternal = originalMouseMoves.get(page) ?? page.mouse.move.bind(page.mouse);
|
|
693
|
+
const moveStartTime = Date.now();
|
|
694
|
+
const sourceRect = await scrollIntoViewIfNeeded(locator);
|
|
695
|
+
const scrollElapsedMs = Date.now() - moveStartTime;
|
|
696
|
+
const targetBb = await target.boundingBox();
|
|
697
|
+
const targetRect = targetBb
|
|
698
|
+
? {
|
|
699
|
+
x: targetBb.x,
|
|
700
|
+
y: targetBb.y,
|
|
701
|
+
width: targetBb.width,
|
|
702
|
+
height: targetBb.height,
|
|
703
|
+
}
|
|
704
|
+
: undefined;
|
|
705
|
+
const innerEvents = [];
|
|
706
|
+
const sourcePos = sourcePosition ??
|
|
707
|
+
(sourceRect
|
|
708
|
+
? { x: sourceRect.width / 2, y: sourceRect.height / 2 }
|
|
709
|
+
: undefined);
|
|
710
|
+
const targetPos = targetPosition ??
|
|
711
|
+
(targetRect
|
|
712
|
+
? { x: targetRect.width / 2, y: targetRect.height / 2 }
|
|
713
|
+
: undefined);
|
|
714
|
+
// 1. Animate cursor to source
|
|
715
|
+
if (sourcePos && sourceRect) {
|
|
716
|
+
const toX = sourceRect.x + sourcePos.x;
|
|
717
|
+
const toY = sourceRect.y + sourcePos.y;
|
|
718
|
+
const effectiveDuration = Math.max(0, moveDuration - scrollElapsedMs);
|
|
719
|
+
innerEvents.push(await animateMouseMove(page, mouseMoveInternal, toX, toY, effectiveDuration, moveEasing, moveStartTime, sourceRect));
|
|
720
|
+
}
|
|
721
|
+
// 2. preDragPause + mouseDown
|
|
722
|
+
await sleep(preDragPause);
|
|
723
|
+
const mouseDownStart = Date.now();
|
|
724
|
+
await page.mouse.down();
|
|
725
|
+
await sleep(CLICK_DURATION_MS / 2);
|
|
726
|
+
innerEvents.push({
|
|
727
|
+
type: 'mouseDown',
|
|
728
|
+
startMs: mouseDownStart,
|
|
729
|
+
endMs: Date.now(),
|
|
730
|
+
easing: 'ease-in-out',
|
|
731
|
+
});
|
|
732
|
+
// 3. Drag: animate cursor from source to target
|
|
733
|
+
const dragStartTime = Date.now();
|
|
734
|
+
if (targetPos && targetRect) {
|
|
735
|
+
const toX = targetRect.x + targetPos.x;
|
|
736
|
+
const toY = targetRect.y + targetPos.y;
|
|
737
|
+
innerEvents.push(await animateMouseMove(page, mouseMoveInternal, toX, toY, dragDuration, dragEasing, dragStartTime, targetRect));
|
|
738
|
+
}
|
|
739
|
+
// 4. mouseUp at target
|
|
740
|
+
const mouseUpStart = Date.now();
|
|
741
|
+
await page.mouse.up();
|
|
742
|
+
await sleep(CLICK_DURATION_MS / 2);
|
|
743
|
+
innerEvents.push({
|
|
744
|
+
type: 'mouseUp',
|
|
745
|
+
startMs: mouseUpStart,
|
|
746
|
+
endMs: Date.now(),
|
|
747
|
+
easing: 'ease-in-out',
|
|
748
|
+
});
|
|
749
|
+
if (activeClickRecorder && innerEvents.length > 0) {
|
|
750
|
+
activeClickRecorder.addInput('dragTo', sourceRect, innerEvents);
|
|
751
|
+
}
|
|
752
|
+
};
|
|
753
|
+
const originalPage = locator.page.bind(locator);
|
|
754
|
+
locator.page = () => originalPage();
|
|
755
|
+
instrumentLocatorMethods(locator);
|
|
756
|
+
for (const method of LOCATOR_ONLY_SYNC_RETURN_METHODS) {
|
|
757
|
+
const original = locator[method].bind(locator);
|
|
758
|
+
locator[method] = (...args) => instrumentLocator(original(...args));
|
|
759
|
+
}
|
|
760
|
+
const originalAll = locator.all.bind(locator);
|
|
761
|
+
locator.all = async () => {
|
|
762
|
+
const locators = await originalAll();
|
|
763
|
+
return locators.map(instrumentLocator);
|
|
764
|
+
};
|
|
765
|
+
const originalContentFrame = locator.contentFrame.bind(locator);
|
|
766
|
+
locator.contentFrame =
|
|
767
|
+
() => instrumentFrameLocator(originalContentFrame());
|
|
768
|
+
const originalLocatorFrameLocator = locator.frameLocator.bind(locator);
|
|
769
|
+
locator.frameLocator = (...args) => instrumentFrameLocator(originalLocatorFrameLocator(...args));
|
|
770
|
+
return locator;
|
|
771
|
+
}
|
|
772
|
+
export async function instrumentPage(page) {
|
|
773
|
+
if (instrumented.has(page))
|
|
774
|
+
return page;
|
|
775
|
+
instrumented.add(page);
|
|
776
|
+
// Expose a Node.js function to the browser that captures DOM click event data.
|
|
777
|
+
// Called synchronously from the click handler before any navigation can occur.
|
|
778
|
+
await page.exposeFunction('__screenciOnClick', (data) => {
|
|
779
|
+
pendingClickData.set(page, data);
|
|
780
|
+
});
|
|
781
|
+
// Inject a capture listener on every page load (including after navigation).
|
|
782
|
+
await page.addInitScript(() => {
|
|
783
|
+
document.addEventListener('click', (e) => {
|
|
784
|
+
const target = e.target;
|
|
785
|
+
const r = target.getBoundingClientRect();
|
|
786
|
+
window.__screenciOnClick({
|
|
787
|
+
x: e.clientX,
|
|
788
|
+
y: e.clientY,
|
|
789
|
+
targetRect: { x: r.x, y: r.y, width: r.width, height: r.height },
|
|
790
|
+
});
|
|
791
|
+
}, { capture: true });
|
|
792
|
+
});
|
|
793
|
+
instrumentLocatorMethods(page);
|
|
794
|
+
const originalPageFrameLocator = page.frameLocator.bind(page);
|
|
795
|
+
page.frameLocator = (...args) => instrumentFrameLocator(originalPageFrameLocator(...args));
|
|
796
|
+
// Delegate page.click to the instrumented locator so all click recording
|
|
797
|
+
// flows through the same path.
|
|
798
|
+
page.click = async (selector, options) => {
|
|
799
|
+
return page.locator(selector).click(options);
|
|
800
|
+
};
|
|
801
|
+
// Instrument page.mouse to record mouse moves and visibility toggles.
|
|
802
|
+
const originalMouse = page.mouse;
|
|
803
|
+
const originalMove = originalMouse.move.bind(originalMouse);
|
|
804
|
+
originalMouseMoves.set(page, originalMove);
|
|
805
|
+
originalMouse.move = async (x, y, options) => {
|
|
806
|
+
const duration = options?.duration ?? 0;
|
|
807
|
+
const easing = options?.easing ?? 'ease-in-out';
|
|
808
|
+
const startMs = Date.now();
|
|
809
|
+
if (duration > 0) {
|
|
810
|
+
const startPos = mousePositions.get(page) ?? { x: 0, y: 0 };
|
|
811
|
+
const steps = Math.max(1, Math.floor(duration / CURSOR_STEP_MS));
|
|
812
|
+
const stepMs = duration / steps;
|
|
813
|
+
for (let i = 0; i <= steps; i++) {
|
|
814
|
+
const t = i / steps;
|
|
815
|
+
const easedT = evaluateEasingAtT(t, easing);
|
|
816
|
+
const cx = startPos.x + easedT * (x - startPos.x);
|
|
817
|
+
const cy = startPos.y + easedT * (y - startPos.y);
|
|
818
|
+
await originalMove(cx, cy);
|
|
819
|
+
if (i < steps) {
|
|
820
|
+
await new Promise((resolve) => setTimeout(resolve, stepMs));
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
else {
|
|
825
|
+
await originalMove(x, y);
|
|
826
|
+
}
|
|
827
|
+
mousePositions.set(page, { x, y });
|
|
828
|
+
const endMs = Date.now();
|
|
829
|
+
if (activeClickRecorder) {
|
|
830
|
+
// Auto-show cursor when moving after a typing auto-hide
|
|
831
|
+
if (!(mouseVisibilities.get(page) ?? true)) {
|
|
832
|
+
mouseVisibilities.set(page, true);
|
|
833
|
+
const showMs = startMs;
|
|
834
|
+
const showEvent = {
|
|
835
|
+
type: 'mouseShow',
|
|
836
|
+
startMs: showMs,
|
|
837
|
+
endMs: showMs,
|
|
838
|
+
};
|
|
839
|
+
activeClickRecorder.addInput('mouseShow', undefined, [showEvent]);
|
|
840
|
+
}
|
|
841
|
+
const moveEvent = {
|
|
842
|
+
type: 'mouseMove',
|
|
843
|
+
startMs,
|
|
844
|
+
endMs,
|
|
845
|
+
x,
|
|
846
|
+
y,
|
|
847
|
+
...(duration > 0 ? { easing } : {}),
|
|
848
|
+
};
|
|
849
|
+
activeClickRecorder.addInput('mouseMove', undefined, [moveEvent]);
|
|
850
|
+
}
|
|
851
|
+
};
|
|
852
|
+
mouseVisibilities.set(page, true);
|
|
853
|
+
originalMouse.show = () => {
|
|
854
|
+
if (!(mouseVisibilities.get(page) ?? true)) {
|
|
855
|
+
mouseVisibilities.set(page, true);
|
|
856
|
+
if (activeClickRecorder) {
|
|
857
|
+
const timeMs = Date.now();
|
|
858
|
+
const showEvent = {
|
|
859
|
+
type: 'mouseShow',
|
|
860
|
+
startMs: timeMs,
|
|
861
|
+
endMs: timeMs,
|
|
862
|
+
};
|
|
863
|
+
activeClickRecorder.addInput('mouseShow', undefined, [showEvent]);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
};
|
|
867
|
+
originalMouse.hide = () => {
|
|
868
|
+
if (mouseVisibilities.get(page) ?? true) {
|
|
869
|
+
mouseVisibilities.set(page, false);
|
|
870
|
+
if (activeClickRecorder) {
|
|
871
|
+
const timeMs = Date.now();
|
|
872
|
+
const hideEvent = {
|
|
873
|
+
type: 'mouseHide',
|
|
874
|
+
startMs: timeMs,
|
|
875
|
+
endMs: timeMs,
|
|
876
|
+
};
|
|
877
|
+
activeClickRecorder.addInput('mouseHide', undefined, [hideEvent]);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
};
|
|
881
|
+
page.on('popup', (popup) => {
|
|
882
|
+
void instrumentPage(popup);
|
|
883
|
+
});
|
|
884
|
+
return page;
|
|
885
|
+
}
|
|
886
|
+
export function instrumentContext(context) {
|
|
887
|
+
if (instrumented.has(context))
|
|
888
|
+
return context;
|
|
889
|
+
instrumented.add(context);
|
|
890
|
+
const originalNewPage = context.newPage.bind(context);
|
|
891
|
+
context.newPage = async (...args) => {
|
|
892
|
+
return instrumentPage(await originalNewPage(...args));
|
|
893
|
+
};
|
|
894
|
+
return context;
|
|
895
|
+
}
|
|
896
|
+
export function instrumentBrowser(browser) {
|
|
897
|
+
if (instrumented.has(browser))
|
|
898
|
+
return browser;
|
|
899
|
+
instrumented.add(browser);
|
|
900
|
+
const originalNewContext = browser.newContext.bind(browser);
|
|
901
|
+
browser.newContext = async (...args) => {
|
|
902
|
+
return instrumentContext(await originalNewContext(...args));
|
|
903
|
+
};
|
|
904
|
+
const originalNewPage = browser.newPage.bind(browser);
|
|
905
|
+
browser.newPage = async (...args) => {
|
|
906
|
+
return instrumentPage(await originalNewPage(...args));
|
|
907
|
+
};
|
|
908
|
+
return browser;
|
|
909
|
+
}
|
|
910
|
+
//# sourceMappingURL=instrument.js.map
|