@tandryukha/aidemo 0.14.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/AUTHORING.md +142 -20
- package/package.json +1 -1
- 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 +317 -15
- package/src/qa.ts +295 -0
- package/src/recorder.ts +65 -7
- package/src/safe-regex.ts +36 -0
- package/src/types.ts +155 -19
package/src/player.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { Page, Locator, Frame, Request, Response } from "playwright";
|
|
1
|
+
import type { Page, Locator, Frame, Request, Response, Dialog } from "playwright";
|
|
2
2
|
import { scanInteractive, rankCandidates, type DriftCandidate } from "./inspect.js";
|
|
3
|
+
import { compileUserRegex } from "./safe-regex.js";
|
|
3
4
|
import { promises as fs } from "node:fs";
|
|
4
5
|
import { basename, isAbsolute, join, resolve } from "node:path";
|
|
5
6
|
import { existsSync } from "node:fs";
|
|
@@ -83,6 +84,14 @@ export interface PlayerOptions {
|
|
|
83
84
|
* previous take's footage for them).
|
|
84
85
|
*/
|
|
85
86
|
replayUntil?: number;
|
|
87
|
+
/**
|
|
88
|
+
* With `replayUntil`: do NOT run the replayed scenes' actions at all — the
|
|
89
|
+
* app state is whatever the profile already holds. For flows that EARN
|
|
90
|
+
* one-shot state (a balance moved, a check-in was taken, a day was rolled
|
|
91
|
+
* forward by hand) a replay would fail or double-consume; the resumed scene
|
|
92
|
+
* must then open with its own `goto`.
|
|
93
|
+
*/
|
|
94
|
+
skipReplay?: boolean;
|
|
86
95
|
/** Called after a replayed (not recorded) scene finished. */
|
|
87
96
|
onSceneReplayed?: (sceneId: string, index: number, total: number) => void;
|
|
88
97
|
/** Demo directory — `upload` file paths resolve against it (default: cwd). */
|
|
@@ -121,10 +130,25 @@ interface SceneCapture {
|
|
|
121
130
|
keyEvents: KeyEvent[];
|
|
122
131
|
redactSpans: RedactSpan[];
|
|
123
132
|
anchorEvents: Array<{ name: string; tMs: number; action: number }>;
|
|
133
|
+
/** Armed one-shot native-dialog handlers awaiting their dialog (`dialog` op). */
|
|
134
|
+
pendingDialogs: PendingDialog[];
|
|
135
|
+
/** Warnings raised inside an action, drained into its timeline record. */
|
|
136
|
+
pendingWarnings: string[];
|
|
124
137
|
/** Replay-only scene (resume): skip the waits that exist for the camera. */
|
|
125
138
|
fast?: boolean;
|
|
126
139
|
}
|
|
127
140
|
|
|
141
|
+
/** A `dialog` action's armed handler, resolved when its dialog actually fires. */
|
|
142
|
+
interface PendingDialog {
|
|
143
|
+
/** 0-based index of the `dialog` action that armed it. */
|
|
144
|
+
index: number;
|
|
145
|
+
label: string;
|
|
146
|
+
required: boolean;
|
|
147
|
+
timeoutMs: number;
|
|
148
|
+
seen: Promise<string | null>;
|
|
149
|
+
disarm: () => void;
|
|
150
|
+
}
|
|
151
|
+
|
|
128
152
|
/** Record-time dwell for an attention beat (the hold itself is compose-time). */
|
|
129
153
|
const ATTENTION_DWELL_MAX_MS = 800;
|
|
130
154
|
const ATTENTION_HOLD_DEFAULT_MS = 1600;
|
|
@@ -322,6 +346,8 @@ export async function runStoryboard(
|
|
|
322
346
|
keyEvents: [],
|
|
323
347
|
redactSpans: [],
|
|
324
348
|
anchorEvents: [],
|
|
349
|
+
pendingDialogs: [],
|
|
350
|
+
pendingWarnings: [],
|
|
325
351
|
fast: opts.replayUntil != null && si < opts.replayUntil,
|
|
326
352
|
};
|
|
327
353
|
// Per-scene `hide` (top-level hides are injected by the recorder's init
|
|
@@ -349,6 +375,12 @@ export async function runStoryboard(
|
|
|
349
375
|
(capture.fast ? " (replay only — footage reused from the previous take)" : "")
|
|
350
376
|
);
|
|
351
377
|
opts.onSceneStart?.(scene.id, si, total);
|
|
378
|
+
if (capture.fast && opts.skipReplay) {
|
|
379
|
+
log(` --no-replay: actions skipped, app state left as the profile holds it`);
|
|
380
|
+
if (scene.hide?.length) await setSceneHide(page, []);
|
|
381
|
+
opts.onSceneReplayed?.(scene.id, si, total);
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
352
384
|
const probeOutcomes: ProbeActionOutcome[] = [];
|
|
353
385
|
|
|
354
386
|
for (let i = 0; i < scene.actions.length; i++) {
|
|
@@ -446,6 +478,12 @@ export async function runStoryboard(
|
|
|
446
478
|
});
|
|
447
479
|
}
|
|
448
480
|
}
|
|
481
|
+
if (action.op !== "dialog") {
|
|
482
|
+
recWarnings.push(...(await settlePendingDialogs(capture)));
|
|
483
|
+
}
|
|
484
|
+
if (capture.pendingWarnings.length) {
|
|
485
|
+
recWarnings.push(...capture.pendingWarnings.splice(0));
|
|
486
|
+
}
|
|
449
487
|
rec.endMs = now();
|
|
450
488
|
const failedInWindow = net.since(rec.startMs);
|
|
451
489
|
if (failedInWindow.length) {
|
|
@@ -479,6 +517,8 @@ export async function runStoryboard(
|
|
|
479
517
|
probeOutcomes.push(outcome);
|
|
480
518
|
}
|
|
481
519
|
}
|
|
520
|
+
// A `dialog` armed by the scene's LAST action still has to be accounted for.
|
|
521
|
+
await settlePendingDialogs(capture);
|
|
482
522
|
if (opts.probe) opts.probe.push({ id: scene.id, actions: probeOutcomes });
|
|
483
523
|
// Close every open redact span at the scene boundary and drop the scene hide.
|
|
484
524
|
for (const span of redactOpen.values()) {
|
|
@@ -700,7 +740,18 @@ async function countNestedMatches(page: Page, selector: string): Promise<number>
|
|
|
700
740
|
async function resolveTargetLocator(
|
|
701
741
|
page: Page,
|
|
702
742
|
storyboard: Storyboard,
|
|
703
|
-
target: Target
|
|
743
|
+
target: Target,
|
|
744
|
+
opts: {
|
|
745
|
+
/**
|
|
746
|
+
* Pick the first VISIBLE match instead of the first match in DOM order
|
|
747
|
+
* (issue #55): a page whose first `table` is a hidden template would
|
|
748
|
+
* otherwise time out waiting for an element that is never shown. Only
|
|
749
|
+
* honoured when the target does not pin a match with `last`/`nth`.
|
|
750
|
+
*/
|
|
751
|
+
preferVisible?: boolean;
|
|
752
|
+
/** Return the whole matching SET (no first/last/nth) — for counting. */
|
|
753
|
+
all?: boolean;
|
|
754
|
+
} = {}
|
|
704
755
|
): Promise<Locator> {
|
|
705
756
|
let selector = target.selector;
|
|
706
757
|
if (target.named) {
|
|
@@ -717,7 +768,10 @@ async function resolveTargetLocator(
|
|
|
717
768
|
// Frameless targets honor last/nth at the ELEMENT level (e.g. the newest
|
|
718
769
|
// assistant message); previously they silently collapsed to .first().
|
|
719
770
|
const loc = page.locator(selector);
|
|
720
|
-
|
|
771
|
+
if (opts.all) return loc;
|
|
772
|
+
if (target.last) return loc.last();
|
|
773
|
+
if (target.nth != null) return loc.nth(target.nth);
|
|
774
|
+
return opts.preferVisible ? loc.locator("visible=true").first() : loc.first();
|
|
721
775
|
}
|
|
722
776
|
const frameSelector = storyboard.frames[target.frame];
|
|
723
777
|
if (!frameSelector) {
|
|
@@ -739,7 +793,11 @@ async function resolveTargetLocator(
|
|
|
739
793
|
: target.nth != null
|
|
740
794
|
? frame.nth(target.nth)
|
|
741
795
|
: frame.first();
|
|
742
|
-
|
|
796
|
+
const inFrame = frame.locator(selector);
|
|
797
|
+
if (opts.all) return inFrame;
|
|
798
|
+
return opts.preferVisible && !target.last && target.nth == null
|
|
799
|
+
? inFrame.locator("visible=true").first()
|
|
800
|
+
: inFrame.first();
|
|
743
801
|
}
|
|
744
802
|
|
|
745
803
|
/** Human-readable target for error messages: `frame >> selector [last]`. */
|
|
@@ -765,9 +823,15 @@ async function waitForTargetVisible(
|
|
|
765
823
|
const started = Date.now();
|
|
766
824
|
const deadline = started + timeoutMs;
|
|
767
825
|
let lastErr: unknown;
|
|
826
|
+
// Half the budget on the exact first match (the old behavior, so a selector
|
|
827
|
+
// that resolves keeps resolving identically), then on the first VISIBLE match
|
|
828
|
+
// — the fix for a hidden template/print copy sitting ahead of the real one in
|
|
829
|
+
// DOM order (issue #55).
|
|
830
|
+
const flipAt = started + Math.min(timeoutMs / 2, 4000);
|
|
768
831
|
for (;;) {
|
|
832
|
+
const preferVisible = state === "visible" && Date.now() >= flipAt;
|
|
769
833
|
try {
|
|
770
|
-
const loc = await resolveTargetLocator(page, storyboard, target);
|
|
834
|
+
const loc = await resolveTargetLocator(page, storyboard, target, { preferVisible });
|
|
771
835
|
const remaining = deadline - Date.now();
|
|
772
836
|
await loc.waitFor({
|
|
773
837
|
state,
|
|
@@ -783,9 +847,14 @@ async function waitForTargetVisible(
|
|
|
783
847
|
const hint = /Timeout \d+ms exceeded/.test(reason)
|
|
784
848
|
? "" // Playwright's chunk timeout — nothing more to say than the budget
|
|
785
849
|
: ` (last error: ${reason})`;
|
|
850
|
+
const counts = await countMatches(page, storyboard, target).catch(() => null);
|
|
851
|
+
const census =
|
|
852
|
+
counts && counts.total > 0
|
|
853
|
+
? ` — ${counts.total} match(es) in the DOM, ${counts.visible} visible`
|
|
854
|
+
: "";
|
|
786
855
|
throw new Error(
|
|
787
856
|
`waitFor: "${describeTarget(storyboard, target)}" not ${state} after ` +
|
|
788
|
-
`${waited}ms (budget ${timeoutMs}ms)${hint}` +
|
|
857
|
+
`${waited}ms (budget ${timeoutMs}ms)${hint}${census}` +
|
|
789
858
|
(state === "visible"
|
|
790
859
|
? ` — if the element is a zero-size mount point that fills later, use state: "attached"`
|
|
791
860
|
: "")
|
|
@@ -795,6 +864,18 @@ async function waitForTargetVisible(
|
|
|
795
864
|
}
|
|
796
865
|
}
|
|
797
866
|
|
|
867
|
+
/** How many elements the target matches, and how many of those are visible. */
|
|
868
|
+
async function countMatches(
|
|
869
|
+
page: Page,
|
|
870
|
+
storyboard: Storyboard,
|
|
871
|
+
target: Target
|
|
872
|
+
): Promise<{ total: number; visible: number }> {
|
|
873
|
+
const base = await resolveTargetLocator(page, storyboard, target, { all: true });
|
|
874
|
+
const total = await base.count().catch(() => 0);
|
|
875
|
+
const visible = await base.locator("visible=true").count().catch(() => 0);
|
|
876
|
+
return { total, visible };
|
|
877
|
+
}
|
|
878
|
+
|
|
798
879
|
/**
|
|
799
880
|
* Wait until the scroll position has been still for `settleMs` (bounded at
|
|
800
881
|
* 3 s total) — an honest "scroll finished" for smooth-scrolling pages, where a
|
|
@@ -857,7 +938,7 @@ async function waitForNewWidget(
|
|
|
857
938
|
await waitForTargetVisible(page, storyboard, target, opts.timeoutMs);
|
|
858
939
|
return;
|
|
859
940
|
}
|
|
860
|
-
const re = opts.textMatches ?
|
|
941
|
+
const re = opts.textMatches ? compileUserRegex(opts.textMatches, "i", "textMatches") : null;
|
|
861
942
|
const deadline = Date.now() + opts.timeoutMs;
|
|
862
943
|
const baseline = await countNestedMatches(page, selector);
|
|
863
944
|
for (;;) {
|
|
@@ -922,7 +1003,7 @@ async function waitForNewReply(
|
|
|
922
1003
|
): Promise<void> {
|
|
923
1004
|
const messages = page.locator(opts.selector);
|
|
924
1005
|
const stop = page.locator(STOP_BUTTON_SELECTOR);
|
|
925
|
-
const re = opts.textMatches ?
|
|
1006
|
+
const re = opts.textMatches ? compileUserRegex(opts.textMatches, "i", "textMatches") : null;
|
|
926
1007
|
const baseline = await messages.count().catch(() => 0);
|
|
927
1008
|
let sawStreaming = (await stop.count().catch(() => 0)) > 0;
|
|
928
1009
|
const deadline = Date.now() + opts.timeoutMs;
|
|
@@ -1004,6 +1085,34 @@ async function optionalTargetAbsent(
|
|
|
1004
1085
|
* with a beat between attempts. Returns the number of extra attempts used.
|
|
1005
1086
|
* The last failure propagates so the normal fail/optional paths apply.
|
|
1006
1087
|
*/
|
|
1088
|
+
/**
|
|
1089
|
+
* Verify every armed `dialog` handler got its dialog (issue #54). Called after
|
|
1090
|
+
* the action that was expected to trigger it, and again at scene end. Returns
|
|
1091
|
+
* the warnings to attach; throws when a REQUIRED dialog never appeared, because
|
|
1092
|
+
* the filmed action then silently did nothing.
|
|
1093
|
+
*/
|
|
1094
|
+
async function settlePendingDialogs(capture: SceneCapture): Promise<string[]> {
|
|
1095
|
+
if (!capture.pendingDialogs.length) return [];
|
|
1096
|
+
const pending = capture.pendingDialogs.splice(0);
|
|
1097
|
+
const warnings: string[] = [];
|
|
1098
|
+
for (const p of pending) {
|
|
1099
|
+
const message = await Promise.race([
|
|
1100
|
+
p.seen,
|
|
1101
|
+
sleep(p.timeoutMs).then(() => undefined),
|
|
1102
|
+
]);
|
|
1103
|
+
if (typeof message === "string") continue;
|
|
1104
|
+
p.disarm();
|
|
1105
|
+
const why =
|
|
1106
|
+
`${p.label}: no native dialog appeared within ${p.timeoutMs}ms — the ` +
|
|
1107
|
+
`action after it did not open one (wrong action order, or the control ` +
|
|
1108
|
+
`no longer confirms)`;
|
|
1109
|
+
if (p.required) throw new Error(why);
|
|
1110
|
+
warnings.push(why);
|
|
1111
|
+
log(` ⚠ ${why}`);
|
|
1112
|
+
}
|
|
1113
|
+
return warnings;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1007
1116
|
async function runActionWithRetry(
|
|
1008
1117
|
page: Page,
|
|
1009
1118
|
storyboard: Storyboard,
|
|
@@ -1097,8 +1206,8 @@ async function runAction(
|
|
|
1097
1206
|
}
|
|
1098
1207
|
const timeoutMs = action.timeoutMs ?? 5000;
|
|
1099
1208
|
const deadline = Date.now() + timeoutMs;
|
|
1100
|
-
const textRe = action.textMatches ?
|
|
1101
|
-
const urlRe = action.url ?
|
|
1209
|
+
const textRe = action.textMatches ? compileUserRegex(action.textMatches, "", "assert.textMatches") : null;
|
|
1210
|
+
const urlRe = action.url ? compileUserRegex(action.url, "", "assert.url") : null;
|
|
1102
1211
|
let why = "";
|
|
1103
1212
|
if (action.target) {
|
|
1104
1213
|
// Visible first (honest total-budget error if it never shows up).
|
|
@@ -1165,6 +1274,46 @@ async function runAction(
|
|
|
1165
1274
|
await awaitGotoReadiness(page, capture, t0);
|
|
1166
1275
|
return;
|
|
1167
1276
|
|
|
1277
|
+
// Arm a one-shot answer for the NEXT native dialog (issue #54). Playwright
|
|
1278
|
+
// auto-dismisses dialogs when nothing listens, so a filmed click on a
|
|
1279
|
+
// confirm-guarded control would otherwise put a false outcome on camera.
|
|
1280
|
+
// The handler is verified after the action that follows: an armed dialog
|
|
1281
|
+
// that never fired is a failure unless `required: false`.
|
|
1282
|
+
case "dialog": {
|
|
1283
|
+
const mode = action.action ?? "accept";
|
|
1284
|
+
const label = `dialog ${mode}`;
|
|
1285
|
+
let settle: (message: string | null) => void = () => {};
|
|
1286
|
+
const seen = new Promise<string | null>((r) => {
|
|
1287
|
+
settle = r;
|
|
1288
|
+
});
|
|
1289
|
+
const handler = (dialog: Dialog) => {
|
|
1290
|
+
void (async () => {
|
|
1291
|
+
log(` ${dialog.type()}: "${dialog.message()}" → ${mode}`);
|
|
1292
|
+
if (action.holdMs) await sleep(action.holdMs);
|
|
1293
|
+
try {
|
|
1294
|
+
if (mode === "accept") await dialog.accept(action.promptText);
|
|
1295
|
+
else await dialog.dismiss();
|
|
1296
|
+
} catch (err) {
|
|
1297
|
+
log(` ! answering the dialog failed — ${firstLine(err)}`);
|
|
1298
|
+
}
|
|
1299
|
+
settle(dialog.message());
|
|
1300
|
+
})();
|
|
1301
|
+
};
|
|
1302
|
+
page.once("dialog", handler);
|
|
1303
|
+
capture.pendingDialogs.push({
|
|
1304
|
+
index: capture.actions.length,
|
|
1305
|
+
label,
|
|
1306
|
+
required: action.required !== false,
|
|
1307
|
+
timeoutMs: action.timeoutMs ?? 5000,
|
|
1308
|
+
seen,
|
|
1309
|
+
disarm: () => {
|
|
1310
|
+
page.off("dialog", handler);
|
|
1311
|
+
settle(null);
|
|
1312
|
+
},
|
|
1313
|
+
});
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1168
1317
|
case "select": {
|
|
1169
1318
|
if (action.value == null && action.label == null) {
|
|
1170
1319
|
throw new Error("select needs a value or a label");
|
|
@@ -1281,8 +1430,18 @@ async function runAction(
|
|
|
1281
1430
|
case "highlight":
|
|
1282
1431
|
case "spotlight":
|
|
1283
1432
|
case "callout": {
|
|
1284
|
-
const loc = await resolveTargetLocator(page, storyboard, action.target
|
|
1285
|
-
|
|
1433
|
+
const loc = await resolveTargetLocator(page, storyboard, action.target, {
|
|
1434
|
+
preferVisible: true,
|
|
1435
|
+
});
|
|
1436
|
+
// #56: the overlay is drawn at compose time from THIS rect, so it has to
|
|
1437
|
+
// be the rect the element actually occupies while the overlay is on
|
|
1438
|
+
// screen. Lazy content loading in above the target shifts it after the
|
|
1439
|
+
// measure with the scroll offset unchanged — invisible to the scroll
|
|
1440
|
+
// settle — and the outline then lands on whatever moved into its place.
|
|
1441
|
+
const rect = await settledRectOf(page, loc, capture.fast ? 0 : 400);
|
|
1442
|
+
if (storyboard.attention?.cursorClear && !capture.fast) {
|
|
1443
|
+
await clearCursorOf(page, mouse, rect, sample);
|
|
1444
|
+
}
|
|
1286
1445
|
const holdMs =
|
|
1287
1446
|
action.holdMs ??
|
|
1288
1447
|
(action.op === "callout" ? CALLOUT_HOLD_DEFAULT_MS : ATTENTION_HOLD_DEFAULT_MS);
|
|
@@ -1306,13 +1465,65 @@ async function runAction(
|
|
|
1306
1465
|
}
|
|
1307
1466
|
capture.attentionEvents.push(ev);
|
|
1308
1467
|
if (!capture.fast) await sleep(Math.min(holdMs, ATTENTION_DWELL_MAX_MS));
|
|
1468
|
+
// Re-measure once the dwell is over: if it moved, the overlay we just
|
|
1469
|
+
// logged would be drawn over the wrong pixels. Prefer the later rect
|
|
1470
|
+
// (that is where the element sits for most of the hold) and say so.
|
|
1471
|
+
const after = await rectOf(page, loc).catch(() => null);
|
|
1472
|
+
if (after) {
|
|
1473
|
+
const moved = Math.max(Math.abs(after.x - rect.x), Math.abs(after.y - rect.y));
|
|
1474
|
+
if (moved > ATTENTION_DRIFT_TOLERANCE_PX) {
|
|
1475
|
+
ev.x = after.x;
|
|
1476
|
+
ev.y = after.y;
|
|
1477
|
+
ev.w = after.w;
|
|
1478
|
+
ev.h = after.h;
|
|
1479
|
+
const warning =
|
|
1480
|
+
`${action.op} target moved ${moved}px after the measure (layout ` +
|
|
1481
|
+
`settled late — lazy content above it?); used the later rect`;
|
|
1482
|
+
capture.pendingWarnings.push(warning);
|
|
1483
|
+
log(` ⚠ ${warning}`);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
const box = after ?? rect;
|
|
1487
|
+
const vp = page.viewportSize();
|
|
1488
|
+
if (vp && (box.y + box.h <= 0 || box.y >= vp.height || box.x + box.w <= 0 || box.x >= vp.width)) {
|
|
1489
|
+
const warning =
|
|
1490
|
+
`${action.op} target is OFF SCREEN at draw time ` +
|
|
1491
|
+
`(${box.x},${box.y} ${box.w}x${box.h} in a ${vp.width}x${vp.height} viewport) — ` +
|
|
1492
|
+
`the overlay will be drawn outside the frame; scroll it into view first`;
|
|
1493
|
+
capture.pendingWarnings.push(warning);
|
|
1494
|
+
log(` ⚠ ${warning}`);
|
|
1495
|
+
}
|
|
1309
1496
|
return;
|
|
1310
1497
|
}
|
|
1311
1498
|
|
|
1312
1499
|
case "hover": {
|
|
1313
1500
|
const loc = await resolveTargetLocator(page, storyboard, action.target);
|
|
1314
1501
|
const box = await boxOf(page, loc);
|
|
1315
|
-
|
|
1502
|
+
let x = box.cx;
|
|
1503
|
+
let y = box.cy;
|
|
1504
|
+
// Park off the element when asked (issue #50): a pointer on the centre
|
|
1505
|
+
// of a 30-px chip hides the very number the narration is about.
|
|
1506
|
+
if (action.anchor && action.anchor !== "center") {
|
|
1507
|
+
const rect = await rectOf(page, loc).catch(() => null);
|
|
1508
|
+
if (rect) {
|
|
1509
|
+
const pad = 10;
|
|
1510
|
+
const side =
|
|
1511
|
+
action.anchor === "edge"
|
|
1512
|
+
? rect.y > (page.viewportSize()?.height ?? 0) / 2
|
|
1513
|
+
? "top"
|
|
1514
|
+
: "bottom"
|
|
1515
|
+
: action.anchor;
|
|
1516
|
+
if (side === "top") y = rect.y - pad;
|
|
1517
|
+
else if (side === "bottom") y = rect.y + rect.h + pad;
|
|
1518
|
+
else if (side === "left") x = rect.x - pad;
|
|
1519
|
+
else if (side === "right") x = rect.x + rect.w + pad;
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
if (action.offset) {
|
|
1523
|
+
x += action.offset.x;
|
|
1524
|
+
y += action.offset.y;
|
|
1525
|
+
}
|
|
1526
|
+
await moveMouseTo(page, mouse, x, y, sample);
|
|
1316
1527
|
await sleep(300);
|
|
1317
1528
|
return;
|
|
1318
1529
|
}
|
|
@@ -1658,7 +1869,7 @@ async function waitForChange(
|
|
|
1658
1869
|
target: Target,
|
|
1659
1870
|
opts: { textMatches?: string; timeoutMs: number }
|
|
1660
1871
|
): Promise<void> {
|
|
1661
|
-
const re = opts.textMatches ?
|
|
1872
|
+
const re = opts.textMatches ? compileUserRegex(opts.textMatches, "i", "textMatches") : null;
|
|
1662
1873
|
const base = await signatureOf(page, storyboard, target);
|
|
1663
1874
|
const deadline = Date.now() + opts.timeoutMs;
|
|
1664
1875
|
for (;;) {
|
|
@@ -1893,7 +2104,26 @@ async function boxOf(page: Page, loc: Locator): Promise<{ cx: number; cy: number
|
|
|
1893
2104
|
for (let i = 0; i < 3 && box && vp; i++) {
|
|
1894
2105
|
const cy = box.y + box.height / 2;
|
|
1895
2106
|
if (cy >= 80 && cy <= vp.height - 120) break;
|
|
1896
|
-
|
|
2107
|
+
const dy = cy - vp.height * 0.55;
|
|
2108
|
+
// A sticky/fixed element (a header chip) never moves with the scroller,
|
|
2109
|
+
// and a page already at its top/bottom cannot scroll that way — wheeling
|
|
2110
|
+
// anyway only rubber-bands the whole page (a 1 Hz ~6 px bob on the take,
|
|
2111
|
+
// three bounces per hover/click on a header target; loyalty v7 take 1).
|
|
2112
|
+
const canNudge = await loc
|
|
2113
|
+
.evaluate((el, d) => {
|
|
2114
|
+
let n: Element | null = el;
|
|
2115
|
+
while (n && n !== document.body) {
|
|
2116
|
+
const pos = getComputedStyle(n).position;
|
|
2117
|
+
if (pos === "fixed" || pos === "sticky") return false;
|
|
2118
|
+
n = n.parentElement;
|
|
2119
|
+
}
|
|
2120
|
+
const se = document.scrollingElement ?? document.documentElement;
|
|
2121
|
+
const max = se.scrollHeight - se.clientHeight;
|
|
2122
|
+
return d < 0 ? se.scrollTop > 0 : se.scrollTop < max - 1;
|
|
2123
|
+
}, dy)
|
|
2124
|
+
.catch(() => true);
|
|
2125
|
+
if (!canNudge) break;
|
|
2126
|
+
await easedWheel(page, dy, { durationMs: 500 });
|
|
1897
2127
|
box = await loc.boundingBox();
|
|
1898
2128
|
}
|
|
1899
2129
|
if (!box) throw new Error("Element has no bounding box (not visible?)");
|
|
@@ -1916,6 +2146,78 @@ async function rectOf(
|
|
|
1916
2146
|
};
|
|
1917
2147
|
}
|
|
1918
2148
|
|
|
2149
|
+
/**
|
|
2150
|
+
* Glide the pointer just outside `rect` so it does not sit on top of what the
|
|
2151
|
+
* beat is showcasing (`attention.cursorClear`, issue #50). Picks the nearest
|
|
2152
|
+
* side with room in the viewport; a no-op when the cursor is already clear.
|
|
2153
|
+
*/
|
|
2154
|
+
async function clearCursorOf(
|
|
2155
|
+
page: Page,
|
|
2156
|
+
mouse: MouseState,
|
|
2157
|
+
rect: { x: number; y: number; w: number; h: number },
|
|
2158
|
+
sample?: CursorSampler
|
|
2159
|
+
): Promise<void> {
|
|
2160
|
+
const pad = 18;
|
|
2161
|
+
const inside =
|
|
2162
|
+
mouse.x >= rect.x - 2 &&
|
|
2163
|
+
mouse.x <= rect.x + rect.w + 2 &&
|
|
2164
|
+
mouse.y >= rect.y - 2 &&
|
|
2165
|
+
mouse.y <= rect.y + rect.h + 2;
|
|
2166
|
+
if (!inside) return;
|
|
2167
|
+
const vp = page.viewportSize() ?? { width: 1280, height: 720 };
|
|
2168
|
+
const below = rect.y + rect.h + pad;
|
|
2169
|
+
const above = rect.y - pad;
|
|
2170
|
+
const right = rect.x + rect.w + pad;
|
|
2171
|
+
const left = rect.x - pad;
|
|
2172
|
+
let x = mouse.x;
|
|
2173
|
+
let y = mouse.y;
|
|
2174
|
+
if (below < vp.height - 8) y = below;
|
|
2175
|
+
else if (above > 8) y = above;
|
|
2176
|
+
else if (right < vp.width - 8) x = right;
|
|
2177
|
+
else if (left > 8) x = left;
|
|
2178
|
+
else return;
|
|
2179
|
+
await moveMouseTo(page, mouse, x, y, sample);
|
|
2180
|
+
await sleep(120);
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
/** How far an attention target may shift after its measure before we re-aim. */
|
|
2184
|
+
const ATTENTION_DRIFT_TOLERANCE_PX = 24;
|
|
2185
|
+
|
|
2186
|
+
/**
|
|
2187
|
+
* `rectOf`, but only once the element's box has stopped moving for `quietMs`
|
|
2188
|
+
* (bounded at 2 s). Unlike `waitScrollSettled` this watches the RECT, so a
|
|
2189
|
+
* layout shift at an unchanged scroll offset — lazily-loaded content inserted
|
|
2190
|
+
* above the target — is caught (issue #56).
|
|
2191
|
+
*/
|
|
2192
|
+
async function settledRectOf(
|
|
2193
|
+
page: Page,
|
|
2194
|
+
loc: Locator,
|
|
2195
|
+
quietMs: number
|
|
2196
|
+
): Promise<{ x: number; y: number; w: number; h: number }> {
|
|
2197
|
+
if (quietMs <= 0) return rectOf(page, loc);
|
|
2198
|
+
const deadline = Date.now() + 2000;
|
|
2199
|
+
let last = await rectOf(page, loc);
|
|
2200
|
+
let stillSince = Date.now();
|
|
2201
|
+
while (Date.now() < deadline) {
|
|
2202
|
+
await sleep(80);
|
|
2203
|
+
const cur = await rectOf(page, loc).catch(() => null);
|
|
2204
|
+
if (!cur) continue;
|
|
2205
|
+
if (
|
|
2206
|
+
Math.abs(cur.x - last.x) > 1 ||
|
|
2207
|
+
Math.abs(cur.y - last.y) > 1 ||
|
|
2208
|
+
Math.abs(cur.w - last.w) > 1 ||
|
|
2209
|
+
Math.abs(cur.h - last.h) > 1
|
|
2210
|
+
) {
|
|
2211
|
+
last = cur;
|
|
2212
|
+
stillSince = Date.now();
|
|
2213
|
+
continue;
|
|
2214
|
+
}
|
|
2215
|
+
last = cur;
|
|
2216
|
+
if (Date.now() - stillSince >= quietMs) break;
|
|
2217
|
+
}
|
|
2218
|
+
return last;
|
|
2219
|
+
}
|
|
2220
|
+
|
|
1919
2221
|
/** CSS used for `hide` selectors (record-time, the one non-compose exception). */
|
|
1920
2222
|
export function hideCss(selectors: string[]): string {
|
|
1921
2223
|
return selectors.length
|