@tandryukha/aidemo 0.8.0 → 0.14.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,6 +1,8 @@
1
- import type { Page, Locator, Frame } from "playwright";
1
+ import type { Page, Locator, Frame, Request, Response } from "playwright";
2
+ import { scanInteractive, rankCandidates, type DriftCandidate } from "./inspect.js";
2
3
  import { promises as fs } from "node:fs";
3
- import { join } from "node:path";
4
+ import { basename, isAbsolute, join, resolve } from "node:path";
5
+ import { existsSync } from "node:fs";
4
6
  import type {
5
7
  Storyboard,
6
8
  Scene,
@@ -13,8 +15,14 @@ import type {
13
15
  StillEvent,
14
16
  CursorSample,
15
17
  EasingPreset,
18
+ WaitState,
16
19
  ProbeActionOutcome,
17
20
  ProbeGoldenScene,
21
+ TimelineAction,
22
+ AttentionEvent,
23
+ KeyEvent,
24
+ RedactSpan,
25
+ Redact,
18
26
  } from "./types.js";
19
27
  import {
20
28
  easeInOutCubic,
@@ -67,6 +75,18 @@ export interface PlayerOptions {
67
75
  onSceneComplete?: (scene: TimelineScene, index: number, total: number) => void;
68
76
  /** Called when a scene's first action is about to run (job progress). */
69
77
  onSceneStart?: (sceneId: string, index: number, total: number) => void;
78
+ /**
79
+ * Resume: scenes before this index are REPLAYED, not recorded — their
80
+ * actions run at speed (no humanized typing, pauses/dwells capped, stills
81
+ * skipped) purely to rebuild the app state the resumed scene starts from,
82
+ * and they don't enter the returned timeline (the recorder reuses the
83
+ * previous take's footage for them).
84
+ */
85
+ replayUntil?: number;
86
+ /** Called after a replayed (not recorded) scene finished. */
87
+ onSceneReplayed?: (sceneId: string, index: number, total: number) => void;
88
+ /** Demo directory — `upload` file paths resolve against it (default: cwd). */
89
+ baseDir?: string;
70
90
  /**
71
91
  * Best-effort cancellation, checked before every action. Aborting mid-scene
72
92
  * throws CanceledError, which rides the recorder's salvage path (partial
@@ -96,11 +116,179 @@ interface SceneCapture {
96
116
  focusEvents: FocusEvent[];
97
117
  stillEvents: StillEvent[];
98
118
  cursorSamples: CursorSample[];
119
+ actions: TimelineAction[];
120
+ attentionEvents: AttentionEvent[];
121
+ keyEvents: KeyEvent[];
122
+ redactSpans: RedactSpan[];
123
+ anchorEvents: Array<{ name: string; tMs: number; action: number }>;
124
+ /** Replay-only scene (resume): skip the waits that exist for the camera. */
125
+ fast?: boolean;
99
126
  }
100
127
 
128
+ /** Record-time dwell for an attention beat (the hold itself is compose-time). */
129
+ const ATTENTION_DWELL_MAX_MS = 800;
130
+ const ATTENTION_HOLD_DEFAULT_MS = 1600;
131
+ const CALLOUT_HOLD_DEFAULT_MS = 2000;
132
+ /** Max matches per redact selector measured after each action. */
133
+ const REDACT_MAX_MATCHES = 8;
134
+
135
+ /** Pretty keystroke label for the chip: "Meta+K" → "⌘ K", "Enter" → "Enter". */
136
+ export function prettyKeys(key: string): string {
137
+ const map: Record<string, string> = {
138
+ meta: "⌘",
139
+ cmd: "⌘",
140
+ command: "⌘",
141
+ control: "Ctrl",
142
+ ctrl: "Ctrl",
143
+ shift: "⇧",
144
+ alt: "⌥",
145
+ option: "⌥",
146
+ enter: "Enter",
147
+ return: "Enter",
148
+ escape: "Esc",
149
+ backspace: "⌫",
150
+ delete: "Del",
151
+ tab: "Tab",
152
+ space: "Space",
153
+ arrowup: "↑",
154
+ arrowdown: "↓",
155
+ arrowleft: "←",
156
+ arrowright: "→",
157
+ pageup: "PgUp",
158
+ pagedown: "PgDn",
159
+ };
160
+ return key
161
+ .split("+")
162
+ .map((k) => map[k.toLowerCase()] ?? (k.length === 1 ? k.toUpperCase() : k))
163
+ .join(" ");
164
+ }
165
+
166
+ /** Ops `retry` applies to: interactions whose failure is usually transient. */
167
+ const RETRYABLE_OPS = new Set([
168
+ "click",
169
+ "type",
170
+ "hover",
171
+ "scrollTo",
172
+ "focus",
173
+ "moveTo",
174
+ "assert",
175
+ "highlight",
176
+ "spotlight",
177
+ "callout",
178
+ ]);
179
+ const RETRY_GAP_MS = 400;
180
+
181
+ /** After `goto`'s domcontentloaded: how long we're willing to wait for quiet. */
182
+ const GOTO_QUIET_CAP_MS = 3400;
183
+
101
184
  /** Records a cursor position into the current scene (compose-cursor mode only). */
102
185
  type CursorSampler = (x: number, y: number) => void;
103
186
 
187
+ /** A failed network exchange seen during the take (issue #44). */
188
+ export interface FailedRequest {
189
+ /** Timeline offset (ms since t0) when the response/failure arrived. */
190
+ tMs: number;
191
+ method: string;
192
+ url: string;
193
+ /** HTTP status, or null when the request never got a response. */
194
+ status: number | null;
195
+ /** Playwright's failure text for a request that never got a response. */
196
+ error?: string;
197
+ resourceType: string;
198
+ /** First bytes of an xhr/fetch error body — often the actual reason. */
199
+ body?: string;
200
+ }
201
+
202
+ const NET_WATCH_CAP = 300;
203
+ const NET_BODY_MAX = 300;
204
+
205
+ /**
206
+ * Passive watch for HTTP ≥400 responses and failed requests across the page
207
+ * (all frames). The engine has no other visibility into app-side writes: a
208
+ * backend 500 on a check-in click looks exactly like a missed click in the
209
+ * screenshot (issue #44), so a failing action's window is dumped into
210
+ * logs/fail-*.json. Ring-buffered; never throws.
211
+ */
212
+ class NetworkWatch {
213
+ private entries: FailedRequest[] = [];
214
+ private readonly onResponse: (res: Response) => void;
215
+ private readonly onFailed: (req: Request) => void;
216
+
217
+ constructor(
218
+ private readonly page: Page,
219
+ private readonly t0: number
220
+ ) {
221
+ 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(() => {});
240
+ }
241
+ };
242
+ 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
+ });
254
+ };
255
+ }
256
+
257
+ private push(e: FailedRequest): void {
258
+ this.entries.push(e);
259
+ if (this.entries.length > NET_WATCH_CAP) this.entries.shift();
260
+ }
261
+
262
+ start(): void {
263
+ this.page.on("response", this.onResponse);
264
+ this.page.on("requestfailed", this.onFailed);
265
+ }
266
+
267
+ stop(): void {
268
+ this.page.off("response", this.onResponse);
269
+ this.page.off("requestfailed", this.onFailed);
270
+ }
271
+
272
+ /** Failures that arrived at or after `tMs` (a failing action's window). */
273
+ since(tMs: number): FailedRequest[] {
274
+ return this.entries.filter((e) => e.tMs >= tMs);
275
+ }
276
+ }
277
+
278
+ /** One-line-per-entry summary for the log (≤ `max` lines). */
279
+ function describeFailedRequests(list: FailedRequest[], max = 5): string[] {
280
+ const lines = list
281
+ .slice(-max)
282
+ .map(
283
+ (e) =>
284
+ ` ${e.status ?? "ERR"} ${e.method} ${e.url}` +
285
+ (e.error ? ` — ${e.error}` : "") +
286
+ (e.body ? ` — ${JSON.stringify(e.body.slice(0, 120))}` : "")
287
+ );
288
+ if (list.length > max) lines.unshift(` … ${list.length - max} earlier`);
289
+ return lines;
290
+ }
291
+
104
292
  export async function runStoryboard(
105
293
  page: Page,
106
294
  storyboard: Storyboard,
@@ -115,7 +303,11 @@ export async function runStoryboard(
115
303
 
116
304
  const now = () => Date.now() - opts.t0;
117
305
  const scenes: TimelineScene[] = [];
306
+ const net = new NetworkWatch(page, opts.t0);
307
+ net.start();
308
+ let prevActionStartMs = 0;
118
309
 
310
+ try {
119
311
  const total = storyboard.scenes.length;
120
312
  for (let si = 0; si < total; si++) {
121
313
  const scene = storyboard.scenes[si];
@@ -125,7 +317,23 @@ export async function runStoryboard(
125
317
  focusEvents: [],
126
318
  stillEvents: [],
127
319
  cursorSamples: [],
320
+ actions: [],
321
+ attentionEvents: [],
322
+ keyEvents: [],
323
+ redactSpans: [],
324
+ anchorEvents: [],
325
+ fast: opts.replayUntil != null && si < opts.replayUntil,
326
+ };
327
+ // Per-scene `hide` (top-level hides are injected by the recorder's init
328
+ // script so they survive navigations); applied now, removed at scene end.
329
+ if (scene.hide?.length) await setSceneHide(page, scene.hide);
330
+ const redactList: Redact[] = [...(storyboard.redact ?? []), ...(scene.redact ?? [])];
331
+ const redactOpen = new Map<string, RedactSpan>();
332
+ const measureRedact = async (): Promise<void> => {
333
+ if (!redactList.length) return;
334
+ await measureRedactions(page, storyboard, redactList, redactOpen, capture, now);
128
335
  };
336
+ await measureRedact();
129
337
  // Cursor path sampler — only records when the storyboard opts into the
130
338
  // compose-time cursor overlay, so a plain take's timeline stays unchanged.
131
339
  const sample: CursorSampler | undefined = opts.captureCursorPath
@@ -136,7 +344,10 @@ export async function runStoryboard(
136
344
  y: Math.round(y),
137
345
  })
138
346
  : undefined;
139
- log(`scene ${scene.id}: ${scene.actions.length} action(s)`);
347
+ log(
348
+ `scene ${scene.id}: ${scene.actions.length} action(s)` +
349
+ (capture.fast ? " (replay only — footage reused from the previous take)" : "")
350
+ );
140
351
  opts.onSceneStart?.(scene.id, si, total);
141
352
  const probeOutcomes: ProbeActionOutcome[] = [];
142
353
 
@@ -145,32 +356,141 @@ export async function runStoryboard(
145
356
  throw new CanceledError(`canceled during scene ${scene.id}`);
146
357
  const action = scene.actions[i];
147
358
  const outcome = opts.probe ? initProbeOutcome(storyboard, action) : null;
359
+ // Failed-request window for diagnostics: from the PREVIOUS action's
360
+ // start — the failing action is usually the wait after the click whose
361
+ // XHR actually failed (issue #44).
362
+ const netWindowMs = prevActionStartMs;
363
+ prevActionStartMs = now();
364
+ const rec: TimelineAction = {
365
+ index: i,
366
+ op: action.op,
367
+ startMs: now(),
368
+ endMs: 0,
369
+ ok: false,
370
+ };
371
+ const recTarget = describeActionTarget(storyboard, action);
372
+ if (recTarget) rec.target = recTarget;
373
+ const recWarnings: string[] = [];
148
374
  try {
149
- await runAction(page, storyboard, action, mouse, capture, opts, sample);
375
+ if (await optionalTargetAbsent(page, storyboard, action)) {
376
+ log(` ⚠ optional ${action.op} skipped — target not present`);
377
+ rec.skipped = true;
378
+ recWarnings.push("optional: target not present");
379
+ } else {
380
+ const retries = await runActionWithRetry(
381
+ page,
382
+ storyboard,
383
+ action,
384
+ mouse,
385
+ capture,
386
+ opts,
387
+ sample
388
+ );
389
+ if (retries > 0) {
390
+ rec.retries = retries;
391
+ recWarnings.push(`succeeded after ${retries} retr${retries === 1 ? "y" : "ies"}`);
392
+ }
393
+ }
150
394
  if (outcome) outcome.ok = true;
395
+ rec.ok = true;
151
396
  } catch (err) {
152
- if (outcome) {
397
+ if (action.optional) {
398
+ // Best-effort by authoring contract: log and continue. Counts as ok
399
+ // for the golden projection too (see ProbeActionOutcomeSchema) —
400
+ // an optional action's outcome varies by environment state, which
401
+ // is the point of marking it optional.
402
+ if (outcome) outcome.ok = true;
403
+ rec.ok = true;
404
+ rec.skipped = true;
405
+ recWarnings.push(`optional: ${firstLine(err)}`);
406
+ log(` ⚠ optional ${action.op} skipped — ${firstLine(err)}`);
407
+ } else if (outcome) {
153
408
  // Golden probe: record the failure and keep going, so a broken
154
409
  // selector shows up as a single flipped field in the diff instead of
155
410
  // aborting the whole projection.
156
411
  outcome.ok = false;
412
+ recWarnings.push(firstLine(err));
157
413
  log(
158
414
  ` ✗ probe: ${action.op} failed — ${firstLine(err)}`
159
415
  );
416
+ const failed = net.since(netWindowMs);
417
+ if (failed.length) {
418
+ log(` ${failed.length} failed request(s) since the previous action:`);
419
+ for (const l of describeFailedRequests(failed)) log(l);
420
+ }
421
+ // Same screenshot + drift suggestions as a hard failure, so a golden
422
+ // mismatch points at the nearest replacement selector.
423
+ if (opts.logsDir) {
424
+ const diag = await dumpDiagnostics(
425
+ page,
426
+ storyboard,
427
+ scene.id,
428
+ i,
429
+ action,
430
+ opts.logsDir,
431
+ failed
432
+ ).catch(() => "");
433
+ for (const l of diag.split("\n")) if (l) log(l);
434
+ }
160
435
  } else {
161
436
  // Name the failing scene/action, screenshot the page, and dump the
162
437
  // widget frames present — so a phantom click or a platform
163
438
  // interruption is diagnosable from the log instead of by
164
439
  // hand-extracting webm frames.
165
- await failAction(page, storyboard, scene, i, action, opts, err);
440
+ rec.endMs = now();
441
+ recWarnings.push(firstLine(err));
442
+ rec.warnings = recWarnings;
443
+ capture.actions.push(rec);
444
+ await failAction(page, storyboard, scene, i, action, opts, err, {
445
+ failedRequests: net.since(netWindowMs),
446
+ });
166
447
  }
167
448
  }
449
+ rec.endMs = now();
450
+ const failedInWindow = net.since(rec.startMs);
451
+ if (failedInWindow.length) {
452
+ recWarnings.push(
453
+ `${failedInWindow.length} failed request(s): ` +
454
+ failedInWindow
455
+ .slice(-3)
456
+ .map((e) => `${e.status ?? "ERR"} ${e.method} ${e.url}`)
457
+ .join("; ")
458
+ );
459
+ }
460
+ if (recWarnings.length) rec.warnings = recWarnings;
461
+ capture.actions.push(rec);
462
+ if (action.anchor && !rec.skipped) {
463
+ // The beat = the action's focus moment when it produced one (a click
464
+ // fires its focus event right after the press), else the action start.
465
+ const focusAt = capture.focusEvents.length
466
+ ? capture.focusEvents[capture.focusEvents.length - 1].tMs
467
+ : -1;
468
+ const tMs = focusAt >= rec.startMs && focusAt <= rec.endMs ? focusAt : rec.startMs;
469
+ capture.anchorEvents.push({ name: action.anchor, tMs, action: i });
470
+ }
471
+ await measureRedact();
168
472
  if (outcome) {
169
- await enrichProbeOutcome(page, storyboard, action, outcome);
473
+ // Optional actions skip the found-enrichment: whether their target
474
+ // resolves is environment-dependent, and the golden projection must
475
+ // stay deterministic across runs.
476
+ if (!action.optional) {
477
+ await enrichProbeOutcome(page, storyboard, action, outcome);
478
+ }
170
479
  probeOutcomes.push(outcome);
171
480
  }
172
481
  }
173
482
  if (opts.probe) opts.probe.push({ id: scene.id, actions: probeOutcomes });
483
+ // Close every open redact span at the scene boundary and drop the scene hide.
484
+ for (const span of redactOpen.values()) {
485
+ span.endMs = now();
486
+ capture.redactSpans.push(span);
487
+ }
488
+ redactOpen.clear();
489
+ if (scene.hide?.length) await setSceneHide(page, []);
490
+ if (capture.fast) {
491
+ opts.onSceneReplayed?.(scene.id, si, total);
492
+ continue;
493
+ }
174
494
 
175
495
  const tlScene: TimelineScene = {
176
496
  id: scene.id,
@@ -180,10 +500,18 @@ export async function runStoryboard(
180
500
  focusEvents: capture.focusEvents,
181
501
  stillEvents: capture.stillEvents,
182
502
  cursorSamples: capture.cursorSamples,
503
+ actions: capture.actions,
504
+ attentionEvents: capture.attentionEvents,
505
+ keyEvents: capture.keyEvents,
506
+ redactSpans: capture.redactSpans,
507
+ anchorEvents: capture.anchorEvents,
183
508
  };
184
509
  scenes.push(tlScene);
185
510
  opts.onSceneComplete?.(tlScene, si, total);
186
511
  }
512
+ } finally {
513
+ net.stop();
514
+ }
187
515
 
188
516
  // leadInMs is filled in by the recorder (it knows the video start).
189
517
  return { totalMs: now(), leadInMs: 0, scenes };
@@ -414,25 +742,35 @@ async function resolveTargetLocator(
414
742
  return frame.locator(selector).first();
415
743
  }
416
744
 
745
+ /** Human-readable target for error messages: `frame >> selector [last]`. */
746
+ function describeTarget(storyboard: Storyboard, target: Target): string {
747
+ return describeProbeTarget(storyboard, target);
748
+ }
749
+
417
750
  /**
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.
751
+ * Wait until `target` resolves to an element in `state` ("visible" by default,
752
+ * or merely "attached" for zero-size mount points), re-resolving each poll so
753
+ * a nested widget frame that appears (and fills) mid-wait is picked up.
754
+ * Throws on timeout — naming the TOTAL budget waited, not Playwright's last
755
+ * ~2.5 s poll chunk (which read as "Timeout 1945ms exceeded" against a 30 s
756
+ * storyboard budget and sent people hunting a bug that didn't exist, #44).
421
757
  */
422
758
  async function waitForTargetVisible(
423
759
  page: Page,
424
760
  storyboard: Storyboard,
425
761
  target: Target,
426
- timeoutMs: number
762
+ timeoutMs: number,
763
+ state: WaitState = "visible"
427
764
  ): Promise<void> {
428
- const deadline = Date.now() + timeoutMs;
765
+ const started = Date.now();
766
+ const deadline = started + timeoutMs;
429
767
  let lastErr: unknown;
430
768
  for (;;) {
431
769
  try {
432
770
  const loc = await resolveTargetLocator(page, storyboard, target);
433
771
  const remaining = deadline - Date.now();
434
772
  await loc.waitFor({
435
- state: "visible",
773
+ state,
436
774
  timeout: Math.max(400, Math.min(2500, remaining)),
437
775
  });
438
776
  return;
@@ -440,14 +778,59 @@ async function waitForTargetVisible(
440
778
  lastErr = err;
441
779
  }
442
780
  if (Date.now() >= deadline) {
443
- throw lastErr instanceof Error
444
- ? lastErr
445
- : new Error(`waitForTargetVisible timed out for ${JSON.stringify(target)}`);
781
+ const waited = Date.now() - started;
782
+ const reason = lastErr instanceof Error ? firstLine(lastErr) : String(lastErr);
783
+ const hint = /Timeout \d+ms exceeded/.test(reason)
784
+ ? "" // Playwright's chunk timeout — nothing more to say than the budget
785
+ : ` (last error: ${reason})`;
786
+ throw new Error(
787
+ `waitFor: "${describeTarget(storyboard, target)}" not ${state} after ` +
788
+ `${waited}ms (budget ${timeoutMs}ms)${hint}` +
789
+ (state === "visible"
790
+ ? ` — if the element is a zero-size mount point that fills later, use state: "attached"`
791
+ : "")
792
+ );
446
793
  }
447
794
  await sleep(300);
448
795
  }
449
796
  }
450
797
 
798
+ /**
799
+ * Wait until the scroll position has been still for `settleMs` (bounded at
800
+ * 3 s total) — an honest "scroll finished" for smooth-scrolling pages, where a
801
+ * fixed pause is a race. `sampleAt` reads a position signature; the default is
802
+ * the main document's scroll offset.
803
+ */
804
+ async function waitScrollSettled(
805
+ page: Page,
806
+ settleMs: number,
807
+ loc?: Locator
808
+ ): Promise<void> {
809
+ const quiet = Math.max(50, settleMs);
810
+ const deadline = Date.now() + Math.max(quiet, 3000);
811
+ const sampleAt = async (): Promise<string> => {
812
+ if (loc) {
813
+ const box = await loc.boundingBox().catch(() => null);
814
+ if (box) return `${Math.round(box.x)},${Math.round(box.y)}`;
815
+ }
816
+ return page
817
+ .evaluate(() => `${Math.round(scrollX)},${Math.round(scrollY)}`)
818
+ .catch(() => "");
819
+ };
820
+ let last = await sampleAt();
821
+ let stillSince = Date.now();
822
+ while (Date.now() < deadline) {
823
+ await sleep(50);
824
+ const cur = await sampleAt();
825
+ if (cur !== last) {
826
+ last = cur;
827
+ stillSince = Date.now();
828
+ } else if (Date.now() - stillSince >= quiet) {
829
+ return;
830
+ }
831
+ }
832
+ }
833
+
451
834
  /**
452
835
  * Wait for the model's NEW reply widget — not a stale prior one. ChatGPT keeps
453
836
  * earlier widgets in the DOM and different tools share selectors (search &
@@ -572,6 +955,106 @@ async function waitForNewReply(
572
955
  }
573
956
  }
574
957
 
958
+ /**
959
+ * Ops that interact with a target element. An absent target would stall these
960
+ * for the full Playwright action timeout (~30s of dead recording) before the
961
+ * optional-skip even triggers, so optional interactions get a short existence
962
+ * probe first. Wait ops are excluded — waiting is their whole job, and they
963
+ * carry their own timeoutMs.
964
+ */
965
+ const INTERACTION_OPS = new Set([
966
+ "click",
967
+ "type",
968
+ "hover",
969
+ "scrollTo",
970
+ "focus",
971
+ "moveTo",
972
+ "highlight",
973
+ "spotlight",
974
+ "callout",
975
+ ]);
976
+ const OPTIONAL_PROBE_MS = 3000;
977
+
978
+ /**
979
+ * For an `optional` interaction, briefly probe whether the target is present
980
+ * (state-dependent UI may still be rendering, so give it a beat). Returns true
981
+ * when the action should be skipped. Non-optional actions and wait ops always
982
+ * return false — they run normally.
983
+ */
984
+ async function optionalTargetAbsent(
985
+ page: Page,
986
+ storyboard: Storyboard,
987
+ action: Action
988
+ ): Promise<boolean> {
989
+ const target = (action as { target?: Target }).target;
990
+ if (!action.optional || !INTERACTION_OPS.has(action.op) || !target) {
991
+ return false;
992
+ }
993
+ const state = (action as { state?: WaitState }).state ?? "visible";
994
+ try {
995
+ await waitForTargetVisible(page, storyboard, target, OPTIONAL_PROBE_MS, state);
996
+ return false;
997
+ } catch {
998
+ return true;
999
+ }
1000
+ }
1001
+
1002
+ /**
1003
+ * Run an action, re-attempting it `action.retry` times (retryable ops only)
1004
+ * with a beat between attempts. Returns the number of extra attempts used.
1005
+ * The last failure propagates so the normal fail/optional paths apply.
1006
+ */
1007
+ async function runActionWithRetry(
1008
+ page: Page,
1009
+ storyboard: Storyboard,
1010
+ action: Action,
1011
+ mouse: MouseState,
1012
+ capture: SceneCapture,
1013
+ opts: PlayerOptions,
1014
+ sample?: CursorSampler
1015
+ ): Promise<number> {
1016
+ const budget = action.retry && RETRYABLE_OPS.has(action.op) ? action.retry : 0;
1017
+ for (let attempt = 0; ; attempt++) {
1018
+ try {
1019
+ await runAction(page, storyboard, action, mouse, capture, opts, sample);
1020
+ return attempt;
1021
+ } catch (err) {
1022
+ if (attempt >= budget || opts.signal?.aborted) throw err;
1023
+ log(
1024
+ ` ↻ ${action.op} failed (${firstLine(err)}) — retry ${attempt + 1}/${budget}`
1025
+ );
1026
+ await sleep(RETRY_GAP_MS);
1027
+ }
1028
+ }
1029
+ }
1030
+
1031
+ /**
1032
+ * After a navigation's domcontentloaded: wait for the page to go quiet (no
1033
+ * network for 500 ms, fonts loaded), capped. The wait beyond the classic
1034
+ * 600 ms settle is recorded as an idle span so compose trims it — the take
1035
+ * doesn't get slower, but the first click no longer lands on a half-fetched
1036
+ * page (a skeleton screen, a still-loading nav) and gets misread as a miss.
1037
+ */
1038
+ async function awaitGotoReadiness(
1039
+ page: Page,
1040
+ capture: SceneCapture,
1041
+ t0: number
1042
+ ): Promise<void> {
1043
+ await sleep(600);
1044
+ const start = Date.now();
1045
+ await Promise.all([
1046
+ page.waitForLoadState("networkidle", { timeout: GOTO_QUIET_CAP_MS }).catch(() => {}),
1047
+ page
1048
+ .evaluate(() => (document as unknown as { fonts?: { ready: Promise<unknown> } }).fonts?.ready)
1049
+ .catch(() => {}),
1050
+ ]);
1051
+ const waited = Date.now() - start;
1052
+ if (waited > 150) {
1053
+ capture.idleSpans.push({ startMs: start - t0, endMs: Date.now() - t0, label: "load" });
1054
+ log(` page quiet after +${waited}ms (idle "load")`);
1055
+ }
1056
+ }
1057
+
575
1058
  async function runAction(
576
1059
  page: Page,
577
1060
  storyboard: Storyboard,
@@ -588,13 +1071,165 @@ async function runAction(
588
1071
  switch (action.op) {
589
1072
  case "goto":
590
1073
  await page.goto(action.url, { waitUntil: "domcontentloaded" });
591
- await sleep(600);
1074
+ await awaitGotoReadiness(page, capture, t0);
1075
+ return;
1076
+
1077
+ case "moveTo": {
1078
+ if (!action.target && (action.x == null || action.y == null)) {
1079
+ throw new Error("moveTo needs a target or both x and y");
1080
+ }
1081
+ let x = action.x ?? mouse.x;
1082
+ let y = action.y ?? mouse.y;
1083
+ if (action.target) {
1084
+ const loc = await resolveTargetLocator(page, storyboard, action.target);
1085
+ const box = await boxOf(page, loc);
1086
+ x = box.cx;
1087
+ y = box.cy;
1088
+ }
1089
+ await moveMouseTo(page, mouse, x, y, sample);
1090
+ await sleep(150);
592
1091
  return;
1092
+ }
1093
+
1094
+ case "assert": {
1095
+ if (!action.target && !action.url) {
1096
+ throw new Error("assert needs a target and/or a url");
1097
+ }
1098
+ const timeoutMs = action.timeoutMs ?? 5000;
1099
+ 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;
1102
+ let why = "";
1103
+ if (action.target) {
1104
+ // Visible first (honest total-budget error if it never shows up).
1105
+ await waitForTargetVisible(page, storyboard, action.target, timeoutMs);
1106
+ }
1107
+ for (;;) {
1108
+ why = "";
1109
+ if (urlRe && !urlRe.test(page.url())) {
1110
+ why = `url ${JSON.stringify(page.url())} does not match /${action.url}/`;
1111
+ } else if (textRe && action.target) {
1112
+ const loc = await resolveTargetLocator(page, storyboard, action.target);
1113
+ const text =
1114
+ ((await loc.first().textContent({ timeout: 800 }).catch(() => null)) ?? "")
1115
+ .replace(/\s+/g, " ")
1116
+ .trim();
1117
+ if (!textRe.test(text)) {
1118
+ why =
1119
+ `text ${JSON.stringify(text.slice(0, 80))} does not match ` +
1120
+ `/${action.textMatches}/`;
1121
+ }
1122
+ }
1123
+ if (!why) return;
1124
+ if (Date.now() >= deadline) break;
1125
+ await sleep(200);
1126
+ }
1127
+ throw new Error(
1128
+ `assert failed after ${timeoutMs}ms: ${why}` +
1129
+ (action.comment ? ` — ${action.comment}` : "")
1130
+ );
1131
+ }
593
1132
 
594
1133
  case "click": {
595
1134
  const loc = await resolveTargetLocator(page, storyboard, action.target);
1135
+ // followPopup: catch a tab the click opens, then bring its URL into the
1136
+ // recorded tab — Playwright records one video per page, so a second tab
1137
+ // would never reach the take.
1138
+ const popup = action.followPopup
1139
+ ? page.context().waitForEvent("page", { timeout: 4000 }).catch(() => null)
1140
+ : null;
596
1141
  const { cx, cy } = await humanClick(page, loc, mouse, !!action.target.frame, sample);
597
1142
  markFocus(cx, cy, "click");
1143
+ if (popup) {
1144
+ const tab = await popup;
1145
+ if (tab) {
1146
+ await tab.waitForLoadState("domcontentloaded", { timeout: 8000 }).catch(() => {});
1147
+ const url = tab.url();
1148
+ await tab.close().catch(() => {});
1149
+ if (url && url !== "about:blank") {
1150
+ log(` popup → ${url} (followed in the recorded tab)`);
1151
+ await page.goto(url, { waitUntil: "domcontentloaded" });
1152
+ await awaitGotoReadiness(page, capture, t0);
1153
+ } else {
1154
+ log(" ! popup opened without a URL; nothing to follow");
1155
+ }
1156
+ } else {
1157
+ log(" (followPopup: the click opened no new tab)");
1158
+ }
1159
+ }
1160
+ return;
1161
+ }
1162
+
1163
+ case "back":
1164
+ await page.goBack({ waitUntil: "domcontentloaded" });
1165
+ await awaitGotoReadiness(page, capture, t0);
1166
+ return;
1167
+
1168
+ case "select": {
1169
+ if (action.value == null && action.label == null) {
1170
+ throw new Error("select needs a value or a label");
1171
+ }
1172
+ const loc = await resolveTargetLocator(page, storyboard, action.target);
1173
+ const { cx, cy } = await humanClick(page, loc, mouse, !!action.target.frame, sample);
1174
+ markFocus(cx, cy, "click");
1175
+ // The native dropdown never paints into the recording; commit the choice
1176
+ // programmatically (fires input/change like a real pick) and close it.
1177
+ await loc.selectOption(action.value != null ? { value: action.value } : { label: action.label! });
1178
+ await page.keyboard.press("Escape").catch(() => {});
1179
+ await sleep(200);
1180
+ return;
1181
+ }
1182
+
1183
+ case "drag": {
1184
+ if (!action.to.target && (action.to.x == null || action.to.y == null)) {
1185
+ throw new Error("drag needs `to.target` or both `to.x` and `to.y`");
1186
+ }
1187
+ const loc = await resolveTargetLocator(page, storyboard, action.target);
1188
+ const from = await boxOf(page, loc);
1189
+ let tx = action.to.x ?? mouse.x;
1190
+ let ty = action.to.y ?? mouse.y;
1191
+ if (action.to.target) {
1192
+ const dest = await resolveTargetLocator(page, storyboard, action.to.target);
1193
+ const box = await boxOf(page, dest);
1194
+ tx = box.cx;
1195
+ ty = box.cy;
1196
+ }
1197
+ await moveMouseTo(page, mouse, from.cx, from.cy, sample);
1198
+ await sleep(120);
1199
+ await page.mouse.down();
1200
+ // A few pixels first so HTML5 drag-and-drop and pointer-based libraries
1201
+ // both pass their drag threshold before the real glide.
1202
+ await page.mouse.move(from.cx + 4, from.cy + 4);
1203
+ await sleep(80);
1204
+ await moveMouseTo(page, mouse, tx, ty, sample);
1205
+ await sleep(120);
1206
+ await page.mouse.up();
1207
+ markFocus(tx, ty, "drag");
1208
+ await sleep(200);
1209
+ return;
1210
+ }
1211
+
1212
+ case "upload": {
1213
+ const loc = await resolveTargetLocator(page, storyboard, action.target);
1214
+ const files = action.files.map((f) =>
1215
+ isAbsolute(f) ? f : resolve(opts.baseDir ?? process.cwd(), f)
1216
+ );
1217
+ for (const f of files) {
1218
+ if (!existsSync(f)) throw new Error(`upload: file not found: ${f}`);
1219
+ }
1220
+ const isFileInput = await loc
1221
+ .evaluate((el) => el.tagName === "INPUT" && (el as HTMLInputElement).type === "file")
1222
+ .catch(() => false);
1223
+ if (isFileInput) {
1224
+ await loc.setInputFiles(files);
1225
+ } else {
1226
+ const chooser = page.waitForEvent("filechooser", { timeout: 8000 });
1227
+ const { cx, cy } = await humanClick(page, loc, mouse, !!action.target.frame, sample);
1228
+ markFocus(cx, cy, "click");
1229
+ await (await chooser).setFiles(files);
1230
+ }
1231
+ log(` upload: ${files.map((f) => basename(f)).join(", ")}`);
1232
+ await sleep(300);
598
1233
  return;
599
1234
  }
600
1235
 
@@ -631,15 +1266,49 @@ async function runAction(
631
1266
  await page.keyboard.press("ControlOrMeta+a");
632
1267
  await page.keyboard.press("Backspace");
633
1268
  }
634
- await humanType(page, loc, action.text, action.humanize !== false);
1269
+ await humanType(page, loc, action.text, !capture.fast && action.humanize !== false);
635
1270
  return;
636
1271
  }
637
1272
 
638
1273
  case "press":
1274
+ if (action.keystrokes ?? storyboard.keystrokes) {
1275
+ capture.keyEvents.push({ tMs: Date.now() - t0, keys: prettyKeys(action.key) });
1276
+ }
639
1277
  await page.keyboard.press(action.key);
640
1278
  await sleep(200);
641
1279
  return;
642
1280
 
1281
+ case "highlight":
1282
+ case "spotlight":
1283
+ case "callout": {
1284
+ const loc = await resolveTargetLocator(page, storyboard, action.target);
1285
+ const rect = await rectOf(page, loc);
1286
+ const holdMs =
1287
+ action.holdMs ??
1288
+ (action.op === "callout" ? CALLOUT_HOLD_DEFAULT_MS : ATTENTION_HOLD_DEFAULT_MS);
1289
+ const ev: AttentionEvent = {
1290
+ tMs: Date.now() - t0,
1291
+ kind: action.op,
1292
+ x: rect.x,
1293
+ y: rect.y,
1294
+ w: rect.w,
1295
+ h: rect.h,
1296
+ holdMs,
1297
+ };
1298
+ if (action.style) ev.style = action.style;
1299
+ if (action.op === "callout") {
1300
+ ev.text = action.text;
1301
+ if (action.placement) ev.placement = action.placement;
1302
+ }
1303
+ if (action.op === "spotlight") {
1304
+ if (action.dimTo != null) ev.dimTo = action.dimTo;
1305
+ if (action.padding != null) ev.padding = action.padding;
1306
+ }
1307
+ capture.attentionEvents.push(ev);
1308
+ if (!capture.fast) await sleep(Math.min(holdMs, ATTENTION_DWELL_MAX_MS));
1309
+ return;
1310
+ }
1311
+
643
1312
  case "hover": {
644
1313
  const loc = await resolveTargetLocator(page, storyboard, action.target);
645
1314
  const box = await boxOf(page, loc);
@@ -669,6 +1338,7 @@ async function runAction(
669
1338
  // Let the frame settle first, then record the marker at the settled
670
1339
  // moment so compose-time extraction lands on a clean frame (not mid-
671
1340
  // transition). The PNG is pulled from the CLEAN take by `aidemo stills`.
1341
+ if (capture.fast) return;
672
1342
  await sleep(120);
673
1343
  capture.stillEvents.push({ tMs: Date.now() - t0, name: action.name });
674
1344
  log(` still "${action.name}" @ ${Date.now() - t0}ms`);
@@ -676,7 +1346,13 @@ async function runAction(
676
1346
  }
677
1347
 
678
1348
  case "scrollTo": {
1349
+ const state = action.state ?? "visible";
679
1350
  const loc = await resolveTargetLocator(page, storyboard, action.target);
1351
+ if (state === "attached") {
1352
+ // A zero-size mount point has no visibility to wait for; require it
1353
+ // to be in the DOM, then scroll by geometry alone (issue #44).
1354
+ await waitForTargetVisible(page, storyboard, action.target, 15000, "attached");
1355
+ }
680
1356
  // Top-page targets get the cinematic eased scroll; targets inside a
681
1357
  // frame keep the reliable scrollIntoViewIfNeeded (wheel deltas would go
682
1358
  // to whatever scroller is under the cursor, not necessarily the frame).
@@ -687,13 +1363,74 @@ async function runAction(
687
1363
  if (Math.abs(dy) > 30) await easedWheel(page, dy, action);
688
1364
  }
689
1365
  }
690
- await loc.scrollIntoViewIfNeeded();
691
- await sleep(300);
1366
+ if (state === "attached") {
1367
+ // scrollIntoViewIfNeeded needs a visible element; a plain
1368
+ // scrollIntoView on the node works for an empty one.
1369
+ await loc
1370
+ .evaluate((el) => el.scrollIntoView({ block: "center" }))
1371
+ .catch(() => {});
1372
+ } else {
1373
+ await loc.scrollIntoViewIfNeeded();
1374
+ }
1375
+ if (action.settleMs != null) {
1376
+ await waitScrollSettled(page, action.settleMs, loc);
1377
+ } else {
1378
+ await sleep(300);
1379
+ }
692
1380
  return;
693
1381
  }
694
1382
 
695
1383
  case "scrollBy": {
696
- await easedWheel(page, action.dy, action);
1384
+ let dy = action.dy;
1385
+ let loc: Locator | undefined;
1386
+ if (action.target) {
1387
+ // Target-scoped scroll (issue #43): wheel OVER the element, and never
1388
+ // more than its own scroller has room for — a bottomed-out inner
1389
+ // panel otherwise chains the wheel to the page and the whole app
1390
+ // slides off-screen mid-take with nothing failing.
1391
+ loc = await resolveTargetLocator(page, storyboard, action.target);
1392
+ await waitForTargetVisible(page, storyboard, action.target, 15000, "attached");
1393
+ const box = await loc.boundingBox().catch(() => null);
1394
+ if (box) {
1395
+ const vp = page.viewportSize() ?? opts.video;
1396
+ const cx = Math.min(Math.max(box.x + box.width / 2, 2), vp.width - 2);
1397
+ const cy = Math.min(Math.max(box.y + box.height / 2, 2), vp.height - 2);
1398
+ await moveMouseTo(page, mouse, cx, cy, sample);
1399
+ }
1400
+ // NOTE: no inner named functions inside evaluate callbacks — tsx's
1401
+ // esbuild keepNames wraps them in a `__name` helper that doesn't
1402
+ // exist in the page (ReferenceError, swallowed → no clamp).
1403
+ const room = await loc
1404
+ .evaluate((el, want) => {
1405
+ let n: Element | null = el;
1406
+ while (
1407
+ n &&
1408
+ !(
1409
+ /(auto|scroll|overlay)/.test(getComputedStyle(n).overflowY) &&
1410
+ n.scrollHeight > n.clientHeight + 1
1411
+ )
1412
+ ) {
1413
+ n = n.parentElement;
1414
+ }
1415
+ const sc = n ?? document.scrollingElement ?? document.documentElement;
1416
+ return want > 0
1417
+ ? sc.scrollHeight - sc.clientHeight - sc.scrollTop
1418
+ : sc.scrollTop;
1419
+ }, dy)
1420
+ .catch((err: Error) => {
1421
+ log(` ! scrollBy: could not measure target scroller (${firstLine(err)}); unclamped`);
1422
+ return Math.abs(dy);
1423
+ });
1424
+ if (Math.abs(dy) > room) {
1425
+ log(
1426
+ ` scrollBy clamped ${dy} → ${Math.sign(dy) * Math.round(room)}px ` +
1427
+ `(target's scroller has no more room)`
1428
+ );
1429
+ dy = Math.sign(dy) * room;
1430
+ }
1431
+ }
1432
+ if (Math.abs(dy) >= 1) await easedWheel(page, dy, action);
1433
+ if (action.settleMs != null) await waitScrollSettled(page, action.settleMs, loc);
697
1434
  return;
698
1435
  }
699
1436
 
@@ -702,7 +1439,8 @@ async function runAction(
702
1439
  page,
703
1440
  storyboard,
704
1441
  action.target,
705
- action.timeoutMs ?? 15000
1442
+ action.timeoutMs ?? 15000,
1443
+ action.state ?? "visible"
706
1444
  );
707
1445
  return;
708
1446
  }
@@ -747,7 +1485,8 @@ async function runAction(
747
1485
  }
748
1486
 
749
1487
  case "pause":
750
- await sleep(action.ms);
1488
+ // A replayed scene (resume) only needs the state, not the beat.
1489
+ await sleep(capture.fast ? Math.min(action.ms, 100) : action.ms);
751
1490
  return;
752
1491
  }
753
1492
  }
@@ -781,6 +1520,7 @@ function initProbeOutcome(
781
1520
  action: Action
782
1521
  ): ProbeActionOutcome {
783
1522
  const o: ProbeActionOutcome = { op: action.op, ok: false };
1523
+ if (action.optional) o.optional = true;
784
1524
  switch (action.op) {
785
1525
  case "goto":
786
1526
  o.target = action.url;
@@ -793,6 +1533,7 @@ function initProbeOutcome(
793
1533
  break;
794
1534
  case "scrollBy":
795
1535
  o.dy = action.dy;
1536
+ if (action.target) o.target = describeProbeTarget(storyboard, action.target);
796
1537
  break;
797
1538
  case "waitForReply":
798
1539
  o.target = action.selector ?? ASSISTANT_MESSAGE_SELECTOR;
@@ -809,12 +1550,34 @@ function initProbeOutcome(
809
1550
  case "scrollTo":
810
1551
  case "waitFor":
811
1552
  case "focus":
1553
+ case "highlight":
1554
+ case "spotlight":
1555
+ case "callout":
1556
+ case "select":
1557
+ case "drag":
1558
+ case "upload":
812
1559
  o.target = describeProbeTarget(storyboard, action.target);
813
1560
  break;
1561
+ case "moveTo":
1562
+ o.target = action.target
1563
+ ? describeProbeTarget(storyboard, action.target)
1564
+ : `${action.x},${action.y}`;
1565
+ break;
1566
+ case "assert":
1567
+ o.target = action.target
1568
+ ? describeProbeTarget(storyboard, action.target)
1569
+ : `url:${action.url}`;
1570
+ break;
814
1571
  }
815
1572
  return o;
816
1573
  }
817
1574
 
1575
+ /** The timeline.json `actions[].target` string for any action (or undefined). */
1576
+ function describeActionTarget(storyboard: Storyboard, action: Action): string | undefined {
1577
+ const t = initProbeOutcome(storyboard, action).target;
1578
+ return t;
1579
+ }
1580
+
818
1581
  /** Add the post-run stable signals: goto's final URL, target element-found. */
819
1582
  async function enrichProbeOutcome(
820
1583
  page: Page,
@@ -833,8 +1596,9 @@ async function enrichProbeOutcome(
833
1596
  );
834
1597
  return;
835
1598
  }
836
- if ("target" in action) {
837
- o.found = await targetResolves(page, storyboard, action.target);
1599
+ const target = (action as { target?: Target }).target;
1600
+ if (target) {
1601
+ o.found = await targetResolves(page, storyboard, target);
838
1602
  }
839
1603
  }
840
1604
 
@@ -936,17 +1700,34 @@ async function failAction(
936
1700
  index: number,
937
1701
  action: Action,
938
1702
  opts: PlayerOptions,
939
- err: unknown
1703
+ err: unknown,
1704
+ extra: { failedRequests: FailedRequest[] }
940
1705
  ): Promise<never> {
941
1706
  const prefix = `scene ${scene.id}, action #${index + 1} (${action.op})`;
942
1707
  const base = err instanceof Error ? err.message : String(err);
1708
+ // Lead with the app's own refusal when the action provoked a 4xx/5xx (issue
1709
+ // #45): the selector headline blames the selector, and on a rate-limited or
1710
+ // auth-gated flow the selector was fine — the write just failed. The
1711
+ // original message stays, one line down, in parentheses.
1712
+ const refusal = extra.failedRequests.filter((r) => (r.status ?? 0) >= 400).slice(-1)[0];
1713
+ const headline = refusal
1714
+ ? `the app refused a request this action triggered\n` +
1715
+ describeFailedRequests([refusal]).join("\n").trim().replace(/^/, " ") +
1716
+ `\n (${base})`
1717
+ : base;
943
1718
  let diag = "";
944
1719
  if (opts.logsDir) {
945
- diag = await dumpDiagnostics(page, storyboard, scene.id, index, action, opts.logsDir).catch(
946
- () => ""
947
- );
1720
+ diag = await dumpDiagnostics(
1721
+ page,
1722
+ storyboard,
1723
+ scene.id,
1724
+ index,
1725
+ action,
1726
+ opts.logsDir,
1727
+ extra.failedRequests
1728
+ ).catch(() => "");
948
1729
  }
949
- throw new Error(`${prefix}: ${base}${diag ? `\n${diag}` : ""}`);
1730
+ throw new Error(`${prefix}: ${headline}${diag ? `\n${diag}` : ""}`);
950
1731
  }
951
1732
 
952
1733
  async function dumpDiagnostics(
@@ -955,7 +1736,8 @@ async function dumpDiagnostics(
955
1736
  sceneId: string,
956
1737
  index: number,
957
1738
  action: Action,
958
- logsDir: string
1739
+ logsDir: string,
1740
+ failedRequests: FailedRequest[] = []
959
1741
  ): Promise<string> {
960
1742
  await ensureDir(logsDir);
961
1743
  const stem = join(logsDir, `fail-${sceneId}-${index + 1}`);
@@ -966,7 +1748,7 @@ async function dumpDiagnostics(
966
1748
 
967
1749
  // Selector diagnostics for targeted actions: how many widget frames (and the
968
1750
  // main frame) currently match the selector we were after.
969
- const target = "target" in action ? (action.target as Target) : undefined;
1751
+ const target = (action as { target?: Target }).target;
970
1752
  const selector = target?.named ? NAMED_SELECTORS[target.named] : target?.selector;
971
1753
  const roots = widgetRootFrames(page);
972
1754
  lines.push(` widget frames present: ${roots.length}`);
@@ -981,6 +1763,59 @@ async function dumpDiagnostics(
981
1763
  lines.push(` main frame: ${mainC} match(es) for "${selector}"`);
982
1764
  }
983
1765
 
1766
+ // App-side failures in this action's window (issue #44): a backend 500 on
1767
+ // the click's XHR is invisible in a screenshot and reads as a click miss.
1768
+ if (failedRequests.length) {
1769
+ lines.push(
1770
+ ` failed requests since the previous action: ${failedRequests.length} ` +
1771
+ `(an app-side write that failed reads as a click miss on screen)`
1772
+ );
1773
+ lines.push(...describeFailedRequests(failedRequests));
1774
+ } else {
1775
+ lines.push(` failed requests since the previous action: none`);
1776
+ }
1777
+
1778
+ // Drift suggestions: when the selector matched nothing, scan the page (and
1779
+ // the widget frames) for the elements that look most like what it asked
1780
+ // for, so the fix is one edit away instead of a round of guessing.
1781
+ let drift: DriftCandidate[] = [];
1782
+ let driftFile: string | null = null;
1783
+ const nothingMatched =
1784
+ !!selector && frameCounts.every((c) => c.matches <= 0) &&
1785
+ ((await page.locator(selector).count().catch(() => 0)) === 0);
1786
+ if (nothingMatched) {
1787
+ const scanned: DriftCandidate[] = [];
1788
+ const targets: Array<Page | Frame> = [page, ...roots];
1789
+ for (const t of targets) {
1790
+ const res = await scanInteractive(t, 120).catch(() => null);
1791
+ if (!res) continue;
1792
+ const frameTag = t === page ? undefined : truncateUrl((t as Frame).url());
1793
+ scanned.push(
1794
+ ...rankCandidates(selector!, res.elements, 8).map((c) =>
1795
+ frameTag ? { ...c, frame: frameTag } : c
1796
+ )
1797
+ );
1798
+ }
1799
+ drift = scanned.sort((a, b) => b.score - a.score).slice(0, 8);
1800
+ driftFile = join(logsDir, `drift-${sceneId}-${index + 1}.json`);
1801
+ await fs
1802
+ .writeFile(
1803
+ driftFile,
1804
+ JSON.stringify({ sceneId, actionIndex: index + 1, selector, url: page.url(), candidates: drift }, null, 2)
1805
+ )
1806
+ .catch(() => {});
1807
+ if (drift.length) {
1808
+ lines.push(` nearest elements to "${selector}" (drift suggestions → ${driftFile}):`);
1809
+ for (const c of drift.slice(0, 3)) {
1810
+ lines.push(
1811
+ ` ${c.role} "${c.name.slice(0, 40)}"${c.frame ? ` [frame ${c.frame}]` : ""} → ${c.selector || "(no unique selector)"} (score ${c.score})`
1812
+ );
1813
+ }
1814
+ } else {
1815
+ lines.push(` no similar interactive element on the page (drift file → ${driftFile}) — is this the right screen / state?`);
1816
+ }
1817
+ }
1818
+
984
1819
  const detail = {
985
1820
  sceneId,
986
1821
  actionIndex: index + 1,
@@ -989,6 +1824,8 @@ async function dumpDiagnostics(
989
1824
  url: page.url(),
990
1825
  widgetFrames: frameCounts,
991
1826
  allFrames: page.frames().map((f) => truncateUrl(f.url())),
1827
+ failedRequests,
1828
+ ...(driftFile ? { driftFile, driftCandidates: drift } : {}),
992
1829
  };
993
1830
  await fs.writeFile(`${stem}.json`, JSON.stringify(detail, null, 2)).catch(() => {});
994
1831
  lines.push(` detail → ${stem}.json`);
@@ -1063,6 +1900,112 @@ async function boxOf(page: Page, loc: Locator): Promise<{ cx: number; cy: number
1063
1900
  return { cx: box.x + box.width / 2, cy: box.y + box.height / 2 };
1064
1901
  }
1065
1902
 
1903
+ /** Full viewport rect of an element (after boxOf's bring-into-view nudge). */
1904
+ async function rectOf(
1905
+ page: Page,
1906
+ loc: Locator
1907
+ ): Promise<{ x: number; y: number; w: number; h: number }> {
1908
+ await boxOf(page, loc);
1909
+ const box = await loc.boundingBox();
1910
+ if (!box) throw new Error("Element has no bounding box (not visible?)");
1911
+ return {
1912
+ x: Math.round(box.x),
1913
+ y: Math.round(box.y),
1914
+ w: Math.round(box.width),
1915
+ h: Math.round(box.height),
1916
+ };
1917
+ }
1918
+
1919
+ /** CSS used for `hide` selectors (record-time, the one non-compose exception). */
1920
+ export function hideCss(selectors: string[]): string {
1921
+ return selectors.length
1922
+ ? `${selectors.join(",")}{visibility:hidden !important;}`
1923
+ : "";
1924
+ }
1925
+
1926
+ /** Install / replace the per-scene hide stylesheet in the main frame. */
1927
+ async function setSceneHide(page: Page, selectors: string[]): Promise<void> {
1928
+ const css = hideCss(selectors);
1929
+ await page
1930
+ .evaluate((text) => {
1931
+ const id = "__aidemo_scene_hide";
1932
+ let el = document.getElementById(id);
1933
+ if (!text) {
1934
+ el?.remove();
1935
+ return;
1936
+ }
1937
+ if (!el) {
1938
+ el = document.createElement("style");
1939
+ el.id = id;
1940
+ document.documentElement.appendChild(el);
1941
+ }
1942
+ el.textContent = text;
1943
+ }, css)
1944
+ .catch(() => {});
1945
+ }
1946
+
1947
+ /**
1948
+ * Re-measure every redact selector and update the open spans: a box that
1949
+ * moved or vanished closes its span; a new/moved box opens one. Cheap (one
1950
+ * boundingBox per match), runs after every action.
1951
+ */
1952
+ async function measureRedactions(
1953
+ page: Page,
1954
+ storyboard: Storyboard,
1955
+ list: Redact[],
1956
+ open: Map<string, RedactSpan>,
1957
+ capture: SceneCapture,
1958
+ now: () => number
1959
+ ): Promise<void> {
1960
+ const seen = new Set<string>();
1961
+ const t = now();
1962
+ for (const r of list) {
1963
+ let boxes: Array<{ x: number; y: number; width: number; height: number } | null> = [];
1964
+ try {
1965
+ const loc = await resolveTargetLocator(page, storyboard, {
1966
+ selector: r.selector,
1967
+ ...(r.frame ? { frame: r.frame } : {}),
1968
+ });
1969
+ const n = Math.min(await loc.count().catch(() => 0), REDACT_MAX_MATCHES);
1970
+ for (let i = 0; i < n; i++) {
1971
+ boxes.push(await loc.nth(i).boundingBox().catch(() => null));
1972
+ }
1973
+ } catch {
1974
+ boxes = [];
1975
+ }
1976
+ boxes.forEach((b, i) => {
1977
+ if (!b || b.width < 1 || b.height < 1) return;
1978
+ const key = `${r.frame ?? ""}>>${r.selector}#${i}`;
1979
+ seen.add(key);
1980
+ const rect = {
1981
+ x: Math.round(b.x),
1982
+ y: Math.round(b.y),
1983
+ w: Math.round(b.width),
1984
+ h: Math.round(b.height),
1985
+ };
1986
+ const cur = open.get(key);
1987
+ const same =
1988
+ cur &&
1989
+ Math.abs(cur.x - rect.x) <= 2 &&
1990
+ Math.abs(cur.y - rect.y) <= 2 &&
1991
+ Math.abs(cur.w - rect.w) <= 2 &&
1992
+ Math.abs(cur.h - rect.h) <= 2;
1993
+ if (same) return;
1994
+ if (cur) {
1995
+ cur.endMs = t;
1996
+ capture.redactSpans.push(cur);
1997
+ }
1998
+ open.set(key, { startMs: t, endMs: t, ...rect, blur: r.blur ?? 14 });
1999
+ });
2000
+ }
2001
+ for (const [key, span] of open) {
2002
+ if (seen.has(key)) continue;
2003
+ span.endMs = t;
2004
+ capture.redactSpans.push(span);
2005
+ open.delete(key);
2006
+ }
2007
+ }
2008
+
1066
2009
  /** Eased cursor glide via many small mouse.move steps. */
1067
2010
  async function moveMouseTo(
1068
2011
  page: Page,