@tandryukha/aidemo 0.8.0

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/src/player.ts ADDED
@@ -0,0 +1,1173 @@
1
+ import type { Page, Locator, Frame } from "playwright";
2
+ import { promises as fs } from "node:fs";
3
+ import { join } from "node:path";
4
+ import type {
5
+ Storyboard,
6
+ Scene,
7
+ Action,
8
+ Target,
9
+ Timeline,
10
+ TimelineScene,
11
+ IdleSpan,
12
+ FocusEvent,
13
+ StillEvent,
14
+ CursorSample,
15
+ EasingPreset,
16
+ ProbeActionOutcome,
17
+ ProbeGoldenScene,
18
+ } from "./types.js";
19
+ import {
20
+ easeInOutCubic,
21
+ easeOutCubic,
22
+ easeInOutSine,
23
+ sleep,
24
+ log,
25
+ ensureDir,
26
+ CanceledError,
27
+ } from "./util.js";
28
+
29
+ /** Semantic targets the player resolves without a raw selector. */
30
+ const NAMED_SELECTORS: Record<string, string> = {
31
+ // ChatGPT prompt box — the contenteditable with id="prompt-textarea".
32
+ // NOTE: keep this a *single, visible* match. The old broad union added
33
+ // `form textarea`, which matched a HIDDEN fallback textarea that sorted
34
+ // first in the DOM; `.first()` then picked the hidden node and every click
35
+ // timed out. `#prompt-textarea` is unique and visible (confirmed live).
36
+ composer: "#prompt-textarea",
37
+ };
38
+
39
+ interface MouseState {
40
+ x: number;
41
+ y: number;
42
+ }
43
+
44
+ /**
45
+ * Scroll easing presets — duration + curve for the eased wheel scroll. The old
46
+ * behavior was 8 chunky wheel steps; these run ~60Hz micro-deltas along an
47
+ * easing curve so scrolls read as intentional camera moves.
48
+ */
49
+ const SCROLL_PRESETS: Record<EasingPreset, { ms: number; fn: (t: number) => number }> = {
50
+ smooth: { ms: 850, fn: easeInOutCubic },
51
+ snappy: { ms: 450, fn: easeOutCubic },
52
+ glide: { ms: 1500, fn: easeInOutSine },
53
+ linear: { ms: 700, fn: (t) => t },
54
+ };
55
+
56
+ export interface PlayerOptions {
57
+ /** Epoch ms captured at record start; timeline offsets are relative to it. */
58
+ t0: number;
59
+ video: { width: number; height: number };
60
+ /** Where to write failure diagnostics (screenshot + frame dump). */
61
+ logsDir?: string;
62
+ /**
63
+ * Called after each scene finishes so the recorder can salvage a partial
64
+ * timeline if a later scene fails (a late failure otherwise discards minutes
65
+ * of good recording).
66
+ */
67
+ onSceneComplete?: (scene: TimelineScene, index: number, total: number) => void;
68
+ /** Called when a scene's first action is about to run (job progress). */
69
+ onSceneStart?: (sceneId: string, index: number, total: number) => void;
70
+ /**
71
+ * Best-effort cancellation, checked before every action. Aborting mid-scene
72
+ * throws CanceledError, which rides the recorder's salvage path (partial
73
+ * timeline + kept footage).
74
+ */
75
+ signal?: AbortSignal;
76
+ /**
77
+ * Golden-probe capture. When present, the player records a normalized,
78
+ * timing-free outcome per action (op, resolved target, ok, found, goto final
79
+ * URL) into this array — and does NOT abort the run on an action failure
80
+ * (records ok:false and continues), so a broken selector yields a complete
81
+ * diffable projection rather than a truncated one. Absent → unchanged behavior
82
+ * (a failing action throws via failAction). See src/golden.ts.
83
+ */
84
+ probe?: ProbeGoldenScene[];
85
+ /**
86
+ * Log the cursor's glide path into each scene's `cursorSamples` (for the
87
+ * compose-time cursor overlay). On only when the storyboard opts into `cursor`
88
+ * control; off by default so a plain take's timeline.json is unchanged.
89
+ */
90
+ captureCursorPath?: boolean;
91
+ }
92
+
93
+ /** Per-scene mutable capture state threaded through actions. */
94
+ interface SceneCapture {
95
+ idleSpans: IdleSpan[];
96
+ focusEvents: FocusEvent[];
97
+ stillEvents: StillEvent[];
98
+ cursorSamples: CursorSample[];
99
+ }
100
+
101
+ /** Records a cursor position into the current scene (compose-cursor mode only). */
102
+ type CursorSampler = (x: number, y: number) => void;
103
+
104
+ export async function runStoryboard(
105
+ page: Page,
106
+ storyboard: Storyboard,
107
+ opts: PlayerOptions
108
+ ): Promise<Timeline> {
109
+ const mouse: MouseState = {
110
+ x: Math.round(opts.video.width / 2),
111
+ y: Math.round(opts.video.height / 2),
112
+ };
113
+ // Establish the cursor position so the overlay appears from the start.
114
+ await page.mouse.move(mouse.x, mouse.y);
115
+
116
+ const now = () => Date.now() - opts.t0;
117
+ const scenes: TimelineScene[] = [];
118
+
119
+ const total = storyboard.scenes.length;
120
+ for (let si = 0; si < total; si++) {
121
+ const scene = storyboard.scenes[si];
122
+ const startMs = now();
123
+ const capture: SceneCapture = {
124
+ idleSpans: [],
125
+ focusEvents: [],
126
+ stillEvents: [],
127
+ cursorSamples: [],
128
+ };
129
+ // Cursor path sampler — only records when the storyboard opts into the
130
+ // compose-time cursor overlay, so a plain take's timeline stays unchanged.
131
+ const sample: CursorSampler | undefined = opts.captureCursorPath
132
+ ? (x, y) =>
133
+ capture.cursorSamples.push({
134
+ tMs: now(),
135
+ x: Math.round(x),
136
+ y: Math.round(y),
137
+ })
138
+ : undefined;
139
+ log(`scene ${scene.id}: ${scene.actions.length} action(s)`);
140
+ opts.onSceneStart?.(scene.id, si, total);
141
+ const probeOutcomes: ProbeActionOutcome[] = [];
142
+
143
+ for (let i = 0; i < scene.actions.length; i++) {
144
+ if (opts.signal?.aborted)
145
+ throw new CanceledError(`canceled during scene ${scene.id}`);
146
+ const action = scene.actions[i];
147
+ const outcome = opts.probe ? initProbeOutcome(storyboard, action) : null;
148
+ try {
149
+ await runAction(page, storyboard, action, mouse, capture, opts, sample);
150
+ if (outcome) outcome.ok = true;
151
+ } catch (err) {
152
+ if (outcome) {
153
+ // Golden probe: record the failure and keep going, so a broken
154
+ // selector shows up as a single flipped field in the diff instead of
155
+ // aborting the whole projection.
156
+ outcome.ok = false;
157
+ log(
158
+ ` ✗ probe: ${action.op} failed — ${firstLine(err)}`
159
+ );
160
+ } else {
161
+ // Name the failing scene/action, screenshot the page, and dump the
162
+ // widget frames present — so a phantom click or a platform
163
+ // interruption is diagnosable from the log instead of by
164
+ // hand-extracting webm frames.
165
+ await failAction(page, storyboard, scene, i, action, opts, err);
166
+ }
167
+ }
168
+ if (outcome) {
169
+ await enrichProbeOutcome(page, storyboard, action, outcome);
170
+ probeOutcomes.push(outcome);
171
+ }
172
+ }
173
+ if (opts.probe) opts.probe.push({ id: scene.id, actions: probeOutcomes });
174
+
175
+ const tlScene: TimelineScene = {
176
+ id: scene.id,
177
+ startMs,
178
+ endMs: now(),
179
+ idleSpans: capture.idleSpans,
180
+ focusEvents: capture.focusEvents,
181
+ stillEvents: capture.stillEvents,
182
+ cursorSamples: capture.cursorSamples,
183
+ };
184
+ scenes.push(tlScene);
185
+ opts.onSceneComplete?.(tlScene, si, total);
186
+ }
187
+
188
+ // leadInMs is filled in by the recorder (it knows the video start).
189
+ return { totalMs: now(), leadInMs: 0, scenes };
190
+ }
191
+
192
+ /**
193
+ * A frame whose declared selector points at a *.web-sandbox.oaiusercontent.com
194
+ * iframe is a ChatGPT Apps SDK widget. Those render their real markup inside a
195
+ * NESTED `<iframe name="root">` (about:blank) within the sandbox — a single-
196
+ * level frameLocator only reaches the empty sandbox wrapper. When the frame
197
+ * selector opts into this (mentions oaiusercontent), we descend to the nested
198
+ * content frame.
199
+ */
200
+ const NESTED_WIDGET_RE = /oaiusercontent\.com/i;
201
+
202
+ /** Nested widget "root" frames (children of a ChatGPT widget sandbox). */
203
+ function widgetRootFrames(page: Page): Frame[] {
204
+ return page.frames().filter((f) => {
205
+ const parent = f.parentFrame();
206
+ return !!parent && NESTED_WIDGET_RE.test(parent.url());
207
+ });
208
+ }
209
+
210
+ /**
211
+ * Pick the nested widget frame that actually CONTAINS `selector`, honoring
212
+ * last/nth. ChatGPT renders two sandbox iframes per widget (one populated, one
213
+ * an empty duplicate) and one sandbox per tool call across a conversation;
214
+ * filtering to frames that contain the selector both skips the empty duplicate
215
+ * and lets `last` mean "newest widget that has this element". Returns null when
216
+ * no content frame is ready yet (callers retry).
217
+ */
218
+ async function pickNestedFrame(
219
+ page: Page,
220
+ selector: string,
221
+ target: Target
222
+ ): Promise<Frame | null> {
223
+ const matching: Frame[] = [];
224
+ for (const f of widgetRootFrames(page)) {
225
+ if (f.isDetached()) continue;
226
+ const count = await f.locator(selector).count().catch(() => 0);
227
+ if (count > 0) matching.push(f);
228
+ }
229
+ if (matching.length === 0) return null;
230
+ const chosen = target.last
231
+ ? matching[matching.length - 1]
232
+ : target.nth != null
233
+ ? matching[target.nth]
234
+ : matching[0];
235
+ return chosen ?? null;
236
+ }
237
+
238
+ async function pickNestedLocator(
239
+ page: Page,
240
+ selector: string,
241
+ target: Target
242
+ ): Promise<Locator | null> {
243
+ const frame = await pickNestedFrame(page, selector, target);
244
+ return frame ? frame.locator(selector).first() : null;
245
+ }
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // Interruption handlers — ChatGPT sometimes injects its own UI between the
249
+ // storyboard and the page (an A/B "which response do you prefer?" eval screen,
250
+ // a consent wall). Unlike the scroll-to-bottom arrow it can't be scrolled away,
251
+ // so a take dies at the next waitForWidget. These best-effort handlers detect
252
+ // and try to clear known interruptions before composer typing and during the
253
+ // widget wait. They NEVER throw; if one can't self-resolve, the scene still
254
+ // fails — but with a named error + screenshot (see failAction), so the operator
255
+ // sees it was platform roulette, not a storyboard bug.
256
+ //
257
+ // Selectors here track live ChatGPT and may drift; keep them permissive
258
+ // (text/role based) and treat non-resolution as acceptable.
259
+ // ---------------------------------------------------------------------------
260
+ interface Interruption {
261
+ name: string;
262
+ detect: (page: Page) => Promise<boolean>;
263
+ /** Try to clear it; return true if an action was taken. */
264
+ resolve: (page: Page) => Promise<boolean>;
265
+ }
266
+
267
+ async function visible(loc: Locator): Promise<boolean> {
268
+ return (
269
+ (await loc.count().catch(() => 0)) > 0 &&
270
+ (await loc.first().isVisible().catch(() => false))
271
+ );
272
+ }
273
+
274
+ const INTERRUPTIONS: Interruption[] = [
275
+ {
276
+ name: "A/B response eval",
277
+ detect: (page) => visible(page.getByText(/which response do you prefer/i)),
278
+ resolve: async (page) => {
279
+ // Prefer a neutral dismissal; only pick a candidate as a last resort so
280
+ // the turn unblocks. (Picking a response may still land on one that
281
+ // doesn't call the app — this is genuinely roulette.)
282
+ for (const name of [/skip/i, /dismiss/i, /prefer this response/i]) {
283
+ const b = page.getByRole("button", { name });
284
+ if (await visible(b)) {
285
+ await b.first().click({ timeout: 2000 }).catch(() => {});
286
+ return true;
287
+ }
288
+ }
289
+ return false;
290
+ },
291
+ },
292
+ {
293
+ name: "cookie/consent wall",
294
+ detect: (page) =>
295
+ visible(page.getByRole("button", { name: /accept all|accept cookies|i agree/i })),
296
+ resolve: async (page) => {
297
+ const b = page.getByRole("button", { name: /accept all|accept cookies|i agree/i });
298
+ await b.first().click({ timeout: 2000 }).catch(() => {});
299
+ return true;
300
+ },
301
+ },
302
+ ];
303
+
304
+ /** Warn-once dedupe so a still-blocked interruption doesn't spam the wait loop. */
305
+ const warnedInterruptions = new Set<string>();
306
+
307
+ /**
308
+ * Detect + best-effort clear known ChatGPT interruptions. Gated to chatgpt.com
309
+ * so it never clicks stray "Accept" buttons on other pages. Best-effort — all
310
+ * failures are swallowed.
311
+ */
312
+ async function resolveInterruptions(page: Page): Promise<void> {
313
+ if (!/chatgpt\.com|chat\.openai\.com/.test(page.url())) return;
314
+ for (const it of INTERRUPTIONS) {
315
+ let present = false;
316
+ try {
317
+ present = await it.detect(page);
318
+ } catch {
319
+ present = false;
320
+ }
321
+ if (!present) continue;
322
+ let acted = false;
323
+ try {
324
+ acted = await it.resolve(page);
325
+ } catch {
326
+ acted = false;
327
+ }
328
+ if (acted) {
329
+ log(` ⚠ cleared ChatGPT interruption: ${it.name}`);
330
+ warnedInterruptions.delete(it.name);
331
+ } else if (!warnedInterruptions.has(it.name)) {
332
+ log(` ⚠ ChatGPT interruption present (not auto-resolvable): ${it.name}`);
333
+ warnedInterruptions.add(it.name);
334
+ }
335
+ }
336
+ }
337
+
338
+ /** ChatGPT's Stop control — visible exactly while a reply is streaming. */
339
+ const STOP_BUTTON_SELECTOR =
340
+ 'button[data-testid="stop-button"], button[aria-label="Stop streaming"], button[aria-label*="Stop" i]';
341
+
342
+ /** ChatGPT's assistant message nodes — the default `waitForReply` counter. */
343
+ const ASSISTANT_MESSAGE_SELECTOR = '[data-message-author-role="assistant"]';
344
+
345
+ /**
346
+ * ChatGPT shows a Stop control while a reply streams. Wait until it's gone so
347
+ * the next prompt isn't typed into a still-generating (send-disabled) composer —
348
+ * which drops the Enter and interleaves the following scene's text. Safe no-op
349
+ * if the control isn't found (returns after a short settle).
350
+ */
351
+ async function waitComposerReady(page: Page, timeoutMs = 20000): Promise<void> {
352
+ const stop = page.locator(STOP_BUTTON_SELECTOR);
353
+ await sleep(400); // let a just-started generation register the Stop control
354
+ const deadline = Date.now() + timeoutMs;
355
+ while (Date.now() < deadline) {
356
+ if ((await stop.count().catch(() => 0)) === 0) return;
357
+ await sleep(300);
358
+ }
359
+ }
360
+
361
+ /** How many nested widget frames currently contain `selector`. */
362
+ async function countNestedMatches(page: Page, selector: string): Promise<number> {
363
+ let n = 0;
364
+ for (const f of widgetRootFrames(page)) {
365
+ if (f.isDetached()) continue;
366
+ const c = await f.locator(selector).count().catch(() => 0);
367
+ if (c > 0) n++;
368
+ }
369
+ return n;
370
+ }
371
+
372
+ async function resolveTargetLocator(
373
+ page: Page,
374
+ storyboard: Storyboard,
375
+ target: Target
376
+ ): Promise<Locator> {
377
+ let selector = target.selector;
378
+ if (target.named) {
379
+ selector = NAMED_SELECTORS[target.named];
380
+ }
381
+ if (!selector) {
382
+ throw new Error(
383
+ `Target has neither a selector nor a known 'named' value: ${JSON.stringify(
384
+ target
385
+ )}`
386
+ );
387
+ }
388
+ if (!target.frame) {
389
+ // Frameless targets honor last/nth at the ELEMENT level (e.g. the newest
390
+ // assistant message); previously they silently collapsed to .first().
391
+ const loc = page.locator(selector);
392
+ return target.last ? loc.last() : target.nth != null ? loc.nth(target.nth) : loc.first();
393
+ }
394
+ const frameSelector = storyboard.frames[target.frame];
395
+ if (!frameSelector) {
396
+ throw new Error(
397
+ `Action references frame "${target.frame}" not declared in storyboard.frames`
398
+ );
399
+ }
400
+ if (NESTED_WIDGET_RE.test(frameSelector)) {
401
+ const nested = await pickNestedLocator(page, selector, target);
402
+ if (nested) return nested;
403
+ // No populated widget frame yet; let callers retry against this hint.
404
+ throw new Error(`Widget content frame not ready for selector "${selector}"`);
405
+ }
406
+ // Normal single-level iframe. Disambiguate so multiple matches don't trip
407
+ // Playwright strict mode; newest for multi-turn conversations.
408
+ let frame = page.frameLocator(frameSelector);
409
+ frame = target.last
410
+ ? frame.last()
411
+ : target.nth != null
412
+ ? frame.nth(target.nth)
413
+ : frame.first();
414
+ return frame.locator(selector).first();
415
+ }
416
+
417
+ /**
418
+ * Wait until `target` resolves to a visible element, re-resolving each poll so
419
+ * a nested widget frame that appears (and fills) mid-wait is picked up. Returns
420
+ * the elapsed wait; throws on timeout.
421
+ */
422
+ async function waitForTargetVisible(
423
+ page: Page,
424
+ storyboard: Storyboard,
425
+ target: Target,
426
+ timeoutMs: number
427
+ ): Promise<void> {
428
+ const deadline = Date.now() + timeoutMs;
429
+ let lastErr: unknown;
430
+ for (;;) {
431
+ try {
432
+ const loc = await resolveTargetLocator(page, storyboard, target);
433
+ const remaining = deadline - Date.now();
434
+ await loc.waitFor({
435
+ state: "visible",
436
+ timeout: Math.max(400, Math.min(2500, remaining)),
437
+ });
438
+ return;
439
+ } catch (err) {
440
+ lastErr = err;
441
+ }
442
+ if (Date.now() >= deadline) {
443
+ throw lastErr instanceof Error
444
+ ? lastErr
445
+ : new Error(`waitForTargetVisible timed out for ${JSON.stringify(target)}`);
446
+ }
447
+ await sleep(300);
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Wait for the model's NEW reply widget — not a stale prior one. ChatGPT keeps
453
+ * earlier widgets in the DOM and different tools share selectors (search &
454
+ * detail both render button[data-add-id]; every cart op renders
455
+ * button[data-cart-act]), so a plain "is the selector present" check matches an
456
+ * older widget instantly and the scene advances before the reply renders. We
457
+ * instead capture the match COUNT at wait-start (after the prompt was sent) and
458
+ * wait for it to grow — i.e. a genuinely new widget carrying the selector — then
459
+ * confirm the newest one is visible. Falls back to a plain visible-wait for
460
+ * non-nested (fixture) frames.
461
+ */
462
+ async function waitForNewWidget(
463
+ page: Page,
464
+ storyboard: Storyboard,
465
+ target: Target,
466
+ opts: { timeoutMs: number; textMatches?: string }
467
+ ): Promise<void> {
468
+ const selector = target.named
469
+ ? NAMED_SELECTORS[target.named]
470
+ : target.selector;
471
+ const frameSelector = target.frame ? storyboard.frames[target.frame] : undefined;
472
+ const nested = !!frameSelector && !!selector && NESTED_WIDGET_RE.test(frameSelector);
473
+ if (!nested || !selector) {
474
+ await waitForTargetVisible(page, storyboard, target, opts.timeoutMs);
475
+ return;
476
+ }
477
+ const re = opts.textMatches ? new RegExp(opts.textMatches, "i") : null;
478
+ const deadline = Date.now() + opts.timeoutMs;
479
+ const baseline = await countNestedMatches(page, selector);
480
+ for (;;) {
481
+ // A mid-turn A/B eval / consent wall shows text instead of a widget and
482
+ // would otherwise silently burn the whole timeout; try to clear it.
483
+ await resolveInterruptions(page);
484
+ if ((await countNestedMatches(page, selector)) > baseline) {
485
+ const frame = await pickNestedFrame(page, selector, target).catch(() => null);
486
+ // With textMatches, the chosen NEW widget must also carry the expected
487
+ // text — different tools share selectors (carousel and compare both have
488
+ // button[data-add-id]), so when the model could render either, an
489
+ // unqualified match would pass on the wrong widget type. A mismatch
490
+ // keeps polling: the right widget may still be streaming in.
491
+ let textOk = true;
492
+ if (frame && re) {
493
+ const text = await frame
494
+ .locator("body")
495
+ .textContent({ timeout: 800 })
496
+ .catch(() => null);
497
+ textOk = !!text && re.test(text);
498
+ }
499
+ if (frame && textOk) {
500
+ try {
501
+ await frame.locator(selector).first().waitFor({
502
+ state: "visible",
503
+ timeout: Math.max(400, Math.min(2000, deadline - Date.now())),
504
+ });
505
+ // The widget is up, but the model may still be streaming trailing
506
+ // text. Wait it out here (inside the trimmed idle span) so the next
507
+ // scene types into a ready composer.
508
+ await waitComposerReady(page, Math.max(1000, deadline - Date.now()));
509
+ return;
510
+ } catch {
511
+ /* the new frame is still filling in; keep polling */
512
+ }
513
+ }
514
+ }
515
+ if (Date.now() >= deadline) {
516
+ throw new Error(
517
+ `waitForWidget: no new "${selector}" widget` +
518
+ (re ? ` with text matching ${re}` : "") +
519
+ ` within ${opts.timeoutMs}ms (baseline ${baseline})`
520
+ );
521
+ }
522
+ await sleep(350);
523
+ }
524
+ }
525
+
526
+ /**
527
+ * Wait for a NEW assistant reply that answers in TEXT (no widget) — the
528
+ * main-frame twin of waitForNewWidget, for tools whose result the model
529
+ * narrates without rendering a widget. Baseline = assistant-message count at
530
+ * wait start; satisfied when it grows, then the generation is waited out so
531
+ * the on-screen text is complete before the scene advances. Fallback: if the
532
+ * reply node streamed in BEFORE the baseline sample (fast first token), a
533
+ * Stop-button appear→disappear cycle counts as the reply — the prior turn
534
+ * can't be the one streaming, because `type` waits out the composer first.
535
+ */
536
+ async function waitForNewReply(
537
+ page: Page,
538
+ opts: { selector: string; textMatches?: string; timeoutMs: number }
539
+ ): Promise<void> {
540
+ const messages = page.locator(opts.selector);
541
+ const stop = page.locator(STOP_BUTTON_SELECTOR);
542
+ const re = opts.textMatches ? new RegExp(opts.textMatches, "i") : null;
543
+ const baseline = await messages.count().catch(() => 0);
544
+ let sawStreaming = (await stop.count().catch(() => 0)) > 0;
545
+ const deadline = Date.now() + opts.timeoutMs;
546
+ for (;;) {
547
+ await resolveInterruptions(page);
548
+ const streamingNow = (await stop.count().catch(() => 0)) > 0;
549
+ sawStreaming ||= streamingNow;
550
+ const grown = (await messages.count().catch(() => 0)) > baseline;
551
+ if (grown || (sawStreaming && !streamingNow)) {
552
+ await waitComposerReady(page, Math.max(1000, deadline - Date.now()));
553
+ if (re) {
554
+ const text =
555
+ (await messages.last().textContent({ timeout: 2000 }).catch(() => "")) ?? "";
556
+ if (!re.test(text)) {
557
+ throw new Error(
558
+ `waitForReply: new assistant reply does not match ${re} ` +
559
+ `(got ${JSON.stringify(text.replace(/\s+/g, " ").trim().slice(0, 80))})`
560
+ );
561
+ }
562
+ }
563
+ return;
564
+ }
565
+ if (Date.now() >= deadline) {
566
+ throw new Error(
567
+ `waitForReply: no new assistant message ("${opts.selector}") within ` +
568
+ `${opts.timeoutMs}ms (baseline ${baseline})`
569
+ );
570
+ }
571
+ await sleep(350);
572
+ }
573
+ }
574
+
575
+ async function runAction(
576
+ page: Page,
577
+ storyboard: Storyboard,
578
+ action: Action,
579
+ mouse: MouseState,
580
+ capture: SceneCapture,
581
+ opts: PlayerOptions,
582
+ sample?: CursorSampler
583
+ ): Promise<void> {
584
+ const t0 = opts.t0;
585
+ const markFocus = (x: number, y: number, kind: string) =>
586
+ capture.focusEvents.push({ tMs: Date.now() - t0, x, y, kind });
587
+
588
+ switch (action.op) {
589
+ case "goto":
590
+ await page.goto(action.url, { waitUntil: "domcontentloaded" });
591
+ await sleep(600);
592
+ return;
593
+
594
+ case "click": {
595
+ const loc = await resolveTargetLocator(page, storyboard, action.target);
596
+ const { cx, cy } = await humanClick(page, loc, mouse, !!action.target.frame, sample);
597
+ markFocus(cx, cy, "click");
598
+ return;
599
+ }
600
+
601
+ case "type": {
602
+ const loc = await resolveTargetLocator(page, storyboard, action.target);
603
+ const isComposer = action.target.named === "composer";
604
+ // Guard multi-turn ChatGPT: clear any interruption UI (A/B eval, consent
605
+ // wall) then never type while the prior reply is still streaming (the
606
+ // Enter would be dropped and prompts would interleave). The wait is
607
+ // recorded as IDLE: a still-streaming prior turn can hold the composer
608
+ // for tens of seconds, and untrimmed that time blows the scene past its
609
+ // narration and compose tail-trims the scene's PAYOFF instead (bit us
610
+ // on maxfit-chatgpt-v3 s8: 38s raw vs 9s narration).
611
+ if (isComposer) {
612
+ const waitStart = Date.now() - t0;
613
+ await resolveInterruptions(page);
614
+ await waitComposerReady(page);
615
+ const waitEnd = Date.now() - t0;
616
+ // Below ~1s there's nothing worth trimming (waitComposerReady's settle
617
+ // alone is 400ms) and each span costs a keep-interval split in compose.
618
+ if (waitEnd - waitStart > 1000) {
619
+ capture.idleSpans.push({
620
+ startMs: waitStart,
621
+ endMs: waitEnd,
622
+ label: "reply streaming",
623
+ });
624
+ log(` idle "reply streaming": ${waitEnd - waitStart}ms (pre-type composer wait)`);
625
+ }
626
+ }
627
+ const { cx, cy } = await humanClick(page, loc, mouse, !!action.target.frame, sample);
628
+ markFocus(cx, cy, "type");
629
+ if (isComposer) {
630
+ // Clear any residual text so a new prompt can't concatenate onto it.
631
+ await page.keyboard.press("ControlOrMeta+a");
632
+ await page.keyboard.press("Backspace");
633
+ }
634
+ await humanType(page, loc, action.text, action.humanize !== false);
635
+ return;
636
+ }
637
+
638
+ case "press":
639
+ await page.keyboard.press(action.key);
640
+ await sleep(200);
641
+ return;
642
+
643
+ case "hover": {
644
+ const loc = await resolveTargetLocator(page, storyboard, action.target);
645
+ const box = await boxOf(page, loc);
646
+ await moveMouseTo(page, mouse, box.cx, box.cy, sample);
647
+ await sleep(300);
648
+ return;
649
+ }
650
+
651
+ case "focus": {
652
+ // A camera note, not an interaction: aim the auto-zoom at this element.
653
+ const loc = await resolveTargetLocator(page, storyboard, action.target);
654
+ const box = await boxOf(page, loc);
655
+ capture.focusEvents.push({
656
+ tMs: Date.now() - t0,
657
+ x: box.cx,
658
+ y: box.cy,
659
+ kind: "focus",
660
+ scale: action.scale,
661
+ holdMs: action.holdMs,
662
+ });
663
+ await sleep(150);
664
+ return;
665
+ }
666
+
667
+ case "still": {
668
+ // Screenshot mode: a pure timeline marker — no screenshot is taken now.
669
+ // Let the frame settle first, then record the marker at the settled
670
+ // moment so compose-time extraction lands on a clean frame (not mid-
671
+ // transition). The PNG is pulled from the CLEAN take by `aidemo stills`.
672
+ await sleep(120);
673
+ capture.stillEvents.push({ tMs: Date.now() - t0, name: action.name });
674
+ log(` still "${action.name}" @ ${Date.now() - t0}ms`);
675
+ return;
676
+ }
677
+
678
+ case "scrollTo": {
679
+ const loc = await resolveTargetLocator(page, storyboard, action.target);
680
+ // Top-page targets get the cinematic eased scroll; targets inside a
681
+ // frame keep the reliable scrollIntoViewIfNeeded (wheel deltas would go
682
+ // to whatever scroller is under the cursor, not necessarily the frame).
683
+ if (!action.target.frame) {
684
+ const box = await loc.boundingBox().catch(() => null);
685
+ if (box) {
686
+ const dy = box.y + box.height / 2 - opts.video.height * 0.45;
687
+ if (Math.abs(dy) > 30) await easedWheel(page, dy, action);
688
+ }
689
+ }
690
+ await loc.scrollIntoViewIfNeeded();
691
+ await sleep(300);
692
+ return;
693
+ }
694
+
695
+ case "scrollBy": {
696
+ await easedWheel(page, action.dy, action);
697
+ return;
698
+ }
699
+
700
+ case "waitFor": {
701
+ await waitForTargetVisible(
702
+ page,
703
+ storyboard,
704
+ action.target,
705
+ action.timeoutMs ?? 15000
706
+ );
707
+ return;
708
+ }
709
+
710
+ case "waitForWidget": {
711
+ const start = Date.now() - t0;
712
+ await waitForNewWidget(page, storyboard, action.target, {
713
+ timeoutMs: action.timeoutMs ?? 30000,
714
+ textMatches: action.textMatches,
715
+ });
716
+ const end = Date.now() - t0;
717
+ capture.idleSpans.push({ startMs: start, endMs: end, label: action.label });
718
+ log(` idle "${action.label}": ${end - start}ms`);
719
+ return;
720
+ }
721
+
722
+ case "waitForReply": {
723
+ const start = Date.now() - t0;
724
+ await waitForNewReply(page, {
725
+ selector: action.selector ?? ASSISTANT_MESSAGE_SELECTOR,
726
+ textMatches: action.textMatches,
727
+ timeoutMs: action.timeoutMs ?? 30000,
728
+ });
729
+ const end = Date.now() - t0;
730
+ capture.idleSpans.push({ startMs: start, endMs: end, label: action.label });
731
+ log(` idle "${action.label}": ${end - start}ms`);
732
+ return;
733
+ }
734
+
735
+ case "waitForChange": {
736
+ const start = Date.now() - t0;
737
+ await waitForChange(page, storyboard, action.target, {
738
+ textMatches: action.textMatches,
739
+ timeoutMs: action.timeoutMs ?? 15000,
740
+ });
741
+ if (action.idle) {
742
+ const end = Date.now() - t0;
743
+ capture.idleSpans.push({ startMs: start, endMs: end, label: action.label });
744
+ log(` idle "${action.label}": ${end - start}ms`);
745
+ }
746
+ return;
747
+ }
748
+
749
+ case "pause":
750
+ await sleep(action.ms);
751
+ return;
752
+ }
753
+ }
754
+
755
+ // ---------------------------------------------------------------------------
756
+ // Golden-probe outcome capture — a normalized, timing-free projection of each
757
+ // action's result. Deterministic across runs by construction: op + resolved
758
+ // selector (from the storyboard, not the clock) + ok + a page-side element-found
759
+ // check + goto's final URL. See src/golden.ts for how these are diffed.
760
+ // ---------------------------------------------------------------------------
761
+
762
+ /** A stable, resolved description of a target: named→selector, frame-prefixed. */
763
+ function describeProbeTarget(storyboard: Storyboard, target: Target): string {
764
+ const selector = target.named
765
+ ? NAMED_SELECTORS[target.named] ?? `named:${target.named}`
766
+ : target.selector ?? "<no-selector>";
767
+ const prefix = target.frame
768
+ ? `${storyboard.frames[target.frame] ?? target.frame} >> `
769
+ : "";
770
+ const suffix = target.last
771
+ ? " [last]"
772
+ : target.nth != null
773
+ ? ` [nth=${target.nth}]`
774
+ : "";
775
+ return `${prefix}${selector}${suffix}`;
776
+ }
777
+
778
+ /** Seed an outcome with the action's stable, storyboard-derived fields. */
779
+ function initProbeOutcome(
780
+ storyboard: Storyboard,
781
+ action: Action
782
+ ): ProbeActionOutcome {
783
+ const o: ProbeActionOutcome = { op: action.op, ok: false };
784
+ switch (action.op) {
785
+ case "goto":
786
+ o.target = action.url;
787
+ break;
788
+ case "press":
789
+ o.key = action.key;
790
+ break;
791
+ case "pause":
792
+ o.ms = action.ms;
793
+ break;
794
+ case "scrollBy":
795
+ o.dy = action.dy;
796
+ break;
797
+ case "waitForReply":
798
+ o.target = action.selector ?? ASSISTANT_MESSAGE_SELECTOR;
799
+ o.label = action.label;
800
+ break;
801
+ case "waitForWidget":
802
+ case "waitForChange":
803
+ o.target = describeProbeTarget(storyboard, action.target);
804
+ o.label = action.label;
805
+ break;
806
+ case "click":
807
+ case "type":
808
+ case "hover":
809
+ case "scrollTo":
810
+ case "waitFor":
811
+ case "focus":
812
+ o.target = describeProbeTarget(storyboard, action.target);
813
+ break;
814
+ }
815
+ return o;
816
+ }
817
+
818
+ /** Add the post-run stable signals: goto's final URL, target element-found. */
819
+ async function enrichProbeOutcome(
820
+ page: Page,
821
+ storyboard: Storyboard,
822
+ action: Action,
823
+ o: ProbeActionOutcome
824
+ ): Promise<void> {
825
+ if (action.op === "goto") {
826
+ o.finalUrl = page.url();
827
+ return;
828
+ }
829
+ if (action.op === "waitForReply") {
830
+ o.found = await selectorResolves(
831
+ page,
832
+ action.selector ?? ASSISTANT_MESSAGE_SELECTOR
833
+ );
834
+ return;
835
+ }
836
+ if ("target" in action) {
837
+ o.found = await targetResolves(page, storyboard, action.target);
838
+ }
839
+ }
840
+
841
+ /** Does `target` currently resolve to ≥1 element? Never throws (→ false). */
842
+ async function targetResolves(
843
+ page: Page,
844
+ storyboard: Storyboard,
845
+ target: Target
846
+ ): Promise<boolean> {
847
+ try {
848
+ const loc = await resolveTargetLocator(page, storyboard, target);
849
+ return (await loc.count().catch(() => 0)) > 0;
850
+ } catch {
851
+ return false;
852
+ }
853
+ }
854
+
855
+ async function selectorResolves(page: Page, selector: string): Promise<boolean> {
856
+ return (await page.locator(selector).count().catch(() => 0)) > 0;
857
+ }
858
+
859
+ /** First line of an error message — a compact reason for the probe log. */
860
+ function firstLine(err: unknown): string {
861
+ const msg = err instanceof Error ? err.message : String(err);
862
+ return msg.split("\n")[0];
863
+ }
864
+
865
+ /**
866
+ * A stable-ish signature of `target`: whether it currently resolves, and its
867
+ * normalized text. Missing/unresolvable targets read as absent (empty) — so a
868
+ * mutation that first *introduces* the selector (e.g. the basket bar rendering
869
+ * into an empty [data-cart-bar]) also counts as a change.
870
+ */
871
+ async function signatureOf(
872
+ page: Page,
873
+ storyboard: Storyboard,
874
+ target: Target
875
+ ): Promise<{ present: boolean; text: string }> {
876
+ try {
877
+ const loc = await resolveTargetLocator(page, storyboard, target);
878
+ const txt = await loc.textContent({ timeout: 800 }).catch(() => null);
879
+ return { present: true, text: (txt ?? "").replace(/\s+/g, " ").trim() };
880
+ } catch {
881
+ return { present: false, text: "" };
882
+ }
883
+ }
884
+
885
+ /**
886
+ * Wait until `target` mutates in place. Baseline = its signature now; satisfied
887
+ * when the signature differs (presence or text). With `textMatches`, additionally
888
+ * require the NEW text to match — so it waits for a change *to* the desired state,
889
+ * never firing on a stale widget that already matches.
890
+ */
891
+ async function waitForChange(
892
+ page: Page,
893
+ storyboard: Storyboard,
894
+ target: Target,
895
+ opts: { textMatches?: string; timeoutMs: number }
896
+ ): Promise<void> {
897
+ const re = opts.textMatches ? new RegExp(opts.textMatches, "i") : null;
898
+ const base = await signatureOf(page, storyboard, target);
899
+ const deadline = Date.now() + opts.timeoutMs;
900
+ for (;;) {
901
+ const cur = await signatureOf(page, storyboard, target);
902
+ const changed = cur.present !== base.present || cur.text !== base.text;
903
+ if (changed && (!re || re.test(cur.text))) {
904
+ await sleep(250); // let the re-render settle before the next action
905
+ return;
906
+ }
907
+ if (Date.now() >= deadline) {
908
+ const sel = target.named ? NAMED_SELECTORS[target.named] : target.selector;
909
+ throw new Error(
910
+ `waitForChange: "${sel}" did not change` +
911
+ (re ? ` to match ${re}` : "") +
912
+ ` within ${opts.timeoutMs}ms (baseline text ${JSON.stringify(
913
+ base.text.slice(0, 40)
914
+ )})`
915
+ );
916
+ }
917
+ await sleep(250);
918
+ }
919
+ }
920
+
921
+ // ---------------------------------------------------------------------------
922
+ // Failure diagnostics — on any action error, name the scene/action, screenshot
923
+ // the page, and dump the widget frames present + selector match counts. Turns a
924
+ // silent phantom-click failure into something diagnosable from the log.
925
+ // ---------------------------------------------------------------------------
926
+
927
+ function truncateUrl(url: string, n = 90): string {
928
+ return url.length > n ? url.slice(0, n) + "…" : url;
929
+ }
930
+
931
+ /** Never returns — enriches the error with scene/action context + diagnostics. */
932
+ async function failAction(
933
+ page: Page,
934
+ storyboard: Storyboard,
935
+ scene: Scene,
936
+ index: number,
937
+ action: Action,
938
+ opts: PlayerOptions,
939
+ err: unknown
940
+ ): Promise<never> {
941
+ const prefix = `scene ${scene.id}, action #${index + 1} (${action.op})`;
942
+ const base = err instanceof Error ? err.message : String(err);
943
+ let diag = "";
944
+ if (opts.logsDir) {
945
+ diag = await dumpDiagnostics(page, storyboard, scene.id, index, action, opts.logsDir).catch(
946
+ () => ""
947
+ );
948
+ }
949
+ throw new Error(`${prefix}: ${base}${diag ? `\n${diag}` : ""}`);
950
+ }
951
+
952
+ async function dumpDiagnostics(
953
+ page: Page,
954
+ storyboard: Storyboard,
955
+ sceneId: string,
956
+ index: number,
957
+ action: Action,
958
+ logsDir: string
959
+ ): Promise<string> {
960
+ await ensureDir(logsDir);
961
+ const stem = join(logsDir, `fail-${sceneId}-${index + 1}`);
962
+ const lines: string[] = [];
963
+
964
+ await page.screenshot({ path: `${stem}.png`, timeout: 5000 }).catch(() => {});
965
+ lines.push(` screenshot → ${stem}.png`);
966
+
967
+ // Selector diagnostics for targeted actions: how many widget frames (and the
968
+ // main frame) currently match the selector we were after.
969
+ const target = "target" in action ? (action.target as Target) : undefined;
970
+ const selector = target?.named ? NAMED_SELECTORS[target.named] : target?.selector;
971
+ const roots = widgetRootFrames(page);
972
+ lines.push(` widget frames present: ${roots.length}`);
973
+ const frameCounts: Array<{ url: string; matches: number }> = [];
974
+ if (selector) {
975
+ for (const f of roots) {
976
+ const c = await f.locator(selector).count().catch(() => -1);
977
+ frameCounts.push({ url: f.url(), matches: c });
978
+ lines.push(` widget ${truncateUrl(f.url())}: ${c} match(es)`);
979
+ }
980
+ const mainC = await page.locator(selector).count().catch(() => -1);
981
+ lines.push(` main frame: ${mainC} match(es) for "${selector}"`);
982
+ }
983
+
984
+ const detail = {
985
+ sceneId,
986
+ actionIndex: index + 1,
987
+ action,
988
+ selector: selector ?? null,
989
+ url: page.url(),
990
+ widgetFrames: frameCounts,
991
+ allFrames: page.frames().map((f) => truncateUrl(f.url())),
992
+ };
993
+ await fs.writeFile(`${stem}.json`, JSON.stringify(detail, null, 2)).catch(() => {});
994
+ lines.push(` detail → ${stem}.json`);
995
+ return lines.join("\n");
996
+ }
997
+
998
+ /**
999
+ * Eased wheel scroll: many small deltas along an easing curve (~60Hz), instead
1000
+ * of a handful of chunky steps. Fractional remainders are carried so the total
1001
+ * lands exactly on `dy`.
1002
+ */
1003
+ async function easedWheel(
1004
+ page: Page,
1005
+ dy: number,
1006
+ opts: { easing?: EasingPreset; durationMs?: number }
1007
+ ): Promise<void> {
1008
+ const preset = SCROLL_PRESETS[opts.easing ?? "smooth"];
1009
+ const durationMs = Math.max(80, opts.durationMs ?? preset.ms);
1010
+ const tickMs = 16;
1011
+ const steps = Math.max(2, Math.round(durationMs / tickMs));
1012
+ let emitted = 0;
1013
+ for (let i = 1; i <= steps; i++) {
1014
+ const target = dy * preset.fn(i / steps);
1015
+ const delta = target - emitted;
1016
+ await page.mouse.wheel(0, delta);
1017
+ emitted = target;
1018
+ await sleep(tickMs);
1019
+ }
1020
+ await sleep(250);
1021
+ }
1022
+
1023
+ // ---------------------------------------------------------------------------
1024
+ // Human-like motion helpers
1025
+ // ---------------------------------------------------------------------------
1026
+
1027
+ /**
1028
+ * Center of `loc` in MAIN-viewport CSS px, guaranteed to be inside the
1029
+ * viewport's comfortable band. scrollIntoViewIfNeeded alone is NOT enough for
1030
+ * ChatGPT widget targets: the sandbox iframe is out-of-process, and a scroll
1031
+ * initiated inside an OOPIF cannot scroll the embedding page — the element
1032
+ * stays off-screen and a coordinate click lands on nothing (probe recording
1033
+ * 2026-07-06: a below-the-fold click-Add never fired). When the measured
1034
+ * center is outside the band, nudge the main scroller with an eased wheel
1035
+ * (dispatched at the current mouse position, over the conversation column)
1036
+ * and re-measure. The bottom margin also clears ChatGPT's sticky composer
1037
+ * footer, which would otherwise swallow clicks on "visible" elements under it.
1038
+ */
1039
+ async function boxOf(page: Page, loc: Locator): Promise<{ cx: number; cy: number }> {
1040
+ // Short timeout: with the default 30s action timeout, an element Playwright
1041
+ // deems "unstable" (e.g. a still-animating widget card) silently stalls the
1042
+ // recording for half a minute — the catch below hides the failure and the
1043
+ // wheel loop makes the element reachable anyway (live 2026-07-06: a stalled
1044
+ // focus beat inflated one scene's active video by 31s).
1045
+ await loc.scrollIntoViewIfNeeded({ timeout: 2000 }).catch(() => {});
1046
+ let box = await loc.boundingBox();
1047
+ // External capture runs un-emulated (viewport: null → viewportSize() is
1048
+ // null); fall back to the real window content size, which the recorder has
1049
+ // sized to the storyboard viewport.
1050
+ const vp =
1051
+ page.viewportSize() ??
1052
+ (await page.evaluate(() => ({
1053
+ width: window.innerWidth,
1054
+ height: window.innerHeight,
1055
+ })));
1056
+ for (let i = 0; i < 3 && box && vp; i++) {
1057
+ const cy = box.y + box.height / 2;
1058
+ if (cy >= 80 && cy <= vp.height - 120) break;
1059
+ await easedWheel(page, cy - vp.height * 0.55, { durationMs: 500 });
1060
+ box = await loc.boundingBox();
1061
+ }
1062
+ if (!box) throw new Error("Element has no bounding box (not visible?)");
1063
+ return { cx: box.x + box.width / 2, cy: box.y + box.height / 2 };
1064
+ }
1065
+
1066
+ /** Eased cursor glide via many small mouse.move steps. */
1067
+ async function moveMouseTo(
1068
+ page: Page,
1069
+ mouse: MouseState,
1070
+ targetX: number,
1071
+ targetY: number,
1072
+ sample?: CursorSampler
1073
+ ): Promise<void> {
1074
+ const startX = mouse.x;
1075
+ const startY = mouse.y;
1076
+ const dist = Math.hypot(targetX - startX, targetY - startY);
1077
+ const steps = Math.max(12, Math.min(40, Math.round(dist / 12)));
1078
+ for (let i = 1; i <= steps; i++) {
1079
+ const t = easeInOutCubic(i / steps);
1080
+ const x = startX + (targetX - startX) * t;
1081
+ const y = startY + (targetY - startY) * t;
1082
+ await page.mouse.move(x, y);
1083
+ sample?.(x, y);
1084
+ await sleep(8);
1085
+ }
1086
+ mouse.x = targetX;
1087
+ mouse.y = targetY;
1088
+ }
1089
+
1090
+ /**
1091
+ * True when something OTHER than the target would receive a click at (x, y).
1092
+ * Evaluated in the MAIN frame: for widget-frame targets the top-level hit must
1093
+ * be an <iframe> (the sandbox); anything else is an overlay that would swallow
1094
+ * a raw coordinate click. Seen live 2026-07-06: ChatGPT's floating
1095
+ * scroll-to-bottom arrow hovered exactly over the cart's Checkout button and
1096
+ * ate the click. Playwright's locator.click() hit-tests this itself, but we
1097
+ * click via page.mouse for the cinematic cursor, so we must check ourselves.
1098
+ */
1099
+ async function clickObstructed(
1100
+ page: Page,
1101
+ loc: Locator,
1102
+ inFrame: boolean,
1103
+ x: number,
1104
+ y: number
1105
+ ): Promise<boolean> {
1106
+ try {
1107
+ if (inFrame) {
1108
+ const tag = await page.evaluate(
1109
+ ([px, py]) => document.elementFromPoint(px, py)?.tagName ?? "",
1110
+ [x, y] as [number, number]
1111
+ );
1112
+ return tag.toLowerCase() !== "iframe";
1113
+ }
1114
+ return await loc.evaluate(
1115
+ (el, [px, py]) => {
1116
+ const hit = document.elementFromPoint(px, py);
1117
+ return !(hit && (hit === el || el.contains(hit) || hit.contains(el)));
1118
+ },
1119
+ [x, y] as [number, number]
1120
+ );
1121
+ } catch {
1122
+ return false; // never block the click on a failed probe
1123
+ }
1124
+ }
1125
+
1126
+ async function humanClick(
1127
+ page: Page,
1128
+ loc: Locator,
1129
+ mouse: MouseState,
1130
+ inFrame: boolean,
1131
+ sample?: CursorSampler
1132
+ ): Promise<{ cx: number; cy: number }> {
1133
+ let { cx, cy } = await boxOf(page, loc);
1134
+ // Dodge floating overlays: ChatGPT's scroll-to-bottom arrow only shows when
1135
+ // the conversation isn't at the bottom, so wheeling further down dismisses
1136
+ // it; re-measure (boxOf keeps the target in the viewport band) and re-test.
1137
+ for (let i = 0; i < 3; i++) {
1138
+ if (!(await clickObstructed(page, loc, inFrame, cx, cy))) break;
1139
+ if (i === 2) {
1140
+ log(` ! click point still obstructed after scrolling; clicking anyway`);
1141
+ break;
1142
+ }
1143
+ await easedWheel(page, 240, { durationMs: 350 });
1144
+ ({ cx, cy } = await boxOf(page, loc));
1145
+ }
1146
+ await moveMouseTo(page, mouse, cx, cy, sample);
1147
+ await sleep(120);
1148
+ await page.mouse.down();
1149
+ await sleep(60);
1150
+ await page.mouse.up();
1151
+ await sleep(150);
1152
+ return { cx, cy };
1153
+ }
1154
+
1155
+ async function humanType(
1156
+ page: Page,
1157
+ loc: Locator,
1158
+ text: string,
1159
+ humanize: boolean
1160
+ ): Promise<void> {
1161
+ await loc.focus().catch(() => {});
1162
+ if (!humanize) {
1163
+ await loc.pressSequentially(text, { delay: 25 });
1164
+ return;
1165
+ }
1166
+ for (const ch of text) {
1167
+ await page.keyboard.type(ch);
1168
+ // Jittered cadence; brief extra pause after spaces/punctuation.
1169
+ const base = 45 + Math.random() * 55;
1170
+ const extra = /[\s.,!?]/.test(ch) ? 60 : 0;
1171
+ await sleep(base + extra);
1172
+ }
1173
+ }