/** * human-typing.ts * * Helpers to make Playwright type like a real human: variable cadence, * thinking pauses around punctuation, occasional typos that get corrected * with backspace, short words sent as bursts, and longer words slowed down. * * Designed for demo/marketing videos where the goal is "this looks like a * person writing", not deterministic test output. */ import type { Page, Locator } from "playwright"; import { humanClick } from "./lib/mouse-cursor.js"; export interface HumanTypingOptions { /** Baseline delay between keystrokes (ms). Actual delay is jittered. */ baseDelay?: number; /** Jitter range added on top of baseDelay (ms). */ jitter?: number; /** Probability (0-1) of mistyping a character, then fixing it. */ typoRate?: number; /** Probability (0-1) of a short "thinking" pause between words. */ thinkRate?: number; /** Multiplier applied to all delays (1 = normal, 0.5 = fast, 2 = slow). */ speed?: number; /** * Optional hook called before every keystroke (including typo chars and * backspaces). Useful for re-setting the editor caret to a tracked * position in concurrent/CRDT demos so remote edits can't push the * persona's cursor off-target. */ beforeKey?: () => void | Promise; } const DEFAULTS: Required> = { baseDelay: 55, jitter: 85, typoRate: 0.025, thinkRate: 0.18, speed: 1, }; function rand(min: number, max: number): number { return min + Math.random() * (max - min); } function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } /** Picks a plausible typo character adjacent to the target on QWERTY. */ const TYPO_MAP: Record = { a: "s", b: "v", c: "v", d: "s", e: "r", f: "d", g: "h", h: "j", i: "o", j: "k", k: "l", l: "k", m: "n", n: "m", o: "p", p: "o", q: "w", r: "t", s: "a", t: "y", u: "i", v: "b", w: "e", x: "c", y: "u", z: "x", }; function typoFor(ch: string): string | null { const lower = ch.toLowerCase(); const neighbor = TYPO_MAP[lower]; if (!neighbor) return null; return ch === lower ? neighbor : neighbor.toUpperCase(); } /** * Type `text` into the currently focused element of `page` with human-like * rhythm. Adds pauses after punctuation, thinking pauses between words, * the occasional typo followed by a correction, and small speed variation. */ export async function humanType( page: Page, text: string, options: HumanTypingOptions = {} ): Promise { const { baseDelay, jitter, typoRate, thinkRate, speed } = { ...DEFAULTS, ...options }; const beforeKey = options.beforeKey; const scale = 1 / speed; // Small helper that guarantees the caller's `beforeKey` runs before // every Playwright keystroke. Used by showcase.ts to re-anchor the PM // selection to a tracked `MappablePosition` so concurrent remote // edits can't steal the persona's cursor. const tap = async (fn: () => Promise) => { if (beforeKey) await beforeKey(); await fn(); }; const tokens = text.split(/(\s+)/); for (let t = 0; t < tokens.length; t++) { const token = tokens[t]; if (!token) continue; if (/^\s+$/.test(token)) { // Whitespace is still a keystroke from PM's point of view (it can // trigger input rules like heading or list shortcuts), so keep it // anchored the same way as regular chars. for (const ws of token) { await tap(() => page.keyboard.type(ws, { delay: 0 })); } if (Math.random() < thinkRate) { await sleep(rand(180, 520) * scale); } continue; } const wordSpeed = token.length > 7 ? rand(1.0, 1.3) : rand(0.7, 1.0); for (let i = 0; i < token.length; i++) { const ch = token[i]; const delay = (baseDelay + Math.random() * jitter) * wordSpeed * scale; const shouldTypo = Math.random() < typoRate && /[a-zA-Z]/.test(ch); if (shouldTypo) { const wrong = typoFor(ch); if (wrong) { await tap(() => page.keyboard.type(wrong, { delay })); await sleep(rand(90, 260) * scale); await tap(() => page.keyboard.press("Backspace")); await sleep(rand(60, 180) * scale); } } await tap(() => page.keyboard.type(ch, { delay })); if (/[,;:]/.test(ch)) { await sleep(rand(280, 700) * scale); } else if (/[.!?]/.test(ch)) { await sleep(rand(600, 1400) * scale); } } } } /** * Focus a locator and type with `humanType`. Waits for visibility first. */ export async function humanTypeInto( page: Page, target: Locator, text: string, options: HumanTypingOptions = {} ): Promise { await target.waitFor({ state: "visible" }); await humanClick(page, target); await sleep(rand(250, 550)); await humanType(page, text, options); } /** * Sleep for a human-ish "reading/thinking" pause. */ export function pause(min: number, max: number = min): Promise { return sleep(rand(min, max)); }