@tandryukha/aidemo 0.8.0 → 0.15.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 CHANGED
@@ -1,6 +1,9 @@
1
- import type { Page, Locator, Frame } from "playwright";
1
+ import type { Page, Locator, Frame, Request, Response, Dialog } from "playwright";
2
+ import { scanInteractive, rankCandidates, type DriftCandidate } from "./inspect.js";
3
+ import { compileUserRegex } from "./safe-regex.js";
2
4
  import { promises as fs } from "node:fs";
3
- import { join } from "node:path";
5
+ import { basename, isAbsolute, join, resolve } from "node:path";
6
+ import { existsSync } from "node:fs";
4
7
  import type {
5
8
  Storyboard,
6
9
  Scene,
@@ -13,8 +16,14 @@ import type {
13
16
  StillEvent,
14
17
  CursorSample,
15
18
  EasingPreset,
19
+ WaitState,
16
20
  ProbeActionOutcome,
17
21
  ProbeGoldenScene,
22
+ TimelineAction,
23
+ AttentionEvent,
24
+ KeyEvent,
25
+ RedactSpan,
26
+ Redact,
18
27
  } from "./types.js";
19
28
  import {
20
29
  easeInOutCubic,
@@ -67,6 +76,26 @@ export interface PlayerOptions {
67
76
  onSceneComplete?: (scene: TimelineScene, index: number, total: number) => void;
68
77
  /** Called when a scene's first action is about to run (job progress). */
69
78
  onSceneStart?: (sceneId: string, index: number, total: number) => void;
79
+ /**
80
+ * Resume: scenes before this index are REPLAYED, not recorded — their
81
+ * actions run at speed (no humanized typing, pauses/dwells capped, stills
82
+ * skipped) purely to rebuild the app state the resumed scene starts from,
83
+ * and they don't enter the returned timeline (the recorder reuses the
84
+ * previous take's footage for them).
85
+ */
86
+ replayUntil?: number;
87
+ /**
88
+ * With `replayUntil`: do NOT run the replayed scenes' actions at all — the
89
+ * app state is whatever the profile already holds. For flows that EARN
90
+ * one-shot state (a balance moved, a check-in was taken, a day was rolled
91
+ * forward by hand) a replay would fail or double-consume; the resumed scene
92
+ * must then open with its own `goto`.
93
+ */
94
+ skipReplay?: boolean;
95
+ /** Called after a replayed (not recorded) scene finished. */
96
+ onSceneReplayed?: (sceneId: string, index: number, total: number) => void;
97
+ /** Demo directory — `upload` file paths resolve against it (default: cwd). */
98
+ baseDir?: string;
70
99
  /**
71
100
  * Best-effort cancellation, checked before every action. Aborting mid-scene
72
101
  * throws CanceledError, which rides the recorder's salvage path (partial
@@ -96,11 +125,194 @@ interface SceneCapture {
96
125
  focusEvents: FocusEvent[];
97
126
  stillEvents: StillEvent[];
98
127
  cursorSamples: CursorSample[];
128
+ actions: TimelineAction[];
129
+ attentionEvents: AttentionEvent[];
130
+ keyEvents: KeyEvent[];
131
+ redactSpans: RedactSpan[];
132
+ anchorEvents: Array<{ name: string; tMs: number; action: number }>;
133
+ /** Armed one-shot native-dialog handlers awaiting their dialog (`dialog` op). */
134
+ pendingDialogs: PendingDialog[];
135
+ /** Warnings raised inside an action, drained into its timeline record. */
136
+ pendingWarnings: string[];
137
+ /** Replay-only scene (resume): skip the waits that exist for the camera. */
138
+ fast?: boolean;
139
+ }
140
+
141
+ /** A `dialog` action's armed handler, resolved when its dialog actually fires. */
142
+ interface PendingDialog {
143
+ /** 0-based index of the `dialog` action that armed it. */
144
+ index: number;
145
+ label: string;
146
+ required: boolean;
147
+ timeoutMs: number;
148
+ seen: Promise<string | null>;
149
+ disarm: () => void;
150
+ }
151
+
152
+ /** Record-time dwell for an attention beat (the hold itself is compose-time). */
153
+ const ATTENTION_DWELL_MAX_MS = 800;
154
+ const ATTENTION_HOLD_DEFAULT_MS = 1600;
155
+ const CALLOUT_HOLD_DEFAULT_MS = 2000;
156
+ /** Max matches per redact selector measured after each action. */
157
+ const REDACT_MAX_MATCHES = 8;
158
+
159
+ /** Pretty keystroke label for the chip: "Meta+K" → "⌘ K", "Enter" → "Enter". */
160
+ export function prettyKeys(key: string): string {
161
+ const map: Record<string, string> = {
162
+ meta: "⌘",
163
+ cmd: "⌘",
164
+ command: "⌘",
165
+ control: "Ctrl",
166
+ ctrl: "Ctrl",
167
+ shift: "⇧",
168
+ alt: "⌥",
169
+ option: "⌥",
170
+ enter: "Enter",
171
+ return: "Enter",
172
+ escape: "Esc",
173
+ backspace: "⌫",
174
+ delete: "Del",
175
+ tab: "Tab",
176
+ space: "Space",
177
+ arrowup: "↑",
178
+ arrowdown: "↓",
179
+ arrowleft: "←",
180
+ arrowright: "→",
181
+ pageup: "PgUp",
182
+ pagedown: "PgDn",
183
+ };
184
+ return key
185
+ .split("+")
186
+ .map((k) => map[k.toLowerCase()] ?? (k.length === 1 ? k.toUpperCase() : k))
187
+ .join(" ");
99
188
  }
100
189
 
190
+ /** Ops `retry` applies to: interactions whose failure is usually transient. */
191
+ const RETRYABLE_OPS = new Set([
192
+ "click",
193
+ "type",
194
+ "hover",
195
+ "scrollTo",
196
+ "focus",
197
+ "moveTo",
198
+ "assert",
199
+ "highlight",
200
+ "spotlight",
201
+ "callout",
202
+ ]);
203
+ const RETRY_GAP_MS = 400;
204
+
205
+ /** After `goto`'s domcontentloaded: how long we're willing to wait for quiet. */
206
+ const GOTO_QUIET_CAP_MS = 3400;
207
+
101
208
  /** Records a cursor position into the current scene (compose-cursor mode only). */
102
209
  type CursorSampler = (x: number, y: number) => void;
103
210
 
211
+ /** A failed network exchange seen during the take (issue #44). */
212
+ export interface FailedRequest {
213
+ /** Timeline offset (ms since t0) when the response/failure arrived. */
214
+ tMs: number;
215
+ method: string;
216
+ url: string;
217
+ /** HTTP status, or null when the request never got a response. */
218
+ status: number | null;
219
+ /** Playwright's failure text for a request that never got a response. */
220
+ error?: string;
221
+ resourceType: string;
222
+ /** First bytes of an xhr/fetch error body — often the actual reason. */
223
+ body?: string;
224
+ }
225
+
226
+ const NET_WATCH_CAP = 300;
227
+ const NET_BODY_MAX = 300;
228
+
229
+ /**
230
+ * Passive watch for HTTP ≥400 responses and failed requests across the page
231
+ * (all frames). The engine has no other visibility into app-side writes: a
232
+ * backend 500 on a check-in click looks exactly like a missed click in the
233
+ * screenshot (issue #44), so a failing action's window is dumped into
234
+ * logs/fail-*.json. Ring-buffered; never throws.
235
+ */
236
+ class NetworkWatch {
237
+ private entries: FailedRequest[] = [];
238
+ private readonly onResponse: (res: Response) => void;
239
+ private readonly onFailed: (req: Request) => void;
240
+
241
+ constructor(
242
+ private readonly page: Page,
243
+ private readonly t0: number
244
+ ) {
245
+ this.onResponse = (res: Response) => {
246
+ if (res.status() < 400) return;
247
+ const req = res.request();
248
+ const entry: FailedRequest = {
249
+ tMs: Date.now() - t0,
250
+ method: req.method(),
251
+ url: truncateUrl(res.url(), 200),
252
+ status: res.status(),
253
+ resourceType: req.resourceType(),
254
+ };
255
+ this.push(entry);
256
+ if (/^(xhr|fetch)$/.test(entry.resourceType)) {
257
+ res
258
+ .text()
259
+ .then((b) => {
260
+ const snippet = b.replace(/\s+/g, " ").trim().slice(0, NET_BODY_MAX);
261
+ if (snippet) entry.body = snippet;
262
+ })
263
+ .catch(() => {});
264
+ }
265
+ };
266
+ this.onFailed = (req: Request) => {
267
+ const err = req.failure()?.errorText ?? "failed";
268
+ // Navigations cancel in-flight requests; that's not an app failure.
269
+ if (/ERR_ABORTED/.test(err)) return;
270
+ this.push({
271
+ tMs: Date.now() - t0,
272
+ method: req.method(),
273
+ url: truncateUrl(req.url(), 200),
274
+ status: null,
275
+ error: err,
276
+ resourceType: req.resourceType(),
277
+ });
278
+ };
279
+ }
280
+
281
+ private push(e: FailedRequest): void {
282
+ this.entries.push(e);
283
+ if (this.entries.length > NET_WATCH_CAP) this.entries.shift();
284
+ }
285
+
286
+ start(): void {
287
+ this.page.on("response", this.onResponse);
288
+ this.page.on("requestfailed", this.onFailed);
289
+ }
290
+
291
+ stop(): void {
292
+ this.page.off("response", this.onResponse);
293
+ this.page.off("requestfailed", this.onFailed);
294
+ }
295
+
296
+ /** Failures that arrived at or after `tMs` (a failing action's window). */
297
+ since(tMs: number): FailedRequest[] {
298
+ return this.entries.filter((e) => e.tMs >= tMs);
299
+ }
300
+ }
301
+
302
+ /** One-line-per-entry summary for the log (≤ `max` lines). */
303
+ function describeFailedRequests(list: FailedRequest[], max = 5): string[] {
304
+ const lines = list
305
+ .slice(-max)
306
+ .map(
307
+ (e) =>
308
+ ` ${e.status ?? "ERR"} ${e.method} ${e.url}` +
309
+ (e.error ? ` — ${e.error}` : "") +
310
+ (e.body ? ` — ${JSON.stringify(e.body.slice(0, 120))}` : "")
311
+ );
312
+ if (list.length > max) lines.unshift(` … ${list.length - max} earlier`);
313
+ return lines;
314
+ }
315
+
104
316
  export async function runStoryboard(
105
317
  page: Page,
106
318
  storyboard: Storyboard,
@@ -115,7 +327,11 @@ export async function runStoryboard(
115
327
 
116
328
  const now = () => Date.now() - opts.t0;
117
329
  const scenes: TimelineScene[] = [];
330
+ const net = new NetworkWatch(page, opts.t0);
331
+ net.start();
332
+ let prevActionStartMs = 0;
118
333
 
334
+ try {
119
335
  const total = storyboard.scenes.length;
120
336
  for (let si = 0; si < total; si++) {
121
337
  const scene = storyboard.scenes[si];
@@ -125,7 +341,25 @@ export async function runStoryboard(
125
341
  focusEvents: [],
126
342
  stillEvents: [],
127
343
  cursorSamples: [],
344
+ actions: [],
345
+ attentionEvents: [],
346
+ keyEvents: [],
347
+ redactSpans: [],
348
+ anchorEvents: [],
349
+ pendingDialogs: [],
350
+ pendingWarnings: [],
351
+ fast: opts.replayUntil != null && si < opts.replayUntil,
352
+ };
353
+ // Per-scene `hide` (top-level hides are injected by the recorder's init
354
+ // script so they survive navigations); applied now, removed at scene end.
355
+ if (scene.hide?.length) await setSceneHide(page, scene.hide);
356
+ const redactList: Redact[] = [...(storyboard.redact ?? []), ...(scene.redact ?? [])];
357
+ const redactOpen = new Map<string, RedactSpan>();
358
+ const measureRedact = async (): Promise<void> => {
359
+ if (!redactList.length) return;
360
+ await measureRedactions(page, storyboard, redactList, redactOpen, capture, now);
128
361
  };
362
+ await measureRedact();
129
363
  // Cursor path sampler — only records when the storyboard opts into the
130
364
  // compose-time cursor overlay, so a plain take's timeline stays unchanged.
131
365
  const sample: CursorSampler | undefined = opts.captureCursorPath
@@ -136,8 +370,17 @@ export async function runStoryboard(
136
370
  y: Math.round(y),
137
371
  })
138
372
  : undefined;
139
- log(`scene ${scene.id}: ${scene.actions.length} action(s)`);
373
+ log(
374
+ `scene ${scene.id}: ${scene.actions.length} action(s)` +
375
+ (capture.fast ? " (replay only — footage reused from the previous take)" : "")
376
+ );
140
377
  opts.onSceneStart?.(scene.id, si, total);
378
+ if (capture.fast && opts.skipReplay) {
379
+ log(` --no-replay: actions skipped, app state left as the profile holds it`);
380
+ if (scene.hide?.length) await setSceneHide(page, []);
381
+ opts.onSceneReplayed?.(scene.id, si, total);
382
+ continue;
383
+ }
141
384
  const probeOutcomes: ProbeActionOutcome[] = [];
142
385
 
143
386
  for (let i = 0; i < scene.actions.length; i++) {
@@ -145,32 +388,149 @@ export async function runStoryboard(
145
388
  throw new CanceledError(`canceled during scene ${scene.id}`);
146
389
  const action = scene.actions[i];
147
390
  const outcome = opts.probe ? initProbeOutcome(storyboard, action) : null;
391
+ // Failed-request window for diagnostics: from the PREVIOUS action's
392
+ // start — the failing action is usually the wait after the click whose
393
+ // XHR actually failed (issue #44).
394
+ const netWindowMs = prevActionStartMs;
395
+ prevActionStartMs = now();
396
+ const rec: TimelineAction = {
397
+ index: i,
398
+ op: action.op,
399
+ startMs: now(),
400
+ endMs: 0,
401
+ ok: false,
402
+ };
403
+ const recTarget = describeActionTarget(storyboard, action);
404
+ if (recTarget) rec.target = recTarget;
405
+ const recWarnings: string[] = [];
148
406
  try {
149
- await runAction(page, storyboard, action, mouse, capture, opts, sample);
407
+ if (await optionalTargetAbsent(page, storyboard, action)) {
408
+ log(` ⚠ optional ${action.op} skipped — target not present`);
409
+ rec.skipped = true;
410
+ recWarnings.push("optional: target not present");
411
+ } else {
412
+ const retries = await runActionWithRetry(
413
+ page,
414
+ storyboard,
415
+ action,
416
+ mouse,
417
+ capture,
418
+ opts,
419
+ sample
420
+ );
421
+ if (retries > 0) {
422
+ rec.retries = retries;
423
+ recWarnings.push(`succeeded after ${retries} retr${retries === 1 ? "y" : "ies"}`);
424
+ }
425
+ }
150
426
  if (outcome) outcome.ok = true;
427
+ rec.ok = true;
151
428
  } catch (err) {
152
- if (outcome) {
429
+ if (action.optional) {
430
+ // Best-effort by authoring contract: log and continue. Counts as ok
431
+ // for the golden projection too (see ProbeActionOutcomeSchema) —
432
+ // an optional action's outcome varies by environment state, which
433
+ // is the point of marking it optional.
434
+ if (outcome) outcome.ok = true;
435
+ rec.ok = true;
436
+ rec.skipped = true;
437
+ recWarnings.push(`optional: ${firstLine(err)}`);
438
+ log(` ⚠ optional ${action.op} skipped — ${firstLine(err)}`);
439
+ } else if (outcome) {
153
440
  // Golden probe: record the failure and keep going, so a broken
154
441
  // selector shows up as a single flipped field in the diff instead of
155
442
  // aborting the whole projection.
156
443
  outcome.ok = false;
444
+ recWarnings.push(firstLine(err));
157
445
  log(
158
446
  ` ✗ probe: ${action.op} failed — ${firstLine(err)}`
159
447
  );
448
+ const failed = net.since(netWindowMs);
449
+ if (failed.length) {
450
+ log(` ${failed.length} failed request(s) since the previous action:`);
451
+ for (const l of describeFailedRequests(failed)) log(l);
452
+ }
453
+ // Same screenshot + drift suggestions as a hard failure, so a golden
454
+ // mismatch points at the nearest replacement selector.
455
+ if (opts.logsDir) {
456
+ const diag = await dumpDiagnostics(
457
+ page,
458
+ storyboard,
459
+ scene.id,
460
+ i,
461
+ action,
462
+ opts.logsDir,
463
+ failed
464
+ ).catch(() => "");
465
+ for (const l of diag.split("\n")) if (l) log(l);
466
+ }
160
467
  } else {
161
468
  // Name the failing scene/action, screenshot the page, and dump the
162
469
  // widget frames present — so a phantom click or a platform
163
470
  // interruption is diagnosable from the log instead of by
164
471
  // hand-extracting webm frames.
165
- await failAction(page, storyboard, scene, i, action, opts, err);
472
+ rec.endMs = now();
473
+ recWarnings.push(firstLine(err));
474
+ rec.warnings = recWarnings;
475
+ capture.actions.push(rec);
476
+ await failAction(page, storyboard, scene, i, action, opts, err, {
477
+ failedRequests: net.since(netWindowMs),
478
+ });
166
479
  }
167
480
  }
481
+ if (action.op !== "dialog") {
482
+ recWarnings.push(...(await settlePendingDialogs(capture)));
483
+ }
484
+ if (capture.pendingWarnings.length) {
485
+ recWarnings.push(...capture.pendingWarnings.splice(0));
486
+ }
487
+ rec.endMs = now();
488
+ const failedInWindow = net.since(rec.startMs);
489
+ if (failedInWindow.length) {
490
+ recWarnings.push(
491
+ `${failedInWindow.length} failed request(s): ` +
492
+ failedInWindow
493
+ .slice(-3)
494
+ .map((e) => `${e.status ?? "ERR"} ${e.method} ${e.url}`)
495
+ .join("; ")
496
+ );
497
+ }
498
+ if (recWarnings.length) rec.warnings = recWarnings;
499
+ capture.actions.push(rec);
500
+ if (action.anchor && !rec.skipped) {
501
+ // The beat = the action's focus moment when it produced one (a click
502
+ // fires its focus event right after the press), else the action start.
503
+ const focusAt = capture.focusEvents.length
504
+ ? capture.focusEvents[capture.focusEvents.length - 1].tMs
505
+ : -1;
506
+ const tMs = focusAt >= rec.startMs && focusAt <= rec.endMs ? focusAt : rec.startMs;
507
+ capture.anchorEvents.push({ name: action.anchor, tMs, action: i });
508
+ }
509
+ await measureRedact();
168
510
  if (outcome) {
169
- await enrichProbeOutcome(page, storyboard, action, outcome);
511
+ // Optional actions skip the found-enrichment: whether their target
512
+ // resolves is environment-dependent, and the golden projection must
513
+ // stay deterministic across runs.
514
+ if (!action.optional) {
515
+ await enrichProbeOutcome(page, storyboard, action, outcome);
516
+ }
170
517
  probeOutcomes.push(outcome);
171
518
  }
172
519
  }
520
+ // A `dialog` armed by the scene's LAST action still has to be accounted for.
521
+ await settlePendingDialogs(capture);
173
522
  if (opts.probe) opts.probe.push({ id: scene.id, actions: probeOutcomes });
523
+ // Close every open redact span at the scene boundary and drop the scene hide.
524
+ for (const span of redactOpen.values()) {
525
+ span.endMs = now();
526
+ capture.redactSpans.push(span);
527
+ }
528
+ redactOpen.clear();
529
+ if (scene.hide?.length) await setSceneHide(page, []);
530
+ if (capture.fast) {
531
+ opts.onSceneReplayed?.(scene.id, si, total);
532
+ continue;
533
+ }
174
534
 
175
535
  const tlScene: TimelineScene = {
176
536
  id: scene.id,
@@ -180,10 +540,18 @@ export async function runStoryboard(
180
540
  focusEvents: capture.focusEvents,
181
541
  stillEvents: capture.stillEvents,
182
542
  cursorSamples: capture.cursorSamples,
543
+ actions: capture.actions,
544
+ attentionEvents: capture.attentionEvents,
545
+ keyEvents: capture.keyEvents,
546
+ redactSpans: capture.redactSpans,
547
+ anchorEvents: capture.anchorEvents,
183
548
  };
184
549
  scenes.push(tlScene);
185
550
  opts.onSceneComplete?.(tlScene, si, total);
186
551
  }
552
+ } finally {
553
+ net.stop();
554
+ }
187
555
 
188
556
  // leadInMs is filled in by the recorder (it knows the video start).
189
557
  return { totalMs: now(), leadInMs: 0, scenes };
@@ -372,7 +740,18 @@ async function countNestedMatches(page: Page, selector: string): Promise<number>
372
740
  async function resolveTargetLocator(
373
741
  page: Page,
374
742
  storyboard: Storyboard,
375
- target: Target
743
+ target: Target,
744
+ opts: {
745
+ /**
746
+ * Pick the first VISIBLE match instead of the first match in DOM order
747
+ * (issue #55): a page whose first `table` is a hidden template would
748
+ * otherwise time out waiting for an element that is never shown. Only
749
+ * honoured when the target does not pin a match with `last`/`nth`.
750
+ */
751
+ preferVisible?: boolean;
752
+ /** Return the whole matching SET (no first/last/nth) — for counting. */
753
+ all?: boolean;
754
+ } = {}
376
755
  ): Promise<Locator> {
377
756
  let selector = target.selector;
378
757
  if (target.named) {
@@ -389,7 +768,10 @@ async function resolveTargetLocator(
389
768
  // Frameless targets honor last/nth at the ELEMENT level (e.g. the newest
390
769
  // assistant message); previously they silently collapsed to .first().
391
770
  const loc = page.locator(selector);
392
- return target.last ? loc.last() : target.nth != null ? loc.nth(target.nth) : loc.first();
771
+ if (opts.all) return loc;
772
+ if (target.last) return loc.last();
773
+ if (target.nth != null) return loc.nth(target.nth);
774
+ return opts.preferVisible ? loc.locator("visible=true").first() : loc.first();
393
775
  }
394
776
  const frameSelector = storyboard.frames[target.frame];
395
777
  if (!frameSelector) {
@@ -411,28 +793,48 @@ async function resolveTargetLocator(
411
793
  : target.nth != null
412
794
  ? frame.nth(target.nth)
413
795
  : frame.first();
414
- return frame.locator(selector).first();
796
+ const inFrame = frame.locator(selector);
797
+ if (opts.all) return inFrame;
798
+ return opts.preferVisible && !target.last && target.nth == null
799
+ ? inFrame.locator("visible=true").first()
800
+ : inFrame.first();
801
+ }
802
+
803
+ /** Human-readable target for error messages: `frame >> selector [last]`. */
804
+ function describeTarget(storyboard: Storyboard, target: Target): string {
805
+ return describeProbeTarget(storyboard, target);
415
806
  }
416
807
 
417
808
  /**
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.
809
+ * Wait until `target` resolves to an element in `state` ("visible" by default,
810
+ * or merely "attached" for zero-size mount points), re-resolving each poll so
811
+ * a nested widget frame that appears (and fills) mid-wait is picked up.
812
+ * Throws on timeout — naming the TOTAL budget waited, not Playwright's last
813
+ * ~2.5 s poll chunk (which read as "Timeout 1945ms exceeded" against a 30 s
814
+ * storyboard budget and sent people hunting a bug that didn't exist, #44).
421
815
  */
422
816
  async function waitForTargetVisible(
423
817
  page: Page,
424
818
  storyboard: Storyboard,
425
819
  target: Target,
426
- timeoutMs: number
820
+ timeoutMs: number,
821
+ state: WaitState = "visible"
427
822
  ): Promise<void> {
428
- const deadline = Date.now() + timeoutMs;
823
+ const started = Date.now();
824
+ const deadline = started + timeoutMs;
429
825
  let lastErr: unknown;
826
+ // Half the budget on the exact first match (the old behavior, so a selector
827
+ // that resolves keeps resolving identically), then on the first VISIBLE match
828
+ // — the fix for a hidden template/print copy sitting ahead of the real one in
829
+ // DOM order (issue #55).
830
+ const flipAt = started + Math.min(timeoutMs / 2, 4000);
430
831
  for (;;) {
832
+ const preferVisible = state === "visible" && Date.now() >= flipAt;
431
833
  try {
432
- const loc = await resolveTargetLocator(page, storyboard, target);
834
+ const loc = await resolveTargetLocator(page, storyboard, target, { preferVisible });
433
835
  const remaining = deadline - Date.now();
434
836
  await loc.waitFor({
435
- state: "visible",
837
+ state,
436
838
  timeout: Math.max(400, Math.min(2500, remaining)),
437
839
  });
438
840
  return;
@@ -440,14 +842,76 @@ async function waitForTargetVisible(
440
842
  lastErr = err;
441
843
  }
442
844
  if (Date.now() >= deadline) {
443
- throw lastErr instanceof Error
444
- ? lastErr
445
- : new Error(`waitForTargetVisible timed out for ${JSON.stringify(target)}`);
845
+ const waited = Date.now() - started;
846
+ const reason = lastErr instanceof Error ? firstLine(lastErr) : String(lastErr);
847
+ const hint = /Timeout \d+ms exceeded/.test(reason)
848
+ ? "" // Playwright's chunk timeout — nothing more to say than the budget
849
+ : ` (last error: ${reason})`;
850
+ const counts = await countMatches(page, storyboard, target).catch(() => null);
851
+ const census =
852
+ counts && counts.total > 0
853
+ ? ` — ${counts.total} match(es) in the DOM, ${counts.visible} visible`
854
+ : "";
855
+ throw new Error(
856
+ `waitFor: "${describeTarget(storyboard, target)}" not ${state} after ` +
857
+ `${waited}ms (budget ${timeoutMs}ms)${hint}${census}` +
858
+ (state === "visible"
859
+ ? ` — if the element is a zero-size mount point that fills later, use state: "attached"`
860
+ : "")
861
+ );
446
862
  }
447
863
  await sleep(300);
448
864
  }
449
865
  }
450
866
 
867
+ /** How many elements the target matches, and how many of those are visible. */
868
+ async function countMatches(
869
+ page: Page,
870
+ storyboard: Storyboard,
871
+ target: Target
872
+ ): Promise<{ total: number; visible: number }> {
873
+ const base = await resolveTargetLocator(page, storyboard, target, { all: true });
874
+ const total = await base.count().catch(() => 0);
875
+ const visible = await base.locator("visible=true").count().catch(() => 0);
876
+ return { total, visible };
877
+ }
878
+
879
+ /**
880
+ * Wait until the scroll position has been still for `settleMs` (bounded at
881
+ * 3 s total) — an honest "scroll finished" for smooth-scrolling pages, where a
882
+ * fixed pause is a race. `sampleAt` reads a position signature; the default is
883
+ * the main document's scroll offset.
884
+ */
885
+ async function waitScrollSettled(
886
+ page: Page,
887
+ settleMs: number,
888
+ loc?: Locator
889
+ ): Promise<void> {
890
+ const quiet = Math.max(50, settleMs);
891
+ const deadline = Date.now() + Math.max(quiet, 3000);
892
+ const sampleAt = async (): Promise<string> => {
893
+ if (loc) {
894
+ const box = await loc.boundingBox().catch(() => null);
895
+ if (box) return `${Math.round(box.x)},${Math.round(box.y)}`;
896
+ }
897
+ return page
898
+ .evaluate(() => `${Math.round(scrollX)},${Math.round(scrollY)}`)
899
+ .catch(() => "");
900
+ };
901
+ let last = await sampleAt();
902
+ let stillSince = Date.now();
903
+ while (Date.now() < deadline) {
904
+ await sleep(50);
905
+ const cur = await sampleAt();
906
+ if (cur !== last) {
907
+ last = cur;
908
+ stillSince = Date.now();
909
+ } else if (Date.now() - stillSince >= quiet) {
910
+ return;
911
+ }
912
+ }
913
+ }
914
+
451
915
  /**
452
916
  * Wait for the model's NEW reply widget — not a stale prior one. ChatGPT keeps
453
917
  * earlier widgets in the DOM and different tools share selectors (search &
@@ -474,7 +938,7 @@ async function waitForNewWidget(
474
938
  await waitForTargetVisible(page, storyboard, target, opts.timeoutMs);
475
939
  return;
476
940
  }
477
- const re = opts.textMatches ? new RegExp(opts.textMatches, "i") : null;
941
+ const re = opts.textMatches ? compileUserRegex(opts.textMatches, "i", "textMatches") : null;
478
942
  const deadline = Date.now() + opts.timeoutMs;
479
943
  const baseline = await countNestedMatches(page, selector);
480
944
  for (;;) {
@@ -539,7 +1003,7 @@ async function waitForNewReply(
539
1003
  ): Promise<void> {
540
1004
  const messages = page.locator(opts.selector);
541
1005
  const stop = page.locator(STOP_BUTTON_SELECTOR);
542
- const re = opts.textMatches ? new RegExp(opts.textMatches, "i") : null;
1006
+ const re = opts.textMatches ? compileUserRegex(opts.textMatches, "i", "textMatches") : null;
543
1007
  const baseline = await messages.count().catch(() => 0);
544
1008
  let sawStreaming = (await stop.count().catch(() => 0)) > 0;
545
1009
  const deadline = Date.now() + opts.timeoutMs;
@@ -572,6 +1036,134 @@ async function waitForNewReply(
572
1036
  }
573
1037
  }
574
1038
 
1039
+ /**
1040
+ * Ops that interact with a target element. An absent target would stall these
1041
+ * for the full Playwright action timeout (~30s of dead recording) before the
1042
+ * optional-skip even triggers, so optional interactions get a short existence
1043
+ * probe first. Wait ops are excluded — waiting is their whole job, and they
1044
+ * carry their own timeoutMs.
1045
+ */
1046
+ const INTERACTION_OPS = new Set([
1047
+ "click",
1048
+ "type",
1049
+ "hover",
1050
+ "scrollTo",
1051
+ "focus",
1052
+ "moveTo",
1053
+ "highlight",
1054
+ "spotlight",
1055
+ "callout",
1056
+ ]);
1057
+ const OPTIONAL_PROBE_MS = 3000;
1058
+
1059
+ /**
1060
+ * For an `optional` interaction, briefly probe whether the target is present
1061
+ * (state-dependent UI may still be rendering, so give it a beat). Returns true
1062
+ * when the action should be skipped. Non-optional actions and wait ops always
1063
+ * return false — they run normally.
1064
+ */
1065
+ async function optionalTargetAbsent(
1066
+ page: Page,
1067
+ storyboard: Storyboard,
1068
+ action: Action
1069
+ ): Promise<boolean> {
1070
+ const target = (action as { target?: Target }).target;
1071
+ if (!action.optional || !INTERACTION_OPS.has(action.op) || !target) {
1072
+ return false;
1073
+ }
1074
+ const state = (action as { state?: WaitState }).state ?? "visible";
1075
+ try {
1076
+ await waitForTargetVisible(page, storyboard, target, OPTIONAL_PROBE_MS, state);
1077
+ return false;
1078
+ } catch {
1079
+ return true;
1080
+ }
1081
+ }
1082
+
1083
+ /**
1084
+ * Run an action, re-attempting it `action.retry` times (retryable ops only)
1085
+ * with a beat between attempts. Returns the number of extra attempts used.
1086
+ * The last failure propagates so the normal fail/optional paths apply.
1087
+ */
1088
+ /**
1089
+ * Verify every armed `dialog` handler got its dialog (issue #54). Called after
1090
+ * the action that was expected to trigger it, and again at scene end. Returns
1091
+ * the warnings to attach; throws when a REQUIRED dialog never appeared, because
1092
+ * the filmed action then silently did nothing.
1093
+ */
1094
+ async function settlePendingDialogs(capture: SceneCapture): Promise<string[]> {
1095
+ if (!capture.pendingDialogs.length) return [];
1096
+ const pending = capture.pendingDialogs.splice(0);
1097
+ const warnings: string[] = [];
1098
+ for (const p of pending) {
1099
+ const message = await Promise.race([
1100
+ p.seen,
1101
+ sleep(p.timeoutMs).then(() => undefined),
1102
+ ]);
1103
+ if (typeof message === "string") continue;
1104
+ p.disarm();
1105
+ const why =
1106
+ `${p.label}: no native dialog appeared within ${p.timeoutMs}ms — the ` +
1107
+ `action after it did not open one (wrong action order, or the control ` +
1108
+ `no longer confirms)`;
1109
+ if (p.required) throw new Error(why);
1110
+ warnings.push(why);
1111
+ log(` ⚠ ${why}`);
1112
+ }
1113
+ return warnings;
1114
+ }
1115
+
1116
+ async function runActionWithRetry(
1117
+ page: Page,
1118
+ storyboard: Storyboard,
1119
+ action: Action,
1120
+ mouse: MouseState,
1121
+ capture: SceneCapture,
1122
+ opts: PlayerOptions,
1123
+ sample?: CursorSampler
1124
+ ): Promise<number> {
1125
+ const budget = action.retry && RETRYABLE_OPS.has(action.op) ? action.retry : 0;
1126
+ for (let attempt = 0; ; attempt++) {
1127
+ try {
1128
+ await runAction(page, storyboard, action, mouse, capture, opts, sample);
1129
+ return attempt;
1130
+ } catch (err) {
1131
+ if (attempt >= budget || opts.signal?.aborted) throw err;
1132
+ log(
1133
+ ` ↻ ${action.op} failed (${firstLine(err)}) — retry ${attempt + 1}/${budget}`
1134
+ );
1135
+ await sleep(RETRY_GAP_MS);
1136
+ }
1137
+ }
1138
+ }
1139
+
1140
+ /**
1141
+ * After a navigation's domcontentloaded: wait for the page to go quiet (no
1142
+ * network for 500 ms, fonts loaded), capped. The wait beyond the classic
1143
+ * 600 ms settle is recorded as an idle span so compose trims it — the take
1144
+ * doesn't get slower, but the first click no longer lands on a half-fetched
1145
+ * page (a skeleton screen, a still-loading nav) and gets misread as a miss.
1146
+ */
1147
+ async function awaitGotoReadiness(
1148
+ page: Page,
1149
+ capture: SceneCapture,
1150
+ t0: number
1151
+ ): Promise<void> {
1152
+ await sleep(600);
1153
+ const start = Date.now();
1154
+ await Promise.all([
1155
+ page.waitForLoadState("networkidle", { timeout: GOTO_QUIET_CAP_MS }).catch(() => {}),
1156
+ page
1157
+ .evaluate(() => (document as unknown as { fonts?: { ready: Promise<unknown> } }).fonts?.ready)
1158
+ .catch(() => {}),
1159
+ ]);
1160
+ const waited = Date.now() - start;
1161
+ if (waited > 150) {
1162
+ capture.idleSpans.push({ startMs: start - t0, endMs: Date.now() - t0, label: "load" });
1163
+ log(` page quiet after +${waited}ms (idle "load")`);
1164
+ }
1165
+ }
1166
+
575
1167
  async function runAction(
576
1168
  page: Page,
577
1169
  storyboard: Storyboard,
@@ -588,13 +1180,205 @@ async function runAction(
588
1180
  switch (action.op) {
589
1181
  case "goto":
590
1182
  await page.goto(action.url, { waitUntil: "domcontentloaded" });
591
- await sleep(600);
1183
+ await awaitGotoReadiness(page, capture, t0);
1184
+ return;
1185
+
1186
+ case "moveTo": {
1187
+ if (!action.target && (action.x == null || action.y == null)) {
1188
+ throw new Error("moveTo needs a target or both x and y");
1189
+ }
1190
+ let x = action.x ?? mouse.x;
1191
+ let y = action.y ?? mouse.y;
1192
+ if (action.target) {
1193
+ const loc = await resolveTargetLocator(page, storyboard, action.target);
1194
+ const box = await boxOf(page, loc);
1195
+ x = box.cx;
1196
+ y = box.cy;
1197
+ }
1198
+ await moveMouseTo(page, mouse, x, y, sample);
1199
+ await sleep(150);
592
1200
  return;
1201
+ }
1202
+
1203
+ case "assert": {
1204
+ if (!action.target && !action.url) {
1205
+ throw new Error("assert needs a target and/or a url");
1206
+ }
1207
+ const timeoutMs = action.timeoutMs ?? 5000;
1208
+ const deadline = Date.now() + timeoutMs;
1209
+ const textRe = action.textMatches ? compileUserRegex(action.textMatches, "", "assert.textMatches") : null;
1210
+ const urlRe = action.url ? compileUserRegex(action.url, "", "assert.url") : null;
1211
+ let why = "";
1212
+ if (action.target) {
1213
+ // Visible first (honest total-budget error if it never shows up).
1214
+ await waitForTargetVisible(page, storyboard, action.target, timeoutMs);
1215
+ }
1216
+ for (;;) {
1217
+ why = "";
1218
+ if (urlRe && !urlRe.test(page.url())) {
1219
+ why = `url ${JSON.stringify(page.url())} does not match /${action.url}/`;
1220
+ } else if (textRe && action.target) {
1221
+ const loc = await resolveTargetLocator(page, storyboard, action.target);
1222
+ const text =
1223
+ ((await loc.first().textContent({ timeout: 800 }).catch(() => null)) ?? "")
1224
+ .replace(/\s+/g, " ")
1225
+ .trim();
1226
+ if (!textRe.test(text)) {
1227
+ why =
1228
+ `text ${JSON.stringify(text.slice(0, 80))} does not match ` +
1229
+ `/${action.textMatches}/`;
1230
+ }
1231
+ }
1232
+ if (!why) return;
1233
+ if (Date.now() >= deadline) break;
1234
+ await sleep(200);
1235
+ }
1236
+ throw new Error(
1237
+ `assert failed after ${timeoutMs}ms: ${why}` +
1238
+ (action.comment ? ` — ${action.comment}` : "")
1239
+ );
1240
+ }
593
1241
 
594
1242
  case "click": {
595
1243
  const loc = await resolveTargetLocator(page, storyboard, action.target);
1244
+ // followPopup: catch a tab the click opens, then bring its URL into the
1245
+ // recorded tab — Playwright records one video per page, so a second tab
1246
+ // would never reach the take.
1247
+ const popup = action.followPopup
1248
+ ? page.context().waitForEvent("page", { timeout: 4000 }).catch(() => null)
1249
+ : null;
596
1250
  const { cx, cy } = await humanClick(page, loc, mouse, !!action.target.frame, sample);
597
1251
  markFocus(cx, cy, "click");
1252
+ if (popup) {
1253
+ const tab = await popup;
1254
+ if (tab) {
1255
+ await tab.waitForLoadState("domcontentloaded", { timeout: 8000 }).catch(() => {});
1256
+ const url = tab.url();
1257
+ await tab.close().catch(() => {});
1258
+ if (url && url !== "about:blank") {
1259
+ log(` popup → ${url} (followed in the recorded tab)`);
1260
+ await page.goto(url, { waitUntil: "domcontentloaded" });
1261
+ await awaitGotoReadiness(page, capture, t0);
1262
+ } else {
1263
+ log(" ! popup opened without a URL; nothing to follow");
1264
+ }
1265
+ } else {
1266
+ log(" (followPopup: the click opened no new tab)");
1267
+ }
1268
+ }
1269
+ return;
1270
+ }
1271
+
1272
+ case "back":
1273
+ await page.goBack({ waitUntil: "domcontentloaded" });
1274
+ await awaitGotoReadiness(page, capture, t0);
1275
+ return;
1276
+
1277
+ // Arm a one-shot answer for the NEXT native dialog (issue #54). Playwright
1278
+ // auto-dismisses dialogs when nothing listens, so a filmed click on a
1279
+ // confirm-guarded control would otherwise put a false outcome on camera.
1280
+ // The handler is verified after the action that follows: an armed dialog
1281
+ // that never fired is a failure unless `required: false`.
1282
+ case "dialog": {
1283
+ const mode = action.action ?? "accept";
1284
+ const label = `dialog ${mode}`;
1285
+ let settle: (message: string | null) => void = () => {};
1286
+ const seen = new Promise<string | null>((r) => {
1287
+ settle = r;
1288
+ });
1289
+ const handler = (dialog: Dialog) => {
1290
+ void (async () => {
1291
+ log(` ${dialog.type()}: "${dialog.message()}" → ${mode}`);
1292
+ if (action.holdMs) await sleep(action.holdMs);
1293
+ try {
1294
+ if (mode === "accept") await dialog.accept(action.promptText);
1295
+ else await dialog.dismiss();
1296
+ } catch (err) {
1297
+ log(` ! answering the dialog failed — ${firstLine(err)}`);
1298
+ }
1299
+ settle(dialog.message());
1300
+ })();
1301
+ };
1302
+ page.once("dialog", handler);
1303
+ capture.pendingDialogs.push({
1304
+ index: capture.actions.length,
1305
+ label,
1306
+ required: action.required !== false,
1307
+ timeoutMs: action.timeoutMs ?? 5000,
1308
+ seen,
1309
+ disarm: () => {
1310
+ page.off("dialog", handler);
1311
+ settle(null);
1312
+ },
1313
+ });
1314
+ return;
1315
+ }
1316
+
1317
+ case "select": {
1318
+ if (action.value == null && action.label == null) {
1319
+ throw new Error("select needs a value or a label");
1320
+ }
1321
+ const loc = await resolveTargetLocator(page, storyboard, action.target);
1322
+ const { cx, cy } = await humanClick(page, loc, mouse, !!action.target.frame, sample);
1323
+ markFocus(cx, cy, "click");
1324
+ // The native dropdown never paints into the recording; commit the choice
1325
+ // programmatically (fires input/change like a real pick) and close it.
1326
+ await loc.selectOption(action.value != null ? { value: action.value } : { label: action.label! });
1327
+ await page.keyboard.press("Escape").catch(() => {});
1328
+ await sleep(200);
1329
+ return;
1330
+ }
1331
+
1332
+ case "drag": {
1333
+ if (!action.to.target && (action.to.x == null || action.to.y == null)) {
1334
+ throw new Error("drag needs `to.target` or both `to.x` and `to.y`");
1335
+ }
1336
+ const loc = await resolveTargetLocator(page, storyboard, action.target);
1337
+ const from = await boxOf(page, loc);
1338
+ let tx = action.to.x ?? mouse.x;
1339
+ let ty = action.to.y ?? mouse.y;
1340
+ if (action.to.target) {
1341
+ const dest = await resolveTargetLocator(page, storyboard, action.to.target);
1342
+ const box = await boxOf(page, dest);
1343
+ tx = box.cx;
1344
+ ty = box.cy;
1345
+ }
1346
+ await moveMouseTo(page, mouse, from.cx, from.cy, sample);
1347
+ await sleep(120);
1348
+ await page.mouse.down();
1349
+ // A few pixels first so HTML5 drag-and-drop and pointer-based libraries
1350
+ // both pass their drag threshold before the real glide.
1351
+ await page.mouse.move(from.cx + 4, from.cy + 4);
1352
+ await sleep(80);
1353
+ await moveMouseTo(page, mouse, tx, ty, sample);
1354
+ await sleep(120);
1355
+ await page.mouse.up();
1356
+ markFocus(tx, ty, "drag");
1357
+ await sleep(200);
1358
+ return;
1359
+ }
1360
+
1361
+ case "upload": {
1362
+ const loc = await resolveTargetLocator(page, storyboard, action.target);
1363
+ const files = action.files.map((f) =>
1364
+ isAbsolute(f) ? f : resolve(opts.baseDir ?? process.cwd(), f)
1365
+ );
1366
+ for (const f of files) {
1367
+ if (!existsSync(f)) throw new Error(`upload: file not found: ${f}`);
1368
+ }
1369
+ const isFileInput = await loc
1370
+ .evaluate((el) => el.tagName === "INPUT" && (el as HTMLInputElement).type === "file")
1371
+ .catch(() => false);
1372
+ if (isFileInput) {
1373
+ await loc.setInputFiles(files);
1374
+ } else {
1375
+ const chooser = page.waitForEvent("filechooser", { timeout: 8000 });
1376
+ const { cx, cy } = await humanClick(page, loc, mouse, !!action.target.frame, sample);
1377
+ markFocus(cx, cy, "click");
1378
+ await (await chooser).setFiles(files);
1379
+ }
1380
+ log(` upload: ${files.map((f) => basename(f)).join(", ")}`);
1381
+ await sleep(300);
598
1382
  return;
599
1383
  }
600
1384
 
@@ -631,19 +1415,115 @@ async function runAction(
631
1415
  await page.keyboard.press("ControlOrMeta+a");
632
1416
  await page.keyboard.press("Backspace");
633
1417
  }
634
- await humanType(page, loc, action.text, action.humanize !== false);
1418
+ await humanType(page, loc, action.text, !capture.fast && action.humanize !== false);
635
1419
  return;
636
1420
  }
637
1421
 
638
1422
  case "press":
1423
+ if (action.keystrokes ?? storyboard.keystrokes) {
1424
+ capture.keyEvents.push({ tMs: Date.now() - t0, keys: prettyKeys(action.key) });
1425
+ }
639
1426
  await page.keyboard.press(action.key);
640
1427
  await sleep(200);
641
1428
  return;
642
1429
 
1430
+ case "highlight":
1431
+ case "spotlight":
1432
+ case "callout": {
1433
+ const loc = await resolveTargetLocator(page, storyboard, action.target, {
1434
+ preferVisible: true,
1435
+ });
1436
+ // #56: the overlay is drawn at compose time from THIS rect, so it has to
1437
+ // be the rect the element actually occupies while the overlay is on
1438
+ // screen. Lazy content loading in above the target shifts it after the
1439
+ // measure with the scroll offset unchanged — invisible to the scroll
1440
+ // settle — and the outline then lands on whatever moved into its place.
1441
+ const rect = await settledRectOf(page, loc, capture.fast ? 0 : 400);
1442
+ if (storyboard.attention?.cursorClear && !capture.fast) {
1443
+ await clearCursorOf(page, mouse, rect, sample);
1444
+ }
1445
+ const holdMs =
1446
+ action.holdMs ??
1447
+ (action.op === "callout" ? CALLOUT_HOLD_DEFAULT_MS : ATTENTION_HOLD_DEFAULT_MS);
1448
+ const ev: AttentionEvent = {
1449
+ tMs: Date.now() - t0,
1450
+ kind: action.op,
1451
+ x: rect.x,
1452
+ y: rect.y,
1453
+ w: rect.w,
1454
+ h: rect.h,
1455
+ holdMs,
1456
+ };
1457
+ if (action.style) ev.style = action.style;
1458
+ if (action.op === "callout") {
1459
+ ev.text = action.text;
1460
+ if (action.placement) ev.placement = action.placement;
1461
+ }
1462
+ if (action.op === "spotlight") {
1463
+ if (action.dimTo != null) ev.dimTo = action.dimTo;
1464
+ if (action.padding != null) ev.padding = action.padding;
1465
+ }
1466
+ capture.attentionEvents.push(ev);
1467
+ if (!capture.fast) await sleep(Math.min(holdMs, ATTENTION_DWELL_MAX_MS));
1468
+ // Re-measure once the dwell is over: if it moved, the overlay we just
1469
+ // logged would be drawn over the wrong pixels. Prefer the later rect
1470
+ // (that is where the element sits for most of the hold) and say so.
1471
+ const after = await rectOf(page, loc).catch(() => null);
1472
+ if (after) {
1473
+ const moved = Math.max(Math.abs(after.x - rect.x), Math.abs(after.y - rect.y));
1474
+ if (moved > ATTENTION_DRIFT_TOLERANCE_PX) {
1475
+ ev.x = after.x;
1476
+ ev.y = after.y;
1477
+ ev.w = after.w;
1478
+ ev.h = after.h;
1479
+ const warning =
1480
+ `${action.op} target moved ${moved}px after the measure (layout ` +
1481
+ `settled late — lazy content above it?); used the later rect`;
1482
+ capture.pendingWarnings.push(warning);
1483
+ log(` ⚠ ${warning}`);
1484
+ }
1485
+ }
1486
+ const box = after ?? rect;
1487
+ const vp = page.viewportSize();
1488
+ if (vp && (box.y + box.h <= 0 || box.y >= vp.height || box.x + box.w <= 0 || box.x >= vp.width)) {
1489
+ const warning =
1490
+ `${action.op} target is OFF SCREEN at draw time ` +
1491
+ `(${box.x},${box.y} ${box.w}x${box.h} in a ${vp.width}x${vp.height} viewport) — ` +
1492
+ `the overlay will be drawn outside the frame; scroll it into view first`;
1493
+ capture.pendingWarnings.push(warning);
1494
+ log(` ⚠ ${warning}`);
1495
+ }
1496
+ return;
1497
+ }
1498
+
643
1499
  case "hover": {
644
1500
  const loc = await resolveTargetLocator(page, storyboard, action.target);
645
1501
  const box = await boxOf(page, loc);
646
- await moveMouseTo(page, mouse, box.cx, box.cy, sample);
1502
+ let x = box.cx;
1503
+ let y = box.cy;
1504
+ // Park off the element when asked (issue #50): a pointer on the centre
1505
+ // of a 30-px chip hides the very number the narration is about.
1506
+ if (action.anchor && action.anchor !== "center") {
1507
+ const rect = await rectOf(page, loc).catch(() => null);
1508
+ if (rect) {
1509
+ const pad = 10;
1510
+ const side =
1511
+ action.anchor === "edge"
1512
+ ? rect.y > (page.viewportSize()?.height ?? 0) / 2
1513
+ ? "top"
1514
+ : "bottom"
1515
+ : action.anchor;
1516
+ if (side === "top") y = rect.y - pad;
1517
+ else if (side === "bottom") y = rect.y + rect.h + pad;
1518
+ else if (side === "left") x = rect.x - pad;
1519
+ else if (side === "right") x = rect.x + rect.w + pad;
1520
+ }
1521
+ }
1522
+ if (action.offset) {
1523
+ x += action.offset.x;
1524
+ y += action.offset.y;
1525
+ }
1526
+ await moveMouseTo(page, mouse, x, y, sample);
647
1527
  await sleep(300);
648
1528
  return;
649
1529
  }
@@ -669,6 +1549,7 @@ async function runAction(
669
1549
  // Let the frame settle first, then record the marker at the settled
670
1550
  // moment so compose-time extraction lands on a clean frame (not mid-
671
1551
  // transition). The PNG is pulled from the CLEAN take by `aidemo stills`.
1552
+ if (capture.fast) return;
672
1553
  await sleep(120);
673
1554
  capture.stillEvents.push({ tMs: Date.now() - t0, name: action.name });
674
1555
  log(` still "${action.name}" @ ${Date.now() - t0}ms`);
@@ -676,7 +1557,13 @@ async function runAction(
676
1557
  }
677
1558
 
678
1559
  case "scrollTo": {
1560
+ const state = action.state ?? "visible";
679
1561
  const loc = await resolveTargetLocator(page, storyboard, action.target);
1562
+ if (state === "attached") {
1563
+ // A zero-size mount point has no visibility to wait for; require it
1564
+ // to be in the DOM, then scroll by geometry alone (issue #44).
1565
+ await waitForTargetVisible(page, storyboard, action.target, 15000, "attached");
1566
+ }
680
1567
  // Top-page targets get the cinematic eased scroll; targets inside a
681
1568
  // frame keep the reliable scrollIntoViewIfNeeded (wheel deltas would go
682
1569
  // to whatever scroller is under the cursor, not necessarily the frame).
@@ -687,13 +1574,74 @@ async function runAction(
687
1574
  if (Math.abs(dy) > 30) await easedWheel(page, dy, action);
688
1575
  }
689
1576
  }
690
- await loc.scrollIntoViewIfNeeded();
691
- await sleep(300);
1577
+ if (state === "attached") {
1578
+ // scrollIntoViewIfNeeded needs a visible element; a plain
1579
+ // scrollIntoView on the node works for an empty one.
1580
+ await loc
1581
+ .evaluate((el) => el.scrollIntoView({ block: "center" }))
1582
+ .catch(() => {});
1583
+ } else {
1584
+ await loc.scrollIntoViewIfNeeded();
1585
+ }
1586
+ if (action.settleMs != null) {
1587
+ await waitScrollSettled(page, action.settleMs, loc);
1588
+ } else {
1589
+ await sleep(300);
1590
+ }
692
1591
  return;
693
1592
  }
694
1593
 
695
1594
  case "scrollBy": {
696
- await easedWheel(page, action.dy, action);
1595
+ let dy = action.dy;
1596
+ let loc: Locator | undefined;
1597
+ if (action.target) {
1598
+ // Target-scoped scroll (issue #43): wheel OVER the element, and never
1599
+ // more than its own scroller has room for — a bottomed-out inner
1600
+ // panel otherwise chains the wheel to the page and the whole app
1601
+ // slides off-screen mid-take with nothing failing.
1602
+ loc = await resolveTargetLocator(page, storyboard, action.target);
1603
+ await waitForTargetVisible(page, storyboard, action.target, 15000, "attached");
1604
+ const box = await loc.boundingBox().catch(() => null);
1605
+ if (box) {
1606
+ const vp = page.viewportSize() ?? opts.video;
1607
+ const cx = Math.min(Math.max(box.x + box.width / 2, 2), vp.width - 2);
1608
+ const cy = Math.min(Math.max(box.y + box.height / 2, 2), vp.height - 2);
1609
+ await moveMouseTo(page, mouse, cx, cy, sample);
1610
+ }
1611
+ // NOTE: no inner named functions inside evaluate callbacks — tsx's
1612
+ // esbuild keepNames wraps them in a `__name` helper that doesn't
1613
+ // exist in the page (ReferenceError, swallowed → no clamp).
1614
+ const room = await loc
1615
+ .evaluate((el, want) => {
1616
+ let n: Element | null = el;
1617
+ while (
1618
+ n &&
1619
+ !(
1620
+ /(auto|scroll|overlay)/.test(getComputedStyle(n).overflowY) &&
1621
+ n.scrollHeight > n.clientHeight + 1
1622
+ )
1623
+ ) {
1624
+ n = n.parentElement;
1625
+ }
1626
+ const sc = n ?? document.scrollingElement ?? document.documentElement;
1627
+ return want > 0
1628
+ ? sc.scrollHeight - sc.clientHeight - sc.scrollTop
1629
+ : sc.scrollTop;
1630
+ }, dy)
1631
+ .catch((err: Error) => {
1632
+ log(` ! scrollBy: could not measure target scroller (${firstLine(err)}); unclamped`);
1633
+ return Math.abs(dy);
1634
+ });
1635
+ if (Math.abs(dy) > room) {
1636
+ log(
1637
+ ` scrollBy clamped ${dy} → ${Math.sign(dy) * Math.round(room)}px ` +
1638
+ `(target's scroller has no more room)`
1639
+ );
1640
+ dy = Math.sign(dy) * room;
1641
+ }
1642
+ }
1643
+ if (Math.abs(dy) >= 1) await easedWheel(page, dy, action);
1644
+ if (action.settleMs != null) await waitScrollSettled(page, action.settleMs, loc);
697
1645
  return;
698
1646
  }
699
1647
 
@@ -702,7 +1650,8 @@ async function runAction(
702
1650
  page,
703
1651
  storyboard,
704
1652
  action.target,
705
- action.timeoutMs ?? 15000
1653
+ action.timeoutMs ?? 15000,
1654
+ action.state ?? "visible"
706
1655
  );
707
1656
  return;
708
1657
  }
@@ -747,7 +1696,8 @@ async function runAction(
747
1696
  }
748
1697
 
749
1698
  case "pause":
750
- await sleep(action.ms);
1699
+ // A replayed scene (resume) only needs the state, not the beat.
1700
+ await sleep(capture.fast ? Math.min(action.ms, 100) : action.ms);
751
1701
  return;
752
1702
  }
753
1703
  }
@@ -781,6 +1731,7 @@ function initProbeOutcome(
781
1731
  action: Action
782
1732
  ): ProbeActionOutcome {
783
1733
  const o: ProbeActionOutcome = { op: action.op, ok: false };
1734
+ if (action.optional) o.optional = true;
784
1735
  switch (action.op) {
785
1736
  case "goto":
786
1737
  o.target = action.url;
@@ -793,6 +1744,7 @@ function initProbeOutcome(
793
1744
  break;
794
1745
  case "scrollBy":
795
1746
  o.dy = action.dy;
1747
+ if (action.target) o.target = describeProbeTarget(storyboard, action.target);
796
1748
  break;
797
1749
  case "waitForReply":
798
1750
  o.target = action.selector ?? ASSISTANT_MESSAGE_SELECTOR;
@@ -809,12 +1761,34 @@ function initProbeOutcome(
809
1761
  case "scrollTo":
810
1762
  case "waitFor":
811
1763
  case "focus":
1764
+ case "highlight":
1765
+ case "spotlight":
1766
+ case "callout":
1767
+ case "select":
1768
+ case "drag":
1769
+ case "upload":
812
1770
  o.target = describeProbeTarget(storyboard, action.target);
813
1771
  break;
1772
+ case "moveTo":
1773
+ o.target = action.target
1774
+ ? describeProbeTarget(storyboard, action.target)
1775
+ : `${action.x},${action.y}`;
1776
+ break;
1777
+ case "assert":
1778
+ o.target = action.target
1779
+ ? describeProbeTarget(storyboard, action.target)
1780
+ : `url:${action.url}`;
1781
+ break;
814
1782
  }
815
1783
  return o;
816
1784
  }
817
1785
 
1786
+ /** The timeline.json `actions[].target` string for any action (or undefined). */
1787
+ function describeActionTarget(storyboard: Storyboard, action: Action): string | undefined {
1788
+ const t = initProbeOutcome(storyboard, action).target;
1789
+ return t;
1790
+ }
1791
+
818
1792
  /** Add the post-run stable signals: goto's final URL, target element-found. */
819
1793
  async function enrichProbeOutcome(
820
1794
  page: Page,
@@ -833,8 +1807,9 @@ async function enrichProbeOutcome(
833
1807
  );
834
1808
  return;
835
1809
  }
836
- if ("target" in action) {
837
- o.found = await targetResolves(page, storyboard, action.target);
1810
+ const target = (action as { target?: Target }).target;
1811
+ if (target) {
1812
+ o.found = await targetResolves(page, storyboard, target);
838
1813
  }
839
1814
  }
840
1815
 
@@ -894,7 +1869,7 @@ async function waitForChange(
894
1869
  target: Target,
895
1870
  opts: { textMatches?: string; timeoutMs: number }
896
1871
  ): Promise<void> {
897
- const re = opts.textMatches ? new RegExp(opts.textMatches, "i") : null;
1872
+ const re = opts.textMatches ? compileUserRegex(opts.textMatches, "i", "textMatches") : null;
898
1873
  const base = await signatureOf(page, storyboard, target);
899
1874
  const deadline = Date.now() + opts.timeoutMs;
900
1875
  for (;;) {
@@ -936,17 +1911,34 @@ async function failAction(
936
1911
  index: number,
937
1912
  action: Action,
938
1913
  opts: PlayerOptions,
939
- err: unknown
1914
+ err: unknown,
1915
+ extra: { failedRequests: FailedRequest[] }
940
1916
  ): Promise<never> {
941
1917
  const prefix = `scene ${scene.id}, action #${index + 1} (${action.op})`;
942
1918
  const base = err instanceof Error ? err.message : String(err);
1919
+ // Lead with the app's own refusal when the action provoked a 4xx/5xx (issue
1920
+ // #45): the selector headline blames the selector, and on a rate-limited or
1921
+ // auth-gated flow the selector was fine — the write just failed. The
1922
+ // original message stays, one line down, in parentheses.
1923
+ const refusal = extra.failedRequests.filter((r) => (r.status ?? 0) >= 400).slice(-1)[0];
1924
+ const headline = refusal
1925
+ ? `the app refused a request this action triggered\n` +
1926
+ describeFailedRequests([refusal]).join("\n").trim().replace(/^/, " ") +
1927
+ `\n (${base})`
1928
+ : base;
943
1929
  let diag = "";
944
1930
  if (opts.logsDir) {
945
- diag = await dumpDiagnostics(page, storyboard, scene.id, index, action, opts.logsDir).catch(
946
- () => ""
947
- );
1931
+ diag = await dumpDiagnostics(
1932
+ page,
1933
+ storyboard,
1934
+ scene.id,
1935
+ index,
1936
+ action,
1937
+ opts.logsDir,
1938
+ extra.failedRequests
1939
+ ).catch(() => "");
948
1940
  }
949
- throw new Error(`${prefix}: ${base}${diag ? `\n${diag}` : ""}`);
1941
+ throw new Error(`${prefix}: ${headline}${diag ? `\n${diag}` : ""}`);
950
1942
  }
951
1943
 
952
1944
  async function dumpDiagnostics(
@@ -955,7 +1947,8 @@ async function dumpDiagnostics(
955
1947
  sceneId: string,
956
1948
  index: number,
957
1949
  action: Action,
958
- logsDir: string
1950
+ logsDir: string,
1951
+ failedRequests: FailedRequest[] = []
959
1952
  ): Promise<string> {
960
1953
  await ensureDir(logsDir);
961
1954
  const stem = join(logsDir, `fail-${sceneId}-${index + 1}`);
@@ -966,7 +1959,7 @@ async function dumpDiagnostics(
966
1959
 
967
1960
  // Selector diagnostics for targeted actions: how many widget frames (and the
968
1961
  // main frame) currently match the selector we were after.
969
- const target = "target" in action ? (action.target as Target) : undefined;
1962
+ const target = (action as { target?: Target }).target;
970
1963
  const selector = target?.named ? NAMED_SELECTORS[target.named] : target?.selector;
971
1964
  const roots = widgetRootFrames(page);
972
1965
  lines.push(` widget frames present: ${roots.length}`);
@@ -981,6 +1974,59 @@ async function dumpDiagnostics(
981
1974
  lines.push(` main frame: ${mainC} match(es) for "${selector}"`);
982
1975
  }
983
1976
 
1977
+ // App-side failures in this action's window (issue #44): a backend 500 on
1978
+ // the click's XHR is invisible in a screenshot and reads as a click miss.
1979
+ if (failedRequests.length) {
1980
+ lines.push(
1981
+ ` failed requests since the previous action: ${failedRequests.length} ` +
1982
+ `(an app-side write that failed reads as a click miss on screen)`
1983
+ );
1984
+ lines.push(...describeFailedRequests(failedRequests));
1985
+ } else {
1986
+ lines.push(` failed requests since the previous action: none`);
1987
+ }
1988
+
1989
+ // Drift suggestions: when the selector matched nothing, scan the page (and
1990
+ // the widget frames) for the elements that look most like what it asked
1991
+ // for, so the fix is one edit away instead of a round of guessing.
1992
+ let drift: DriftCandidate[] = [];
1993
+ let driftFile: string | null = null;
1994
+ const nothingMatched =
1995
+ !!selector && frameCounts.every((c) => c.matches <= 0) &&
1996
+ ((await page.locator(selector).count().catch(() => 0)) === 0);
1997
+ if (nothingMatched) {
1998
+ const scanned: DriftCandidate[] = [];
1999
+ const targets: Array<Page | Frame> = [page, ...roots];
2000
+ for (const t of targets) {
2001
+ const res = await scanInteractive(t, 120).catch(() => null);
2002
+ if (!res) continue;
2003
+ const frameTag = t === page ? undefined : truncateUrl((t as Frame).url());
2004
+ scanned.push(
2005
+ ...rankCandidates(selector!, res.elements, 8).map((c) =>
2006
+ frameTag ? { ...c, frame: frameTag } : c
2007
+ )
2008
+ );
2009
+ }
2010
+ drift = scanned.sort((a, b) => b.score - a.score).slice(0, 8);
2011
+ driftFile = join(logsDir, `drift-${sceneId}-${index + 1}.json`);
2012
+ await fs
2013
+ .writeFile(
2014
+ driftFile,
2015
+ JSON.stringify({ sceneId, actionIndex: index + 1, selector, url: page.url(), candidates: drift }, null, 2)
2016
+ )
2017
+ .catch(() => {});
2018
+ if (drift.length) {
2019
+ lines.push(` nearest elements to "${selector}" (drift suggestions → ${driftFile}):`);
2020
+ for (const c of drift.slice(0, 3)) {
2021
+ lines.push(
2022
+ ` ${c.role} "${c.name.slice(0, 40)}"${c.frame ? ` [frame ${c.frame}]` : ""} → ${c.selector || "(no unique selector)"} (score ${c.score})`
2023
+ );
2024
+ }
2025
+ } else {
2026
+ lines.push(` no similar interactive element on the page (drift file → ${driftFile}) — is this the right screen / state?`);
2027
+ }
2028
+ }
2029
+
984
2030
  const detail = {
985
2031
  sceneId,
986
2032
  actionIndex: index + 1,
@@ -989,6 +2035,8 @@ async function dumpDiagnostics(
989
2035
  url: page.url(),
990
2036
  widgetFrames: frameCounts,
991
2037
  allFrames: page.frames().map((f) => truncateUrl(f.url())),
2038
+ failedRequests,
2039
+ ...(driftFile ? { driftFile, driftCandidates: drift } : {}),
992
2040
  };
993
2041
  await fs.writeFile(`${stem}.json`, JSON.stringify(detail, null, 2)).catch(() => {});
994
2042
  lines.push(` detail → ${stem}.json`);
@@ -1056,13 +2104,210 @@ async function boxOf(page: Page, loc: Locator): Promise<{ cx: number; cy: number
1056
2104
  for (let i = 0; i < 3 && box && vp; i++) {
1057
2105
  const cy = box.y + box.height / 2;
1058
2106
  if (cy >= 80 && cy <= vp.height - 120) break;
1059
- await easedWheel(page, cy - vp.height * 0.55, { durationMs: 500 });
2107
+ const dy = cy - vp.height * 0.55;
2108
+ // A sticky/fixed element (a header chip) never moves with the scroller,
2109
+ // and a page already at its top/bottom cannot scroll that way — wheeling
2110
+ // anyway only rubber-bands the whole page (a 1 Hz ~6 px bob on the take,
2111
+ // three bounces per hover/click on a header target; loyalty v7 take 1).
2112
+ const canNudge = await loc
2113
+ .evaluate((el, d) => {
2114
+ let n: Element | null = el;
2115
+ while (n && n !== document.body) {
2116
+ const pos = getComputedStyle(n).position;
2117
+ if (pos === "fixed" || pos === "sticky") return false;
2118
+ n = n.parentElement;
2119
+ }
2120
+ const se = document.scrollingElement ?? document.documentElement;
2121
+ const max = se.scrollHeight - se.clientHeight;
2122
+ return d < 0 ? se.scrollTop > 0 : se.scrollTop < max - 1;
2123
+ }, dy)
2124
+ .catch(() => true);
2125
+ if (!canNudge) break;
2126
+ await easedWheel(page, dy, { durationMs: 500 });
1060
2127
  box = await loc.boundingBox();
1061
2128
  }
1062
2129
  if (!box) throw new Error("Element has no bounding box (not visible?)");
1063
2130
  return { cx: box.x + box.width / 2, cy: box.y + box.height / 2 };
1064
2131
  }
1065
2132
 
2133
+ /** Full viewport rect of an element (after boxOf's bring-into-view nudge). */
2134
+ async function rectOf(
2135
+ page: Page,
2136
+ loc: Locator
2137
+ ): Promise<{ x: number; y: number; w: number; h: number }> {
2138
+ await boxOf(page, loc);
2139
+ const box = await loc.boundingBox();
2140
+ if (!box) throw new Error("Element has no bounding box (not visible?)");
2141
+ return {
2142
+ x: Math.round(box.x),
2143
+ y: Math.round(box.y),
2144
+ w: Math.round(box.width),
2145
+ h: Math.round(box.height),
2146
+ };
2147
+ }
2148
+
2149
+ /**
2150
+ * Glide the pointer just outside `rect` so it does not sit on top of what the
2151
+ * beat is showcasing (`attention.cursorClear`, issue #50). Picks the nearest
2152
+ * side with room in the viewport; a no-op when the cursor is already clear.
2153
+ */
2154
+ async function clearCursorOf(
2155
+ page: Page,
2156
+ mouse: MouseState,
2157
+ rect: { x: number; y: number; w: number; h: number },
2158
+ sample?: CursorSampler
2159
+ ): Promise<void> {
2160
+ const pad = 18;
2161
+ const inside =
2162
+ mouse.x >= rect.x - 2 &&
2163
+ mouse.x <= rect.x + rect.w + 2 &&
2164
+ mouse.y >= rect.y - 2 &&
2165
+ mouse.y <= rect.y + rect.h + 2;
2166
+ if (!inside) return;
2167
+ const vp = page.viewportSize() ?? { width: 1280, height: 720 };
2168
+ const below = rect.y + rect.h + pad;
2169
+ const above = rect.y - pad;
2170
+ const right = rect.x + rect.w + pad;
2171
+ const left = rect.x - pad;
2172
+ let x = mouse.x;
2173
+ let y = mouse.y;
2174
+ if (below < vp.height - 8) y = below;
2175
+ else if (above > 8) y = above;
2176
+ else if (right < vp.width - 8) x = right;
2177
+ else if (left > 8) x = left;
2178
+ else return;
2179
+ await moveMouseTo(page, mouse, x, y, sample);
2180
+ await sleep(120);
2181
+ }
2182
+
2183
+ /** How far an attention target may shift after its measure before we re-aim. */
2184
+ const ATTENTION_DRIFT_TOLERANCE_PX = 24;
2185
+
2186
+ /**
2187
+ * `rectOf`, but only once the element's box has stopped moving for `quietMs`
2188
+ * (bounded at 2 s). Unlike `waitScrollSettled` this watches the RECT, so a
2189
+ * layout shift at an unchanged scroll offset — lazily-loaded content inserted
2190
+ * above the target — is caught (issue #56).
2191
+ */
2192
+ async function settledRectOf(
2193
+ page: Page,
2194
+ loc: Locator,
2195
+ quietMs: number
2196
+ ): Promise<{ x: number; y: number; w: number; h: number }> {
2197
+ if (quietMs <= 0) return rectOf(page, loc);
2198
+ const deadline = Date.now() + 2000;
2199
+ let last = await rectOf(page, loc);
2200
+ let stillSince = Date.now();
2201
+ while (Date.now() < deadline) {
2202
+ await sleep(80);
2203
+ const cur = await rectOf(page, loc).catch(() => null);
2204
+ if (!cur) continue;
2205
+ if (
2206
+ Math.abs(cur.x - last.x) > 1 ||
2207
+ Math.abs(cur.y - last.y) > 1 ||
2208
+ Math.abs(cur.w - last.w) > 1 ||
2209
+ Math.abs(cur.h - last.h) > 1
2210
+ ) {
2211
+ last = cur;
2212
+ stillSince = Date.now();
2213
+ continue;
2214
+ }
2215
+ last = cur;
2216
+ if (Date.now() - stillSince >= quietMs) break;
2217
+ }
2218
+ return last;
2219
+ }
2220
+
2221
+ /** CSS used for `hide` selectors (record-time, the one non-compose exception). */
2222
+ export function hideCss(selectors: string[]): string {
2223
+ return selectors.length
2224
+ ? `${selectors.join(",")}{visibility:hidden !important;}`
2225
+ : "";
2226
+ }
2227
+
2228
+ /** Install / replace the per-scene hide stylesheet in the main frame. */
2229
+ async function setSceneHide(page: Page, selectors: string[]): Promise<void> {
2230
+ const css = hideCss(selectors);
2231
+ await page
2232
+ .evaluate((text) => {
2233
+ const id = "__aidemo_scene_hide";
2234
+ let el = document.getElementById(id);
2235
+ if (!text) {
2236
+ el?.remove();
2237
+ return;
2238
+ }
2239
+ if (!el) {
2240
+ el = document.createElement("style");
2241
+ el.id = id;
2242
+ document.documentElement.appendChild(el);
2243
+ }
2244
+ el.textContent = text;
2245
+ }, css)
2246
+ .catch(() => {});
2247
+ }
2248
+
2249
+ /**
2250
+ * Re-measure every redact selector and update the open spans: a box that
2251
+ * moved or vanished closes its span; a new/moved box opens one. Cheap (one
2252
+ * boundingBox per match), runs after every action.
2253
+ */
2254
+ async function measureRedactions(
2255
+ page: Page,
2256
+ storyboard: Storyboard,
2257
+ list: Redact[],
2258
+ open: Map<string, RedactSpan>,
2259
+ capture: SceneCapture,
2260
+ now: () => number
2261
+ ): Promise<void> {
2262
+ const seen = new Set<string>();
2263
+ const t = now();
2264
+ for (const r of list) {
2265
+ let boxes: Array<{ x: number; y: number; width: number; height: number } | null> = [];
2266
+ try {
2267
+ const loc = await resolveTargetLocator(page, storyboard, {
2268
+ selector: r.selector,
2269
+ ...(r.frame ? { frame: r.frame } : {}),
2270
+ });
2271
+ const n = Math.min(await loc.count().catch(() => 0), REDACT_MAX_MATCHES);
2272
+ for (let i = 0; i < n; i++) {
2273
+ boxes.push(await loc.nth(i).boundingBox().catch(() => null));
2274
+ }
2275
+ } catch {
2276
+ boxes = [];
2277
+ }
2278
+ boxes.forEach((b, i) => {
2279
+ if (!b || b.width < 1 || b.height < 1) return;
2280
+ const key = `${r.frame ?? ""}>>${r.selector}#${i}`;
2281
+ seen.add(key);
2282
+ const rect = {
2283
+ x: Math.round(b.x),
2284
+ y: Math.round(b.y),
2285
+ w: Math.round(b.width),
2286
+ h: Math.round(b.height),
2287
+ };
2288
+ const cur = open.get(key);
2289
+ const same =
2290
+ cur &&
2291
+ Math.abs(cur.x - rect.x) <= 2 &&
2292
+ Math.abs(cur.y - rect.y) <= 2 &&
2293
+ Math.abs(cur.w - rect.w) <= 2 &&
2294
+ Math.abs(cur.h - rect.h) <= 2;
2295
+ if (same) return;
2296
+ if (cur) {
2297
+ cur.endMs = t;
2298
+ capture.redactSpans.push(cur);
2299
+ }
2300
+ open.set(key, { startMs: t, endMs: t, ...rect, blur: r.blur ?? 14 });
2301
+ });
2302
+ }
2303
+ for (const [key, span] of open) {
2304
+ if (seen.has(key)) continue;
2305
+ span.endMs = t;
2306
+ capture.redactSpans.push(span);
2307
+ open.delete(key);
2308
+ }
2309
+ }
2310
+
1066
2311
  /** Eased cursor glide via many small mouse.move steps. */
1067
2312
  async function moveMouseTo(
1068
2313
  page: Page,