@tandryukha/aidemo 0.14.1 → 0.15.1

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,5 +1,7 @@
1
- import type { Page, Locator, Frame, Request, Response } from "playwright";
1
+ import type { Page, Locator, Frame, Request, Response, Dialog } from "playwright";
2
2
  import { scanInteractive, rankCandidates, type DriftCandidate } from "./inspect.js";
3
+ import { compileUserRegex } from "./safe-regex.js";
4
+ import { redactUrl, redactSecrets } from "./redact.js";
3
5
  import { promises as fs } from "node:fs";
4
6
  import { basename, isAbsolute, join, resolve } from "node:path";
5
7
  import { existsSync } from "node:fs";
@@ -83,6 +85,14 @@ export interface PlayerOptions {
83
85
  * previous take's footage for them).
84
86
  */
85
87
  replayUntil?: number;
88
+ /**
89
+ * With `replayUntil`: do NOT run the replayed scenes' actions at all — the
90
+ * app state is whatever the profile already holds. For flows that EARN
91
+ * one-shot state (a balance moved, a check-in was taken, a day was rolled
92
+ * forward by hand) a replay would fail or double-consume; the resumed scene
93
+ * must then open with its own `goto`.
94
+ */
95
+ skipReplay?: boolean;
86
96
  /** Called after a replayed (not recorded) scene finished. */
87
97
  onSceneReplayed?: (sceneId: string, index: number, total: number) => void;
88
98
  /** Demo directory — `upload` file paths resolve against it (default: cwd). */
@@ -121,10 +131,25 @@ interface SceneCapture {
121
131
  keyEvents: KeyEvent[];
122
132
  redactSpans: RedactSpan[];
123
133
  anchorEvents: Array<{ name: string; tMs: number; action: number }>;
134
+ /** Armed one-shot native-dialog handlers awaiting their dialog (`dialog` op). */
135
+ pendingDialogs: PendingDialog[];
136
+ /** Warnings raised inside an action, drained into its timeline record. */
137
+ pendingWarnings: string[];
124
138
  /** Replay-only scene (resume): skip the waits that exist for the camera. */
125
139
  fast?: boolean;
126
140
  }
127
141
 
142
+ /** A `dialog` action's armed handler, resolved when its dialog actually fires. */
143
+ interface PendingDialog {
144
+ /** 0-based index of the `dialog` action that armed it. */
145
+ index: number;
146
+ label: string;
147
+ required: boolean;
148
+ timeoutMs: number;
149
+ seen: Promise<string | null>;
150
+ disarm: () => void;
151
+ }
152
+
128
153
  /** Record-time dwell for an attention beat (the hold itself is compose-time). */
129
154
  const ATTENTION_DWELL_MAX_MS = 800;
130
155
  const ATTENTION_HOLD_DEFAULT_MS = 1600;
@@ -218,39 +243,51 @@ class NetworkWatch {
218
243
  private readonly page: Page,
219
244
  private readonly t0: number
220
245
  ) {
246
+ // A listener that throws surfaces as an unhandled rejection on the page
247
+ // and can abort a take, so each one is wrapped: diagnostics are strictly
248
+ // best-effort and must never take the recording down with them.
221
249
  this.onResponse = (res: Response) => {
222
- if (res.status() < 400) return;
223
- const req = res.request();
224
- const entry: FailedRequest = {
225
- tMs: Date.now() - t0,
226
- method: req.method(),
227
- url: truncateUrl(res.url(), 200),
228
- status: res.status(),
229
- resourceType: req.resourceType(),
230
- };
231
- this.push(entry);
232
- if (/^(xhr|fetch)$/.test(entry.resourceType)) {
233
- res
234
- .text()
235
- .then((b) => {
236
- const snippet = b.replace(/\s+/g, " ").trim().slice(0, NET_BODY_MAX);
237
- if (snippet) entry.body = snippet;
238
- })
239
- .catch(() => {});
250
+ try {
251
+ if (res.status() < 400) return;
252
+ const req = res.request();
253
+ const entry: FailedRequest = {
254
+ tMs: Date.now() - t0,
255
+ method: req.method(),
256
+ url: redactUrl(res.url(), 200),
257
+ status: res.status(),
258
+ resourceType: req.resourceType(),
259
+ };
260
+ this.push(entry);
261
+ if (/^(xhr|fetch)$/.test(entry.resourceType)) {
262
+ res
263
+ .text()
264
+ .then((b) => {
265
+ const snippet = redactSecrets(b.replace(/\s+/g, " ").trim()).slice(0, NET_BODY_MAX);
266
+ if (snippet) entry.body = snippet;
267
+ })
268
+ // The body is gone once the page navigates; that's expected.
269
+ .catch(() => {});
270
+ }
271
+ } catch {
272
+ /* diagnostics only — a broken response record is not a take failure */
240
273
  }
241
274
  };
242
275
  this.onFailed = (req: Request) => {
243
- const err = req.failure()?.errorText ?? "failed";
244
- // Navigations cancel in-flight requests; that's not an app failure.
245
- if (/ERR_ABORTED/.test(err)) return;
246
- this.push({
247
- tMs: Date.now() - t0,
248
- method: req.method(),
249
- url: truncateUrl(req.url(), 200),
250
- status: null,
251
- error: err,
252
- resourceType: req.resourceType(),
253
- });
276
+ try {
277
+ const err = req.failure()?.errorText ?? "failed";
278
+ // Navigations cancel in-flight requests; that's not an app failure.
279
+ if (/ERR_ABORTED/.test(err)) return;
280
+ this.push({
281
+ tMs: Date.now() - t0,
282
+ method: req.method(),
283
+ url: redactUrl(req.url(), 200),
284
+ status: null,
285
+ error: err,
286
+ resourceType: req.resourceType(),
287
+ });
288
+ } catch {
289
+ /* diagnostics only */
290
+ }
254
291
  };
255
292
  }
256
293
 
@@ -322,6 +359,8 @@ export async function runStoryboard(
322
359
  keyEvents: [],
323
360
  redactSpans: [],
324
361
  anchorEvents: [],
362
+ pendingDialogs: [],
363
+ pendingWarnings: [],
325
364
  fast: opts.replayUntil != null && si < opts.replayUntil,
326
365
  };
327
366
  // Per-scene `hide` (top-level hides are injected by the recorder's init
@@ -349,6 +388,12 @@ export async function runStoryboard(
349
388
  (capture.fast ? " (replay only — footage reused from the previous take)" : "")
350
389
  );
351
390
  opts.onSceneStart?.(scene.id, si, total);
391
+ if (capture.fast && opts.skipReplay) {
392
+ log(` --no-replay: actions skipped, app state left as the profile holds it`);
393
+ if (scene.hide?.length) await setSceneHide(page, []);
394
+ opts.onSceneReplayed?.(scene.id, si, total);
395
+ continue;
396
+ }
352
397
  const probeOutcomes: ProbeActionOutcome[] = [];
353
398
 
354
399
  for (let i = 0; i < scene.actions.length; i++) {
@@ -446,6 +491,12 @@ export async function runStoryboard(
446
491
  });
447
492
  }
448
493
  }
494
+ if (action.op !== "dialog") {
495
+ recWarnings.push(...(await settlePendingDialogs(capture)));
496
+ }
497
+ if (capture.pendingWarnings.length) {
498
+ recWarnings.push(...capture.pendingWarnings.splice(0));
499
+ }
449
500
  rec.endMs = now();
450
501
  const failedInWindow = net.since(rec.startMs);
451
502
  if (failedInWindow.length) {
@@ -479,6 +530,8 @@ export async function runStoryboard(
479
530
  probeOutcomes.push(outcome);
480
531
  }
481
532
  }
533
+ // A `dialog` armed by the scene's LAST action still has to be accounted for.
534
+ await settlePendingDialogs(capture);
482
535
  if (opts.probe) opts.probe.push({ id: scene.id, actions: probeOutcomes });
483
536
  // Close every open redact span at the scene boundary and drop the scene hide.
484
537
  for (const span of redactOpen.values()) {
@@ -700,7 +753,18 @@ async function countNestedMatches(page: Page, selector: string): Promise<number>
700
753
  async function resolveTargetLocator(
701
754
  page: Page,
702
755
  storyboard: Storyboard,
703
- target: Target
756
+ target: Target,
757
+ opts: {
758
+ /**
759
+ * Pick the first VISIBLE match instead of the first match in DOM order
760
+ * (issue #55): a page whose first `table` is a hidden template would
761
+ * otherwise time out waiting for an element that is never shown. Only
762
+ * honoured when the target does not pin a match with `last`/`nth`.
763
+ */
764
+ preferVisible?: boolean;
765
+ /** Return the whole matching SET (no first/last/nth) — for counting. */
766
+ all?: boolean;
767
+ } = {}
704
768
  ): Promise<Locator> {
705
769
  let selector = target.selector;
706
770
  if (target.named) {
@@ -717,7 +781,10 @@ async function resolveTargetLocator(
717
781
  // Frameless targets honor last/nth at the ELEMENT level (e.g. the newest
718
782
  // assistant message); previously they silently collapsed to .first().
719
783
  const loc = page.locator(selector);
720
- return target.last ? loc.last() : target.nth != null ? loc.nth(target.nth) : loc.first();
784
+ if (opts.all) return loc;
785
+ if (target.last) return loc.last();
786
+ if (target.nth != null) return loc.nth(target.nth);
787
+ return opts.preferVisible ? loc.locator("visible=true").first() : loc.first();
721
788
  }
722
789
  const frameSelector = storyboard.frames[target.frame];
723
790
  if (!frameSelector) {
@@ -739,7 +806,11 @@ async function resolveTargetLocator(
739
806
  : target.nth != null
740
807
  ? frame.nth(target.nth)
741
808
  : frame.first();
742
- return frame.locator(selector).first();
809
+ const inFrame = frame.locator(selector);
810
+ if (opts.all) return inFrame;
811
+ return opts.preferVisible && !target.last && target.nth == null
812
+ ? inFrame.locator("visible=true").first()
813
+ : inFrame.first();
743
814
  }
744
815
 
745
816
  /** Human-readable target for error messages: `frame >> selector [last]`. */
@@ -765,9 +836,15 @@ async function waitForTargetVisible(
765
836
  const started = Date.now();
766
837
  const deadline = started + timeoutMs;
767
838
  let lastErr: unknown;
839
+ // Half the budget on the exact first match (the old behavior, so a selector
840
+ // that resolves keeps resolving identically), then on the first VISIBLE match
841
+ // — the fix for a hidden template/print copy sitting ahead of the real one in
842
+ // DOM order (issue #55).
843
+ const flipAt = started + Math.min(timeoutMs / 2, 4000);
768
844
  for (;;) {
845
+ const preferVisible = state === "visible" && Date.now() >= flipAt;
769
846
  try {
770
- const loc = await resolveTargetLocator(page, storyboard, target);
847
+ const loc = await resolveTargetLocator(page, storyboard, target, { preferVisible });
771
848
  const remaining = deadline - Date.now();
772
849
  await loc.waitFor({
773
850
  state,
@@ -783,9 +860,14 @@ async function waitForTargetVisible(
783
860
  const hint = /Timeout \d+ms exceeded/.test(reason)
784
861
  ? "" // Playwright's chunk timeout — nothing more to say than the budget
785
862
  : ` (last error: ${reason})`;
863
+ const counts = await countMatches(page, storyboard, target).catch(() => null);
864
+ const census =
865
+ counts && counts.total > 0
866
+ ? ` — ${counts.total} match(es) in the DOM, ${counts.visible} visible`
867
+ : "";
786
868
  throw new Error(
787
869
  `waitFor: "${describeTarget(storyboard, target)}" not ${state} after ` +
788
- `${waited}ms (budget ${timeoutMs}ms)${hint}` +
870
+ `${waited}ms (budget ${timeoutMs}ms)${hint}${census}` +
789
871
  (state === "visible"
790
872
  ? ` — if the element is a zero-size mount point that fills later, use state: "attached"`
791
873
  : "")
@@ -795,6 +877,18 @@ async function waitForTargetVisible(
795
877
  }
796
878
  }
797
879
 
880
+ /** How many elements the target matches, and how many of those are visible. */
881
+ async function countMatches(
882
+ page: Page,
883
+ storyboard: Storyboard,
884
+ target: Target
885
+ ): Promise<{ total: number; visible: number }> {
886
+ const base = await resolveTargetLocator(page, storyboard, target, { all: true });
887
+ const total = await base.count().catch(() => 0);
888
+ const visible = await base.locator("visible=true").count().catch(() => 0);
889
+ return { total, visible };
890
+ }
891
+
798
892
  /**
799
893
  * Wait until the scroll position has been still for `settleMs` (bounded at
800
894
  * 3 s total) — an honest "scroll finished" for smooth-scrolling pages, where a
@@ -857,7 +951,7 @@ async function waitForNewWidget(
857
951
  await waitForTargetVisible(page, storyboard, target, opts.timeoutMs);
858
952
  return;
859
953
  }
860
- const re = opts.textMatches ? new RegExp(opts.textMatches, "i") : null;
954
+ const re = opts.textMatches ? compileUserRegex(opts.textMatches, "i", "textMatches") : null;
861
955
  const deadline = Date.now() + opts.timeoutMs;
862
956
  const baseline = await countNestedMatches(page, selector);
863
957
  for (;;) {
@@ -922,7 +1016,7 @@ async function waitForNewReply(
922
1016
  ): Promise<void> {
923
1017
  const messages = page.locator(opts.selector);
924
1018
  const stop = page.locator(STOP_BUTTON_SELECTOR);
925
- const re = opts.textMatches ? new RegExp(opts.textMatches, "i") : null;
1019
+ const re = opts.textMatches ? compileUserRegex(opts.textMatches, "i", "textMatches") : null;
926
1020
  const baseline = await messages.count().catch(() => 0);
927
1021
  let sawStreaming = (await stop.count().catch(() => 0)) > 0;
928
1022
  const deadline = Date.now() + opts.timeoutMs;
@@ -1004,6 +1098,34 @@ async function optionalTargetAbsent(
1004
1098
  * with a beat between attempts. Returns the number of extra attempts used.
1005
1099
  * The last failure propagates so the normal fail/optional paths apply.
1006
1100
  */
1101
+ /**
1102
+ * Verify every armed `dialog` handler got its dialog (issue #54). Called after
1103
+ * the action that was expected to trigger it, and again at scene end. Returns
1104
+ * the warnings to attach; throws when a REQUIRED dialog never appeared, because
1105
+ * the filmed action then silently did nothing.
1106
+ */
1107
+ async function settlePendingDialogs(capture: SceneCapture): Promise<string[]> {
1108
+ if (!capture.pendingDialogs.length) return [];
1109
+ const pending = capture.pendingDialogs.splice(0);
1110
+ const warnings: string[] = [];
1111
+ for (const p of pending) {
1112
+ const message = await Promise.race([
1113
+ p.seen,
1114
+ sleep(p.timeoutMs).then(() => undefined),
1115
+ ]);
1116
+ if (typeof message === "string") continue;
1117
+ p.disarm();
1118
+ const why =
1119
+ `${p.label}: no native dialog appeared within ${p.timeoutMs}ms — the ` +
1120
+ `action after it did not open one (wrong action order, or the control ` +
1121
+ `no longer confirms)`;
1122
+ if (p.required) throw new Error(why);
1123
+ warnings.push(why);
1124
+ log(` ⚠ ${why}`);
1125
+ }
1126
+ return warnings;
1127
+ }
1128
+
1007
1129
  async function runActionWithRetry(
1008
1130
  page: Page,
1009
1131
  storyboard: Storyboard,
@@ -1097,8 +1219,8 @@ async function runAction(
1097
1219
  }
1098
1220
  const timeoutMs = action.timeoutMs ?? 5000;
1099
1221
  const deadline = Date.now() + timeoutMs;
1100
- const textRe = action.textMatches ? new RegExp(action.textMatches) : null;
1101
- const urlRe = action.url ? new RegExp(action.url) : null;
1222
+ const textRe = action.textMatches ? compileUserRegex(action.textMatches, "", "assert.textMatches") : null;
1223
+ const urlRe = action.url ? compileUserRegex(action.url, "", "assert.url") : null;
1102
1224
  let why = "";
1103
1225
  if (action.target) {
1104
1226
  // Visible first (honest total-budget error if it never shows up).
@@ -1165,6 +1287,46 @@ async function runAction(
1165
1287
  await awaitGotoReadiness(page, capture, t0);
1166
1288
  return;
1167
1289
 
1290
+ // Arm a one-shot answer for the NEXT native dialog (issue #54). Playwright
1291
+ // auto-dismisses dialogs when nothing listens, so a filmed click on a
1292
+ // confirm-guarded control would otherwise put a false outcome on camera.
1293
+ // The handler is verified after the action that follows: an armed dialog
1294
+ // that never fired is a failure unless `required: false`.
1295
+ case "dialog": {
1296
+ const mode = action.action ?? "accept";
1297
+ const label = `dialog ${mode}`;
1298
+ let settle: (message: string | null) => void = () => {};
1299
+ const seen = new Promise<string | null>((r) => {
1300
+ settle = r;
1301
+ });
1302
+ const handler = (dialog: Dialog) => {
1303
+ void (async () => {
1304
+ log(` ${dialog.type()}: "${dialog.message()}" → ${mode}`);
1305
+ if (action.holdMs) await sleep(action.holdMs);
1306
+ try {
1307
+ if (mode === "accept") await dialog.accept(action.promptText);
1308
+ else await dialog.dismiss();
1309
+ } catch (err) {
1310
+ log(` ! answering the dialog failed — ${firstLine(err)}`);
1311
+ }
1312
+ settle(dialog.message());
1313
+ })();
1314
+ };
1315
+ page.once("dialog", handler);
1316
+ capture.pendingDialogs.push({
1317
+ index: capture.actions.length,
1318
+ label,
1319
+ required: action.required !== false,
1320
+ timeoutMs: action.timeoutMs ?? 5000,
1321
+ seen,
1322
+ disarm: () => {
1323
+ page.off("dialog", handler);
1324
+ settle(null);
1325
+ },
1326
+ });
1327
+ return;
1328
+ }
1329
+
1168
1330
  case "select": {
1169
1331
  if (action.value == null && action.label == null) {
1170
1332
  throw new Error("select needs a value or a label");
@@ -1281,8 +1443,18 @@ async function runAction(
1281
1443
  case "highlight":
1282
1444
  case "spotlight":
1283
1445
  case "callout": {
1284
- const loc = await resolveTargetLocator(page, storyboard, action.target);
1285
- const rect = await rectOf(page, loc);
1446
+ const loc = await resolveTargetLocator(page, storyboard, action.target, {
1447
+ preferVisible: true,
1448
+ });
1449
+ // #56: the overlay is drawn at compose time from THIS rect, so it has to
1450
+ // be the rect the element actually occupies while the overlay is on
1451
+ // screen. Lazy content loading in above the target shifts it after the
1452
+ // measure with the scroll offset unchanged — invisible to the scroll
1453
+ // settle — and the outline then lands on whatever moved into its place.
1454
+ const rect = await settledRectOf(page, loc, capture.fast ? 0 : 400);
1455
+ if (storyboard.attention?.cursorClear && !capture.fast) {
1456
+ await clearCursorOf(page, mouse, rect, sample);
1457
+ }
1286
1458
  const holdMs =
1287
1459
  action.holdMs ??
1288
1460
  (action.op === "callout" ? CALLOUT_HOLD_DEFAULT_MS : ATTENTION_HOLD_DEFAULT_MS);
@@ -1306,13 +1478,65 @@ async function runAction(
1306
1478
  }
1307
1479
  capture.attentionEvents.push(ev);
1308
1480
  if (!capture.fast) await sleep(Math.min(holdMs, ATTENTION_DWELL_MAX_MS));
1481
+ // Re-measure once the dwell is over: if it moved, the overlay we just
1482
+ // logged would be drawn over the wrong pixels. Prefer the later rect
1483
+ // (that is where the element sits for most of the hold) and say so.
1484
+ const after = await rectOf(page, loc).catch(() => null);
1485
+ if (after) {
1486
+ const moved = Math.max(Math.abs(after.x - rect.x), Math.abs(after.y - rect.y));
1487
+ if (moved > ATTENTION_DRIFT_TOLERANCE_PX) {
1488
+ ev.x = after.x;
1489
+ ev.y = after.y;
1490
+ ev.w = after.w;
1491
+ ev.h = after.h;
1492
+ const warning =
1493
+ `${action.op} target moved ${moved}px after the measure (layout ` +
1494
+ `settled late — lazy content above it?); used the later rect`;
1495
+ capture.pendingWarnings.push(warning);
1496
+ log(` ⚠ ${warning}`);
1497
+ }
1498
+ }
1499
+ const box = after ?? rect;
1500
+ const vp = page.viewportSize();
1501
+ if (vp && (box.y + box.h <= 0 || box.y >= vp.height || box.x + box.w <= 0 || box.x >= vp.width)) {
1502
+ const warning =
1503
+ `${action.op} target is OFF SCREEN at draw time ` +
1504
+ `(${box.x},${box.y} ${box.w}x${box.h} in a ${vp.width}x${vp.height} viewport) — ` +
1505
+ `the overlay will be drawn outside the frame; scroll it into view first`;
1506
+ capture.pendingWarnings.push(warning);
1507
+ log(` ⚠ ${warning}`);
1508
+ }
1309
1509
  return;
1310
1510
  }
1311
1511
 
1312
1512
  case "hover": {
1313
1513
  const loc = await resolveTargetLocator(page, storyboard, action.target);
1314
1514
  const box = await boxOf(page, loc);
1315
- await moveMouseTo(page, mouse, box.cx, box.cy, sample);
1515
+ let x = box.cx;
1516
+ let y = box.cy;
1517
+ // Park off the element when asked (issue #50): a pointer on the centre
1518
+ // of a 30-px chip hides the very number the narration is about.
1519
+ if (action.anchor && action.anchor !== "center") {
1520
+ const rect = await rectOf(page, loc).catch(() => null);
1521
+ if (rect) {
1522
+ const pad = 10;
1523
+ const side =
1524
+ action.anchor === "edge"
1525
+ ? rect.y > (page.viewportSize()?.height ?? 0) / 2
1526
+ ? "top"
1527
+ : "bottom"
1528
+ : action.anchor;
1529
+ if (side === "top") y = rect.y - pad;
1530
+ else if (side === "bottom") y = rect.y + rect.h + pad;
1531
+ else if (side === "left") x = rect.x - pad;
1532
+ else if (side === "right") x = rect.x + rect.w + pad;
1533
+ }
1534
+ }
1535
+ if (action.offset) {
1536
+ x += action.offset.x;
1537
+ y += action.offset.y;
1538
+ }
1539
+ await moveMouseTo(page, mouse, x, y, sample);
1316
1540
  await sleep(300);
1317
1541
  return;
1318
1542
  }
@@ -1658,7 +1882,7 @@ async function waitForChange(
1658
1882
  target: Target,
1659
1883
  opts: { textMatches?: string; timeoutMs: number }
1660
1884
  ): Promise<void> {
1661
- const re = opts.textMatches ? new RegExp(opts.textMatches, "i") : null;
1885
+ const re = opts.textMatches ? compileUserRegex(opts.textMatches, "i", "textMatches") : null;
1662
1886
  const base = await signatureOf(page, storyboard, target);
1663
1887
  const deadline = Date.now() + opts.timeoutMs;
1664
1888
  for (;;) {
@@ -1689,7 +1913,9 @@ async function waitForChange(
1689
1913
  // ---------------------------------------------------------------------------
1690
1914
 
1691
1915
  function truncateUrl(url: string, n = 90): string {
1692
- return url.length > n ? url.slice(0, n) + "…" : url;
1916
+ // Same masking as the network watch: every URL that reaches a log or a
1917
+ // fail-*.json goes through redaction, not just the ones from failed requests.
1918
+ return redactUrl(url, n);
1693
1919
  }
1694
1920
 
1695
1921
  /** Never returns — enriches the error with scene/action context + diagnostics. */
@@ -1893,7 +2119,26 @@ async function boxOf(page: Page, loc: Locator): Promise<{ cx: number; cy: number
1893
2119
  for (let i = 0; i < 3 && box && vp; i++) {
1894
2120
  const cy = box.y + box.height / 2;
1895
2121
  if (cy >= 80 && cy <= vp.height - 120) break;
1896
- await easedWheel(page, cy - vp.height * 0.55, { durationMs: 500 });
2122
+ const dy = cy - vp.height * 0.55;
2123
+ // A sticky/fixed element (a header chip) never moves with the scroller,
2124
+ // and a page already at its top/bottom cannot scroll that way — wheeling
2125
+ // anyway only rubber-bands the whole page (a 1 Hz ~6 px bob on the take,
2126
+ // three bounces per hover/click on a header target; loyalty v7 take 1).
2127
+ const canNudge = await loc
2128
+ .evaluate((el, d) => {
2129
+ let n: Element | null = el;
2130
+ while (n && n !== document.body) {
2131
+ const pos = getComputedStyle(n).position;
2132
+ if (pos === "fixed" || pos === "sticky") return false;
2133
+ n = n.parentElement;
2134
+ }
2135
+ const se = document.scrollingElement ?? document.documentElement;
2136
+ const max = se.scrollHeight - se.clientHeight;
2137
+ return d < 0 ? se.scrollTop > 0 : se.scrollTop < max - 1;
2138
+ }, dy)
2139
+ .catch(() => true);
2140
+ if (!canNudge) break;
2141
+ await easedWheel(page, dy, { durationMs: 500 });
1897
2142
  box = await loc.boundingBox();
1898
2143
  }
1899
2144
  if (!box) throw new Error("Element has no bounding box (not visible?)");
@@ -1916,6 +2161,78 @@ async function rectOf(
1916
2161
  };
1917
2162
  }
1918
2163
 
2164
+ /**
2165
+ * Glide the pointer just outside `rect` so it does not sit on top of what the
2166
+ * beat is showcasing (`attention.cursorClear`, issue #50). Picks the nearest
2167
+ * side with room in the viewport; a no-op when the cursor is already clear.
2168
+ */
2169
+ async function clearCursorOf(
2170
+ page: Page,
2171
+ mouse: MouseState,
2172
+ rect: { x: number; y: number; w: number; h: number },
2173
+ sample?: CursorSampler
2174
+ ): Promise<void> {
2175
+ const pad = 18;
2176
+ const inside =
2177
+ mouse.x >= rect.x - 2 &&
2178
+ mouse.x <= rect.x + rect.w + 2 &&
2179
+ mouse.y >= rect.y - 2 &&
2180
+ mouse.y <= rect.y + rect.h + 2;
2181
+ if (!inside) return;
2182
+ const vp = page.viewportSize() ?? { width: 1280, height: 720 };
2183
+ const below = rect.y + rect.h + pad;
2184
+ const above = rect.y - pad;
2185
+ const right = rect.x + rect.w + pad;
2186
+ const left = rect.x - pad;
2187
+ let x = mouse.x;
2188
+ let y = mouse.y;
2189
+ if (below < vp.height - 8) y = below;
2190
+ else if (above > 8) y = above;
2191
+ else if (right < vp.width - 8) x = right;
2192
+ else if (left > 8) x = left;
2193
+ else return;
2194
+ await moveMouseTo(page, mouse, x, y, sample);
2195
+ await sleep(120);
2196
+ }
2197
+
2198
+ /** How far an attention target may shift after its measure before we re-aim. */
2199
+ const ATTENTION_DRIFT_TOLERANCE_PX = 24;
2200
+
2201
+ /**
2202
+ * `rectOf`, but only once the element's box has stopped moving for `quietMs`
2203
+ * (bounded at 2 s). Unlike `waitScrollSettled` this watches the RECT, so a
2204
+ * layout shift at an unchanged scroll offset — lazily-loaded content inserted
2205
+ * above the target — is caught (issue #56).
2206
+ */
2207
+ async function settledRectOf(
2208
+ page: Page,
2209
+ loc: Locator,
2210
+ quietMs: number
2211
+ ): Promise<{ x: number; y: number; w: number; h: number }> {
2212
+ if (quietMs <= 0) return rectOf(page, loc);
2213
+ const deadline = Date.now() + 2000;
2214
+ let last = await rectOf(page, loc);
2215
+ let stillSince = Date.now();
2216
+ while (Date.now() < deadline) {
2217
+ await sleep(80);
2218
+ const cur = await rectOf(page, loc).catch(() => null);
2219
+ if (!cur) continue;
2220
+ if (
2221
+ Math.abs(cur.x - last.x) > 1 ||
2222
+ Math.abs(cur.y - last.y) > 1 ||
2223
+ Math.abs(cur.w - last.w) > 1 ||
2224
+ Math.abs(cur.h - last.h) > 1
2225
+ ) {
2226
+ last = cur;
2227
+ stillSince = Date.now();
2228
+ continue;
2229
+ }
2230
+ last = cur;
2231
+ if (Date.now() - stillSince >= quietMs) break;
2232
+ }
2233
+ return last;
2234
+ }
2235
+
1919
2236
  /** CSS used for `hide` selectors (record-time, the one non-compose exception). */
1920
2237
  export function hideCss(selectors: string[]): string {
1921
2238
  return selectors.length