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