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