@tandryukha/aidemo 0.14.1 → 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,5 +1,6 @@
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";
3
4
  import { promises as fs } from "node:fs";
4
5
  import { basename, isAbsolute, join, resolve } from "node:path";
5
6
  import { existsSync } from "node:fs";
@@ -83,6 +84,14 @@ export interface PlayerOptions {
83
84
  * previous take's footage for them).
84
85
  */
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;
86
95
  /** Called after a replayed (not recorded) scene finished. */
87
96
  onSceneReplayed?: (sceneId: string, index: number, total: number) => void;
88
97
  /** Demo directory — `upload` file paths resolve against it (default: cwd). */
@@ -121,10 +130,25 @@ interface SceneCapture {
121
130
  keyEvents: KeyEvent[];
122
131
  redactSpans: RedactSpan[];
123
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[];
124
137
  /** Replay-only scene (resume): skip the waits that exist for the camera. */
125
138
  fast?: boolean;
126
139
  }
127
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
+
128
152
  /** Record-time dwell for an attention beat (the hold itself is compose-time). */
129
153
  const ATTENTION_DWELL_MAX_MS = 800;
130
154
  const ATTENTION_HOLD_DEFAULT_MS = 1600;
@@ -322,6 +346,8 @@ export async function runStoryboard(
322
346
  keyEvents: [],
323
347
  redactSpans: [],
324
348
  anchorEvents: [],
349
+ pendingDialogs: [],
350
+ pendingWarnings: [],
325
351
  fast: opts.replayUntil != null && si < opts.replayUntil,
326
352
  };
327
353
  // Per-scene `hide` (top-level hides are injected by the recorder's init
@@ -349,6 +375,12 @@ export async function runStoryboard(
349
375
  (capture.fast ? " (replay only — footage reused from the previous take)" : "")
350
376
  );
351
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
+ }
352
384
  const probeOutcomes: ProbeActionOutcome[] = [];
353
385
 
354
386
  for (let i = 0; i < scene.actions.length; i++) {
@@ -446,6 +478,12 @@ export async function runStoryboard(
446
478
  });
447
479
  }
448
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
+ }
449
487
  rec.endMs = now();
450
488
  const failedInWindow = net.since(rec.startMs);
451
489
  if (failedInWindow.length) {
@@ -479,6 +517,8 @@ export async function runStoryboard(
479
517
  probeOutcomes.push(outcome);
480
518
  }
481
519
  }
520
+ // A `dialog` armed by the scene's LAST action still has to be accounted for.
521
+ await settlePendingDialogs(capture);
482
522
  if (opts.probe) opts.probe.push({ id: scene.id, actions: probeOutcomes });
483
523
  // Close every open redact span at the scene boundary and drop the scene hide.
484
524
  for (const span of redactOpen.values()) {
@@ -700,7 +740,18 @@ async function countNestedMatches(page: Page, selector: string): Promise<number>
700
740
  async function resolveTargetLocator(
701
741
  page: Page,
702
742
  storyboard: Storyboard,
703
- 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
+ } = {}
704
755
  ): Promise<Locator> {
705
756
  let selector = target.selector;
706
757
  if (target.named) {
@@ -717,7 +768,10 @@ async function resolveTargetLocator(
717
768
  // Frameless targets honor last/nth at the ELEMENT level (e.g. the newest
718
769
  // assistant message); previously they silently collapsed to .first().
719
770
  const loc = page.locator(selector);
720
- 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();
721
775
  }
722
776
  const frameSelector = storyboard.frames[target.frame];
723
777
  if (!frameSelector) {
@@ -739,7 +793,11 @@ async function resolveTargetLocator(
739
793
  : target.nth != null
740
794
  ? frame.nth(target.nth)
741
795
  : frame.first();
742
- 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();
743
801
  }
744
802
 
745
803
  /** Human-readable target for error messages: `frame >> selector [last]`. */
@@ -765,9 +823,15 @@ async function waitForTargetVisible(
765
823
  const started = Date.now();
766
824
  const deadline = started + timeoutMs;
767
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);
768
831
  for (;;) {
832
+ const preferVisible = state === "visible" && Date.now() >= flipAt;
769
833
  try {
770
- const loc = await resolveTargetLocator(page, storyboard, target);
834
+ const loc = await resolveTargetLocator(page, storyboard, target, { preferVisible });
771
835
  const remaining = deadline - Date.now();
772
836
  await loc.waitFor({
773
837
  state,
@@ -783,9 +847,14 @@ async function waitForTargetVisible(
783
847
  const hint = /Timeout \d+ms exceeded/.test(reason)
784
848
  ? "" // Playwright's chunk timeout — nothing more to say than the budget
785
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
+ : "";
786
855
  throw new Error(
787
856
  `waitFor: "${describeTarget(storyboard, target)}" not ${state} after ` +
788
- `${waited}ms (budget ${timeoutMs}ms)${hint}` +
857
+ `${waited}ms (budget ${timeoutMs}ms)${hint}${census}` +
789
858
  (state === "visible"
790
859
  ? ` — if the element is a zero-size mount point that fills later, use state: "attached"`
791
860
  : "")
@@ -795,6 +864,18 @@ async function waitForTargetVisible(
795
864
  }
796
865
  }
797
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
+
798
879
  /**
799
880
  * Wait until the scroll position has been still for `settleMs` (bounded at
800
881
  * 3 s total) — an honest "scroll finished" for smooth-scrolling pages, where a
@@ -857,7 +938,7 @@ async function waitForNewWidget(
857
938
  await waitForTargetVisible(page, storyboard, target, opts.timeoutMs);
858
939
  return;
859
940
  }
860
- const re = opts.textMatches ? new RegExp(opts.textMatches, "i") : null;
941
+ const re = opts.textMatches ? compileUserRegex(opts.textMatches, "i", "textMatches") : null;
861
942
  const deadline = Date.now() + opts.timeoutMs;
862
943
  const baseline = await countNestedMatches(page, selector);
863
944
  for (;;) {
@@ -922,7 +1003,7 @@ async function waitForNewReply(
922
1003
  ): Promise<void> {
923
1004
  const messages = page.locator(opts.selector);
924
1005
  const stop = page.locator(STOP_BUTTON_SELECTOR);
925
- const re = opts.textMatches ? new RegExp(opts.textMatches, "i") : null;
1006
+ const re = opts.textMatches ? compileUserRegex(opts.textMatches, "i", "textMatches") : null;
926
1007
  const baseline = await messages.count().catch(() => 0);
927
1008
  let sawStreaming = (await stop.count().catch(() => 0)) > 0;
928
1009
  const deadline = Date.now() + opts.timeoutMs;
@@ -1004,6 +1085,34 @@ async function optionalTargetAbsent(
1004
1085
  * with a beat between attempts. Returns the number of extra attempts used.
1005
1086
  * The last failure propagates so the normal fail/optional paths apply.
1006
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
+
1007
1116
  async function runActionWithRetry(
1008
1117
  page: Page,
1009
1118
  storyboard: Storyboard,
@@ -1097,8 +1206,8 @@ async function runAction(
1097
1206
  }
1098
1207
  const timeoutMs = action.timeoutMs ?? 5000;
1099
1208
  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;
1209
+ const textRe = action.textMatches ? compileUserRegex(action.textMatches, "", "assert.textMatches") : null;
1210
+ const urlRe = action.url ? compileUserRegex(action.url, "", "assert.url") : null;
1102
1211
  let why = "";
1103
1212
  if (action.target) {
1104
1213
  // Visible first (honest total-budget error if it never shows up).
@@ -1165,6 +1274,46 @@ async function runAction(
1165
1274
  await awaitGotoReadiness(page, capture, t0);
1166
1275
  return;
1167
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
+
1168
1317
  case "select": {
1169
1318
  if (action.value == null && action.label == null) {
1170
1319
  throw new Error("select needs a value or a label");
@@ -1281,8 +1430,18 @@ async function runAction(
1281
1430
  case "highlight":
1282
1431
  case "spotlight":
1283
1432
  case "callout": {
1284
- const loc = await resolveTargetLocator(page, storyboard, action.target);
1285
- const rect = await rectOf(page, loc);
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
+ }
1286
1445
  const holdMs =
1287
1446
  action.holdMs ??
1288
1447
  (action.op === "callout" ? CALLOUT_HOLD_DEFAULT_MS : ATTENTION_HOLD_DEFAULT_MS);
@@ -1306,13 +1465,65 @@ async function runAction(
1306
1465
  }
1307
1466
  capture.attentionEvents.push(ev);
1308
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
+ }
1309
1496
  return;
1310
1497
  }
1311
1498
 
1312
1499
  case "hover": {
1313
1500
  const loc = await resolveTargetLocator(page, storyboard, action.target);
1314
1501
  const box = await boxOf(page, loc);
1315
- 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);
1316
1527
  await sleep(300);
1317
1528
  return;
1318
1529
  }
@@ -1658,7 +1869,7 @@ async function waitForChange(
1658
1869
  target: Target,
1659
1870
  opts: { textMatches?: string; timeoutMs: number }
1660
1871
  ): Promise<void> {
1661
- const re = opts.textMatches ? new RegExp(opts.textMatches, "i") : null;
1872
+ const re = opts.textMatches ? compileUserRegex(opts.textMatches, "i", "textMatches") : null;
1662
1873
  const base = await signatureOf(page, storyboard, target);
1663
1874
  const deadline = Date.now() + opts.timeoutMs;
1664
1875
  for (;;) {
@@ -1893,7 +2104,26 @@ async function boxOf(page: Page, loc: Locator): Promise<{ cx: number; cy: number
1893
2104
  for (let i = 0; i < 3 && box && vp; i++) {
1894
2105
  const cy = box.y + box.height / 2;
1895
2106
  if (cy >= 80 && cy <= vp.height - 120) break;
1896
- 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 });
1897
2127
  box = await loc.boundingBox();
1898
2128
  }
1899
2129
  if (!box) throw new Error("Element has no bounding box (not visible?)");
@@ -1916,6 +2146,78 @@ async function rectOf(
1916
2146
  };
1917
2147
  }
1918
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
+
1919
2221
  /** CSS used for `hide` selectors (record-time, the one non-compose exception). */
1920
2222
  export function hideCss(selectors: string[]): string {
1921
2223
  return selectors.length