@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/.claude/skills/record-demo/SKILL.md +5 -2
- package/README.md +16 -4
- package/docs/AUTHORING.md +609 -45
- 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 +70 -6
- package/src/capture.ts +113 -0
- package/src/cards.ts +15 -6
- package/src/cli.ts +580 -22
- package/src/compose.ts +1006 -93
- package/src/cursor-overlay.ts +7 -113
- package/src/cursor.ts +9 -3
- package/src/distribute.ts +37 -4
- package/src/expr.ts +130 -0
- package/src/ffmpeg.ts +156 -0
- package/src/frame.ts +205 -0
- package/src/frames.ts +159 -0
- package/src/golden.ts +27 -0
- package/src/guide.ts +102 -0
- package/src/i18n.ts +7 -4
- package/src/import-trace.ts +629 -0
- package/src/inspect.ts +307 -0
- package/src/lint.ts +601 -0
- package/src/login.ts +143 -0
- package/src/mcp/jobs.ts +4 -1
- package/src/mcp/server.ts +403 -26
- package/src/player.ts +975 -32
- package/src/profile.ts +128 -0
- package/src/project.ts +12 -2
- package/src/recorder.ts +252 -15
- package/src/setup.ts +248 -0
- package/src/starter.ts +244 -0
- package/src/stills.ts +15 -4
- package/src/types.ts +643 -5
- 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,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(
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
419
|
-
*
|
|
420
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
|
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
|
-
|
|
691
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
837
|
-
|
|
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(
|
|
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}: ${
|
|
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 =
|
|
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,
|