@tandryukha/aidemo 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/record-demo/SKILL.md +66 -0
- package/LICENSE +21 -0
- package/README.md +292 -0
- package/bin/aidemo.mjs +53 -0
- package/docs/AUTHORING.md +728 -0
- package/package.json +47 -0
- package/src/caption-render.ts +69 -0
- package/src/captions.ts +441 -0
- package/src/capture.ts +574 -0
- package/src/cards.ts +67 -0
- package/src/cli.ts +646 -0
- package/src/compose.ts +937 -0
- package/src/config.ts +222 -0
- package/src/cursor-overlay.ts +186 -0
- package/src/cursor.ts +134 -0
- package/src/distribute.ts +713 -0
- package/src/embed.ts +202 -0
- package/src/ffmpeg.ts +83 -0
- package/src/gif.ts +65 -0
- package/src/golden.ts +109 -0
- package/src/i18n.ts +86 -0
- package/src/mcp/jobs.ts +285 -0
- package/src/mcp/server.ts +1016 -0
- package/src/music.ts +153 -0
- package/src/params.ts +134 -0
- package/src/player.ts +1173 -0
- package/src/project.ts +287 -0
- package/src/recorder.ts +320 -0
- package/src/starter.ts +141 -0
- package/src/stills.ts +121 -0
- package/src/types.ts +660 -0
- package/src/util.ts +171 -0
- package/src/variants.ts +117 -0
- package/src/voice-local.ts +230 -0
- package/src/voice.ts +405 -0
- package/src/zoom.ts +183 -0
package/src/player.ts
ADDED
|
@@ -0,0 +1,1173 @@
|
|
|
1
|
+
import type { Page, Locator, Frame } from "playwright";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type {
|
|
5
|
+
Storyboard,
|
|
6
|
+
Scene,
|
|
7
|
+
Action,
|
|
8
|
+
Target,
|
|
9
|
+
Timeline,
|
|
10
|
+
TimelineScene,
|
|
11
|
+
IdleSpan,
|
|
12
|
+
FocusEvent,
|
|
13
|
+
StillEvent,
|
|
14
|
+
CursorSample,
|
|
15
|
+
EasingPreset,
|
|
16
|
+
ProbeActionOutcome,
|
|
17
|
+
ProbeGoldenScene,
|
|
18
|
+
} from "./types.js";
|
|
19
|
+
import {
|
|
20
|
+
easeInOutCubic,
|
|
21
|
+
easeOutCubic,
|
|
22
|
+
easeInOutSine,
|
|
23
|
+
sleep,
|
|
24
|
+
log,
|
|
25
|
+
ensureDir,
|
|
26
|
+
CanceledError,
|
|
27
|
+
} from "./util.js";
|
|
28
|
+
|
|
29
|
+
/** Semantic targets the player resolves without a raw selector. */
|
|
30
|
+
const NAMED_SELECTORS: Record<string, string> = {
|
|
31
|
+
// ChatGPT prompt box — the contenteditable with id="prompt-textarea".
|
|
32
|
+
// NOTE: keep this a *single, visible* match. The old broad union added
|
|
33
|
+
// `form textarea`, which matched a HIDDEN fallback textarea that sorted
|
|
34
|
+
// first in the DOM; `.first()` then picked the hidden node and every click
|
|
35
|
+
// timed out. `#prompt-textarea` is unique and visible (confirmed live).
|
|
36
|
+
composer: "#prompt-textarea",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
interface MouseState {
|
|
40
|
+
x: number;
|
|
41
|
+
y: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Scroll easing presets — duration + curve for the eased wheel scroll. The old
|
|
46
|
+
* behavior was 8 chunky wheel steps; these run ~60Hz micro-deltas along an
|
|
47
|
+
* easing curve so scrolls read as intentional camera moves.
|
|
48
|
+
*/
|
|
49
|
+
const SCROLL_PRESETS: Record<EasingPreset, { ms: number; fn: (t: number) => number }> = {
|
|
50
|
+
smooth: { ms: 850, fn: easeInOutCubic },
|
|
51
|
+
snappy: { ms: 450, fn: easeOutCubic },
|
|
52
|
+
glide: { ms: 1500, fn: easeInOutSine },
|
|
53
|
+
linear: { ms: 700, fn: (t) => t },
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export interface PlayerOptions {
|
|
57
|
+
/** Epoch ms captured at record start; timeline offsets are relative to it. */
|
|
58
|
+
t0: number;
|
|
59
|
+
video: { width: number; height: number };
|
|
60
|
+
/** Where to write failure diagnostics (screenshot + frame dump). */
|
|
61
|
+
logsDir?: string;
|
|
62
|
+
/**
|
|
63
|
+
* Called after each scene finishes so the recorder can salvage a partial
|
|
64
|
+
* timeline if a later scene fails (a late failure otherwise discards minutes
|
|
65
|
+
* of good recording).
|
|
66
|
+
*/
|
|
67
|
+
onSceneComplete?: (scene: TimelineScene, index: number, total: number) => void;
|
|
68
|
+
/** Called when a scene's first action is about to run (job progress). */
|
|
69
|
+
onSceneStart?: (sceneId: string, index: number, total: number) => void;
|
|
70
|
+
/**
|
|
71
|
+
* Best-effort cancellation, checked before every action. Aborting mid-scene
|
|
72
|
+
* throws CanceledError, which rides the recorder's salvage path (partial
|
|
73
|
+
* timeline + kept footage).
|
|
74
|
+
*/
|
|
75
|
+
signal?: AbortSignal;
|
|
76
|
+
/**
|
|
77
|
+
* Golden-probe capture. When present, the player records a normalized,
|
|
78
|
+
* timing-free outcome per action (op, resolved target, ok, found, goto final
|
|
79
|
+
* URL) into this array — and does NOT abort the run on an action failure
|
|
80
|
+
* (records ok:false and continues), so a broken selector yields a complete
|
|
81
|
+
* diffable projection rather than a truncated one. Absent → unchanged behavior
|
|
82
|
+
* (a failing action throws via failAction). See src/golden.ts.
|
|
83
|
+
*/
|
|
84
|
+
probe?: ProbeGoldenScene[];
|
|
85
|
+
/**
|
|
86
|
+
* Log the cursor's glide path into each scene's `cursorSamples` (for the
|
|
87
|
+
* compose-time cursor overlay). On only when the storyboard opts into `cursor`
|
|
88
|
+
* control; off by default so a plain take's timeline.json is unchanged.
|
|
89
|
+
*/
|
|
90
|
+
captureCursorPath?: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Per-scene mutable capture state threaded through actions. */
|
|
94
|
+
interface SceneCapture {
|
|
95
|
+
idleSpans: IdleSpan[];
|
|
96
|
+
focusEvents: FocusEvent[];
|
|
97
|
+
stillEvents: StillEvent[];
|
|
98
|
+
cursorSamples: CursorSample[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Records a cursor position into the current scene (compose-cursor mode only). */
|
|
102
|
+
type CursorSampler = (x: number, y: number) => void;
|
|
103
|
+
|
|
104
|
+
export async function runStoryboard(
|
|
105
|
+
page: Page,
|
|
106
|
+
storyboard: Storyboard,
|
|
107
|
+
opts: PlayerOptions
|
|
108
|
+
): Promise<Timeline> {
|
|
109
|
+
const mouse: MouseState = {
|
|
110
|
+
x: Math.round(opts.video.width / 2),
|
|
111
|
+
y: Math.round(opts.video.height / 2),
|
|
112
|
+
};
|
|
113
|
+
// Establish the cursor position so the overlay appears from the start.
|
|
114
|
+
await page.mouse.move(mouse.x, mouse.y);
|
|
115
|
+
|
|
116
|
+
const now = () => Date.now() - opts.t0;
|
|
117
|
+
const scenes: TimelineScene[] = [];
|
|
118
|
+
|
|
119
|
+
const total = storyboard.scenes.length;
|
|
120
|
+
for (let si = 0; si < total; si++) {
|
|
121
|
+
const scene = storyboard.scenes[si];
|
|
122
|
+
const startMs = now();
|
|
123
|
+
const capture: SceneCapture = {
|
|
124
|
+
idleSpans: [],
|
|
125
|
+
focusEvents: [],
|
|
126
|
+
stillEvents: [],
|
|
127
|
+
cursorSamples: [],
|
|
128
|
+
};
|
|
129
|
+
// Cursor path sampler — only records when the storyboard opts into the
|
|
130
|
+
// compose-time cursor overlay, so a plain take's timeline stays unchanged.
|
|
131
|
+
const sample: CursorSampler | undefined = opts.captureCursorPath
|
|
132
|
+
? (x, y) =>
|
|
133
|
+
capture.cursorSamples.push({
|
|
134
|
+
tMs: now(),
|
|
135
|
+
x: Math.round(x),
|
|
136
|
+
y: Math.round(y),
|
|
137
|
+
})
|
|
138
|
+
: undefined;
|
|
139
|
+
log(`scene ${scene.id}: ${scene.actions.length} action(s)`);
|
|
140
|
+
opts.onSceneStart?.(scene.id, si, total);
|
|
141
|
+
const probeOutcomes: ProbeActionOutcome[] = [];
|
|
142
|
+
|
|
143
|
+
for (let i = 0; i < scene.actions.length; i++) {
|
|
144
|
+
if (opts.signal?.aborted)
|
|
145
|
+
throw new CanceledError(`canceled during scene ${scene.id}`);
|
|
146
|
+
const action = scene.actions[i];
|
|
147
|
+
const outcome = opts.probe ? initProbeOutcome(storyboard, action) : null;
|
|
148
|
+
try {
|
|
149
|
+
await runAction(page, storyboard, action, mouse, capture, opts, sample);
|
|
150
|
+
if (outcome) outcome.ok = true;
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if (outcome) {
|
|
153
|
+
// Golden probe: record the failure and keep going, so a broken
|
|
154
|
+
// selector shows up as a single flipped field in the diff instead of
|
|
155
|
+
// aborting the whole projection.
|
|
156
|
+
outcome.ok = false;
|
|
157
|
+
log(
|
|
158
|
+
` ✗ probe: ${action.op} failed — ${firstLine(err)}`
|
|
159
|
+
);
|
|
160
|
+
} else {
|
|
161
|
+
// Name the failing scene/action, screenshot the page, and dump the
|
|
162
|
+
// widget frames present — so a phantom click or a platform
|
|
163
|
+
// interruption is diagnosable from the log instead of by
|
|
164
|
+
// hand-extracting webm frames.
|
|
165
|
+
await failAction(page, storyboard, scene, i, action, opts, err);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (outcome) {
|
|
169
|
+
await enrichProbeOutcome(page, storyboard, action, outcome);
|
|
170
|
+
probeOutcomes.push(outcome);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (opts.probe) opts.probe.push({ id: scene.id, actions: probeOutcomes });
|
|
174
|
+
|
|
175
|
+
const tlScene: TimelineScene = {
|
|
176
|
+
id: scene.id,
|
|
177
|
+
startMs,
|
|
178
|
+
endMs: now(),
|
|
179
|
+
idleSpans: capture.idleSpans,
|
|
180
|
+
focusEvents: capture.focusEvents,
|
|
181
|
+
stillEvents: capture.stillEvents,
|
|
182
|
+
cursorSamples: capture.cursorSamples,
|
|
183
|
+
};
|
|
184
|
+
scenes.push(tlScene);
|
|
185
|
+
opts.onSceneComplete?.(tlScene, si, total);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// leadInMs is filled in by the recorder (it knows the video start).
|
|
189
|
+
return { totalMs: now(), leadInMs: 0, scenes };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* A frame whose declared selector points at a *.web-sandbox.oaiusercontent.com
|
|
194
|
+
* iframe is a ChatGPT Apps SDK widget. Those render their real markup inside a
|
|
195
|
+
* NESTED `<iframe name="root">` (about:blank) within the sandbox — a single-
|
|
196
|
+
* level frameLocator only reaches the empty sandbox wrapper. When the frame
|
|
197
|
+
* selector opts into this (mentions oaiusercontent), we descend to the nested
|
|
198
|
+
* content frame.
|
|
199
|
+
*/
|
|
200
|
+
const NESTED_WIDGET_RE = /oaiusercontent\.com/i;
|
|
201
|
+
|
|
202
|
+
/** Nested widget "root" frames (children of a ChatGPT widget sandbox). */
|
|
203
|
+
function widgetRootFrames(page: Page): Frame[] {
|
|
204
|
+
return page.frames().filter((f) => {
|
|
205
|
+
const parent = f.parentFrame();
|
|
206
|
+
return !!parent && NESTED_WIDGET_RE.test(parent.url());
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Pick the nested widget frame that actually CONTAINS `selector`, honoring
|
|
212
|
+
* last/nth. ChatGPT renders two sandbox iframes per widget (one populated, one
|
|
213
|
+
* an empty duplicate) and one sandbox per tool call across a conversation;
|
|
214
|
+
* filtering to frames that contain the selector both skips the empty duplicate
|
|
215
|
+
* and lets `last` mean "newest widget that has this element". Returns null when
|
|
216
|
+
* no content frame is ready yet (callers retry).
|
|
217
|
+
*/
|
|
218
|
+
async function pickNestedFrame(
|
|
219
|
+
page: Page,
|
|
220
|
+
selector: string,
|
|
221
|
+
target: Target
|
|
222
|
+
): Promise<Frame | null> {
|
|
223
|
+
const matching: Frame[] = [];
|
|
224
|
+
for (const f of widgetRootFrames(page)) {
|
|
225
|
+
if (f.isDetached()) continue;
|
|
226
|
+
const count = await f.locator(selector).count().catch(() => 0);
|
|
227
|
+
if (count > 0) matching.push(f);
|
|
228
|
+
}
|
|
229
|
+
if (matching.length === 0) return null;
|
|
230
|
+
const chosen = target.last
|
|
231
|
+
? matching[matching.length - 1]
|
|
232
|
+
: target.nth != null
|
|
233
|
+
? matching[target.nth]
|
|
234
|
+
: matching[0];
|
|
235
|
+
return chosen ?? null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function pickNestedLocator(
|
|
239
|
+
page: Page,
|
|
240
|
+
selector: string,
|
|
241
|
+
target: Target
|
|
242
|
+
): Promise<Locator | null> {
|
|
243
|
+
const frame = await pickNestedFrame(page, selector, target);
|
|
244
|
+
return frame ? frame.locator(selector).first() : null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// Interruption handlers — ChatGPT sometimes injects its own UI between the
|
|
249
|
+
// storyboard and the page (an A/B "which response do you prefer?" eval screen,
|
|
250
|
+
// a consent wall). Unlike the scroll-to-bottom arrow it can't be scrolled away,
|
|
251
|
+
// so a take dies at the next waitForWidget. These best-effort handlers detect
|
|
252
|
+
// and try to clear known interruptions before composer typing and during the
|
|
253
|
+
// widget wait. They NEVER throw; if one can't self-resolve, the scene still
|
|
254
|
+
// fails — but with a named error + screenshot (see failAction), so the operator
|
|
255
|
+
// sees it was platform roulette, not a storyboard bug.
|
|
256
|
+
//
|
|
257
|
+
// Selectors here track live ChatGPT and may drift; keep them permissive
|
|
258
|
+
// (text/role based) and treat non-resolution as acceptable.
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
interface Interruption {
|
|
261
|
+
name: string;
|
|
262
|
+
detect: (page: Page) => Promise<boolean>;
|
|
263
|
+
/** Try to clear it; return true if an action was taken. */
|
|
264
|
+
resolve: (page: Page) => Promise<boolean>;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function visible(loc: Locator): Promise<boolean> {
|
|
268
|
+
return (
|
|
269
|
+
(await loc.count().catch(() => 0)) > 0 &&
|
|
270
|
+
(await loc.first().isVisible().catch(() => false))
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const INTERRUPTIONS: Interruption[] = [
|
|
275
|
+
{
|
|
276
|
+
name: "A/B response eval",
|
|
277
|
+
detect: (page) => visible(page.getByText(/which response do you prefer/i)),
|
|
278
|
+
resolve: async (page) => {
|
|
279
|
+
// Prefer a neutral dismissal; only pick a candidate as a last resort so
|
|
280
|
+
// the turn unblocks. (Picking a response may still land on one that
|
|
281
|
+
// doesn't call the app — this is genuinely roulette.)
|
|
282
|
+
for (const name of [/skip/i, /dismiss/i, /prefer this response/i]) {
|
|
283
|
+
const b = page.getByRole("button", { name });
|
|
284
|
+
if (await visible(b)) {
|
|
285
|
+
await b.first().click({ timeout: 2000 }).catch(() => {});
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return false;
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
name: "cookie/consent wall",
|
|
294
|
+
detect: (page) =>
|
|
295
|
+
visible(page.getByRole("button", { name: /accept all|accept cookies|i agree/i })),
|
|
296
|
+
resolve: async (page) => {
|
|
297
|
+
const b = page.getByRole("button", { name: /accept all|accept cookies|i agree/i });
|
|
298
|
+
await b.first().click({ timeout: 2000 }).catch(() => {});
|
|
299
|
+
return true;
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
];
|
|
303
|
+
|
|
304
|
+
/** Warn-once dedupe so a still-blocked interruption doesn't spam the wait loop. */
|
|
305
|
+
const warnedInterruptions = new Set<string>();
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Detect + best-effort clear known ChatGPT interruptions. Gated to chatgpt.com
|
|
309
|
+
* so it never clicks stray "Accept" buttons on other pages. Best-effort — all
|
|
310
|
+
* failures are swallowed.
|
|
311
|
+
*/
|
|
312
|
+
async function resolveInterruptions(page: Page): Promise<void> {
|
|
313
|
+
if (!/chatgpt\.com|chat\.openai\.com/.test(page.url())) return;
|
|
314
|
+
for (const it of INTERRUPTIONS) {
|
|
315
|
+
let present = false;
|
|
316
|
+
try {
|
|
317
|
+
present = await it.detect(page);
|
|
318
|
+
} catch {
|
|
319
|
+
present = false;
|
|
320
|
+
}
|
|
321
|
+
if (!present) continue;
|
|
322
|
+
let acted = false;
|
|
323
|
+
try {
|
|
324
|
+
acted = await it.resolve(page);
|
|
325
|
+
} catch {
|
|
326
|
+
acted = false;
|
|
327
|
+
}
|
|
328
|
+
if (acted) {
|
|
329
|
+
log(` ⚠ cleared ChatGPT interruption: ${it.name}`);
|
|
330
|
+
warnedInterruptions.delete(it.name);
|
|
331
|
+
} else if (!warnedInterruptions.has(it.name)) {
|
|
332
|
+
log(` ⚠ ChatGPT interruption present (not auto-resolvable): ${it.name}`);
|
|
333
|
+
warnedInterruptions.add(it.name);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** ChatGPT's Stop control — visible exactly while a reply is streaming. */
|
|
339
|
+
const STOP_BUTTON_SELECTOR =
|
|
340
|
+
'button[data-testid="stop-button"], button[aria-label="Stop streaming"], button[aria-label*="Stop" i]';
|
|
341
|
+
|
|
342
|
+
/** ChatGPT's assistant message nodes — the default `waitForReply` counter. */
|
|
343
|
+
const ASSISTANT_MESSAGE_SELECTOR = '[data-message-author-role="assistant"]';
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* ChatGPT shows a Stop control while a reply streams. Wait until it's gone so
|
|
347
|
+
* the next prompt isn't typed into a still-generating (send-disabled) composer —
|
|
348
|
+
* which drops the Enter and interleaves the following scene's text. Safe no-op
|
|
349
|
+
* if the control isn't found (returns after a short settle).
|
|
350
|
+
*/
|
|
351
|
+
async function waitComposerReady(page: Page, timeoutMs = 20000): Promise<void> {
|
|
352
|
+
const stop = page.locator(STOP_BUTTON_SELECTOR);
|
|
353
|
+
await sleep(400); // let a just-started generation register the Stop control
|
|
354
|
+
const deadline = Date.now() + timeoutMs;
|
|
355
|
+
while (Date.now() < deadline) {
|
|
356
|
+
if ((await stop.count().catch(() => 0)) === 0) return;
|
|
357
|
+
await sleep(300);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** How many nested widget frames currently contain `selector`. */
|
|
362
|
+
async function countNestedMatches(page: Page, selector: string): Promise<number> {
|
|
363
|
+
let n = 0;
|
|
364
|
+
for (const f of widgetRootFrames(page)) {
|
|
365
|
+
if (f.isDetached()) continue;
|
|
366
|
+
const c = await f.locator(selector).count().catch(() => 0);
|
|
367
|
+
if (c > 0) n++;
|
|
368
|
+
}
|
|
369
|
+
return n;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function resolveTargetLocator(
|
|
373
|
+
page: Page,
|
|
374
|
+
storyboard: Storyboard,
|
|
375
|
+
target: Target
|
|
376
|
+
): Promise<Locator> {
|
|
377
|
+
let selector = target.selector;
|
|
378
|
+
if (target.named) {
|
|
379
|
+
selector = NAMED_SELECTORS[target.named];
|
|
380
|
+
}
|
|
381
|
+
if (!selector) {
|
|
382
|
+
throw new Error(
|
|
383
|
+
`Target has neither a selector nor a known 'named' value: ${JSON.stringify(
|
|
384
|
+
target
|
|
385
|
+
)}`
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
if (!target.frame) {
|
|
389
|
+
// Frameless targets honor last/nth at the ELEMENT level (e.g. the newest
|
|
390
|
+
// assistant message); previously they silently collapsed to .first().
|
|
391
|
+
const loc = page.locator(selector);
|
|
392
|
+
return target.last ? loc.last() : target.nth != null ? loc.nth(target.nth) : loc.first();
|
|
393
|
+
}
|
|
394
|
+
const frameSelector = storyboard.frames[target.frame];
|
|
395
|
+
if (!frameSelector) {
|
|
396
|
+
throw new Error(
|
|
397
|
+
`Action references frame "${target.frame}" not declared in storyboard.frames`
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
if (NESTED_WIDGET_RE.test(frameSelector)) {
|
|
401
|
+
const nested = await pickNestedLocator(page, selector, target);
|
|
402
|
+
if (nested) return nested;
|
|
403
|
+
// No populated widget frame yet; let callers retry against this hint.
|
|
404
|
+
throw new Error(`Widget content frame not ready for selector "${selector}"`);
|
|
405
|
+
}
|
|
406
|
+
// Normal single-level iframe. Disambiguate so multiple matches don't trip
|
|
407
|
+
// Playwright strict mode; newest for multi-turn conversations.
|
|
408
|
+
let frame = page.frameLocator(frameSelector);
|
|
409
|
+
frame = target.last
|
|
410
|
+
? frame.last()
|
|
411
|
+
: target.nth != null
|
|
412
|
+
? frame.nth(target.nth)
|
|
413
|
+
: frame.first();
|
|
414
|
+
return frame.locator(selector).first();
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Wait until `target` resolves to a visible element, re-resolving each poll so
|
|
419
|
+
* a nested widget frame that appears (and fills) mid-wait is picked up. Returns
|
|
420
|
+
* the elapsed wait; throws on timeout.
|
|
421
|
+
*/
|
|
422
|
+
async function waitForTargetVisible(
|
|
423
|
+
page: Page,
|
|
424
|
+
storyboard: Storyboard,
|
|
425
|
+
target: Target,
|
|
426
|
+
timeoutMs: number
|
|
427
|
+
): Promise<void> {
|
|
428
|
+
const deadline = Date.now() + timeoutMs;
|
|
429
|
+
let lastErr: unknown;
|
|
430
|
+
for (;;) {
|
|
431
|
+
try {
|
|
432
|
+
const loc = await resolveTargetLocator(page, storyboard, target);
|
|
433
|
+
const remaining = deadline - Date.now();
|
|
434
|
+
await loc.waitFor({
|
|
435
|
+
state: "visible",
|
|
436
|
+
timeout: Math.max(400, Math.min(2500, remaining)),
|
|
437
|
+
});
|
|
438
|
+
return;
|
|
439
|
+
} catch (err) {
|
|
440
|
+
lastErr = err;
|
|
441
|
+
}
|
|
442
|
+
if (Date.now() >= deadline) {
|
|
443
|
+
throw lastErr instanceof Error
|
|
444
|
+
? lastErr
|
|
445
|
+
: new Error(`waitForTargetVisible timed out for ${JSON.stringify(target)}`);
|
|
446
|
+
}
|
|
447
|
+
await sleep(300);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Wait for the model's NEW reply widget — not a stale prior one. ChatGPT keeps
|
|
453
|
+
* earlier widgets in the DOM and different tools share selectors (search &
|
|
454
|
+
* detail both render button[data-add-id]; every cart op renders
|
|
455
|
+
* button[data-cart-act]), so a plain "is the selector present" check matches an
|
|
456
|
+
* older widget instantly and the scene advances before the reply renders. We
|
|
457
|
+
* instead capture the match COUNT at wait-start (after the prompt was sent) and
|
|
458
|
+
* wait for it to grow — i.e. a genuinely new widget carrying the selector — then
|
|
459
|
+
* confirm the newest one is visible. Falls back to a plain visible-wait for
|
|
460
|
+
* non-nested (fixture) frames.
|
|
461
|
+
*/
|
|
462
|
+
async function waitForNewWidget(
|
|
463
|
+
page: Page,
|
|
464
|
+
storyboard: Storyboard,
|
|
465
|
+
target: Target,
|
|
466
|
+
opts: { timeoutMs: number; textMatches?: string }
|
|
467
|
+
): Promise<void> {
|
|
468
|
+
const selector = target.named
|
|
469
|
+
? NAMED_SELECTORS[target.named]
|
|
470
|
+
: target.selector;
|
|
471
|
+
const frameSelector = target.frame ? storyboard.frames[target.frame] : undefined;
|
|
472
|
+
const nested = !!frameSelector && !!selector && NESTED_WIDGET_RE.test(frameSelector);
|
|
473
|
+
if (!nested || !selector) {
|
|
474
|
+
await waitForTargetVisible(page, storyboard, target, opts.timeoutMs);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const re = opts.textMatches ? new RegExp(opts.textMatches, "i") : null;
|
|
478
|
+
const deadline = Date.now() + opts.timeoutMs;
|
|
479
|
+
const baseline = await countNestedMatches(page, selector);
|
|
480
|
+
for (;;) {
|
|
481
|
+
// A mid-turn A/B eval / consent wall shows text instead of a widget and
|
|
482
|
+
// would otherwise silently burn the whole timeout; try to clear it.
|
|
483
|
+
await resolveInterruptions(page);
|
|
484
|
+
if ((await countNestedMatches(page, selector)) > baseline) {
|
|
485
|
+
const frame = await pickNestedFrame(page, selector, target).catch(() => null);
|
|
486
|
+
// With textMatches, the chosen NEW widget must also carry the expected
|
|
487
|
+
// text — different tools share selectors (carousel and compare both have
|
|
488
|
+
// button[data-add-id]), so when the model could render either, an
|
|
489
|
+
// unqualified match would pass on the wrong widget type. A mismatch
|
|
490
|
+
// keeps polling: the right widget may still be streaming in.
|
|
491
|
+
let textOk = true;
|
|
492
|
+
if (frame && re) {
|
|
493
|
+
const text = await frame
|
|
494
|
+
.locator("body")
|
|
495
|
+
.textContent({ timeout: 800 })
|
|
496
|
+
.catch(() => null);
|
|
497
|
+
textOk = !!text && re.test(text);
|
|
498
|
+
}
|
|
499
|
+
if (frame && textOk) {
|
|
500
|
+
try {
|
|
501
|
+
await frame.locator(selector).first().waitFor({
|
|
502
|
+
state: "visible",
|
|
503
|
+
timeout: Math.max(400, Math.min(2000, deadline - Date.now())),
|
|
504
|
+
});
|
|
505
|
+
// The widget is up, but the model may still be streaming trailing
|
|
506
|
+
// text. Wait it out here (inside the trimmed idle span) so the next
|
|
507
|
+
// scene types into a ready composer.
|
|
508
|
+
await waitComposerReady(page, Math.max(1000, deadline - Date.now()));
|
|
509
|
+
return;
|
|
510
|
+
} catch {
|
|
511
|
+
/* the new frame is still filling in; keep polling */
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
if (Date.now() >= deadline) {
|
|
516
|
+
throw new Error(
|
|
517
|
+
`waitForWidget: no new "${selector}" widget` +
|
|
518
|
+
(re ? ` with text matching ${re}` : "") +
|
|
519
|
+
` within ${opts.timeoutMs}ms (baseline ${baseline})`
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
await sleep(350);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Wait for a NEW assistant reply that answers in TEXT (no widget) — the
|
|
528
|
+
* main-frame twin of waitForNewWidget, for tools whose result the model
|
|
529
|
+
* narrates without rendering a widget. Baseline = assistant-message count at
|
|
530
|
+
* wait start; satisfied when it grows, then the generation is waited out so
|
|
531
|
+
* the on-screen text is complete before the scene advances. Fallback: if the
|
|
532
|
+
* reply node streamed in BEFORE the baseline sample (fast first token), a
|
|
533
|
+
* Stop-button appear→disappear cycle counts as the reply — the prior turn
|
|
534
|
+
* can't be the one streaming, because `type` waits out the composer first.
|
|
535
|
+
*/
|
|
536
|
+
async function waitForNewReply(
|
|
537
|
+
page: Page,
|
|
538
|
+
opts: { selector: string; textMatches?: string; timeoutMs: number }
|
|
539
|
+
): Promise<void> {
|
|
540
|
+
const messages = page.locator(opts.selector);
|
|
541
|
+
const stop = page.locator(STOP_BUTTON_SELECTOR);
|
|
542
|
+
const re = opts.textMatches ? new RegExp(opts.textMatches, "i") : null;
|
|
543
|
+
const baseline = await messages.count().catch(() => 0);
|
|
544
|
+
let sawStreaming = (await stop.count().catch(() => 0)) > 0;
|
|
545
|
+
const deadline = Date.now() + opts.timeoutMs;
|
|
546
|
+
for (;;) {
|
|
547
|
+
await resolveInterruptions(page);
|
|
548
|
+
const streamingNow = (await stop.count().catch(() => 0)) > 0;
|
|
549
|
+
sawStreaming ||= streamingNow;
|
|
550
|
+
const grown = (await messages.count().catch(() => 0)) > baseline;
|
|
551
|
+
if (grown || (sawStreaming && !streamingNow)) {
|
|
552
|
+
await waitComposerReady(page, Math.max(1000, deadline - Date.now()));
|
|
553
|
+
if (re) {
|
|
554
|
+
const text =
|
|
555
|
+
(await messages.last().textContent({ timeout: 2000 }).catch(() => "")) ?? "";
|
|
556
|
+
if (!re.test(text)) {
|
|
557
|
+
throw new Error(
|
|
558
|
+
`waitForReply: new assistant reply does not match ${re} ` +
|
|
559
|
+
`(got ${JSON.stringify(text.replace(/\s+/g, " ").trim().slice(0, 80))})`
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
if (Date.now() >= deadline) {
|
|
566
|
+
throw new Error(
|
|
567
|
+
`waitForReply: no new assistant message ("${opts.selector}") within ` +
|
|
568
|
+
`${opts.timeoutMs}ms (baseline ${baseline})`
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
await sleep(350);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async function runAction(
|
|
576
|
+
page: Page,
|
|
577
|
+
storyboard: Storyboard,
|
|
578
|
+
action: Action,
|
|
579
|
+
mouse: MouseState,
|
|
580
|
+
capture: SceneCapture,
|
|
581
|
+
opts: PlayerOptions,
|
|
582
|
+
sample?: CursorSampler
|
|
583
|
+
): Promise<void> {
|
|
584
|
+
const t0 = opts.t0;
|
|
585
|
+
const markFocus = (x: number, y: number, kind: string) =>
|
|
586
|
+
capture.focusEvents.push({ tMs: Date.now() - t0, x, y, kind });
|
|
587
|
+
|
|
588
|
+
switch (action.op) {
|
|
589
|
+
case "goto":
|
|
590
|
+
await page.goto(action.url, { waitUntil: "domcontentloaded" });
|
|
591
|
+
await sleep(600);
|
|
592
|
+
return;
|
|
593
|
+
|
|
594
|
+
case "click": {
|
|
595
|
+
const loc = await resolveTargetLocator(page, storyboard, action.target);
|
|
596
|
+
const { cx, cy } = await humanClick(page, loc, mouse, !!action.target.frame, sample);
|
|
597
|
+
markFocus(cx, cy, "click");
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
case "type": {
|
|
602
|
+
const loc = await resolveTargetLocator(page, storyboard, action.target);
|
|
603
|
+
const isComposer = action.target.named === "composer";
|
|
604
|
+
// Guard multi-turn ChatGPT: clear any interruption UI (A/B eval, consent
|
|
605
|
+
// wall) then never type while the prior reply is still streaming (the
|
|
606
|
+
// Enter would be dropped and prompts would interleave). The wait is
|
|
607
|
+
// recorded as IDLE: a still-streaming prior turn can hold the composer
|
|
608
|
+
// for tens of seconds, and untrimmed that time blows the scene past its
|
|
609
|
+
// narration and compose tail-trims the scene's PAYOFF instead (bit us
|
|
610
|
+
// on maxfit-chatgpt-v3 s8: 38s raw vs 9s narration).
|
|
611
|
+
if (isComposer) {
|
|
612
|
+
const waitStart = Date.now() - t0;
|
|
613
|
+
await resolveInterruptions(page);
|
|
614
|
+
await waitComposerReady(page);
|
|
615
|
+
const waitEnd = Date.now() - t0;
|
|
616
|
+
// Below ~1s there's nothing worth trimming (waitComposerReady's settle
|
|
617
|
+
// alone is 400ms) and each span costs a keep-interval split in compose.
|
|
618
|
+
if (waitEnd - waitStart > 1000) {
|
|
619
|
+
capture.idleSpans.push({
|
|
620
|
+
startMs: waitStart,
|
|
621
|
+
endMs: waitEnd,
|
|
622
|
+
label: "reply streaming",
|
|
623
|
+
});
|
|
624
|
+
log(` idle "reply streaming": ${waitEnd - waitStart}ms (pre-type composer wait)`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
const { cx, cy } = await humanClick(page, loc, mouse, !!action.target.frame, sample);
|
|
628
|
+
markFocus(cx, cy, "type");
|
|
629
|
+
if (isComposer) {
|
|
630
|
+
// Clear any residual text so a new prompt can't concatenate onto it.
|
|
631
|
+
await page.keyboard.press("ControlOrMeta+a");
|
|
632
|
+
await page.keyboard.press("Backspace");
|
|
633
|
+
}
|
|
634
|
+
await humanType(page, loc, action.text, action.humanize !== false);
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
case "press":
|
|
639
|
+
await page.keyboard.press(action.key);
|
|
640
|
+
await sleep(200);
|
|
641
|
+
return;
|
|
642
|
+
|
|
643
|
+
case "hover": {
|
|
644
|
+
const loc = await resolveTargetLocator(page, storyboard, action.target);
|
|
645
|
+
const box = await boxOf(page, loc);
|
|
646
|
+
await moveMouseTo(page, mouse, box.cx, box.cy, sample);
|
|
647
|
+
await sleep(300);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
case "focus": {
|
|
652
|
+
// A camera note, not an interaction: aim the auto-zoom at this element.
|
|
653
|
+
const loc = await resolveTargetLocator(page, storyboard, action.target);
|
|
654
|
+
const box = await boxOf(page, loc);
|
|
655
|
+
capture.focusEvents.push({
|
|
656
|
+
tMs: Date.now() - t0,
|
|
657
|
+
x: box.cx,
|
|
658
|
+
y: box.cy,
|
|
659
|
+
kind: "focus",
|
|
660
|
+
scale: action.scale,
|
|
661
|
+
holdMs: action.holdMs,
|
|
662
|
+
});
|
|
663
|
+
await sleep(150);
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
case "still": {
|
|
668
|
+
// Screenshot mode: a pure timeline marker — no screenshot is taken now.
|
|
669
|
+
// Let the frame settle first, then record the marker at the settled
|
|
670
|
+
// moment so compose-time extraction lands on a clean frame (not mid-
|
|
671
|
+
// transition). The PNG is pulled from the CLEAN take by `aidemo stills`.
|
|
672
|
+
await sleep(120);
|
|
673
|
+
capture.stillEvents.push({ tMs: Date.now() - t0, name: action.name });
|
|
674
|
+
log(` still "${action.name}" @ ${Date.now() - t0}ms`);
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
case "scrollTo": {
|
|
679
|
+
const loc = await resolveTargetLocator(page, storyboard, action.target);
|
|
680
|
+
// Top-page targets get the cinematic eased scroll; targets inside a
|
|
681
|
+
// frame keep the reliable scrollIntoViewIfNeeded (wheel deltas would go
|
|
682
|
+
// to whatever scroller is under the cursor, not necessarily the frame).
|
|
683
|
+
if (!action.target.frame) {
|
|
684
|
+
const box = await loc.boundingBox().catch(() => null);
|
|
685
|
+
if (box) {
|
|
686
|
+
const dy = box.y + box.height / 2 - opts.video.height * 0.45;
|
|
687
|
+
if (Math.abs(dy) > 30) await easedWheel(page, dy, action);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
await loc.scrollIntoViewIfNeeded();
|
|
691
|
+
await sleep(300);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
case "scrollBy": {
|
|
696
|
+
await easedWheel(page, action.dy, action);
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
case "waitFor": {
|
|
701
|
+
await waitForTargetVisible(
|
|
702
|
+
page,
|
|
703
|
+
storyboard,
|
|
704
|
+
action.target,
|
|
705
|
+
action.timeoutMs ?? 15000
|
|
706
|
+
);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
case "waitForWidget": {
|
|
711
|
+
const start = Date.now() - t0;
|
|
712
|
+
await waitForNewWidget(page, storyboard, action.target, {
|
|
713
|
+
timeoutMs: action.timeoutMs ?? 30000,
|
|
714
|
+
textMatches: action.textMatches,
|
|
715
|
+
});
|
|
716
|
+
const end = Date.now() - t0;
|
|
717
|
+
capture.idleSpans.push({ startMs: start, endMs: end, label: action.label });
|
|
718
|
+
log(` idle "${action.label}": ${end - start}ms`);
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
case "waitForReply": {
|
|
723
|
+
const start = Date.now() - t0;
|
|
724
|
+
await waitForNewReply(page, {
|
|
725
|
+
selector: action.selector ?? ASSISTANT_MESSAGE_SELECTOR,
|
|
726
|
+
textMatches: action.textMatches,
|
|
727
|
+
timeoutMs: action.timeoutMs ?? 30000,
|
|
728
|
+
});
|
|
729
|
+
const end = Date.now() - t0;
|
|
730
|
+
capture.idleSpans.push({ startMs: start, endMs: end, label: action.label });
|
|
731
|
+
log(` idle "${action.label}": ${end - start}ms`);
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
case "waitForChange": {
|
|
736
|
+
const start = Date.now() - t0;
|
|
737
|
+
await waitForChange(page, storyboard, action.target, {
|
|
738
|
+
textMatches: action.textMatches,
|
|
739
|
+
timeoutMs: action.timeoutMs ?? 15000,
|
|
740
|
+
});
|
|
741
|
+
if (action.idle) {
|
|
742
|
+
const end = Date.now() - t0;
|
|
743
|
+
capture.idleSpans.push({ startMs: start, endMs: end, label: action.label });
|
|
744
|
+
log(` idle "${action.label}": ${end - start}ms`);
|
|
745
|
+
}
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
case "pause":
|
|
750
|
+
await sleep(action.ms);
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// ---------------------------------------------------------------------------
|
|
756
|
+
// Golden-probe outcome capture — a normalized, timing-free projection of each
|
|
757
|
+
// action's result. Deterministic across runs by construction: op + resolved
|
|
758
|
+
// selector (from the storyboard, not the clock) + ok + a page-side element-found
|
|
759
|
+
// check + goto's final URL. See src/golden.ts for how these are diffed.
|
|
760
|
+
// ---------------------------------------------------------------------------
|
|
761
|
+
|
|
762
|
+
/** A stable, resolved description of a target: named→selector, frame-prefixed. */
|
|
763
|
+
function describeProbeTarget(storyboard: Storyboard, target: Target): string {
|
|
764
|
+
const selector = target.named
|
|
765
|
+
? NAMED_SELECTORS[target.named] ?? `named:${target.named}`
|
|
766
|
+
: target.selector ?? "<no-selector>";
|
|
767
|
+
const prefix = target.frame
|
|
768
|
+
? `${storyboard.frames[target.frame] ?? target.frame} >> `
|
|
769
|
+
: "";
|
|
770
|
+
const suffix = target.last
|
|
771
|
+
? " [last]"
|
|
772
|
+
: target.nth != null
|
|
773
|
+
? ` [nth=${target.nth}]`
|
|
774
|
+
: "";
|
|
775
|
+
return `${prefix}${selector}${suffix}`;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** Seed an outcome with the action's stable, storyboard-derived fields. */
|
|
779
|
+
function initProbeOutcome(
|
|
780
|
+
storyboard: Storyboard,
|
|
781
|
+
action: Action
|
|
782
|
+
): ProbeActionOutcome {
|
|
783
|
+
const o: ProbeActionOutcome = { op: action.op, ok: false };
|
|
784
|
+
switch (action.op) {
|
|
785
|
+
case "goto":
|
|
786
|
+
o.target = action.url;
|
|
787
|
+
break;
|
|
788
|
+
case "press":
|
|
789
|
+
o.key = action.key;
|
|
790
|
+
break;
|
|
791
|
+
case "pause":
|
|
792
|
+
o.ms = action.ms;
|
|
793
|
+
break;
|
|
794
|
+
case "scrollBy":
|
|
795
|
+
o.dy = action.dy;
|
|
796
|
+
break;
|
|
797
|
+
case "waitForReply":
|
|
798
|
+
o.target = action.selector ?? ASSISTANT_MESSAGE_SELECTOR;
|
|
799
|
+
o.label = action.label;
|
|
800
|
+
break;
|
|
801
|
+
case "waitForWidget":
|
|
802
|
+
case "waitForChange":
|
|
803
|
+
o.target = describeProbeTarget(storyboard, action.target);
|
|
804
|
+
o.label = action.label;
|
|
805
|
+
break;
|
|
806
|
+
case "click":
|
|
807
|
+
case "type":
|
|
808
|
+
case "hover":
|
|
809
|
+
case "scrollTo":
|
|
810
|
+
case "waitFor":
|
|
811
|
+
case "focus":
|
|
812
|
+
o.target = describeProbeTarget(storyboard, action.target);
|
|
813
|
+
break;
|
|
814
|
+
}
|
|
815
|
+
return o;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** Add the post-run stable signals: goto's final URL, target element-found. */
|
|
819
|
+
async function enrichProbeOutcome(
|
|
820
|
+
page: Page,
|
|
821
|
+
storyboard: Storyboard,
|
|
822
|
+
action: Action,
|
|
823
|
+
o: ProbeActionOutcome
|
|
824
|
+
): Promise<void> {
|
|
825
|
+
if (action.op === "goto") {
|
|
826
|
+
o.finalUrl = page.url();
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
if (action.op === "waitForReply") {
|
|
830
|
+
o.found = await selectorResolves(
|
|
831
|
+
page,
|
|
832
|
+
action.selector ?? ASSISTANT_MESSAGE_SELECTOR
|
|
833
|
+
);
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
if ("target" in action) {
|
|
837
|
+
o.found = await targetResolves(page, storyboard, action.target);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/** Does `target` currently resolve to ≥1 element? Never throws (→ false). */
|
|
842
|
+
async function targetResolves(
|
|
843
|
+
page: Page,
|
|
844
|
+
storyboard: Storyboard,
|
|
845
|
+
target: Target
|
|
846
|
+
): Promise<boolean> {
|
|
847
|
+
try {
|
|
848
|
+
const loc = await resolveTargetLocator(page, storyboard, target);
|
|
849
|
+
return (await loc.count().catch(() => 0)) > 0;
|
|
850
|
+
} catch {
|
|
851
|
+
return false;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
async function selectorResolves(page: Page, selector: string): Promise<boolean> {
|
|
856
|
+
return (await page.locator(selector).count().catch(() => 0)) > 0;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/** First line of an error message — a compact reason for the probe log. */
|
|
860
|
+
function firstLine(err: unknown): string {
|
|
861
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
862
|
+
return msg.split("\n")[0];
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* A stable-ish signature of `target`: whether it currently resolves, and its
|
|
867
|
+
* normalized text. Missing/unresolvable targets read as absent (empty) — so a
|
|
868
|
+
* mutation that first *introduces* the selector (e.g. the basket bar rendering
|
|
869
|
+
* into an empty [data-cart-bar]) also counts as a change.
|
|
870
|
+
*/
|
|
871
|
+
async function signatureOf(
|
|
872
|
+
page: Page,
|
|
873
|
+
storyboard: Storyboard,
|
|
874
|
+
target: Target
|
|
875
|
+
): Promise<{ present: boolean; text: string }> {
|
|
876
|
+
try {
|
|
877
|
+
const loc = await resolveTargetLocator(page, storyboard, target);
|
|
878
|
+
const txt = await loc.textContent({ timeout: 800 }).catch(() => null);
|
|
879
|
+
return { present: true, text: (txt ?? "").replace(/\s+/g, " ").trim() };
|
|
880
|
+
} catch {
|
|
881
|
+
return { present: false, text: "" };
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Wait until `target` mutates in place. Baseline = its signature now; satisfied
|
|
887
|
+
* when the signature differs (presence or text). With `textMatches`, additionally
|
|
888
|
+
* require the NEW text to match — so it waits for a change *to* the desired state,
|
|
889
|
+
* never firing on a stale widget that already matches.
|
|
890
|
+
*/
|
|
891
|
+
async function waitForChange(
|
|
892
|
+
page: Page,
|
|
893
|
+
storyboard: Storyboard,
|
|
894
|
+
target: Target,
|
|
895
|
+
opts: { textMatches?: string; timeoutMs: number }
|
|
896
|
+
): Promise<void> {
|
|
897
|
+
const re = opts.textMatches ? new RegExp(opts.textMatches, "i") : null;
|
|
898
|
+
const base = await signatureOf(page, storyboard, target);
|
|
899
|
+
const deadline = Date.now() + opts.timeoutMs;
|
|
900
|
+
for (;;) {
|
|
901
|
+
const cur = await signatureOf(page, storyboard, target);
|
|
902
|
+
const changed = cur.present !== base.present || cur.text !== base.text;
|
|
903
|
+
if (changed && (!re || re.test(cur.text))) {
|
|
904
|
+
await sleep(250); // let the re-render settle before the next action
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
if (Date.now() >= deadline) {
|
|
908
|
+
const sel = target.named ? NAMED_SELECTORS[target.named] : target.selector;
|
|
909
|
+
throw new Error(
|
|
910
|
+
`waitForChange: "${sel}" did not change` +
|
|
911
|
+
(re ? ` to match ${re}` : "") +
|
|
912
|
+
` within ${opts.timeoutMs}ms (baseline text ${JSON.stringify(
|
|
913
|
+
base.text.slice(0, 40)
|
|
914
|
+
)})`
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
await sleep(250);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// ---------------------------------------------------------------------------
|
|
922
|
+
// Failure diagnostics — on any action error, name the scene/action, screenshot
|
|
923
|
+
// the page, and dump the widget frames present + selector match counts. Turns a
|
|
924
|
+
// silent phantom-click failure into something diagnosable from the log.
|
|
925
|
+
// ---------------------------------------------------------------------------
|
|
926
|
+
|
|
927
|
+
function truncateUrl(url: string, n = 90): string {
|
|
928
|
+
return url.length > n ? url.slice(0, n) + "…" : url;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/** Never returns — enriches the error with scene/action context + diagnostics. */
|
|
932
|
+
async function failAction(
|
|
933
|
+
page: Page,
|
|
934
|
+
storyboard: Storyboard,
|
|
935
|
+
scene: Scene,
|
|
936
|
+
index: number,
|
|
937
|
+
action: Action,
|
|
938
|
+
opts: PlayerOptions,
|
|
939
|
+
err: unknown
|
|
940
|
+
): Promise<never> {
|
|
941
|
+
const prefix = `scene ${scene.id}, action #${index + 1} (${action.op})`;
|
|
942
|
+
const base = err instanceof Error ? err.message : String(err);
|
|
943
|
+
let diag = "";
|
|
944
|
+
if (opts.logsDir) {
|
|
945
|
+
diag = await dumpDiagnostics(page, storyboard, scene.id, index, action, opts.logsDir).catch(
|
|
946
|
+
() => ""
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
throw new Error(`${prefix}: ${base}${diag ? `\n${diag}` : ""}`);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
async function dumpDiagnostics(
|
|
953
|
+
page: Page,
|
|
954
|
+
storyboard: Storyboard,
|
|
955
|
+
sceneId: string,
|
|
956
|
+
index: number,
|
|
957
|
+
action: Action,
|
|
958
|
+
logsDir: string
|
|
959
|
+
): Promise<string> {
|
|
960
|
+
await ensureDir(logsDir);
|
|
961
|
+
const stem = join(logsDir, `fail-${sceneId}-${index + 1}`);
|
|
962
|
+
const lines: string[] = [];
|
|
963
|
+
|
|
964
|
+
await page.screenshot({ path: `${stem}.png`, timeout: 5000 }).catch(() => {});
|
|
965
|
+
lines.push(` screenshot → ${stem}.png`);
|
|
966
|
+
|
|
967
|
+
// Selector diagnostics for targeted actions: how many widget frames (and the
|
|
968
|
+
// main frame) currently match the selector we were after.
|
|
969
|
+
const target = "target" in action ? (action.target as Target) : undefined;
|
|
970
|
+
const selector = target?.named ? NAMED_SELECTORS[target.named] : target?.selector;
|
|
971
|
+
const roots = widgetRootFrames(page);
|
|
972
|
+
lines.push(` widget frames present: ${roots.length}`);
|
|
973
|
+
const frameCounts: Array<{ url: string; matches: number }> = [];
|
|
974
|
+
if (selector) {
|
|
975
|
+
for (const f of roots) {
|
|
976
|
+
const c = await f.locator(selector).count().catch(() => -1);
|
|
977
|
+
frameCounts.push({ url: f.url(), matches: c });
|
|
978
|
+
lines.push(` widget ${truncateUrl(f.url())}: ${c} match(es)`);
|
|
979
|
+
}
|
|
980
|
+
const mainC = await page.locator(selector).count().catch(() => -1);
|
|
981
|
+
lines.push(` main frame: ${mainC} match(es) for "${selector}"`);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const detail = {
|
|
985
|
+
sceneId,
|
|
986
|
+
actionIndex: index + 1,
|
|
987
|
+
action,
|
|
988
|
+
selector: selector ?? null,
|
|
989
|
+
url: page.url(),
|
|
990
|
+
widgetFrames: frameCounts,
|
|
991
|
+
allFrames: page.frames().map((f) => truncateUrl(f.url())),
|
|
992
|
+
};
|
|
993
|
+
await fs.writeFile(`${stem}.json`, JSON.stringify(detail, null, 2)).catch(() => {});
|
|
994
|
+
lines.push(` detail → ${stem}.json`);
|
|
995
|
+
return lines.join("\n");
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* Eased wheel scroll: many small deltas along an easing curve (~60Hz), instead
|
|
1000
|
+
* of a handful of chunky steps. Fractional remainders are carried so the total
|
|
1001
|
+
* lands exactly on `dy`.
|
|
1002
|
+
*/
|
|
1003
|
+
async function easedWheel(
|
|
1004
|
+
page: Page,
|
|
1005
|
+
dy: number,
|
|
1006
|
+
opts: { easing?: EasingPreset; durationMs?: number }
|
|
1007
|
+
): Promise<void> {
|
|
1008
|
+
const preset = SCROLL_PRESETS[opts.easing ?? "smooth"];
|
|
1009
|
+
const durationMs = Math.max(80, opts.durationMs ?? preset.ms);
|
|
1010
|
+
const tickMs = 16;
|
|
1011
|
+
const steps = Math.max(2, Math.round(durationMs / tickMs));
|
|
1012
|
+
let emitted = 0;
|
|
1013
|
+
for (let i = 1; i <= steps; i++) {
|
|
1014
|
+
const target = dy * preset.fn(i / steps);
|
|
1015
|
+
const delta = target - emitted;
|
|
1016
|
+
await page.mouse.wheel(0, delta);
|
|
1017
|
+
emitted = target;
|
|
1018
|
+
await sleep(tickMs);
|
|
1019
|
+
}
|
|
1020
|
+
await sleep(250);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// ---------------------------------------------------------------------------
|
|
1024
|
+
// Human-like motion helpers
|
|
1025
|
+
// ---------------------------------------------------------------------------
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* Center of `loc` in MAIN-viewport CSS px, guaranteed to be inside the
|
|
1029
|
+
* viewport's comfortable band. scrollIntoViewIfNeeded alone is NOT enough for
|
|
1030
|
+
* ChatGPT widget targets: the sandbox iframe is out-of-process, and a scroll
|
|
1031
|
+
* initiated inside an OOPIF cannot scroll the embedding page — the element
|
|
1032
|
+
* stays off-screen and a coordinate click lands on nothing (probe recording
|
|
1033
|
+
* 2026-07-06: a below-the-fold click-Add never fired). When the measured
|
|
1034
|
+
* center is outside the band, nudge the main scroller with an eased wheel
|
|
1035
|
+
* (dispatched at the current mouse position, over the conversation column)
|
|
1036
|
+
* and re-measure. The bottom margin also clears ChatGPT's sticky composer
|
|
1037
|
+
* footer, which would otherwise swallow clicks on "visible" elements under it.
|
|
1038
|
+
*/
|
|
1039
|
+
async function boxOf(page: Page, loc: Locator): Promise<{ cx: number; cy: number }> {
|
|
1040
|
+
// Short timeout: with the default 30s action timeout, an element Playwright
|
|
1041
|
+
// deems "unstable" (e.g. a still-animating widget card) silently stalls the
|
|
1042
|
+
// recording for half a minute — the catch below hides the failure and the
|
|
1043
|
+
// wheel loop makes the element reachable anyway (live 2026-07-06: a stalled
|
|
1044
|
+
// focus beat inflated one scene's active video by 31s).
|
|
1045
|
+
await loc.scrollIntoViewIfNeeded({ timeout: 2000 }).catch(() => {});
|
|
1046
|
+
let box = await loc.boundingBox();
|
|
1047
|
+
// External capture runs un-emulated (viewport: null → viewportSize() is
|
|
1048
|
+
// null); fall back to the real window content size, which the recorder has
|
|
1049
|
+
// sized to the storyboard viewport.
|
|
1050
|
+
const vp =
|
|
1051
|
+
page.viewportSize() ??
|
|
1052
|
+
(await page.evaluate(() => ({
|
|
1053
|
+
width: window.innerWidth,
|
|
1054
|
+
height: window.innerHeight,
|
|
1055
|
+
})));
|
|
1056
|
+
for (let i = 0; i < 3 && box && vp; i++) {
|
|
1057
|
+
const cy = box.y + box.height / 2;
|
|
1058
|
+
if (cy >= 80 && cy <= vp.height - 120) break;
|
|
1059
|
+
await easedWheel(page, cy - vp.height * 0.55, { durationMs: 500 });
|
|
1060
|
+
box = await loc.boundingBox();
|
|
1061
|
+
}
|
|
1062
|
+
if (!box) throw new Error("Element has no bounding box (not visible?)");
|
|
1063
|
+
return { cx: box.x + box.width / 2, cy: box.y + box.height / 2 };
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/** Eased cursor glide via many small mouse.move steps. */
|
|
1067
|
+
async function moveMouseTo(
|
|
1068
|
+
page: Page,
|
|
1069
|
+
mouse: MouseState,
|
|
1070
|
+
targetX: number,
|
|
1071
|
+
targetY: number,
|
|
1072
|
+
sample?: CursorSampler
|
|
1073
|
+
): Promise<void> {
|
|
1074
|
+
const startX = mouse.x;
|
|
1075
|
+
const startY = mouse.y;
|
|
1076
|
+
const dist = Math.hypot(targetX - startX, targetY - startY);
|
|
1077
|
+
const steps = Math.max(12, Math.min(40, Math.round(dist / 12)));
|
|
1078
|
+
for (let i = 1; i <= steps; i++) {
|
|
1079
|
+
const t = easeInOutCubic(i / steps);
|
|
1080
|
+
const x = startX + (targetX - startX) * t;
|
|
1081
|
+
const y = startY + (targetY - startY) * t;
|
|
1082
|
+
await page.mouse.move(x, y);
|
|
1083
|
+
sample?.(x, y);
|
|
1084
|
+
await sleep(8);
|
|
1085
|
+
}
|
|
1086
|
+
mouse.x = targetX;
|
|
1087
|
+
mouse.y = targetY;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* True when something OTHER than the target would receive a click at (x, y).
|
|
1092
|
+
* Evaluated in the MAIN frame: for widget-frame targets the top-level hit must
|
|
1093
|
+
* be an <iframe> (the sandbox); anything else is an overlay that would swallow
|
|
1094
|
+
* a raw coordinate click. Seen live 2026-07-06: ChatGPT's floating
|
|
1095
|
+
* scroll-to-bottom arrow hovered exactly over the cart's Checkout button and
|
|
1096
|
+
* ate the click. Playwright's locator.click() hit-tests this itself, but we
|
|
1097
|
+
* click via page.mouse for the cinematic cursor, so we must check ourselves.
|
|
1098
|
+
*/
|
|
1099
|
+
async function clickObstructed(
|
|
1100
|
+
page: Page,
|
|
1101
|
+
loc: Locator,
|
|
1102
|
+
inFrame: boolean,
|
|
1103
|
+
x: number,
|
|
1104
|
+
y: number
|
|
1105
|
+
): Promise<boolean> {
|
|
1106
|
+
try {
|
|
1107
|
+
if (inFrame) {
|
|
1108
|
+
const tag = await page.evaluate(
|
|
1109
|
+
([px, py]) => document.elementFromPoint(px, py)?.tagName ?? "",
|
|
1110
|
+
[x, y] as [number, number]
|
|
1111
|
+
);
|
|
1112
|
+
return tag.toLowerCase() !== "iframe";
|
|
1113
|
+
}
|
|
1114
|
+
return await loc.evaluate(
|
|
1115
|
+
(el, [px, py]) => {
|
|
1116
|
+
const hit = document.elementFromPoint(px, py);
|
|
1117
|
+
return !(hit && (hit === el || el.contains(hit) || hit.contains(el)));
|
|
1118
|
+
},
|
|
1119
|
+
[x, y] as [number, number]
|
|
1120
|
+
);
|
|
1121
|
+
} catch {
|
|
1122
|
+
return false; // never block the click on a failed probe
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
async function humanClick(
|
|
1127
|
+
page: Page,
|
|
1128
|
+
loc: Locator,
|
|
1129
|
+
mouse: MouseState,
|
|
1130
|
+
inFrame: boolean,
|
|
1131
|
+
sample?: CursorSampler
|
|
1132
|
+
): Promise<{ cx: number; cy: number }> {
|
|
1133
|
+
let { cx, cy } = await boxOf(page, loc);
|
|
1134
|
+
// Dodge floating overlays: ChatGPT's scroll-to-bottom arrow only shows when
|
|
1135
|
+
// the conversation isn't at the bottom, so wheeling further down dismisses
|
|
1136
|
+
// it; re-measure (boxOf keeps the target in the viewport band) and re-test.
|
|
1137
|
+
for (let i = 0; i < 3; i++) {
|
|
1138
|
+
if (!(await clickObstructed(page, loc, inFrame, cx, cy))) break;
|
|
1139
|
+
if (i === 2) {
|
|
1140
|
+
log(` ! click point still obstructed after scrolling; clicking anyway`);
|
|
1141
|
+
break;
|
|
1142
|
+
}
|
|
1143
|
+
await easedWheel(page, 240, { durationMs: 350 });
|
|
1144
|
+
({ cx, cy } = await boxOf(page, loc));
|
|
1145
|
+
}
|
|
1146
|
+
await moveMouseTo(page, mouse, cx, cy, sample);
|
|
1147
|
+
await sleep(120);
|
|
1148
|
+
await page.mouse.down();
|
|
1149
|
+
await sleep(60);
|
|
1150
|
+
await page.mouse.up();
|
|
1151
|
+
await sleep(150);
|
|
1152
|
+
return { cx, cy };
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
async function humanType(
|
|
1156
|
+
page: Page,
|
|
1157
|
+
loc: Locator,
|
|
1158
|
+
text: string,
|
|
1159
|
+
humanize: boolean
|
|
1160
|
+
): Promise<void> {
|
|
1161
|
+
await loc.focus().catch(() => {});
|
|
1162
|
+
if (!humanize) {
|
|
1163
|
+
await loc.pressSequentially(text, { delay: 25 });
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
for (const ch of text) {
|
|
1167
|
+
await page.keyboard.type(ch);
|
|
1168
|
+
// Jittered cadence; brief extra pause after spaces/punctuation.
|
|
1169
|
+
const base = 45 + Math.random() * 55;
|
|
1170
|
+
const extra = /[\s.,!?]/.test(ch) ? 60 : 0;
|
|
1171
|
+
await sleep(base + extra);
|
|
1172
|
+
}
|
|
1173
|
+
}
|