screenci 0.0.20 → 0.0.22

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.
Files changed (52) hide show
  1. package/dist/cli.d.ts +2 -0
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +29 -19
  4. package/dist/cli.js.map +1 -1
  5. package/dist/e2e/instrument.e2e.js +98 -68
  6. package/dist/e2e/instrument.e2e.js.map +1 -1
  7. package/dist/src/autoZoom.d.ts +34 -15
  8. package/dist/src/autoZoom.d.ts.map +1 -1
  9. package/dist/src/autoZoom.js +100 -69
  10. package/dist/src/autoZoom.js.map +1 -1
  11. package/dist/src/changeFocus.d.ts +129 -0
  12. package/dist/src/changeFocus.d.ts.map +1 -0
  13. package/dist/src/changeFocus.js +885 -0
  14. package/dist/src/changeFocus.js.map +1 -0
  15. package/dist/src/defaults.d.ts +2 -17
  16. package/dist/src/defaults.d.ts.map +1 -1
  17. package/dist/src/defaults.js +8 -16
  18. package/dist/src/defaults.js.map +1 -1
  19. package/dist/src/easing.d.ts +6 -0
  20. package/dist/src/easing.d.ts.map +1 -0
  21. package/dist/src/easing.js +30 -0
  22. package/dist/src/easing.js.map +1 -0
  23. package/dist/src/errors.d.ts +10 -0
  24. package/dist/src/errors.d.ts.map +1 -0
  25. package/dist/src/errors.js +26 -0
  26. package/dist/src/errors.js.map +1 -0
  27. package/dist/src/events.d.ts +35 -12
  28. package/dist/src/events.d.ts.map +1 -1
  29. package/dist/src/events.js +137 -31
  30. package/dist/src/events.js.map +1 -1
  31. package/dist/src/instrument.d.ts +0 -6
  32. package/dist/src/instrument.d.ts.map +1 -1
  33. package/dist/src/instrument.js +382 -812
  34. package/dist/src/instrument.js.map +1 -1
  35. package/dist/src/mouse.d.ts +128 -0
  36. package/dist/src/mouse.d.ts.map +1 -0
  37. package/dist/src/mouse.js +268 -0
  38. package/dist/src/mouse.js.map +1 -0
  39. package/dist/src/recordingData.d.ts +30 -4
  40. package/dist/src/recordingData.d.ts.map +1 -1
  41. package/dist/src/types.d.ts +4 -7
  42. package/dist/src/types.d.ts.map +1 -1
  43. package/dist/src/zoom.d.ts +69 -0
  44. package/dist/src/zoom.d.ts.map +1 -0
  45. package/dist/src/zoom.js +90 -0
  46. package/dist/src/zoom.js.map +1 -0
  47. package/dist/tsconfig.tsbuildinfo +1 -1
  48. package/package.json +1 -1
  49. package/dist/src/scroll.d.ts +0 -47
  50. package/dist/src/scroll.d.ts.map +0 -1
  51. package/dist/src/scroll.js +0 -347
  52. package/dist/src/scroll.js.map +0 -1
@@ -1,76 +1,14 @@
1
- import { logger } from './logger.js';
2
- import { isInsideAutoZoom, getZoomDuration, getZoomEasing, getPostZoomInOutDelay, getLastZoomLocation, setLastZoomLocation, } from './autoZoom.js';
3
1
  import { isInsideHide } from './hide.js';
4
- import { ZoomScrollHandler } from './scroll.js';
2
+ import { changeFocus } from './changeFocus.js';
3
+ import { CLICK_DURATION_MS, assertDurationOrSpeed, buildMouseDownEvent, buildMouseUpEvent, getOriginalLocatorCheck, getOriginalLocatorClick, getOriginalLocatorSelect, getOriginalLocatorTap, getOriginalLocatorUncheck, getOriginalMouseDown, getOriginalMouseHide, getMousePosition, getOriginalMouseShow, getOriginalMouseUp, isMouseVisible, performMouseClickAction, performMouseDown, performMouseHide, performMouseMove, performMouseShow, performMouseUp, resolveMouseMoveDuration, setOriginalLocatorCheck, setOriginalLocatorClick, setOriginalLocatorSelect, setOriginalLocatorTap, setOriginalLocatorUncheck, setMouseVisible, setOriginalMouseClick, setOriginalMouseDown, setOriginalMouseHide, setOriginalMouseMove, setOriginalMouseShow, setOriginalMouseUp, } from './mouse.js';
5
4
  let activeClickRecorder = null;
6
5
  export function setActiveClickRecorder(recorder) {
7
6
  activeClickRecorder = recorder;
8
7
  }
9
- /**
10
- * Evaluate a polynomial easing function at normalized time t ∈ [0, 1].
11
- * Returns the eased progress value (0–1).
12
- */
13
- function evaluateEasingAtT(t, easing) {
14
- if (t <= 0)
15
- return 0;
16
- if (t >= 1)
17
- return 1;
18
- switch (easing) {
19
- case 'linear':
20
- return t;
21
- case 'ease-in':
22
- return t * t * t;
23
- case 'ease-out':
24
- return 1 - (1 - t) * (1 - t) * (1 - t);
25
- case 'ease-in-out':
26
- return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
27
- case 'ease-in-strong':
28
- return t * t * t * t;
29
- case 'ease-out-strong':
30
- return 1 - (1 - t) * (1 - t) * (1 - t) * (1 - t);
31
- case 'ease-in-out-strong':
32
- return t < 0.5 ? 8 * t * t * t * t : 1 - Math.pow(-2 * t + 2, 4) / 2;
33
- default: {
34
- const _ = easing;
35
- throw new Error(`Unknown easing: ${_}`);
36
- }
37
- }
38
- }
39
- /** Tracked cursor position per page, updated after each animated move. */
40
- const mousePositions = new WeakMap();
41
- /** Tracks cursor visibility per page (true = visible). Defaults to true. */
42
- const mouseVisibilities = new WeakMap();
43
- /** Stores the original (un-instrumented) mouse.move per page so internal
44
- * cursor animations don't emit addInput recorder events. */
45
- const originalMouseMoves = new WeakMap();
46
8
  const instrumented = new WeakSet();
47
9
  function sleep(ms) {
48
10
  return new Promise((resolve) => setTimeout(resolve, ms));
49
11
  }
50
- const CLICK_DURATION_MS = 200;
51
- const PRE_ACTION_SLEEP = 50;
52
- const POST_ACTION_SLEEP = 250;
53
- function assertDurationOrSpeed(duration, speed, context) {
54
- if (duration !== undefined && speed !== undefined) {
55
- throw new Error(`[screenci] ${context} accepts either duration or speed, not both.`);
56
- }
57
- if (duration !== undefined && (!Number.isFinite(duration) || duration < 0)) {
58
- throw new Error(`[screenci] ${context} duration must be a finite number >= 0.`);
59
- }
60
- if (speed !== undefined && (!Number.isFinite(speed) || speed <= 0)) {
61
- throw new Error(`[screenci] ${context} speed must be a finite number > 0.`);
62
- }
63
- }
64
- function resolveMouseMoveDuration(page, targetX, targetY, options) {
65
- const { duration, speed, defaultDuration, context } = options;
66
- assertDurationOrSpeed(duration, speed, context);
67
- if (speed !== undefined) {
68
- const startPos = mousePositions.get(page) ?? { x: 0, y: 0 };
69
- const distancePx = Math.hypot(targetX - startPos.x, targetY - startPos.y);
70
- return (distancePx / speed) * 1000;
71
- }
72
- return duration ?? defaultDuration ?? 0;
73
- }
74
12
  const LOCATOR_RETURN_METHODS = [
75
13
  'locator',
76
14
  'getByAltText',
@@ -107,294 +45,129 @@ const FRAME_LOCATOR_SELF_RETURN_METHODS = [
107
45
  'last',
108
46
  'nth',
109
47
  ];
110
- function canUseDirectMouseClickAfterScroll(options) {
111
- if (!options)
112
- return true;
113
- const unsupported = [
114
- 'force',
115
- 'modifiers',
116
- 'noWaitAfter',
117
- 'timeout',
118
- 'trial',
119
- ];
120
- return unsupported.every((key) => options[key] === undefined);
121
- }
122
- // Per-page storage for the most recently captured DOM click event data.
123
- // Reset to null before each instrumented click; set by the exposeFunction callback.
124
- const pendingClickData = new WeakMap();
125
- export function scrollIntoViewAsync(locator, options = {}) {
126
- const { behavior = 'smooth', block = 'center', timeout = 5000, postScrollTimeout = 500, } = options;
127
- return locator.evaluate((element, opts) => new Promise((resolve) => {
128
- let settled = false;
129
- const finish = () => {
130
- if (settled)
131
- return;
132
- settled = true;
133
- clearTimeout(fallback);
134
- setTimeout(resolve, opts.postScrollTimeout);
135
- };
136
- const fallback = setTimeout(finish, opts.timeout);
137
- // scrollend fires once the smooth-scroll animation completes (Chrome 114+).
138
- // It captures scroll on any ancestor element via the capture phase.
139
- window.addEventListener('scrollend', finish, {
140
- capture: true,
141
- once: true,
142
- });
143
- element.scrollIntoView({ behavior: opts.behavior, block: opts.block });
144
- // If the element is already in the target position no scroll occurs and
145
- // scrollend never fires. Detect this: if no scroll event appears within
146
- // two animation frames the element was already in place.
147
- let didScroll = false;
148
- window.addEventListener('scroll', () => {
149
- didScroll = true;
150
- }, {
151
- capture: true,
152
- passive: true,
153
- once: true,
154
- });
155
- requestAnimationFrame(() => requestAnimationFrame(() => {
156
- if (!didScroll)
157
- finish();
158
- }));
159
- }), { behavior, block, timeout, postScrollTimeout });
48
+ function getRecordedInnerEventEndMs(event) {
49
+ if (event.type === 'focusChange') {
50
+ return Math.max(...(event.mouse ? [event.mouse.endMs] : []), ...(event.scroll ? [event.scroll.endMs] : []), ...(event.zoom ? [event.zoom.endMs] : []));
51
+ }
52
+ return event.endMs;
160
53
  }
161
- const CURSOR_STEP_MS = 1000 / 60;
162
- /**
163
- * Physically moves the mouse from its current tracked position to (targetX,
164
- * targetY) over `duration` ms using `easing`, then returns a FocusChangeEvent
165
- * whose startMs is `eventStartMs` (which may predate this call, e.g. when a
166
- * scroll consumed part of moveDuration). When duration ≤ 0 the cursor is
167
- * snapped directly to the target with a single move call.
168
- * Always updates the internal mousePositions tracker.
169
- */
170
- async function animateMouseMove(page, mouseMoveInternal, targetX, targetY, duration, easing, eventStartMs, elementRect) {
171
- if (duration > 0) {
172
- const startPos = mousePositions.get(page) ?? { x: 0, y: 0 };
173
- const steps = Math.max(1, Math.floor(duration / CURSOR_STEP_MS));
174
- const stepMs = duration / steps;
175
- for (let i = 0; i <= steps; i++) {
176
- const t = i / steps;
177
- const easedT = evaluateEasingAtT(t, easing);
178
- const x = startPos.x + easedT * (targetX - startPos.x);
179
- const y = startPos.y + easedT * (targetY - startPos.y);
180
- await mouseMoveInternal(x, y);
181
- if (i < steps) {
182
- await new Promise((resolve) => setTimeout(resolve, stepMs));
54
+ function resolveLocatorMouseAction(locator, interactionType) {
55
+ switch (interactionType) {
56
+ case 'click': {
57
+ const action = getOriginalLocatorClick(locator);
58
+ if (action)
59
+ return { doClick: action, supportsTrial: true };
60
+ break;
61
+ }
62
+ case 'tap': {
63
+ const action = getOriginalLocatorTap(locator);
64
+ if (action)
65
+ return { doClick: action, supportsTrial: true };
66
+ break;
67
+ }
68
+ case 'check': {
69
+ const action = getOriginalLocatorCheck(locator);
70
+ if (action)
71
+ return { doClick: action, supportsTrial: true };
72
+ break;
73
+ }
74
+ case 'uncheck': {
75
+ const action = getOriginalLocatorUncheck(locator);
76
+ if (action)
77
+ return { doClick: action, supportsTrial: true };
78
+ break;
79
+ }
80
+ case 'select': {
81
+ const action = getOriginalLocatorSelect(locator);
82
+ if (action) {
83
+ return {
84
+ doClick: (options) => action(null, options).then(() => { }),
85
+ supportsTrial: false,
86
+ };
183
87
  }
88
+ break;
89
+ }
90
+ default: {
91
+ const _ = interactionType;
92
+ throw new Error(`Unknown mouse click interaction type: ${_}`);
184
93
  }
185
94
  }
186
- else {
187
- await mouseMoveInternal(targetX, targetY);
188
- }
189
- mousePositions.set(page, { x: targetX, y: targetY });
190
- const endMs = Date.now();
191
- return {
192
- type: 'focusChange',
193
- startMs: eventStartMs,
194
- endMs,
195
- x: targetX,
196
- y: targetY,
197
- easing,
198
- ...(elementRect !== undefined ? { elementRect } : {}),
199
- };
200
- }
201
- function resolveMoveStartMs(scrollResult, fallbackStartMs) {
202
- return scrollResult.shouldScrollBeforeMouseMove
203
- ? scrollResult.scrollEndMs
204
- : fallbackStartMs;
205
- }
206
- function resolveMoveDuration(scrollResult, resolvedDuration) {
207
- return scrollResult.shouldScrollBeforeMouseMove
208
- ? resolvedDuration
209
- : Math.max(0, resolvedDuration - scrollResult.scrollElapsedMs);
95
+ throw new Error(`[screenci] Missing original locator action for '${interactionType}'.`);
210
96
  }
211
- /**
212
- * Performs all click mechanics (scroll-check, zoom handling, cursor animation,
213
- * click, post-click move) and returns the collected timing/position data.
214
- * Returns null if coordinates could not be determined (no DOM event and no
215
- * locator bounding box).
216
- */
217
- async function performClickActions(locator, doClick, clickOptions, autoZoomOptions, position, moveDuration, moveSpeed, beforeClickPause = CLICK_DURATION_MS / 2, moveEasing = 'ease-in-out', postClickPause = CLICK_DURATION_MS / 2, postClickMove) {
97
+ async function performAction(mouseMoveRequest, locator, doClick, supportsTrial, mode, autoZoomOptions, position, beforeClickPause = 0, postClickPause = 0, postClickMove, shouldHideMouse = false) {
218
98
  const page = locator.page();
219
- pendingClickData.set(page, null);
220
- const halfClickDuration = CLICK_DURATION_MS / 2;
221
- const mouseMoveInternal = originalMouseMoves.get(page) ?? page.mouse.move.bind(page.mouse);
222
- const moveStartTime = Date.now();
223
- const scrollResult = await new ZoomScrollHandler(autoZoomOptions).scroll(locator);
224
- const { locatorRect, scrollElapsedMs } = scrollResult;
225
- const isFirstAutoZoomEvent = scrollResult.isFirstAutoZoomInteraction;
226
- if (!locatorRect) {
227
- logger.warn('[screenci] Unable to get locator bounding box; skipping auto-scroll check.');
228
- }
229
- const innerEvents = [];
230
- // If inside autoZoom: optionally await zoom duration for camera pan then
231
- // update the zoom location tracker.
232
- if (isInsideAutoZoom() && locatorRect) {
233
- const targetX = position
234
- ? locatorRect.x + position.x
235
- : locatorRect.x + locatorRect.width / 2;
236
- const targetY = position
237
- ? locatorRect.y + position.y
238
- : locatorRect.y + locatorRect.height / 2;
239
- const lastLoc = getLastZoomLocation();
240
- if (lastLoc !== null) {
241
- const zoomDur = getZoomDuration() ?? 0;
242
- if (zoomDur > 0) {
243
- await sleep(zoomDur);
244
- }
245
- }
246
- setLastZoomLocation({
247
- x: targetX,
248
- y: targetY,
249
- elementRect: locatorRect,
250
- eventType: 'click',
251
- });
252
- }
253
- const targetPos = position
254
- ? position
255
- : locatorRect
99
+ const focusChange = await changeFocus(locator, autoZoomOptions, mouseMoveRequest);
100
+ const elementRect = focusChange.elementRect;
101
+ const innerEvents = [focusChange];
102
+ const targetPosition = position ??
103
+ (elementRect
256
104
  ? {
257
- x: locatorRect.width / 2,
258
- y: locatorRect.height / 2,
259
- }
260
- : undefined;
261
- if (targetPos && locatorRect) {
262
- const targetX = locatorRect.x + targetPos.x;
263
- const targetY = locatorRect.y + targetPos.y;
264
- const resolvedDuration = resolveMouseMoveDuration(page, targetX, targetY, {
265
- duration: moveDuration,
266
- speed: moveSpeed,
267
- defaultDuration: 1000,
268
- context: 'click move',
269
- });
270
- if (scrollElapsedMs > 0 && !scrollResult.shouldScrollBeforeMouseMove) {
271
- await mouseMoveInternal(targetX, targetY);
272
- mousePositions.set(page, { x: targetX, y: targetY });
273
- const remainingDuration = Math.max(0, resolvedDuration - scrollElapsedMs);
274
- if (remainingDuration > 0) {
275
- await new Promise((resolve) => setTimeout(resolve, remainingDuration));
105
+ x: elementRect.width / 2,
106
+ y: elementRect.height / 2,
276
107
  }
277
- const moveEndTime = Date.now();
278
- innerEvents.push({
279
- type: 'focusChange',
280
- startMs: moveStartTime,
281
- endMs: moveEndTime,
282
- x: targetX,
283
- y: targetY,
284
- easing: moveEasing,
285
- focusOnly: false,
286
- elementRect: locatorRect,
287
- });
288
- }
289
- else {
290
- innerEvents.push(await animateMouseMove(page, mouseMoveInternal, targetX, targetY, resolveMoveDuration(scrollResult, resolvedDuration), moveEasing, resolveMoveStartMs(scrollResult, moveStartTime), locatorRect));
291
- }
108
+ : undefined);
109
+ if (!elementRect || !targetPosition) {
110
+ throw new Error('[screenci] performAction requires an element rect and target position.');
292
111
  }
293
- else {
294
- assertDurationOrSpeed(moveDuration, moveSpeed, 'click move');
295
- const remainingMs = Math.max(0, moveSpeed === undefined ? (moveDuration ?? 1000) : 0);
296
- if (remainingMs > 0) {
297
- await new Promise((resolve) => setTimeout(resolve, remainingMs));
298
- }
299
- }
300
- const zoomDur = isInsideAutoZoom() && locatorRect ? (getZoomDuration() ?? 0) : 0;
301
- const effectiveBeforeClickPause = isFirstAutoZoomEvent
302
- ? Math.max(beforeClickPause, getPostZoomInOutDelay() ?? 0, zoomDur)
303
- : Math.max(beforeClickPause, zoomDur);
304
- await new Promise((resolve) => setTimeout(resolve, effectiveBeforeClickPause));
305
- await new Promise((resolve) => setTimeout(resolve, halfClickDuration));
306
- // Note click can take some time, but better to show it before than after
307
- const clickTime = Date.now();
308
- const mouseDownStart = clickTime - halfClickDuration;
309
- innerEvents.push({
310
- type: 'mouseDown',
311
- startMs: mouseDownStart,
312
- endMs: clickTime,
313
- easing: 'ease-in-out',
314
- });
315
- if (scrollElapsedMs > 0 &&
316
- targetPos &&
317
- locatorRect &&
318
- canUseDirectMouseClickAfterScroll(clickOptions)) {
319
- const mouseClickOptions = {
320
- ...(clickOptions?.button !== undefined
321
- ? { button: clickOptions.button }
322
- : {}),
323
- ...(clickOptions?.clickCount !== undefined
324
- ? { clickCount: clickOptions.clickCount }
325
- : {}),
326
- };
327
- await page.mouse.down(mouseClickOptions);
328
- if (clickOptions?.delay) {
329
- await new Promise((resolve) => setTimeout(resolve, clickOptions.delay));
330
- }
331
- await page.mouse.up(mouseClickOptions);
332
- }
333
- else {
112
+ await sleep(beforeClickPause);
113
+ if (!mouseMoveRequest) {
334
114
  await doClick({
335
- ...clickOptions,
336
- ...(targetPos ? { position: targetPos } : {}),
115
+ ...(supportsTrial ? { trial: true } : {}),
116
+ ...(mode === 'singleDuring' ? { position: targetPosition } : {}),
337
117
  });
118
+ await sleep(postClickPause);
119
+ return {
120
+ elementRect,
121
+ innerEvents,
122
+ };
338
123
  }
339
- const domClickData = pendingClickData.get(page);
340
- if (domClickData) {
341
- const lastMouseMoveIndex = innerEvents.findIndex((e) => e.type === 'focusChange' || e.type === 'mouseMove');
342
- if (lastMouseMoveIndex !== -1) {
343
- const existingMove = innerEvents[lastMouseMoveIndex];
344
- if (existingMove?.type === 'focusChange' ||
345
- existingMove?.type === 'mouseMove') {
346
- innerEvents[lastMouseMoveIndex] = {
347
- ...existingMove,
348
- x: domClickData.x,
349
- y: domClickData.y,
350
- elementRect: domClickData.targetRect,
351
- };
352
- }
353
- }
354
- mousePositions.set(page, { x: domClickData.x, y: domClickData.y });
355
- }
356
- const mouseUpEnd = Date.now() + halfClickDuration;
357
- innerEvents.push({
358
- type: 'mouseUp',
359
- startMs: clickTime,
360
- endMs: mouseUpEnd,
361
- easing: 'ease-in-out',
362
- });
363
- await new Promise((resolve) => setTimeout(resolve, halfClickDuration));
364
- await new Promise((resolve) => setTimeout(resolve, postClickPause));
365
- // Animate mouse cursor in the specified direction after the click completes,
366
- // capturing start/end times and final position for the recorded event.
124
+ const clickActionBase = {
125
+ locator,
126
+ doClick,
127
+ supportsTrial,
128
+ targetX: elementRect.x + targetPosition.x,
129
+ targetY: elementRect.y + targetPosition.y,
130
+ clickOptions: { position: targetPosition },
131
+ };
132
+ const clickActionOptions = mode === 'singleDuring'
133
+ ? {
134
+ ...clickActionBase,
135
+ mode,
136
+ }
137
+ : {
138
+ ...clickActionBase,
139
+ mode,
140
+ shouldHideMouse,
141
+ };
142
+ const { events, elementRect: actionElementRect } = await performMouseClickAction(clickActionOptions);
143
+ innerEvents.push(...events);
144
+ await sleep(postClickPause);
367
145
  if (postClickMove !== undefined) {
368
- const currentPos = mousePositions.get(page) ?? { x: 0, y: 0 };
146
+ const currentPos = getMousePosition(page) ?? { x: 0, y: 0 };
369
147
  let targetX;
370
148
  let targetY;
371
149
  if ('direction' in postClickMove) {
372
- if (locatorRect === undefined) {
373
- logger.warn('[screenci] postClickMove with direction requires a locator rect; skipping mouse move.');
374
- }
375
- else {
376
- const padding = postClickMove.padding ?? 0;
377
- switch (postClickMove.direction) {
378
- case 'up':
379
- targetX = currentPos.x;
380
- targetY = locatorRect.y - padding;
381
- break;
382
- case 'down':
383
- targetX = currentPos.x;
384
- targetY = locatorRect.y + locatorRect.height + padding;
385
- break;
386
- case 'left':
387
- targetX = locatorRect.x - padding;
388
- targetY = currentPos.y;
389
- break;
390
- case 'right':
391
- targetX = locatorRect.x + locatorRect.width + padding;
392
- targetY = currentPos.y;
393
- break;
394
- default: {
395
- const _ = postClickMove.direction;
396
- throw new Error(`Unknown postClickMove direction: ${_}`);
397
- }
150
+ const padding = postClickMove.padding ?? 0;
151
+ switch (postClickMove.direction) {
152
+ case 'up':
153
+ targetX = currentPos.x;
154
+ targetY = elementRect.y - padding;
155
+ break;
156
+ case 'down':
157
+ targetX = currentPos.x;
158
+ targetY = elementRect.y + elementRect.height + padding;
159
+ break;
160
+ case 'left':
161
+ targetX = elementRect.x - padding;
162
+ targetY = currentPos.y;
163
+ break;
164
+ case 'right':
165
+ targetX = elementRect.x + elementRect.width + padding;
166
+ targetY = currentPos.y;
167
+ break;
168
+ default: {
169
+ const _ = postClickMove.direction;
170
+ throw new Error(`Unknown postClickMove direction: ${_}`);
398
171
  }
399
172
  }
400
173
  }
@@ -410,154 +183,33 @@ async function performClickActions(locator, doClick, clickOptions, autoZoomOptio
410
183
  defaultDuration: undefined,
411
184
  context: 'postClickMove',
412
185
  });
413
- const steps = Math.max(1, Math.floor(duration / CURSOR_STEP_MS));
414
- const stepMs = duration / steps;
415
- const postClickMoveStartMs = Date.now();
416
- const startPos = { ...currentPos };
417
- for (let i = 0; i <= steps; i++) {
418
- const t = i / steps;
419
- const easedT = evaluateEasingAtT(t, easing);
420
- const x = startPos.x + easedT * (targetX - startPos.x);
421
- const y = startPos.y + easedT * (targetY - startPos.y);
422
- await mouseMoveInternal(x, y);
423
- if (i < steps) {
424
- await new Promise((resolve) => setTimeout(resolve, stepMs));
425
- }
426
- }
427
- const postClickMoveEndMs = Date.now();
428
- innerEvents.push({
429
- type: 'focusChange',
430
- startMs: postClickMoveStartMs,
431
- endMs: postClickMoveEndMs,
432
- x: targetX,
433
- y: targetY,
186
+ const startMs = Date.now();
187
+ await performMouseMove({
188
+ page,
189
+ targetX,
190
+ targetY,
191
+ duration,
434
192
  easing,
435
193
  });
436
- mousePositions.set(page, { x: targetX, y: targetY });
437
- }
438
- }
439
- let elementRect;
440
- if (domClickData) {
441
- elementRect = domClickData.targetRect;
442
- }
443
- else if (locatorRect) {
444
- elementRect = locatorRect ?? undefined;
445
- }
446
- if (elementRect) {
447
- return {
448
- elementRect,
449
- innerEvents,
450
- };
451
- }
452
- else {
453
- logger.warn('[screenci] Failed to capture click coordinates from both DOM event and locator bounding box.');
454
- return null;
455
- }
456
- }
457
- async function prepareAutoZoomForLocator(locator, eventType, autoZoomOptions) {
458
- const scrollHandler = new ZoomScrollHandler(autoZoomOptions);
459
- const zoomDur = scrollHandler.isInsideAutoZoom ? (getZoomDuration() ?? 0) : 0;
460
- if (scrollHandler.isInsideAutoZoom &&
461
- scrollHandler.hadPreviousZoomLocation &&
462
- zoomDur > 0) {
463
- await sleep(zoomDur);
464
- }
465
- const { locatorRect, isFirstAutoZoomInteraction } = await scrollHandler.scroll(locator);
466
- if (scrollHandler.isInsideAutoZoom && locatorRect) {
467
- if (isFirstAutoZoomInteraction && zoomDur > 0) {
468
- await sleep(zoomDur);
469
- }
470
- setLastZoomLocation({
471
- x: locatorRect.x + locatorRect.width / 2,
472
- y: locatorRect.y + locatorRect.height / 2,
473
- elementRect: locatorRect,
474
- eventType,
475
- });
476
- }
477
- return { locatorRect, isFirstAutoZoomInteraction };
478
- }
479
- async function performSimpleAction(locator, doAction, options, subType, clickOpt, autoZoomOptions, position, recordMousePress = subType === 'tap') {
480
- await sleep(PRE_ACTION_SLEEP);
481
- let innerEvents = [];
482
- let elementRect;
483
- if (clickOpt !== undefined) {
484
- const { moveDuration, moveSpeed, beforeClickPause, moveEasing, postClickPause, postClickMove, } = clickOpt;
485
- const clickActionResult = await performClickActions(locator, doAction, {}, autoZoomOptions, position, moveDuration, moveSpeed, beforeClickPause, moveEasing, postClickPause, postClickMove);
486
- innerEvents = clickActionResult?.innerEvents ?? [];
487
- elementRect = clickActionResult?.elementRect;
488
- }
489
- else {
490
- const locatorRect = await locator.boundingBox();
491
- if (isInsideAutoZoom() && locatorRect) {
492
- const focusStart = Date.now();
493
- const zoomDur = getZoomDuration() ?? 0;
494
- if (zoomDur > 0)
495
- await sleep(zoomDur);
496
- const focusEnd = Date.now();
194
+ const endMs = Date.now();
497
195
  innerEvents.push({
498
196
  type: 'focusChange',
499
- startMs: focusStart,
500
- endMs: focusEnd,
501
- x: locatorRect.x + locatorRect.width / 2,
502
- y: locatorRect.y + locatorRect.height / 2,
503
- easing: getZoomEasing() ?? 'ease-in-out',
504
- focusOnly: true,
505
- elementRect: locatorRect,
506
- });
507
- }
508
- const targetPosition = locatorRect
509
- ? {
510
- x: locatorRect.width / 2,
511
- y: locatorRect.height / 2,
512
- }
513
- : undefined;
514
- const startTime = Date.now();
515
- await doAction({
516
- ...options,
517
- ...(targetPosition ? { position: targetPosition } : {}),
518
- });
519
- const endTime = Date.now();
520
- elementRect = locatorRect ?? undefined;
521
- if (recordMousePress) {
522
- const midTime = (startTime + endTime) / 2;
523
- innerEvents.push({
524
- type: 'mouseDown',
525
- startMs: startTime,
526
- endMs: midTime,
527
- });
528
- innerEvents.push({
529
- type: 'mouseUp',
530
- startMs: midTime,
531
- endMs: endTime,
197
+ x: targetX,
198
+ y: targetY,
199
+ startMs,
200
+ endMs,
201
+ mouse: {
202
+ startMs,
203
+ endMs,
204
+ ...(duration > 0 ? { easing } : {}),
205
+ },
532
206
  });
533
207
  }
534
208
  }
535
- const simpleWaitStart = Date.now();
536
- await sleep(POST_ACTION_SLEEP);
537
- const simpleWaitEnd = Date.now();
538
- innerEvents.push({
539
- type: 'mouseWait',
540
- startMs: simpleWaitStart,
541
- endMs: simpleWaitEnd,
542
- });
543
- if (activeClickRecorder && innerEvents.length > 0) {
544
- activeClickRecorder.addInput(subType, elementRect, innerEvents);
545
- }
546
- }
547
- async function recordedClick(locator, doClick, clickOptions, autoZoomOptions, position, moveDuration, moveSpeed, beforeClickPause = CLICK_DURATION_MS / 2, moveEasing = 'ease-in-out', postClickPause = CLICK_DURATION_MS / 2, postClickMove) {
548
- await sleep(PRE_ACTION_SLEEP);
549
- const result = await performClickActions(locator, doClick, clickOptions, autoZoomOptions, position, moveDuration, moveSpeed, beforeClickPause, moveEasing, postClickPause, postClickMove);
550
- const clickWaitStart = Date.now();
551
- await sleep(POST_ACTION_SLEEP);
552
- const clickWaitEnd = Date.now();
553
- if (activeClickRecorder && result) {
554
- result.innerEvents.push({
555
- type: 'mouseWait',
556
- startMs: clickWaitStart,
557
- endMs: clickWaitEnd,
558
- });
559
- activeClickRecorder.addInput('click', result.elementRect, result.innerEvents);
560
- }
209
+ return {
210
+ elementRect: actionElementRect ?? elementRect,
211
+ innerEvents,
212
+ };
561
213
  }
562
214
  function instrumentLocatorMethods(obj) {
563
215
  for (const method of LOCATOR_RETURN_METHODS) {
@@ -585,6 +237,7 @@ export function instrumentLocator(locator) {
585
237
  return locator;
586
238
  instrumented.add(locator);
587
239
  const originalClick = locator.click.bind(locator);
240
+ setOriginalLocatorClick(locator, originalClick);
588
241
  locator.click = async (options) => {
589
242
  const { moveDuration, moveSpeed, beforeClickPause, moveEasing, postClickPause, postClickMove, autoZoomOptions, position, steps: _steps, ...clickOptions } = options ?? {};
590
243
  if (isInsideHide()) {
@@ -594,66 +247,36 @@ export function instrumentLocator(locator) {
594
247
  });
595
248
  }
596
249
  assertDurationOrSpeed(moveDuration, moveSpeed, 'click move');
597
- return recordedClick(locator, (options) => originalClick(options), clickOptions, autoZoomOptions, position, moveDuration, moveSpeed, beforeClickPause, moveEasing, postClickPause, postClickMove);
250
+ const { doClick, supportsTrial } = resolveLocatorMouseAction(locator, 'click');
251
+ const result = await performAction({
252
+ targetPosInElement: position ?? { x: 0, y: 0 },
253
+ ...(moveDuration !== undefined ? { duration: moveDuration } : {}),
254
+ ...(moveSpeed !== undefined ? { speed: moveSpeed } : {}),
255
+ easing: moveEasing ?? 'ease-in-out',
256
+ }, locator, doClick, supportsTrial, 'singleDuring', autoZoomOptions, position, beforeClickPause, postClickPause, postClickMove, false);
257
+ if (activeClickRecorder && result) {
258
+ activeClickRecorder.addInput('click', undefined, result.innerEvents);
259
+ }
598
260
  };
599
261
  const originalPressSequentially = locator.pressSequentially.bind(locator);
600
262
  locator.pressSequentially = async (text, options) => {
263
+ const clickOpt = options?.click ?? {};
601
264
  const { click: _click, autoZoomOptions, hideMouse: _hideMouse, position: _position, ...pressOptions } = options ?? {};
602
265
  if (isInsideHide()) {
603
266
  return originalPressSequentially(text, pressOptions);
604
267
  }
605
- await sleep(PRE_ACTION_SLEEP);
606
268
  const innerEvents = [];
607
- let elementRect;
608
- if (options?.click !== undefined) {
609
- // Click before fill: performClickActions handles scrolling and bounding box.
610
- const clickOpt = options.click;
611
- const position = options.position;
612
- const { moveDuration, moveSpeed, beforeClickPause, moveEasing, postClickPause, postClickMove, ...clickOptions } = clickOpt;
613
- const clickActionResult = await performClickActions(locator, (options) => originalClick(options), clickOptions, options.autoZoomOptions, position, moveDuration, moveSpeed, beforeClickPause, moveEasing, postClickPause, postClickMove);
614
- innerEvents.push(...(clickActionResult?.innerEvents ?? []));
615
- elementRect = clickActionResult?.elementRect;
616
- }
617
- else {
618
- const { locatorRect, isFirstAutoZoomInteraction } = await prepareAutoZoomForLocator(locator, 'fill', options?.autoZoomOptions);
619
- if (isFirstAutoZoomInteraction) {
620
- const postDelay = getPostZoomInOutDelay() ?? 0;
621
- if (postDelay > 0)
622
- await sleep(postDelay);
623
- }
624
- elementRect = locatorRect;
625
- if (isInsideAutoZoom() && locatorRect) {
626
- const zoomDur = getZoomDuration() ?? 0;
627
- if (zoomDur > 0) {
628
- await sleep(zoomDur);
629
- }
630
- }
631
- }
632
- // Hide cursor while typing (will be shown again on next mouse move)
633
- const page = locator.page();
634
- const shouldHideMouse = options?.hideMouse === true;
635
- if (shouldHideMouse) {
636
- const cursorVisible = mouseVisibilities.get(page) ?? true;
637
- if (cursorVisible) {
638
- mouseVisibilities.set(page, false);
639
- const hideMs = Date.now();
640
- innerEvents.push({
641
- type: 'mouseHide',
642
- startMs: hideMs,
643
- endMs: hideMs,
644
- });
645
- }
646
- }
647
- await originalPressSequentially(text, pressOptions);
648
- const pressWaitStart = Date.now();
649
- await sleep(POST_ACTION_SLEEP);
650
- const pressWaitEnd = Date.now();
269
+ let elementRect = undefined;
270
+ const { moveDuration, moveSpeed, beforeClickPause, moveEasing = 'ease-in-out', postClickPause, postClickMove, } = clickOpt;
271
+ const clickActionResult = await performAction({
272
+ targetPosInElement: options?.position ?? { x: 0, y: 0 },
273
+ ...(moveDuration !== undefined ? { duration: moveDuration } : {}),
274
+ ...(moveSpeed !== undefined ? { speed: moveSpeed } : {}),
275
+ easing: moveEasing,
276
+ }, locator, async () => originalPressSequentially(text, pressOptions), false, 'singleBefore', autoZoomOptions, options?.position, beforeClickPause ?? CLICK_DURATION_MS / 2, postClickPause ?? CLICK_DURATION_MS / 2, postClickMove, true);
277
+ innerEvents.push(...(clickActionResult?.innerEvents ?? []));
278
+ elementRect = clickActionResult?.elementRect;
651
279
  if (activeClickRecorder) {
652
- innerEvents.push({
653
- type: 'mouseWait',
654
- startMs: pressWaitStart,
655
- endMs: pressWaitEnd,
656
- });
657
280
  activeClickRecorder.addInput('pressSequentially', elementRect, innerEvents);
658
281
  }
659
282
  };
@@ -663,80 +286,16 @@ export function instrumentLocator(locator) {
663
286
  const { duration: _duration, click: _click, position: _position, hideMouse: _hideMouse, autoZoomOptions: _autoZoomOptions, ...fillOptions } = options ?? {};
664
287
  return originalFill(value, fillOptions);
665
288
  }
666
- if (options?.click !== undefined) {
667
- await sleep(PRE_ACTION_SLEEP);
668
- const clickActionResult = await performClickActions(locator, (clickOptions) => originalClick(clickOptions), {}, options.autoZoomOptions, options.position, options.click.moveDuration, options.click.moveSpeed, options.click.beforeClickPause, options.click.moveEasing, options.click.postClickPause, options.click.postClickMove);
669
- const innerEvents = [...(clickActionResult?.innerEvents ?? [])];
670
- const elementRect = clickActionResult?.elementRect;
671
- const page = locator.page();
672
- if (options.hideMouse === true) {
673
- const cursorVisible = mouseVisibilities.get(page) ?? true;
674
- if (cursorVisible) {
675
- mouseVisibilities.set(page, false);
676
- const hideMs = Date.now();
677
- innerEvents.push({
678
- type: 'mouseHide',
679
- startMs: hideMs,
680
- endMs: hideMs,
681
- });
682
- }
683
- }
684
- await locator.evaluate((element) => {
685
- if (element instanceof HTMLInputElement ||
686
- element instanceof HTMLTextAreaElement) {
687
- element.focus();
688
- element.select();
689
- return;
690
- }
691
- if (element instanceof HTMLElement && element.isContentEditable) {
692
- element.focus();
693
- const selection = element.ownerDocument.getSelection();
694
- if (!selection)
695
- return;
696
- const range = element.ownerDocument.createRange();
697
- range.selectNodeContents(element);
698
- selection.removeAllRanges();
699
- selection.addRange(range);
700
- }
701
- });
702
- const duration = options.duration ?? 1000;
703
- const delay = value.length > 0 ? duration / value.length : 0;
704
- await page.keyboard.type(value, { delay });
705
- const fillWaitStart = Date.now();
706
- await sleep(POST_ACTION_SLEEP);
707
- const fillWaitEnd = Date.now();
708
- innerEvents.push({
709
- type: 'mouseWait',
710
- startMs: fillWaitStart,
711
- endMs: fillWaitEnd,
712
- });
713
- if (activeClickRecorder) {
714
- activeClickRecorder.addInput('pressSequentially', elementRect, innerEvents);
715
- }
716
- return;
717
- }
718
- await sleep(PRE_ACTION_SLEEP);
719
- const page = locator.page();
289
+ const clickOpt = options?.click ?? {};
720
290
  const innerEvents = [];
721
- const fillOptions = options ?? {};
722
- const locatorRect = await locator.boundingBox();
723
- let elementRect = locatorRect ?? undefined;
724
- if (fillOptions.hideMouse === true) {
725
- const cursorVisible = mouseVisibilities.get(page) ?? true;
726
- if (cursorVisible) {
727
- mouseVisibilities.set(page, false);
728
- const hideMs = Date.now();
729
- innerEvents.push({
730
- type: 'mouseHide',
731
- startMs: hideMs,
732
- endMs: hideMs,
733
- });
734
- }
735
- }
736
- if (isInsideAutoZoom() && locatorRect) {
737
- const correctedScrollResult = await new ZoomScrollHandler(options?.autoZoomOptions).scroll(locator);
738
- const correctedRect = correctedScrollResult.locatorRect ?? locatorRect;
739
- const focusStart = Date.now();
291
+ let elementRect = undefined;
292
+ const { moveDuration, moveSpeed, beforeClickPause, moveEasing = 'ease-in-out', postClickPause, postClickMove, } = clickOpt;
293
+ const clickActionResult = await performAction({
294
+ targetPosInElement: options?.position ?? { x: 0, y: 0 },
295
+ ...(moveDuration !== undefined ? { duration: moveDuration } : {}),
296
+ ...(moveSpeed !== undefined ? { speed: moveSpeed } : {}),
297
+ easing: moveEasing,
298
+ }, locator, async () => {
740
299
  await locator.evaluate((element) => {
741
300
  if (element instanceof HTMLInputElement ||
742
301
  element instanceof HTMLTextAreaElement) {
@@ -755,63 +314,43 @@ export function instrumentLocator(locator) {
755
314
  selection.addRange(range);
756
315
  }
757
316
  });
758
- const focusEnd = Date.now();
759
- innerEvents.push({
760
- type: 'focusChange',
761
- startMs: focusStart,
762
- endMs: focusEnd,
763
- x: correctedRect.x + correctedRect.width / 2,
764
- y: correctedRect.y + correctedRect.height / 2,
765
- easing: getZoomEasing() ?? 'ease-in-out',
766
- focusOnly: true,
767
- elementRect: correctedRect,
768
- });
769
- elementRect = correctedRect;
770
- }
771
- else {
772
- await locator.evaluate((element) => {
773
- if (element instanceof HTMLInputElement ||
774
- element instanceof HTMLTextAreaElement) {
775
- element.focus();
776
- element.select();
777
- return;
778
- }
779
- if (element instanceof HTMLElement && element.isContentEditable) {
780
- element.focus();
781
- const selection = element.ownerDocument.getSelection();
782
- if (!selection)
783
- return;
784
- const range = element.ownerDocument.createRange();
785
- range.selectNodeContents(element);
786
- selection.removeAllRanges();
787
- selection.addRange(range);
788
- }
789
- });
790
- }
791
- const duration = fillOptions.duration ?? 1000;
792
- const delay = value.length > 0 ? duration / value.length : 0;
793
- await page.keyboard.type(value, { delay });
794
- const fillWaitStart = Date.now();
795
- await sleep(POST_ACTION_SLEEP);
796
- const fillWaitEnd = Date.now();
797
- innerEvents.push({
798
- type: 'mouseWait',
799
- startMs: fillWaitStart,
800
- endMs: fillWaitEnd,
801
- });
802
- if (activeClickRecorder &&
803
- (isInsideAutoZoom() || fillOptions.hideMouse === true)) {
317
+ const duration = options?.duration ?? 1000;
318
+ const delay = value.length > 0 ? duration / value.length : 0;
319
+ await locator.page().keyboard.type(value, { delay });
320
+ }, false, 'singleBefore', options?.autoZoomOptions, options?.position, beforeClickPause ?? CLICK_DURATION_MS / 2, postClickPause ?? CLICK_DURATION_MS / 2, postClickMove, true);
321
+ innerEvents.push(...(clickActionResult?.innerEvents ?? []));
322
+ elementRect = clickActionResult?.elementRect;
323
+ if (activeClickRecorder) {
804
324
  activeClickRecorder.addInput('pressSequentially', elementRect, innerEvents);
805
325
  }
806
- return;
807
326
  };
808
327
  const originalTap = locator.tap.bind(locator);
328
+ setOriginalLocatorTap(locator, originalTap);
809
329
  locator.tap = async (options) => {
810
330
  const clickOpt = options?.click;
811
331
  const { click: _click, position, autoZoomOptions, ...tapOpts } = options ?? {};
812
- return performSimpleAction(locator, (options) => originalTap(options), tapOpts, 'tap', clickOpt, autoZoomOptions, position);
332
+ if (isInsideHide()) {
333
+ return originalTap(tapOpts);
334
+ }
335
+ const { doClick, supportsTrial } = resolveLocatorMouseAction(locator, 'tap');
336
+ const result = await performAction(clickOpt
337
+ ? {
338
+ targetPosInElement: position ?? { x: 0, y: 0 },
339
+ ...(clickOpt.moveDuration !== undefined
340
+ ? { duration: clickOpt.moveDuration }
341
+ : {}),
342
+ ...(clickOpt.moveSpeed !== undefined
343
+ ? { speed: clickOpt.moveSpeed }
344
+ : {}),
345
+ easing: clickOpt.moveEasing ?? 'ease-in-out',
346
+ }
347
+ : undefined, locator, doClick, supportsTrial, 'singleDuring', autoZoomOptions, position, clickOpt?.beforeClickPause, clickOpt?.postClickPause, clickOpt?.postClickMove, false);
348
+ if (activeClickRecorder && result) {
349
+ activeClickRecorder.addInput('tap', result.elementRect, result.innerEvents);
350
+ }
813
351
  };
814
352
  const originalCheck = locator.check.bind(locator);
353
+ setOriginalLocatorCheck(locator, originalCheck);
815
354
  locator.check = async (options) => {
816
355
  const clickOpt = options?.click;
817
356
  const position = options?.position;
@@ -819,9 +358,25 @@ export function instrumentLocator(locator) {
819
358
  if (isInsideHide()) {
820
359
  return originalCheck(checkOpts);
821
360
  }
822
- return performSimpleAction(locator, (options) => originalCheck(options), checkOpts, 'check', clickOpt, autoZoomOptions, position, false);
361
+ const { doClick, supportsTrial } = resolveLocatorMouseAction(locator, 'check');
362
+ const result = await performAction(clickOpt
363
+ ? {
364
+ targetPosInElement: position ?? { x: 0, y: 0 },
365
+ ...(clickOpt.moveDuration !== undefined
366
+ ? { duration: clickOpt.moveDuration }
367
+ : {}),
368
+ ...(clickOpt.moveSpeed !== undefined
369
+ ? { speed: clickOpt.moveSpeed }
370
+ : {}),
371
+ easing: clickOpt.moveEasing ?? 'ease-in-out',
372
+ }
373
+ : undefined, locator, doClick, supportsTrial, 'singleDuring', autoZoomOptions, position, clickOpt?.beforeClickPause, clickOpt?.postClickPause, clickOpt?.postClickMove, false);
374
+ if (activeClickRecorder && result) {
375
+ activeClickRecorder.addInput('check', result.elementRect, result.innerEvents);
376
+ }
823
377
  };
824
378
  const originalUncheck = locator.uncheck.bind(locator);
379
+ setOriginalLocatorUncheck(locator, originalUncheck);
825
380
  locator.uncheck = async (options) => {
826
381
  const clickOpt = options?.click;
827
382
  const position = options?.position;
@@ -829,7 +384,22 @@ export function instrumentLocator(locator) {
829
384
  if (isInsideHide()) {
830
385
  return originalUncheck(uncheckOpts);
831
386
  }
832
- return performSimpleAction(locator, (options) => originalUncheck(options), uncheckOpts, 'uncheck', clickOpt, autoZoomOptions, position, false);
387
+ const { doClick, supportsTrial } = resolveLocatorMouseAction(locator, 'uncheck');
388
+ const result = await performAction(clickOpt
389
+ ? {
390
+ targetPosInElement: position ?? { x: 0, y: 0 },
391
+ ...(clickOpt.moveDuration !== undefined
392
+ ? { duration: clickOpt.moveDuration }
393
+ : {}),
394
+ ...(clickOpt.moveSpeed !== undefined
395
+ ? { speed: clickOpt.moveSpeed }
396
+ : {}),
397
+ easing: clickOpt.moveEasing ?? 'ease-in-out',
398
+ }
399
+ : undefined, locator, doClick, supportsTrial, 'singleDuring', autoZoomOptions, position, clickOpt?.beforeClickPause, clickOpt?.postClickPause, clickOpt?.postClickMove, false);
400
+ if (activeClickRecorder && result) {
401
+ activeClickRecorder.addInput('uncheck', result.elementRect, result.innerEvents);
402
+ }
833
403
  };
834
404
  locator.setChecked = async (checked, options) => {
835
405
  if (checked) {
@@ -840,47 +410,61 @@ export function instrumentLocator(locator) {
840
410
  }
841
411
  };
842
412
  const originalSelectOption = locator.selectOption.bind(locator);
413
+ let currentSelectValues = null;
414
+ let currentSelectOptions;
415
+ let currentSelectResult = [];
416
+ setOriginalLocatorSelect(locator, (_values, actionOptions) => originalSelectOption(currentSelectValues, {
417
+ ...currentSelectOptions,
418
+ ...actionOptions,
419
+ }).then((res) => {
420
+ currentSelectResult = res;
421
+ return res;
422
+ }));
843
423
  locator.selectOption = async (values, options) => {
844
424
  const clickOpt = options?.click;
845
425
  const { click: _click, position, autoZoomOptions, ...selectOpts } = options ?? {};
846
426
  if (isInsideHide()) {
847
427
  return originalSelectOption(values, selectOpts);
848
428
  }
849
- let result = [];
850
- await performSimpleAction(locator, (options) => originalSelectOption(values, options).then((res) => {
851
- result = res;
852
- }), selectOpts, 'select', clickOpt, autoZoomOptions, position, false);
853
- return result;
429
+ currentSelectValues = values;
430
+ currentSelectOptions = selectOpts;
431
+ currentSelectResult = [];
432
+ const { doClick, supportsTrial } = resolveLocatorMouseAction(locator, 'select');
433
+ const actionResult = await performAction(clickOpt
434
+ ? {
435
+ targetPosInElement: position ?? { x: 0, y: 0 },
436
+ ...(clickOpt.moveDuration !== undefined
437
+ ? { duration: clickOpt.moveDuration }
438
+ : {}),
439
+ ...(clickOpt.moveSpeed !== undefined
440
+ ? { speed: clickOpt.moveSpeed }
441
+ : {}),
442
+ easing: clickOpt.moveEasing ?? 'ease-in-out',
443
+ }
444
+ : undefined, locator, doClick, supportsTrial, 'singleDuring', autoZoomOptions, position, clickOpt?.beforeClickPause, clickOpt?.postClickPause, clickOpt?.postClickMove);
445
+ if (activeClickRecorder && actionResult) {
446
+ activeClickRecorder.addInput('select', actionResult.elementRect, actionResult.innerEvents);
447
+ }
448
+ return currentSelectResult;
854
449
  };
855
450
  const originalHover = locator.hover.bind(locator);
856
451
  locator.hover = async (options) => {
857
452
  const { moveDuration, moveSpeed, easing: moveEasing = 'ease-in-out', hoverDuration = 1000, position, ...hoverOptions } = options ?? {};
858
453
  assertDurationOrSpeed(moveDuration, moveSpeed, 'hover move');
859
- const page = locator.page();
860
- const mouseMoveInternal = originalMouseMoves.get(page) ?? page.mouse.move.bind(page.mouse);
861
- const moveStartTime = Date.now();
862
- const scrollResult = await new ZoomScrollHandler().scroll(locator);
863
- const { locatorRect } = scrollResult;
864
454
  const innerEvents = [];
865
- const targetPos = position ??
866
- (locatorRect
867
- ? { x: locatorRect.width / 2, y: locatorRect.height / 2 }
868
- : undefined);
869
- if (targetPos && locatorRect) {
870
- const targetX = locatorRect.x + targetPos.x;
871
- const targetY = locatorRect.y + targetPos.y;
872
- const resolvedDuration = resolveMouseMoveDuration(page, targetX, targetY, {
873
- duration: moveDuration,
874
- speed: moveSpeed,
875
- defaultDuration: 1000,
876
- context: 'hover move',
877
- });
878
- innerEvents.push(await animateMouseMove(page, mouseMoveInternal, targetX, targetY, resolveMoveDuration(scrollResult, resolvedDuration), moveEasing, resolveMoveStartMs(scrollResult, moveStartTime), locatorRect));
879
- }
455
+ const mouseMovePlan = {
456
+ targetPosInElement: position,
457
+ ...(moveDuration !== undefined ? { duration: moveDuration } : {}),
458
+ ...(moveSpeed !== undefined ? { speed: moveSpeed } : {}),
459
+ easing: moveEasing,
460
+ };
461
+ const hoverFocusChange = await changeFocus(locator, options?.autoZoomOptions, mouseMovePlan);
462
+ const locatorRect = hoverFocusChange.elementRect;
463
+ innerEvents.push(hoverFocusChange);
880
464
  const waitStartMs = Date.now();
881
465
  await originalHover({
882
466
  ...hoverOptions,
883
- ...(targetPos ? { position: targetPos } : {}),
467
+ ...(position ? { position } : {}),
884
468
  });
885
469
  if (hoverDuration > 0) {
886
470
  await sleep(hoverDuration);
@@ -895,55 +479,35 @@ export function instrumentLocator(locator) {
895
479
  activeClickRecorder.addInput('hover', locatorRect, innerEvents);
896
480
  }
897
481
  };
482
+ const originalScrollIntoViewIfNeeded = locator.scrollIntoViewIfNeeded.bind(locator);
483
+ locator.scrollIntoViewIfNeeded = async (options) => {
484
+ if (isInsideHide()) {
485
+ return originalScrollIntoViewIfNeeded(options);
486
+ }
487
+ const { easing = 'ease-in-out', duration, amount, centering, } = options ?? {};
488
+ await changeFocus(locator, {
489
+ easing,
490
+ ...(duration !== undefined ? { duration } : {}),
491
+ ...(amount !== undefined ? { amount } : {}),
492
+ ...(centering !== undefined ? { centering } : {}),
493
+ });
494
+ };
898
495
  const originalSelectText = locator.selectText.bind(locator);
899
496
  locator.selectText = async (options) => {
900
- const { moveDuration, moveSpeed, easing: moveEasing = 'ease-in-out', beforeClickPause = CLICK_DURATION_MS / 2, selectDuration = 600, ...selectOpts } = options ?? {};
497
+ const { moveDuration, moveSpeed, easing: moveEasing = 'ease-in-out', beforeClickPause = CLICK_DURATION_MS / 2, ...selectOpts } = options ?? {};
901
498
  assertDurationOrSpeed(moveDuration, moveSpeed, 'selectText move');
902
499
  const page = locator.page();
903
- const mouseMoveInternal = originalMouseMoves.get(page) ?? page.mouse.move.bind(page.mouse);
904
- const moveStartTime = Date.now();
905
- const scrollResult = await new ZoomScrollHandler().scroll(locator);
906
- const { locatorRect } = scrollResult;
907
500
  const innerEvents = [];
908
- const targetPos = locatorRect
909
- ? { x: locatorRect.width / 2, y: locatorRect.height / 2 }
910
- : undefined;
911
- if (targetPos && locatorRect) {
912
- const targetX = locatorRect.x + targetPos.x;
913
- const targetY = locatorRect.y + targetPos.y;
914
- const resolvedDuration = resolveMouseMoveDuration(page, targetX, targetY, {
915
- duration: moveDuration,
916
- speed: moveSpeed,
917
- defaultDuration: 1000,
918
- context: 'selectText move',
919
- });
920
- innerEvents.push(await animateMouseMove(page, mouseMoveInternal, targetX, targetY, resolveMoveDuration(scrollResult, resolvedDuration), moveEasing, resolveMoveStartMs(scrollResult, moveStartTime), locatorRect));
921
- }
922
- await sleep(beforeClickPause);
923
- await originalSelectText(selectOpts);
924
- // Backtrack triple-click events from the moment originalSelectText resolves.
925
- // Clamp start so events don't precede the prior animation (produces a visible
926
- // pre-click pause in the recording, which is acceptable).
927
- // All timestamps use a single base + integer * segmentMs to avoid FP drift.
928
- const selectEndMs = Date.now();
929
- const lastEventEndMs = innerEvents.at(-1)?.endMs ?? 0;
930
- const tripleClickStartMs = Math.max(lastEventEndMs, selectEndMs - selectDuration);
931
- const segmentMs = selectDuration / 6;
932
- for (let i = 0; i < 3; i++) {
933
- const seg = i * 2;
934
- innerEvents.push({
935
- type: 'mouseDown',
936
- startMs: tripleClickStartMs + seg * segmentMs,
937
- endMs: tripleClickStartMs + (seg + 1) * segmentMs,
938
- easing: 'ease-in-out',
939
- });
940
- innerEvents.push({
941
- type: 'mouseUp',
942
- startMs: tripleClickStartMs + (seg + 1) * segmentMs,
943
- endMs: tripleClickStartMs + (seg + 2) * segmentMs,
944
- easing: 'ease-in-out',
945
- });
946
- }
501
+ const selectActionResult = await performAction({
502
+ targetPosInElement: { x: 0, y: 0 },
503
+ ...(moveDuration !== undefined ? { duration: moveDuration } : {}),
504
+ ...(moveSpeed !== undefined ? { speed: moveSpeed } : {}),
505
+ easing: moveEasing,
506
+ }, locator, async () => {
507
+ await originalSelectText(selectOpts);
508
+ }, false, 'tripleBefore', undefined, undefined, beforeClickPause, undefined, undefined, false);
509
+ const locatorRect = selectActionResult?.elementRect;
510
+ innerEvents.push(...(selectActionResult?.innerEvents ?? []));
947
511
  if (activeClickRecorder && innerEvents.length > 0) {
948
512
  activeClickRecorder.addInput('selectText', locatorRect, innerEvents);
949
513
  }
@@ -952,12 +516,8 @@ export function instrumentLocator(locator) {
952
516
  const { moveDuration, moveSpeed, moveEasing = 'ease-in-out', preDragPause = CLICK_DURATION_MS / 2, dragDuration, dragSpeed, dragEasing = 'ease-in-out', sourcePosition, targetPosition, } = options ?? {};
953
517
  assertDurationOrSpeed(moveDuration, moveSpeed, 'dragTo move');
954
518
  assertDurationOrSpeed(dragDuration, dragSpeed, 'dragTo drag');
955
- await sleep(PRE_ACTION_SLEEP);
956
519
  const page = locator.page();
957
- const mouseMoveInternal = originalMouseMoves.get(page) ?? page.mouse.move.bind(page.mouse);
958
- const moveStartTime = Date.now();
959
- const scrollResult = await new ZoomScrollHandler().scroll(locator);
960
- const { locatorRect: sourceRect } = scrollResult;
520
+ const sourceRectPreview = await locator.boundingBox();
961
521
  const targetBb = await target.boundingBox();
962
522
  const targetRect = targetBb
963
523
  ? {
@@ -968,37 +528,31 @@ export function instrumentLocator(locator) {
968
528
  }
969
529
  : undefined;
970
530
  const innerEvents = [];
971
- const sourcePos = sourcePosition ??
972
- (sourceRect
973
- ? { x: sourceRect.width / 2, y: sourceRect.height / 2 }
974
- : undefined);
975
531
  const targetPos = targetPosition ??
976
532
  (targetRect
977
533
  ? { x: targetRect.width / 2, y: targetRect.height / 2 }
978
534
  : undefined);
979
- // 1. Animate cursor to source
980
- if (sourcePos && sourceRect) {
981
- const toX = sourceRect.x + sourcePos.x;
982
- const toY = sourceRect.y + sourcePos.y;
983
- const resolvedDuration = resolveMouseMoveDuration(page, toX, toY, {
984
- duration: moveDuration,
985
- speed: moveSpeed,
986
- defaultDuration: 1000,
987
- context: 'dragTo move',
988
- });
989
- innerEvents.push(await animateMouseMove(page, mouseMoveInternal, toX, toY, resolveMoveDuration(scrollResult, resolvedDuration), moveEasing, resolveMoveStartMs(scrollResult, moveStartTime), sourceRect));
535
+ const sourceFocusChange = await changeFocus(locator, undefined, {
536
+ targetPosInElement: sourcePosition,
537
+ ...(moveDuration !== undefined ? { duration: moveDuration } : {}),
538
+ ...(moveSpeed !== undefined ? { speed: moveSpeed } : {}),
539
+ easing: moveEasing,
540
+ });
541
+ if (sourceFocusChange.elementRect) {
542
+ innerEvents.push(sourceFocusChange);
990
543
  }
991
544
  // 2. preDragPause + mouseDown
992
545
  await sleep(preDragPause);
993
546
  const mouseDownStart = Date.now();
994
- await page.mouse.down();
547
+ await performMouseDown({
548
+ mouseDownInternal: getOriginalMouseDown(page, page.mouse.down.bind(page.mouse)),
549
+ });
995
550
  await sleep(CLICK_DURATION_MS / 2);
996
- innerEvents.push({
997
- type: 'mouseDown',
551
+ innerEvents.push(buildMouseDownEvent({
998
552
  startMs: mouseDownStart,
999
553
  endMs: Date.now(),
1000
554
  easing: 'ease-in-out',
1001
- });
555
+ }));
1002
556
  // 3. Drag: animate cursor from source to target
1003
557
  const dragStartTime = Date.now();
1004
558
  if (targetPos && targetRect) {
@@ -1010,28 +564,36 @@ export function instrumentLocator(locator) {
1010
564
  defaultDuration: 1000,
1011
565
  context: 'dragTo drag',
1012
566
  });
1013
- innerEvents.push(await animateMouseMove(page, mouseMoveInternal, toX, toY, resolvedDuration, dragEasing, dragStartTime, targetRect));
567
+ await performMouseMove({
568
+ page,
569
+ targetX: toX,
570
+ targetY: toY,
571
+ duration: resolvedDuration,
572
+ easing: dragEasing,
573
+ });
574
+ innerEvents.push({
575
+ type: 'mouseMove',
576
+ startMs: dragStartTime,
577
+ endMs: Date.now(),
578
+ x: toX,
579
+ y: toY,
580
+ ...(resolvedDuration > 0 ? { easing: dragEasing } : {}),
581
+ elementRect: targetRect,
582
+ });
1014
583
  }
1015
584
  // 4. mouseUp at target
1016
585
  const mouseUpStart = Date.now();
1017
- await page.mouse.up();
586
+ await performMouseUp({
587
+ mouseUpInternal: getOriginalMouseUp(page, page.mouse.up.bind(page.mouse)),
588
+ });
1018
589
  await sleep(CLICK_DURATION_MS / 2);
1019
- innerEvents.push({
1020
- type: 'mouseUp',
590
+ innerEvents.push(buildMouseUpEvent({
1021
591
  startMs: mouseUpStart,
1022
592
  endMs: Date.now(),
1023
593
  easing: 'ease-in-out',
1024
- });
1025
- const dragWaitStart = Date.now();
1026
- await sleep(POST_ACTION_SLEEP);
1027
- const dragWaitEnd = Date.now();
1028
- innerEvents.push({
1029
- type: 'mouseWait',
1030
- startMs: dragWaitStart,
1031
- endMs: dragWaitEnd,
1032
- });
594
+ }));
1033
595
  if (activeClickRecorder && innerEvents.length > 0) {
1034
- activeClickRecorder.addInput('dragTo', sourceRect, innerEvents);
596
+ activeClickRecorder.addInput('dragTo', sourceFocusChange.elementRect, innerEvents);
1035
597
  }
1036
598
  };
1037
599
  const originalPage = locator.page.bind(locator);
@@ -1057,23 +619,6 @@ export async function instrumentPage(page) {
1057
619
  if (instrumented.has(page))
1058
620
  return page;
1059
621
  instrumented.add(page);
1060
- // Expose a Node.js function to the browser that captures DOM click event data.
1061
- // Called synchronously from the click handler before any navigation can occur.
1062
- await page.exposeFunction('__screenciOnClick', (data) => {
1063
- pendingClickData.set(page, data);
1064
- });
1065
- // Inject a capture listener on every page load (including after navigation).
1066
- await page.addInitScript(() => {
1067
- document.addEventListener('click', (e) => {
1068
- const target = e.target;
1069
- const r = target.getBoundingClientRect();
1070
- window.__screenciOnClick({
1071
- x: e.clientX,
1072
- y: e.clientY,
1073
- targetRect: { x: r.x, y: r.y, width: r.width, height: r.height },
1074
- });
1075
- }, { capture: true });
1076
- });
1077
622
  instrumentLocatorMethods(page);
1078
623
  const originalPageFrameLocator = page.frameLocator.bind(page);
1079
624
  page.frameLocator = (...args) => instrumentFrameLocator(originalPageFrameLocator(...args));
@@ -1085,7 +630,35 @@ export async function instrumentPage(page) {
1085
630
  // Instrument page.mouse to record mouse moves and visibility toggles.
1086
631
  const originalMouse = page.mouse;
1087
632
  const originalMove = originalMouse.move.bind(originalMouse);
1088
- originalMouseMoves.set(page, originalMove);
633
+ const originalDown = originalMouse.down.bind(originalMouse);
634
+ const originalUp = originalMouse.up.bind(originalMouse);
635
+ const originalClickMethod = originalMouse.click;
636
+ const originalClick = typeof originalClickMethod === 'function'
637
+ ? originalClickMethod.bind(originalMouse)
638
+ : async (x, y, options) => {
639
+ await originalMove(x, y);
640
+ await originalDown(options);
641
+ if (options?.delay) {
642
+ await sleep(options.delay);
643
+ }
644
+ await originalUp(options);
645
+ };
646
+ const originalShowMethod = originalMouse
647
+ .show;
648
+ const originalHideMethod = originalMouse
649
+ .hide;
650
+ const originalShow = typeof originalShowMethod === 'function'
651
+ ? originalShowMethod.bind(originalMouse)
652
+ : () => { };
653
+ const originalHide = typeof originalHideMethod === 'function'
654
+ ? originalHideMethod.bind(originalMouse)
655
+ : () => { };
656
+ setOriginalMouseMove(page, originalMove);
657
+ setOriginalMouseClick(page, originalClick);
658
+ setOriginalMouseDown(page, originalDown);
659
+ setOriginalMouseUp(page, originalUp);
660
+ setOriginalMouseShow(page, originalShow);
661
+ setOriginalMouseHide(page, originalHide);
1089
662
  originalMouse.move = async (x, y, options) => {
1090
663
  const duration = resolveMouseMoveDuration(page, x, y, {
1091
664
  duration: options?.duration,
@@ -1095,30 +668,29 @@ export async function instrumentPage(page) {
1095
668
  });
1096
669
  const easing = options?.easing ?? 'ease-in-out';
1097
670
  const startMs = Date.now();
1098
- if (duration > 0) {
1099
- const startPos = mousePositions.get(page) ?? { x: 0, y: 0 };
1100
- const steps = Math.max(1, Math.floor(duration / CURSOR_STEP_MS));
1101
- const stepMs = duration / steps;
1102
- for (let i = 0; i <= steps; i++) {
1103
- const t = i / steps;
1104
- const easedT = evaluateEasingAtT(t, easing);
1105
- const cx = startPos.x + easedT * (x - startPos.x);
1106
- const cy = startPos.y + easedT * (y - startPos.y);
1107
- await originalMove(cx, cy);
1108
- if (i < steps) {
1109
- await new Promise((resolve) => setTimeout(resolve, stepMs));
1110
- }
1111
- }
1112
- }
1113
- else {
1114
- await originalMove(x, y);
1115
- }
1116
- mousePositions.set(page, { x, y });
1117
- const endMs = Date.now();
671
+ await performMouseMove({
672
+ page,
673
+ targetX: x,
674
+ targetY: y,
675
+ duration,
676
+ easing,
677
+ });
678
+ const moveEvent = {
679
+ type: 'focusChange',
680
+ startMs,
681
+ endMs: Date.now(),
682
+ x,
683
+ y,
684
+ mouse: {
685
+ startMs,
686
+ endMs: Date.now(),
687
+ ...(duration > 0 ? { easing } : {}),
688
+ },
689
+ };
1118
690
  if (activeClickRecorder) {
1119
691
  // Auto-show cursor when moving after a typing auto-hide
1120
- if (!(mouseVisibilities.get(page) ?? true)) {
1121
- mouseVisibilities.set(page, true);
692
+ if (!isMouseVisible(page)) {
693
+ setMouseVisible(page, true);
1122
694
  const showMs = startMs;
1123
695
  const showEvent = {
1124
696
  type: 'mouseShow',
@@ -1127,21 +699,16 @@ export async function instrumentPage(page) {
1127
699
  };
1128
700
  activeClickRecorder.addInput('mouseShow', undefined, [showEvent]);
1129
701
  }
1130
- const moveEvent = {
1131
- type: 'focusChange',
1132
- startMs,
1133
- endMs,
1134
- x,
1135
- y,
1136
- ...(duration > 0 ? { easing } : {}),
1137
- };
1138
702
  activeClickRecorder.addInput('focusChange', undefined, [moveEvent]);
1139
703
  }
1140
704
  };
1141
- mouseVisibilities.set(page, true);
705
+ setMouseVisible(page, true);
1142
706
  originalMouse.show = () => {
1143
- if (!(mouseVisibilities.get(page) ?? true)) {
1144
- mouseVisibilities.set(page, true);
707
+ if (!isMouseVisible(page)) {
708
+ performMouseShow({
709
+ mouseShowInternal: getOriginalMouseShow(page, originalShow),
710
+ page,
711
+ });
1145
712
  if (activeClickRecorder) {
1146
713
  const timeMs = Date.now();
1147
714
  const showEvent = {
@@ -1154,8 +721,11 @@ export async function instrumentPage(page) {
1154
721
  }
1155
722
  };
1156
723
  originalMouse.hide = () => {
1157
- if (mouseVisibilities.get(page) ?? true) {
1158
- mouseVisibilities.set(page, false);
724
+ if (isMouseVisible(page)) {
725
+ performMouseHide({
726
+ mouseHideInternal: getOriginalMouseHide(page, originalHide),
727
+ page,
728
+ });
1159
729
  if (activeClickRecorder) {
1160
730
  const timeMs = Date.now();
1161
731
  const hideEvent = {