190 lines
6.7 KiB
TypeScript
190 lines
6.7 KiB
TypeScript
import type { Page } from 'playwright';
|
|
|
|
export const sleep = (ms: number) =>
|
|
new Promise<void>((r) => setTimeout(r, ms));
|
|
|
|
// Cubic ease-in-out: slow start and end, fast middle. Reads as "natural" motion.
|
|
export const easeInOut = (t: number): number =>
|
|
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
|
|
interface MoveOptions {
|
|
durationMs?: number;
|
|
ease?: (t: number) => number;
|
|
realMouse?: boolean;
|
|
}
|
|
|
|
const RAW_RECORDING_FRAME_MS = 40;
|
|
|
|
/**
|
|
* Move the real mouse from its current position to (x, y) along an eased path.
|
|
* The injected cursor follows via its mousemove listener.
|
|
*
|
|
* Real mouse moves are paced by wall-clock deadlines instead of CDP latency.
|
|
* The old version relied on slow software WebGL making each mouse.move call
|
|
* expensive; with hardware GPU those calls return quickly and animations
|
|
* collapse into a burst unless we explicitly pace them.
|
|
*/
|
|
export async function smoothMove(
|
|
page: Page,
|
|
from: { x: number; y: number },
|
|
to: { x: number; y: number },
|
|
{
|
|
durationMs = 600,
|
|
ease = easeInOut,
|
|
realMouse = false,
|
|
}: MoveOptions = {}
|
|
): Promise<void> {
|
|
const wallDuration = durationMs;
|
|
if (!realMouse) {
|
|
const animated = await page.evaluate(
|
|
({ x, y, wallDuration }) => {
|
|
const move = (
|
|
window as typeof window & {
|
|
__demoMoveCursor?: (x: number, y: number, durationMs: number) => void;
|
|
}
|
|
).__demoMoveCursor;
|
|
if (!move) return false;
|
|
move(x, y, wallDuration);
|
|
return true;
|
|
},
|
|
{ x: to.x, y: to.y, wallDuration }
|
|
);
|
|
if (animated) {
|
|
await new Promise((resolve) => setTimeout(resolve, wallDuration));
|
|
await page.mouse.move(to.x, to.y);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const steps = Math.max(2, Math.min(160, Math.round(wallDuration / RAW_RECORDING_FRAME_MS)));
|
|
const start = Date.now();
|
|
for (let i = 1; i <= steps; i++) {
|
|
const t = ease(i / steps);
|
|
const x = from.x + (to.x - from.x) * t;
|
|
const y = from.y + (to.y - from.y) * t;
|
|
await page.mouse.move(x, y);
|
|
|
|
const targetElapsed = (wallDuration * i) / steps;
|
|
const waitMs = start + targetElapsed - Date.now();
|
|
if (waitMs > 0) await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* "Fake" type: progressively set the textarea value, dispatching
|
|
* React-compatible input events.
|
|
*
|
|
* Do not do one Playwright round-trip per character here. Long prompts can
|
|
* turn a 4s typing budget into 9s of wall-clock time on a busy recorder.
|
|
* Instead, animate through paced chunks. It still reads as typing on video,
|
|
* but the runner can keep narration and visuals aligned.
|
|
*/
|
|
export async function fakeType(
|
|
page: Page,
|
|
selector: string,
|
|
text: string,
|
|
totalDurationMs: number
|
|
): Promise<void> {
|
|
if (text.length === 0) {
|
|
if (totalDurationMs > 0) await sleep(totalDurationMs);
|
|
return;
|
|
}
|
|
|
|
const steps = Math.min(
|
|
text.length,
|
|
Math.max(1, Math.min(48, Math.round(totalDurationMs / 95)))
|
|
);
|
|
const startedAt = Date.now();
|
|
|
|
for (let i = 1; i <= steps; i++) {
|
|
const charCount = Math.max(1, Math.round((i / steps) * text.length));
|
|
await page.evaluate(
|
|
({ selector, value }) => {
|
|
const ta = document.querySelector(selector) as HTMLTextAreaElement | null;
|
|
if (!ta) throw new Error('textarea not found: ' + selector);
|
|
ta.focus();
|
|
// React tracks the textarea value by hooking the descriptor; we have to
|
|
// call the prototype setter directly so React sees the change.
|
|
const proto = Object.getPrototypeOf(ta);
|
|
const setValue = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
|
|
if (!setValue) throw new Error('no value setter on textarea');
|
|
setValue.call(ta, value);
|
|
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
},
|
|
{ selector, value: text.slice(0, charCount) }
|
|
);
|
|
if (i < steps) {
|
|
const targetElapsed = (totalDurationMs * i) / steps;
|
|
const waitMs = startedAt + targetElapsed - Date.now();
|
|
if (waitMs > 0) await sleep(waitMs);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Drag the right-hand thumb of a Radix slider to a target track fraction.
|
|
* Returns the final cursor position so callers can chain a smoothMove afterwards.
|
|
*/
|
|
export async function smoothDragSliderThumb(
|
|
page: Page,
|
|
thumbSelector: string,
|
|
trackSelector: string,
|
|
fromCursor: { x: number; y: number },
|
|
toFraction: number,
|
|
durationMs = 520,
|
|
snapToValue?: number
|
|
): Promise<{ x: number; y: number }> {
|
|
const thumbBox = await page.locator(thumbSelector).boundingBox();
|
|
const trackBox = await page.locator(trackSelector).boundingBox();
|
|
if (!thumbBox || !trackBox) throw new Error('slider not found');
|
|
|
|
const thumbCx = thumbBox.x + thumbBox.width / 2;
|
|
const thumbCy = thumbBox.y + thumbBox.height / 2;
|
|
const targetX = trackBox.x + trackBox.width * toFraction;
|
|
|
|
await smoothMove(page, fromCursor, { x: thumbCx, y: thumbCy }, { durationMs: 220 });
|
|
await page.mouse.down();
|
|
// The user explicitly prefers a longer render over stepped motion, so use
|
|
// enough real pointer updates for the thumb itself to read as continuous.
|
|
const dragMs = snapToValue == null ? durationMs : Math.max(200, durationMs - 400);
|
|
await smoothMove(
|
|
page,
|
|
{ x: thumbCx, y: thumbCy },
|
|
{ x: targetX, y: thumbCy },
|
|
{ durationMs: dragMs, realMouse: true }
|
|
);
|
|
|
|
// Converge on the exact value WHILE the pointer is still down. A pixel
|
|
// drop lands a few steps off (thumb inset + snapping): the "20 minutes"
|
|
// drag settled on 15 and the UI contradicted the narration. Keyboard
|
|
// nudging after mouseup doesn't work here: the app's slider only accepts
|
|
// value changes inside a pointer session (onPointerDown starts the drag
|
|
// state, onPointerUp commits), so the correction must be pointer-driven.
|
|
let finalX = targetX;
|
|
if (snapToValue != null) {
|
|
const thumb = page.locator(thumbSelector);
|
|
const readAttr = async (name: string): Promise<number | null> => {
|
|
const raw = await thumb.getAttribute(name).catch(() => null);
|
|
const value = raw == null ? NaN : Number(raw);
|
|
return Number.isFinite(value) ? value : null;
|
|
};
|
|
const min = await readAttr('aria-valuemin');
|
|
const max = await readAttr('aria-valuemax');
|
|
const pxPerUnit =
|
|
min != null && max != null && max > min ? trackBox.width / (max - min) : null;
|
|
if (pxPerUnit != null) {
|
|
for (let i = 0; i < 24; i++) {
|
|
const now = await readAttr('aria-valuenow');
|
|
if (now == null || now === snapToValue) break;
|
|
let dx = (snapToValue - now) * pxPerUnit;
|
|
dx = Math.max(-60, Math.min(60, dx));
|
|
if (Math.abs(dx) < 1) dx = Math.sign(snapToValue - now);
|
|
finalX += dx;
|
|
await page.mouse.move(finalX, thumbCy, { steps: 3 });
|
|
await sleep(50);
|
|
}
|
|
}
|
|
}
|
|
await page.mouse.up();
|
|
return { x: finalX, y: thumbCy };
|
|
}
|