@a1knowhow/demo-maker 0.1.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.
Files changed (44) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +104 -0
  3. package/SECURITY.md +33 -0
  4. package/bin/demo-maker.mjs +14 -0
  5. package/docs/README.md +35 -0
  6. package/docs/actions.md +160 -0
  7. package/docs/cli.md +73 -0
  8. package/docs/getting-started.md +220 -0
  9. package/docs/imports-and-fragments.md +78 -0
  10. package/docs/library-api.md +64 -0
  11. package/docs/locators.md +119 -0
  12. package/docs/recording.md +59 -0
  13. package/docs/repairing-scenarios.md +73 -0
  14. package/docs/scenario-schema.md +80 -0
  15. package/docs/url-capture.md +75 -0
  16. package/docs/variables.md +48 -0
  17. package/docs/video-and-pacing.md +109 -0
  18. package/examples/demo1-introduction.yaml +166 -0
  19. package/examples/demo2-github-actions.yaml +109 -0
  20. package/examples/files/demo1-introduction-run.log +50 -0
  21. package/examples/fragments/open-repository.yaml +15 -0
  22. package/examples/smoke.yaml +18 -0
  23. package/llms.txt +32 -0
  24. package/package.json +66 -0
  25. package/src/cli.ts +226 -0
  26. package/src/env.ts +112 -0
  27. package/src/export-chapters.ts +99 -0
  28. package/src/ffmpeg.ts +168 -0
  29. package/src/index.ts +54 -0
  30. package/src/load.ts +143 -0
  31. package/src/logger.ts +126 -0
  32. package/src/main.ts +68 -0
  33. package/src/parse-errors.ts +99 -0
  34. package/src/parse.ts +622 -0
  35. package/src/playwright-selector-to-locator.ts +328 -0
  36. package/src/record-cli.ts +179 -0
  37. package/src/recorder.ts +442 -0
  38. package/src/recording-to-yaml.ts +435 -0
  39. package/src/runner.ts +1582 -0
  40. package/src/selector-to-locator.ts +69 -0
  41. package/src/templates.ts +48 -0
  42. package/src/types.ts +319 -0
  43. package/src/url-capture.ts +105 -0
  44. package/src/variables.ts +64 -0
package/src/runner.ts ADDED
@@ -0,0 +1,1582 @@
1
+ /**
2
+ * Run a demo scenario via Playwright. Resolves variables, captures IDs from URLs, records step results.
3
+ */
4
+
5
+ import { copyFileSync, mkdirSync, unlinkSync, writeFileSync } from "fs";
6
+ import { tmpdir } from "os";
7
+ import { join, resolve } from "path";
8
+ import { createInterface } from "readline";
9
+ import { spawnSync } from "child_process";
10
+ import { chromium } from "playwright";
11
+ import type { Locator as PlaywrightLocator, Page } from "playwright";
12
+ import type {
13
+ CaptionStyle,
14
+ DemoScenario,
15
+ DemoStep,
16
+ DemoStepSettleUntil,
17
+ IDemoLogger,
18
+ Locator,
19
+ RunContext,
20
+ RunResult,
21
+ StepResult,
22
+ TimingExport,
23
+ VideoConfig,
24
+ VideoExcludeRange,
25
+ } from "./types";
26
+ import { resolveVariables } from "./variables";
27
+ import { resolveTemplateFunctions } from "./templates";
28
+ import { createDemoLogger, formatStepFailedLogLine, formatStepProgressLogLine } from "./logger";
29
+ import { remapTimelineSec, spliceVideoExcludingRanges, type TimeRangeSec } from "./ffmpeg";
30
+ import { applyUrlCapture, type UrlCaptureHook } from "./url-capture";
31
+
32
+ /**
33
+ * Build RegExp for wait steps. Supports:
34
+ * - Plain substring / regex body: `edit-doc`, `sb/workspace/[^/]+/edit-doc/`
35
+ * - Slash-wrapped body (common in YAML): `/edit-doc/` → source `edit-doc`; `/\/edit-doc\//` → source `\/edit-doc\/` → matches one `/edit-doc/` path segment
36
+ * - With flags: `/foo/i` → `foo` + flag `i`
37
+ */
38
+ function scenarioUrlPatternToRegExp(raw: string): RegExp {
39
+ const p = raw.trim();
40
+ if (!p.startsWith("/") || p.length < 2) {
41
+ return new RegExp(p);
42
+ }
43
+ const lastSlash = p.lastIndexOf("/");
44
+ if (lastSlash <= 0) {
45
+ return new RegExp(p);
46
+ }
47
+ const potentialFlags = p.slice(lastSlash + 1);
48
+ if (potentialFlags !== "" && !/^[gimsuy]+$/.test(potentialFlags)) {
49
+ return new RegExp(p);
50
+ }
51
+ const inner = p.slice(1, lastSlash);
52
+ if (inner.length === 0) {
53
+ return new RegExp(p);
54
+ }
55
+ const flags = potentialFlags === "" ? undefined : potentialFlags;
56
+ return new RegExp(inner, flags);
57
+ }
58
+
59
+ function waitForApproval(message: string): Promise<void> {
60
+ return new Promise((resolve) => {
61
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
62
+ rl.question(message, () => {
63
+ rl.close();
64
+ resolve();
65
+ });
66
+ });
67
+ }
68
+
69
+ function captureFromUrl(
70
+ url: string,
71
+ captured: Record<string, string>,
72
+ hooks: UrlCaptureHook[] | undefined
73
+ ): void {
74
+ applyUrlCapture(url, captured, hooks);
75
+ }
76
+
77
+ /** Two animation frames so the page is painted after DOM updates. */
78
+ async function waitForRafPaint(page: Page): Promise<void> {
79
+ try {
80
+ await page.evaluate(
81
+ () =>
82
+ new Promise<void>((r) => {
83
+ requestAnimationFrame(() => requestAnimationFrame(() => r()));
84
+ })
85
+ );
86
+ } catch {
87
+ // Context destroyed (e.g. navigation) — proceed so we don't fail the step
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Max ms to wait for DOM readiness after navigation/reload.
93
+ * Not networkidle — production SPAs keep WebSockets/analytics open and rarely go idle.
94
+ */
95
+ const PAGE_DOM_SETTLE_TIMEOUT_MS = 5000;
96
+
97
+ /**
98
+ * Wait for DOM ready and two animation frames so the page is painted.
99
+ * Callers should then optionally wait a bit longer so the video encoder has time to capture the frame (see VIDEO_CAPTURE_BUFFER_MS).
100
+ */
101
+ async function waitForPageDomSettled(
102
+ page: Page,
103
+ timeoutMs: number = PAGE_DOM_SETTLE_TIMEOUT_MS
104
+ ): Promise<void> {
105
+ try {
106
+ await page.waitForLoadState("domcontentloaded", { timeout: timeoutMs });
107
+ } catch {
108
+ // Timeout: proceed; pacing holds and settle_until cover remaining readiness
109
+ }
110
+ await waitForRafPaint(page);
111
+ }
112
+
113
+ function domSettleTimeoutMs(actionTimeoutMs: number): number {
114
+ return Math.min(actionTimeoutMs, PAGE_DOM_SETTLE_TIMEOUT_MS);
115
+ }
116
+
117
+ const HIDDEN_WAIT_POLL_MS = 500;
118
+
119
+ async function waitUntilHidden(
120
+ page: Page,
121
+ locator: Locator,
122
+ timeoutMs: number,
123
+ refreshEveryMs?: number
124
+ ): Promise<void> {
125
+ const deadline = Date.now() + timeoutMs;
126
+ let lastRefresh = Date.now();
127
+ const locLabel = locatorToLogLabel(locator);
128
+
129
+ while (Date.now() < deadline) {
130
+ const remaining = deadline - Date.now();
131
+ const count = await getPlaywrightLocator(page, locator).count();
132
+ if (count === 0) {
133
+ return;
134
+ }
135
+
136
+ if (refreshEveryMs != null && refreshEveryMs > 0 && Date.now() - lastRefresh >= refreshEveryMs) {
137
+ await page.reload({ waitUntil: "commit" });
138
+ await waitForPageDomSettled(page, domSettleTimeoutMs(remaining));
139
+ lastRefresh = Date.now();
140
+ continue;
141
+ }
142
+
143
+ await new Promise((r) => setTimeout(r, Math.min(HIDDEN_WAIT_POLL_MS, remaining)));
144
+ }
145
+
146
+ throw new Error(`wait until hidden timed out after ${timeoutMs}ms (${locLabel})`);
147
+ }
148
+
149
+ async function isLocatorVisible(page: Page, locator: Locator): Promise<boolean> {
150
+ const loc = getPlaywrightLocator(page, locator);
151
+ const count = await loc.count();
152
+ if (count === 0) {
153
+ return false;
154
+ }
155
+ return loc.first().isVisible();
156
+ }
157
+
158
+ function settleUntilReadyPromise(
159
+ page: Page,
160
+ settleUntil: DemoStepSettleUntil,
161
+ mergedContext: RunContext,
162
+ timeoutMs: number
163
+ ): Promise<void> {
164
+ if ("text" in settleUntil) {
165
+ const t = resolveVariables(settleUntil.text, mergedContext);
166
+ return page.getByText(t, { exact: true }).waitFor({ state: "visible", timeout: timeoutMs });
167
+ }
168
+ const resolved = resolveLocatorVariables(settleUntil.locator, mergedContext);
169
+ const loc = getPlaywrightLocator(page, resolved);
170
+ return loc.waitFor({ state: "visible", timeout: timeoutMs });
171
+ }
172
+
173
+ /**
174
+ * After a click: optionally wait for a visible readiness signal, then rAF paint.
175
+ * Playwright click already auto-waits for actionability; no networkidle (discouraged on SPAs).
176
+ */
177
+ async function waitAfterClickSettled(
178
+ page: Page,
179
+ timeoutMs: number,
180
+ settleUntil: DemoStep["settle_until"],
181
+ mergedContext: RunContext
182
+ ): Promise<void> {
183
+ if (settleUntil) {
184
+ await settleUntilReadyPromise(page, settleUntil, mergedContext, timeoutMs);
185
+ }
186
+ await waitForRafPaint(page);
187
+ }
188
+
189
+ /** When recording video, wait this long after paint so the encoder captures the settled frame (runner shows it but video can lag). */
190
+ const VIDEO_CAPTURE_BUFFER_MS = 400;
191
+
192
+ const CAPTION_FADE_MS = 400;
193
+ const CAPTION_HOLD_MS = 2500;
194
+ const TITLE_CARD_HOLD_MS = 1800;
195
+ const TRANSITION_FADE_MS = 500;
196
+
197
+ function captionStyleCss(style: CaptionStyle): string {
198
+ if (style === "chapter") {
199
+ return [
200
+ "position:fixed", "bottom:0", "left:0", "right:0",
201
+ "transform:none",
202
+ "z-index:2147483647", "pointer-events:none",
203
+ "background:rgba(15,23,42,0.65)", "color:#f8fafc",
204
+ "font:700 28px/1.35 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif",
205
+ "padding:20px 48px 28px",
206
+ "max-width:none", "width:100%", "text-align:center",
207
+ "border-radius:0",
208
+ "letter-spacing:-0.02em",
209
+ "box-shadow:0 -4px 24px rgba(0,0,0,0.25)",
210
+ ].join(";");
211
+ }
212
+ return [
213
+ "position:fixed", "bottom:48px", "left:50%", "transform:translateX(-50%)",
214
+ "z-index:2147483647", "pointer-events:none",
215
+ "background:rgba(0,0,0,0.55)", "color:#fff",
216
+ "font:600 22px/1.45 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif",
217
+ "padding:12px 32px", "border-radius:12px",
218
+ "max-width:min(88%,960px)", "text-align:center",
219
+ "box-shadow:0 8px 32px rgba(0,0,0,0.35)",
220
+ ].join(";");
221
+ }
222
+
223
+ async function injectCaptionOverlay(page: Page, style: CaptionStyle = "default"): Promise<void> {
224
+ await page.evaluate(
225
+ ({ fadeMs, css }) => {
226
+ let el = document.getElementById("__demo_caption");
227
+ if (!el) {
228
+ el = document.createElement("div");
229
+ el.id = "__demo_caption";
230
+ document.body.appendChild(el);
231
+ }
232
+ el.style.cssText = [
233
+ css,
234
+ "opacity:0",
235
+ `transition:opacity ${fadeMs}ms ease`,
236
+ "white-space:pre-wrap",
237
+ ].join(";");
238
+ },
239
+ { fadeMs: CAPTION_FADE_MS, css: captionStyleCss(style) }
240
+ );
241
+ }
242
+
243
+ interface ShowCaptionOptions {
244
+ /** When true, schedule fade-out after holdMs in the page; do not block the runner for the hold. */
245
+ parallelHold?: boolean;
246
+ holdMs?: number;
247
+ fadeMs?: number;
248
+ style?: CaptionStyle;
249
+ }
250
+
251
+ async function showCaption(page: Page, text: string, options?: ShowCaptionOptions): Promise<void> {
252
+ const fadeMs = options?.fadeMs ?? CAPTION_FADE_MS;
253
+ const holdMs = options?.holdMs ?? CAPTION_HOLD_MS;
254
+ const parallelHold = options?.parallelHold === true;
255
+ const style = options?.style ?? "default";
256
+ try {
257
+ await injectCaptionOverlay(page, style);
258
+ await page.evaluate(
259
+ ({ t, h, parallel }) => {
260
+ const el = document.getElementById("__demo_caption");
261
+ if (!el) return;
262
+ const w = window as unknown as {
263
+ __demoCaptionHideTimeout?: ReturnType<typeof setTimeout>;
264
+ };
265
+ if (w.__demoCaptionHideTimeout != null) {
266
+ clearTimeout(w.__demoCaptionHideTimeout);
267
+ w.__demoCaptionHideTimeout = undefined;
268
+ }
269
+ el.textContent = t;
270
+ el.style.opacity = "1";
271
+ if (parallel) {
272
+ w.__demoCaptionHideTimeout = setTimeout(() => {
273
+ w.__demoCaptionHideTimeout = undefined;
274
+ const e = document.getElementById("__demo_caption");
275
+ if (!e || !e.isConnected) return;
276
+ if (e.style.opacity === "0") return;
277
+ e.style.opacity = "0";
278
+ }, h);
279
+ }
280
+ },
281
+ { t: text, h: holdMs, parallel: parallelHold }
282
+ );
283
+ await new Promise((r) => setTimeout(r, fadeMs));
284
+ } catch {
285
+ /* navigation may destroy context */
286
+ }
287
+ }
288
+
289
+ async function showTitleCard(
290
+ page: Page,
291
+ text: string,
292
+ holdMs: number,
293
+ fadeMs: number
294
+ ): Promise<void> {
295
+ try {
296
+ await hideCaption(page);
297
+ await page.evaluate(
298
+ ({ t, fade }) => {
299
+ const id = "__demo_title_card";
300
+ document.getElementById(id)?.remove();
301
+ const el = document.createElement("div");
302
+ el.id = id;
303
+ el.style.cssText = [
304
+ "position:fixed", "inset:0", "z-index:2147483646", "pointer-events:none",
305
+ "display:flex", "align-items:center", "justify-content:center",
306
+ "background:rgba(15,23,42,0.55)", "color:#f8fafc",
307
+ "backdrop-filter:blur(8px)", "-webkit-backdrop-filter:blur(8px)",
308
+ "font:700 42px/1.25 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif",
309
+ "padding:48px", "text-align:center",
310
+ "opacity:0", `transition:opacity ${fade}ms ease`,
311
+ ].join(";");
312
+ const inner = document.createElement("div");
313
+ inner.id = "__demo_title_card_text";
314
+ inner.style.cssText = "max-width:min(80%,900px);";
315
+ inner.textContent = t;
316
+ el.appendChild(inner);
317
+ document.body.appendChild(el);
318
+ el.style.opacity = "1";
319
+ },
320
+ { t: text, fade: fadeMs }
321
+ );
322
+ await new Promise((r) => setTimeout(r, fadeMs));
323
+ await new Promise((r) => setTimeout(r, holdMs));
324
+ await page.evaluate((fade) => {
325
+ const el = document.getElementById("__demo_title_card");
326
+ if (el) el.style.opacity = "0";
327
+ }, fadeMs);
328
+ await new Promise((r) => setTimeout(r, fadeMs));
329
+ await page.evaluate(() => {
330
+ document.getElementById("__demo_title_card")?.remove();
331
+ });
332
+ } catch {
333
+ /* navigation may destroy context */
334
+ }
335
+ }
336
+
337
+ async function showTransitionFade(page: Page, fadeMs: number, holdBlackMs?: number): Promise<void> {
338
+ try {
339
+ await hideCaption(page);
340
+ await page.evaluate((fade) => {
341
+ const id = "__demo_transition";
342
+ let el = document.getElementById(id);
343
+ if (!el) {
344
+ el = document.createElement("div");
345
+ el.id = id;
346
+ el.style.cssText = [
347
+ "position:fixed", "inset:0", "z-index:2147483645", "pointer-events:none",
348
+ "background:#000", "opacity:0", `transition:opacity ${fade}ms ease`,
349
+ ].join(";");
350
+ document.body.appendChild(el);
351
+ }
352
+ el.style.opacity = "1";
353
+ }, fadeMs);
354
+ await new Promise((r) => setTimeout(r, fadeMs));
355
+ if (holdBlackMs != null && holdBlackMs > 0) {
356
+ await new Promise((r) => setTimeout(r, holdBlackMs));
357
+ return;
358
+ }
359
+ await new Promise((r) => setTimeout(r, Math.max(120, Math.floor(fadeMs / 2))));
360
+ await page.evaluate((fade) => {
361
+ const el = document.getElementById("__demo_transition");
362
+ if (el) el.style.opacity = "0";
363
+ }, fadeMs);
364
+ await new Promise((r) => setTimeout(r, fadeMs));
365
+ } catch {
366
+ /* navigation may destroy context */
367
+ }
368
+ }
369
+
370
+ function ensureDir(path: string): void {
371
+ mkdirSync(path, { recursive: true });
372
+ }
373
+
374
+ function excludeRangesToSec(
375
+ excludeRanges: VideoExcludeRange[],
376
+ contextStartMs: number
377
+ ): TimeRangeSec[] {
378
+ return excludeRanges.map((r) => ({
379
+ startSec: Math.max(0, (r.startMs - contextStartMs) / 1000),
380
+ endSec: Math.max(0, (r.endMs - contextStartMs) / 1000),
381
+ }));
382
+ }
383
+
384
+ function buildTimingExport(
385
+ scenario: DemoScenario,
386
+ stepResults: StepResult[],
387
+ contextStartMs: number,
388
+ videoPath?: string,
389
+ excludeRanges: VideoExcludeRange[] = []
390
+ ): TimingExport {
391
+ const excludeSec = excludeRangesToSec(excludeRanges, contextStartMs);
392
+ const nonExcluded = stepResults.filter((sr) => !sr.excluded);
393
+ const last = nonExcluded[nonExcluded.length - 1] ?? stepResults[stepResults.length - 1];
394
+ const rawDurationSec = last ? Math.max(0, (last.endTimeMs - contextStartMs) / 1000) : 0;
395
+ let durationSec =
396
+ excludeSec.length > 0 ? remapTimelineSec(rawDurationSec, excludeSec) : rawDurationSec;
397
+ const playbackSpeed = scenario.video?.playback_speed ?? 1;
398
+ if (playbackSpeed !== 1) {
399
+ durationSec /= playbackSpeed;
400
+ }
401
+
402
+ return {
403
+ scenarioName: scenario.name,
404
+ videoPath,
405
+ durationSec: Math.round(durationSec * 10) / 10,
406
+ steps: stepResults.map((sr, idx) => {
407
+ const rawStart = Math.max(0, (sr.startTimeMs - contextStartMs) / 1000);
408
+ const rawEnd = Math.max(0, (sr.endTimeMs - contextStartMs) / 1000);
409
+ let startSec = excludeSec.length > 0 ? remapTimelineSec(rawStart, excludeSec) : rawStart;
410
+ let endSec = excludeSec.length > 0 ? remapTimelineSec(rawEnd, excludeSec) : rawEnd;
411
+ if (playbackSpeed !== 1) {
412
+ startSec /= playbackSpeed;
413
+ endSec /= playbackSpeed;
414
+ }
415
+ const entry: TimingExport["steps"][number] = {
416
+ index: idx + 1,
417
+ action: sr.action,
418
+ startSec: Math.round(startSec * 10) / 10,
419
+ endSec: Math.round(endSec * 10) / 10,
420
+ };
421
+ if (sr.caption !== undefined) {
422
+ entry.caption = sr.caption;
423
+ }
424
+ if (sr.message !== undefined) {
425
+ entry.message = sr.message;
426
+ }
427
+ if (sr.excluded) {
428
+ entry.excluded = true;
429
+ }
430
+ return entry;
431
+ }),
432
+ };
433
+ }
434
+
435
+ function writeTimingJson(timing: TimingExport, videoPath: string): string {
436
+ const timingPath = videoPath.replace(/\.[^.]+$/, ".timing.json");
437
+ writeFileSync(timingPath, `${JSON.stringify(timing, null, 2)}\n`, "utf8");
438
+ return timingPath;
439
+ }
440
+
441
+ async function hideCaption(page: Page): Promise<void> {
442
+ try {
443
+ await page.evaluate((fadeMs) => {
444
+ const w = window as unknown as {
445
+ __demoCaptionHideTimeout?: ReturnType<typeof setTimeout>;
446
+ };
447
+ if (w.__demoCaptionHideTimeout != null) {
448
+ clearTimeout(w.__demoCaptionHideTimeout);
449
+ w.__demoCaptionHideTimeout = undefined;
450
+ }
451
+ const el = document.getElementById("__demo_caption");
452
+ if (!el || el.style.opacity === "0") return;
453
+ el.style.opacity = "0";
454
+ }, CAPTION_FADE_MS);
455
+ await new Promise((r) => setTimeout(r, CAPTION_FADE_MS));
456
+ } catch {
457
+ /* navigation may destroy context */
458
+ }
459
+ }
460
+
461
+ /** Default pacing when scenario does not set delay fields (so runs without --fast are paced for video). */
462
+ const DEFAULT_PACING = {
463
+ step_delay_ms: 500,
464
+ slow_mo_ms: 100,
465
+ typing_delay_ms: 50,
466
+ post_click_delay_ms: 250,
467
+ navigate_hold_ms: 2000,
468
+ pre_action_delay_ms: 300,
469
+ } as const;
470
+
471
+ function getPlaywrightLocator(page: Page, locator: Locator) {
472
+ switch (locator.kind) {
473
+ case "role": {
474
+ const name = typeof locator.name === "string" ? locator.name : locator.name;
475
+ const options =
476
+ typeof locator.name === "string"
477
+ ? { name, exact: true as const }
478
+ : { name };
479
+ return page.getByRole(locator.role as "button" | "textbox" | "link" | "heading" | "main" | "dialog" | "combobox", options);
480
+ }
481
+ case "label":
482
+ return page.getByLabel(locator.label);
483
+ case "text":
484
+ return page.getByText(locator.text);
485
+ case "placeholder":
486
+ return page.getByPlaceholder(locator.placeholder);
487
+ case "title":
488
+ return page.getByTitle(locator.title);
489
+ case "selector":
490
+ return page.locator(locator.selector);
491
+ default:
492
+ throw new Error(`Unknown locator kind: ${(locator as Locator).kind}`);
493
+ }
494
+ }
495
+
496
+ export function resolveLocatorVariables(locator: Locator, context: RunContext): Locator {
497
+ switch (locator.kind) {
498
+ case "role":
499
+ return {
500
+ kind: "role",
501
+ role: locator.role,
502
+ name:
503
+ typeof locator.name === "string"
504
+ ? resolveVariables(locator.name, context)
505
+ : locator.name,
506
+ };
507
+ case "label":
508
+ return {
509
+ kind: "label",
510
+ label:
511
+ typeof locator.label === "string"
512
+ ? resolveVariables(locator.label, context)
513
+ : locator.label,
514
+ };
515
+ case "text":
516
+ return {
517
+ kind: "text",
518
+ text: resolveVariables(locator.text, context),
519
+ };
520
+ case "placeholder":
521
+ return {
522
+ kind: "placeholder",
523
+ placeholder: resolveVariables(locator.placeholder, context),
524
+ };
525
+ case "title":
526
+ return {
527
+ kind: "title",
528
+ title: resolveVariables(locator.title, context),
529
+ };
530
+ case "selector":
531
+ return {
532
+ kind: "selector",
533
+ selector: resolveVariables(locator.selector, context),
534
+ };
535
+ default:
536
+ return locator;
537
+ }
538
+ }
539
+
540
+ async function isSelectLocator(loc: PlaywrightLocator): Promise<boolean> {
541
+ try {
542
+ return await loc.evaluate((el) => el instanceof HTMLSelectElement);
543
+ } catch {
544
+ return false;
545
+ }
546
+ }
547
+
548
+ async function isFileInputLocator(loc: PlaywrightLocator): Promise<boolean> {
549
+ try {
550
+ return await loc.evaluate((el) => el instanceof HTMLInputElement && el.type === "file");
551
+ } catch {
552
+ return false;
553
+ }
554
+ }
555
+
556
+ async function selectOptionSmart(
557
+ loc: PlaywrightLocator,
558
+ value: string,
559
+ timeout: number,
560
+ logger: IDemoLogger
561
+ ): Promise<void> {
562
+ // Try label first for human names (e.g. workspace), but cap label attempt time: values like
563
+ // "hybrid" are usually <option value="hybrid"> with a longer visible label — a full-timeout
564
+ // label attempt would hang until timeout before falling back to value.
565
+ const labelTimeout = Math.min(2500, Math.max(400, Math.floor(timeout * 0.35)));
566
+ const valueTimeout = Math.max(400, timeout - labelTimeout);
567
+ try {
568
+ await loc.selectOption({ label: value }, { timeout: labelTimeout });
569
+ return;
570
+ } catch (e1) {
571
+ try {
572
+ await loc.selectOption({ value }, { timeout: valueTimeout });
573
+ return;
574
+ } catch (e2) {
575
+ logger.warn("Dropdown select failed for both option label and value.", {
576
+ value,
577
+ byLabelError: e1 instanceof Error ? e1.message : String(e1),
578
+ byValueError: e2 instanceof Error ? e2.message : String(e2),
579
+ });
580
+ throw e2;
581
+ }
582
+ }
583
+ }
584
+
585
+ const SELECT_CONFIRM_POLL_MS = 50;
586
+
587
+ /** Poll until <select> contains an option whose label or value matches `want` (dynamic dropdowns). */
588
+ async function waitForSelectHasOption(
589
+ loc: PlaywrightLocator,
590
+ wantRaw: string,
591
+ timeoutMs: number
592
+ ): Promise<void> {
593
+ const want = wantRaw.trim();
594
+ const deadline = Date.now() + timeoutMs;
595
+ while (Date.now() < deadline) {
596
+ const has = await loc
597
+ .evaluate((el, w) => {
598
+ const s = el as HTMLSelectElement;
599
+ return [...s.options].some((o) => o.text.trim() === w || o.value === w);
600
+ }, want)
601
+ .catch(() => false);
602
+ if (has) return;
603
+ await new Promise((r) => setTimeout(r, SELECT_CONFIRM_POLL_MS));
604
+ }
605
+ throw new Error(`select_confirm: no option matching "${want}" within ${timeoutMs}ms`);
606
+ }
607
+
608
+ /** Poll until the selected option's label or value matches `want` (post-select verification). */
609
+ async function waitForSelectedOptionMatches(
610
+ loc: PlaywrightLocator,
611
+ wantRaw: string,
612
+ timeoutMs: number
613
+ ): Promise<void> {
614
+ const want = wantRaw.trim();
615
+ const deadline = Date.now() + timeoutMs;
616
+ while (Date.now() < deadline) {
617
+ const ok = await loc
618
+ .evaluate((el, w) => {
619
+ const s = el as HTMLSelectElement;
620
+ const idx = s.selectedIndex;
621
+ if (idx < 0) return false;
622
+ const o = s.options[idx];
623
+ if (!o) return false;
624
+ const t = o.text.trim();
625
+ const v = o.value;
626
+ if (!t && !v) return false;
627
+ return t === w || v === w;
628
+ }, want)
629
+ .catch(() => false);
630
+ if (ok) return;
631
+ await new Promise((r) => setTimeout(r, SELECT_CONFIRM_POLL_MS));
632
+ }
633
+ const detail = await loc
634
+ .evaluate((el) => {
635
+ const s = el as HTMLSelectElement;
636
+ const o = s.options[s.selectedIndex];
637
+ return o ? { text: o.text.trim(), value: o.value } : null;
638
+ })
639
+ .catch(() => null);
640
+ throw new Error(
641
+ `select_confirm: expected "${want}" selected, current: ${JSON.stringify(detail)} (${timeoutMs}ms)`
642
+ );
643
+ }
644
+
645
+ /** Optional strict path for <select>: wait for option → select → verify (equal phase budgets). */
646
+ async function runSelectStep(
647
+ loc: PlaywrightLocator,
648
+ value: string,
649
+ timeoutMs: number,
650
+ logger: IDemoLogger,
651
+ confirm: boolean
652
+ ): Promise<void> {
653
+ if (!confirm) {
654
+ await selectOptionSmart(loc, value, timeoutMs, logger);
655
+ return;
656
+ }
657
+ const phaseMs = Math.max(800, Math.floor(timeoutMs / 3));
658
+ await waitForSelectHasOption(loc, value, phaseMs);
659
+ await selectOptionSmart(loc, value, phaseMs, logger);
660
+ await waitForSelectedOptionMatches(loc, value, phaseMs);
661
+ }
662
+
663
+ /** Drag from the center of `locator` by pixel offset (e.g. split-pane resize). */
664
+ async function dragFromLocator(
665
+ page: Page,
666
+ locator: Locator,
667
+ offsetX: number,
668
+ offsetY: number,
669
+ timeoutMs: number
670
+ ): Promise<void> {
671
+ const loc = getPlaywrightLocator(page, locator);
672
+ await loc.scrollIntoViewIfNeeded({ timeout: timeoutMs });
673
+ await loc.waitFor({ state: "visible", timeout: timeoutMs });
674
+ const box = await loc.boundingBox();
675
+ if (!box) {
676
+ throw new Error("drag: element has no bounding box");
677
+ }
678
+ const startX = box.x + box.width / 2;
679
+ const startY = box.y + box.height / 2;
680
+ const steps = Math.max(5, Math.min(24, Math.round(Math.max(Math.abs(offsetX), Math.abs(offsetY)) / 8)));
681
+ await page.mouse.move(startX, startY);
682
+ await page.mouse.down();
683
+ await page.mouse.move(startX + offsetX, startY + offsetY, { steps });
684
+ await page.mouse.up();
685
+ }
686
+
687
+ const LOG_LABEL_MAX = 50;
688
+
689
+ function locatorToLogLabel(locator: Locator): string {
690
+ switch (locator.kind) {
691
+ case "role": {
692
+ const name = typeof locator.name === "string" ? locator.name : locator.name.source;
693
+ return `${locator.role} ${JSON.stringify(name)}`;
694
+ }
695
+ case "label": {
696
+ const l = typeof locator.label === "string" ? locator.label : locator.label.source;
697
+ return `label ${JSON.stringify(l)}`;
698
+ }
699
+ case "text":
700
+ return JSON.stringify(locator.text);
701
+ case "placeholder":
702
+ return `placeholder ${JSON.stringify(locator.placeholder)}`;
703
+ case "title":
704
+ return `title ${JSON.stringify(locator.title)}`;
705
+ case "selector":
706
+ return `selector ${JSON.stringify(locator.selector)}`;
707
+ default:
708
+ return "";
709
+ }
710
+ }
711
+
712
+ /** Optional resolved step fields (after variable interpolation) for logging. */
713
+ interface ResolvedStepHint {
714
+ url?: string;
715
+ locator?: Locator;
716
+ value?: string;
717
+ caption?: string;
718
+ }
719
+
720
+ function stepLogLabel(step: DemoStep, resolved?: ResolvedStepHint): string {
721
+ const captionText = resolved?.caption ?? step.caption;
722
+ if (captionText !== undefined && captionText.trim() !== "") {
723
+ const s = captionText.trim();
724
+ return s.length <= LOG_LABEL_MAX ? s : s.slice(0, LOG_LABEL_MAX) + "…";
725
+ }
726
+ switch (step.action) {
727
+ case "navigate": {
728
+ const u = resolved?.url ?? step.url;
729
+ return u != null ? (u.length <= LOG_LABEL_MAX ? u : u.slice(0, LOG_LABEL_MAX) + "…") : "";
730
+ }
731
+ case "click":
732
+ case "expect_visible": {
733
+ const loc = resolved?.locator ?? step.locator;
734
+ return loc != null ? locatorToLogLabel(loc) : "";
735
+ }
736
+ case "type":
737
+ case "fill": {
738
+ const loc = resolved?.locator ?? step.locator;
739
+ const val = resolved?.value ?? step.value;
740
+ if (loc != null) {
741
+ const locStr = locatorToLogLabel(loc);
742
+ const valStr = val != null ? (val.length <= 20 ? val : val.slice(0, 20) + "…") : "";
743
+ return valStr !== "" ? `${locStr} → ${valStr}` : locStr;
744
+ }
745
+ return "";
746
+ }
747
+ case "wait":
748
+ if (step.until === "url" && step.url_pattern != null) {
749
+ return step.url_pattern.length <= LOG_LABEL_MAX ? step.url_pattern : step.url_pattern.slice(0, LOG_LABEL_MAX) + "…";
750
+ }
751
+ if (step.until === "hidden" && step.locator != null) {
752
+ let label = `hidden ${locatorToLogLabel(step.locator)}`;
753
+ if (step.refresh_every_ms != null) {
754
+ label += ` refresh ${step.refresh_every_ms}ms`;
755
+ }
756
+ return label;
757
+ }
758
+ return step.timeout_ms != null ? `${step.timeout_ms}ms` : "";
759
+ case "pause":
760
+ return step.caption?.trim() ?? "pause";
761
+ case "log": {
762
+ const msg = step.message ?? "";
763
+ return msg.length <= LOG_LABEL_MAX ? msg : msg.slice(0, LOG_LABEL_MAX) + "…";
764
+ }
765
+ case "title_card": {
766
+ const t = step.text ?? "";
767
+ return t.length <= LOG_LABEL_MAX ? t : t.slice(0, LOG_LABEL_MAX) + "…";
768
+ }
769
+ case "transition":
770
+ return step.fade_ms != null ? `fade ${step.fade_ms}ms` : "fade";
771
+ case "repeat_while_visible": {
772
+ const loc = step.locator;
773
+ const n = step.steps?.length ?? 0;
774
+ const label = loc != null ? locatorToLogLabel(loc) : "";
775
+ return n > 0 ? `${label} ×${n} steps` : label;
776
+ }
777
+ case "cut_video": {
778
+ const n = step.steps?.length ?? 0;
779
+ const ph = step.placeholder?.text ?? "";
780
+ const phShort = ph.length <= 24 ? ph : ph.slice(0, 24) + "…";
781
+ return n > 0 ? `exclude ${n} steps${phShort ? ` · ${phShort}` : ""}` : "exclude";
782
+ }
783
+ case "drag": {
784
+ const ox = step.offset_x ?? 0;
785
+ const oy = step.offset_y ?? 0;
786
+ const loc = step.locator;
787
+ const label = loc != null ? locatorToLogLabel(loc) : "";
788
+ return `${label} Δ${ox},${oy}`;
789
+ }
790
+ default:
791
+ return "";
792
+ }
793
+ }
794
+
795
+ export interface RunScenarioOptions {
796
+ logger?: IDemoLogger;
797
+ headless?: boolean;
798
+ /** Working directory for resolving relative paths (e.g. video.dir). Default process.cwd(). */
799
+ cwd?: string;
800
+ /** Path to a Playwright storageState JSON file to load before running (restores cookies/localStorage). Resolved relative to cwd. Overrides scenario.storage_state. */
801
+ storageStatePath?: string;
802
+ /** Path to save the Playwright storageState JSON after the run completes. Resolved relative to cwd. Overrides scenario.save_storage_state. */
803
+ saveStorageStatePath?: string;
804
+ /** Stop on the first step failure instead of continuing on a broken page. */
805
+ failFast?: boolean;
806
+ /** Run only steps 1..N (1-based inclusive). */
807
+ endStep?: number;
808
+ /** After the last executed step, wait for Enter in the terminal (headed only). */
809
+ pauseAtEnd?: boolean;
810
+ /** On step failure, save a PNG screenshot under this directory. */
811
+ debugDir?: string;
812
+ /** Basename for debug screenshots (defaults to scenario name). */
813
+ scenarioLabel?: string;
814
+ /** Optional hooks to capture variables from page URLs during the run. */
815
+ urlCaptureHooks?: UrlCaptureHook[];
816
+ }
817
+
818
+ export interface RunScenarioOnPageOptions {
819
+ logger?: IDemoLogger;
820
+ /** Working directory for resolving relative paths (e.g. file inputs). Default process.cwd(). */
821
+ cwd?: string;
822
+ /** When set, adds video capture buffer delays after navigate/click. Omit for faster replay (e.g. continue-from). */
823
+ videoConfig?: VideoConfig;
824
+ headless?: boolean;
825
+ /** Stop on the first step failure instead of continuing on a broken page. */
826
+ failFast?: boolean;
827
+ /** Run only steps 1..N (1-based inclusive). */
828
+ endStep?: number;
829
+ /** On step failure, save a PNG screenshot under this directory. */
830
+ debugDir?: string;
831
+ /** Basename for debug screenshots (defaults to scenario name). */
832
+ scenarioLabel?: string;
833
+ /** When false, skip the initial "/" bootstrap when the first step is not navigate. */
834
+ skipBootstrap?: boolean;
835
+ /** Collected exclude intervals (absolute wall-clock ms) for ffmpeg splice. */
836
+ excludeRanges?: VideoExcludeRange[];
837
+ /** When true, step results are marked excluded (used by cut_video nested runs). */
838
+ markStepsExcluded?: boolean;
839
+ /** Optional hooks to capture variables from page URLs during the run. */
840
+ urlCaptureHooks?: UrlCaptureHook[];
841
+ }
842
+
843
+ /** Return a copy of the scenario with steps truncated to 1..endStep (1-based inclusive). */
844
+ export function sliceScenarioByEndStep(scenario: DemoScenario, endStep?: number): DemoScenario {
845
+ if (endStep === undefined || endStep <= 0) {
846
+ return scenario;
847
+ }
848
+ const total = scenario.steps.length;
849
+ const capped = Math.min(endStep, total);
850
+ if (capped >= total) {
851
+ return scenario;
852
+ }
853
+ return { ...scenario, steps: scenario.steps.slice(0, capped) };
854
+ }
855
+
856
+ export interface RunScenarioOnPageResult {
857
+ success: boolean;
858
+ stepResults: StepResult[];
859
+ error?: string;
860
+ }
861
+
862
+ /**
863
+ * Run a scenario's steps on an existing Playwright page. Does not open/close browser or handle video.
864
+ * Used by runScenario and by the recorder when continuing from an existing YAML.
865
+ */
866
+ export async function runScenarioOnExistingPage(
867
+ page: Page,
868
+ scenario: DemoScenario,
869
+ mergedContext: RunContext,
870
+ options: RunScenarioOnPageOptions = {}
871
+ ): Promise<RunScenarioOnPageResult> {
872
+ const logger = options.logger ?? createDemoLogger("info");
873
+ const cwd = options.cwd ?? process.cwd();
874
+ const videoConfig = options.videoConfig;
875
+ const headless = options.headless ?? false;
876
+ const failFast = options.failFast === true;
877
+ const debugDirRaw = options.debugDir;
878
+ const debugDir = debugDirRaw ? resolve(cwd, debugDirRaw) : undefined;
879
+ const scenarioLabel = options.scenarioLabel ?? scenario.name.replace(/\s+/g, "-").toLowerCase();
880
+ scenario = sliceScenarioByEndStep(scenario, options.endStep);
881
+ const screenshotsDirRaw = scenario.screenshots?.dir;
882
+ const screenshotsDir = screenshotsDirRaw ? resolve(cwd, screenshotsDirRaw) : undefined;
883
+
884
+ if (!mergedContext.variables) {
885
+ mergedContext.variables = {};
886
+ }
887
+ const variables = mergedContext.variables;
888
+ const stepResults: StepResult[] = [];
889
+ let success = true;
890
+ let lastError: string | undefined;
891
+
892
+ const firstStep = scenario.steps[0];
893
+ const firstIsNavigate =
894
+ firstStep?.action === "navigate" && firstStep?.url !== undefined && firstStep.url.trim() !== "";
895
+ const navigateHoldMs = scenario.navigate_hold_ms ?? DEFAULT_PACING.navigate_hold_ms;
896
+ const skipBootstrap = options.skipBootstrap === true;
897
+ if (!skipBootstrap && !firstIsNavigate) {
898
+ await page.goto("/", { waitUntil: "domcontentloaded" });
899
+ await waitForRafPaint(page);
900
+ if (videoConfig) {
901
+ await new Promise((r) => setTimeout(r, VIDEO_CAPTURE_BUFFER_MS));
902
+ }
903
+ await new Promise((r) => setTimeout(r, navigateHoldMs));
904
+ }
905
+
906
+ const excludeRanges = options.excludeRanges;
907
+ const markStepsExcluded = options.markStepsExcluded === true;
908
+
909
+ for (let i = 0; i < scenario.steps.length; i++) {
910
+ const step = scenario.steps[i]!;
911
+ const startTimeMs = Date.now();
912
+ let stepSuccess = true;
913
+ let stepError: string | undefined;
914
+ let resolvedCaption: string | undefined;
915
+ let skipDefaultStepResult = false;
916
+ const videoExcludeStep = step.video_exclude === true;
917
+
918
+ try {
919
+ const url = page.url();
920
+ captureFromUrl(url, variables, options.urlCaptureHooks);
921
+ mergedContext.variables = variables;
922
+
923
+ const resolvedUrl = step.url ? resolveVariables(step.url, mergedContext) : undefined;
924
+ const resolvedValue = step.value ? resolveVariables(step.value, mergedContext) : undefined;
925
+ resolvedCaption =
926
+ step.caption !== undefined ? resolveVariables(step.caption, mergedContext).trim() : undefined;
927
+ if (step.caption !== undefined && resolvedCaption === "") {
928
+ throw new Error(`Step ${i + 1}: caption resolved to empty (check {{ variables }})`);
929
+ }
930
+ const resolvedLocator = step.locator
931
+ ? resolveLocatorVariables(step.locator, mergedContext)
932
+ : undefined;
933
+ const timeout = step.timeout_ms ?? scenario.action_timeout_ms ?? 10000;
934
+
935
+ const stepHint = stepLogLabel(step, {
936
+ url: resolvedUrl,
937
+ locator: resolvedLocator,
938
+ value: resolvedValue,
939
+ caption: resolvedCaption,
940
+ });
941
+ logger.info(
942
+ formatStepProgressLogLine({
943
+ stepIndex: i + 1,
944
+ stepTotal: scenario.steps.length,
945
+ action: step.action,
946
+ hint: stepHint || undefined,
947
+ })
948
+ );
949
+
950
+ const preActionMs = scenario.pre_action_delay_ms ?? DEFAULT_PACING.pre_action_delay_ms;
951
+
952
+ switch (step.action) {
953
+ case "navigate": {
954
+ if (!resolvedUrl) {
955
+ throw new Error("navigate requires url");
956
+ }
957
+ if (preActionMs > 0) {
958
+ await new Promise((r) => setTimeout(r, preActionMs));
959
+ }
960
+ await page.goto(resolvedUrl, { timeout, waitUntil: "domcontentloaded" });
961
+ await waitForRafPaint(page);
962
+ if (videoConfig) {
963
+ await new Promise((r) => setTimeout(r, VIDEO_CAPTURE_BUFFER_MS));
964
+ }
965
+ await new Promise((r) => setTimeout(r, navigateHoldMs));
966
+ captureFromUrl(page.url(), variables, options.urlCaptureHooks);
967
+ break;
968
+ }
969
+ case "click": {
970
+ if (!resolvedLocator) {
971
+ throw new Error("click requires locator");
972
+ }
973
+ if (preActionMs > 0) {
974
+ await new Promise((r) => setTimeout(r, preActionMs));
975
+ }
976
+ const loc = getPlaywrightLocator(page, resolvedLocator);
977
+ await loc.click({ timeout });
978
+ const settleTimeout = step.settle_until ? timeout : domSettleTimeoutMs(timeout);
979
+ await waitAfterClickSettled(
980
+ page,
981
+ settleTimeout,
982
+ step.settle_until,
983
+ mergedContext
984
+ );
985
+ if (videoConfig) {
986
+ await new Promise((r) => setTimeout(r, VIDEO_CAPTURE_BUFFER_MS));
987
+ }
988
+ const postClick = scenario.post_click_delay_ms ?? DEFAULT_PACING.post_click_delay_ms;
989
+ if (postClick > 0) {
990
+ await new Promise((r) => setTimeout(r, postClick));
991
+ }
992
+ break;
993
+ }
994
+ case "drag": {
995
+ if (!resolvedLocator) {
996
+ throw new Error("drag requires locator");
997
+ }
998
+ if (step.offset_x === undefined) {
999
+ throw new Error("drag requires offset_x");
1000
+ }
1001
+ if (preActionMs > 0) {
1002
+ await new Promise((r) => setTimeout(r, preActionMs));
1003
+ }
1004
+ await dragFromLocator(
1005
+ page,
1006
+ resolvedLocator,
1007
+ step.offset_x,
1008
+ step.offset_y ?? 0,
1009
+ timeout
1010
+ );
1011
+ if (videoConfig) {
1012
+ await new Promise((r) => setTimeout(r, VIDEO_CAPTURE_BUFFER_MS));
1013
+ }
1014
+ const postClick = scenario.post_click_delay_ms ?? DEFAULT_PACING.post_click_delay_ms;
1015
+ if (postClick > 0) {
1016
+ await new Promise((r) => setTimeout(r, postClick));
1017
+ }
1018
+ break;
1019
+ }
1020
+ case "type": {
1021
+ if (!resolvedLocator) {
1022
+ throw new Error("type requires locator");
1023
+ }
1024
+ const loc = getPlaywrightLocator(page, resolvedLocator);
1025
+ const value = resolvedValue ?? "";
1026
+ if (await isFileInputLocator(loc)) {
1027
+ // File inputs require setInputFiles(path). Resolve to absolute path (works for relative or absolute value).
1028
+ await loc.waitFor({ state: "attached", timeout });
1029
+ const filePath = resolve(cwd, value.trim());
1030
+ await loc.setInputFiles(filePath, { timeout });
1031
+ break;
1032
+ }
1033
+ if (await isSelectLocator(loc)) {
1034
+ await runSelectStep(loc, value, timeout, logger, step.select_confirm === true);
1035
+ break;
1036
+ }
1037
+ if (step.clear_before_type !== false) {
1038
+ await loc.clear({ timeout });
1039
+ }
1040
+ const typingDelay =
1041
+ step.typing_delay_ms ?? scenario.typing_delay_ms ?? DEFAULT_PACING.typing_delay_ms;
1042
+ await loc.pressSequentially(value, { timeout, delay: typingDelay });
1043
+ break;
1044
+ }
1045
+ case "fill": {
1046
+ if (!resolvedLocator) {
1047
+ throw new Error("fill requires locator");
1048
+ }
1049
+ const loc = getPlaywrightLocator(page, resolvedLocator);
1050
+ const value = resolvedValue ?? "";
1051
+ if (await isFileInputLocator(loc)) {
1052
+ await loc.waitFor({ state: "attached", timeout });
1053
+ const filePath = resolve(cwd, value.trim());
1054
+ await loc.setInputFiles(filePath, { timeout });
1055
+ break;
1056
+ }
1057
+ if (await isSelectLocator(loc)) {
1058
+ await runSelectStep(loc, value, timeout, logger, step.select_confirm === true);
1059
+ break;
1060
+ }
1061
+ await loc.fill(value, { timeout });
1062
+ break;
1063
+ }
1064
+ case "wait": {
1065
+ if (step.until === "url" && step.url_pattern) {
1066
+ const urlPat = resolveVariables(step.url_pattern, mergedContext);
1067
+ // SPAs (e.g. SvelteKit goto): `commit` tracks history URL changes without waiting for full load.
1068
+ await page.waitForURL(scenarioUrlPatternToRegExp(urlPat), {
1069
+ timeout,
1070
+ waitUntil: "commit",
1071
+ });
1072
+ } else if (step.until === "hidden" && resolvedLocator) {
1073
+ await waitUntilHidden(page, resolvedLocator, timeout, step.refresh_every_ms);
1074
+ } else if (step.timeout_ms) {
1075
+ await new Promise((r) => setTimeout(r, step.timeout_ms));
1076
+ }
1077
+ captureFromUrl(page.url(), variables, options.urlCaptureHooks);
1078
+ break;
1079
+ }
1080
+ case "expect_visible": {
1081
+ if (!resolvedLocator) {
1082
+ throw new Error("expect_visible requires locator");
1083
+ }
1084
+ const loc = getPlaywrightLocator(page, resolvedLocator);
1085
+ await loc.waitFor({ state: "visible", timeout });
1086
+ break;
1087
+ }
1088
+ case "screenshot": {
1089
+ if (!screenshotsDir) {
1090
+ throw new Error('screenshot requires top-level "screenshots: { dir: ... }"');
1091
+ }
1092
+ ensureDir(screenshotsDir);
1093
+
1094
+ // Let the UI settle so we capture the intended state.
1095
+ await waitForPageDomSettled(page, domSettleTimeoutMs(timeout));
1096
+ await new Promise((r) => setTimeout(r, videoConfig ? VIDEO_CAPTURE_BUFFER_MS : 0));
1097
+
1098
+ const sc = step.screenshot ?? {};
1099
+ const fileRaw = sc.file?.trim();
1100
+ const autoName = `step-${String(i + 1).padStart(2, "0")}.png`;
1101
+ const fileResolved = resolveTemplateFunctions(
1102
+ resolveVariables(fileRaw && fileRaw !== "" ? fileRaw : autoName, mergedContext)
1103
+ );
1104
+ const outPath = join(screenshotsDir, fileResolved);
1105
+
1106
+ const omitBackground = sc.omit_background === true;
1107
+ const scale = sc.scale === "device" ? "device" : "css";
1108
+
1109
+ if (resolvedLocator) {
1110
+ const loc = getPlaywrightLocator(page, resolvedLocator);
1111
+ await loc.screenshot({
1112
+ path: outPath,
1113
+ omitBackground,
1114
+ scale,
1115
+ timeout,
1116
+ });
1117
+ } else {
1118
+ await page.screenshot({
1119
+ path: outPath,
1120
+ fullPage: sc.full_page === true,
1121
+ omitBackground,
1122
+ scale,
1123
+ timeout,
1124
+ });
1125
+ }
1126
+ logger.info(`[screenshot] ${outPath}`);
1127
+ break;
1128
+ }
1129
+ case "pause": {
1130
+ const message =
1131
+ step.caption?.trim() || "Press Enter to continue...";
1132
+ if (headless) {
1133
+ logger.info(`[pause skipped in headless] ${message}`);
1134
+ } else {
1135
+ logger.info(`[pause] ${message}`);
1136
+ await waitForApproval(`${message}\n`);
1137
+ }
1138
+ break;
1139
+ }
1140
+ case "log": {
1141
+ const raw = step.message ?? "";
1142
+ const resolved = resolveVariables(raw, mergedContext);
1143
+ logger.info(`[log] ${resolved}`);
1144
+ break;
1145
+ }
1146
+ case "title_card": {
1147
+ const text = resolveVariables(step.text ?? "", mergedContext).trim();
1148
+ if (!text) {
1149
+ throw new Error("title_card requires text");
1150
+ }
1151
+ const holdMs = step.hold_ms ?? TITLE_CARD_HOLD_MS;
1152
+ const fadeMs = step.fade_ms ?? CAPTION_FADE_MS;
1153
+ await showTitleCard(page, text, holdMs, fadeMs);
1154
+ break;
1155
+ }
1156
+ case "transition": {
1157
+ const fadeMs = step.fade_ms ?? TRANSITION_FADE_MS;
1158
+ await showTransitionFade(page, fadeMs, step.hold_ms);
1159
+ break;
1160
+ }
1161
+ case "repeat_while_visible": {
1162
+ if (!resolvedLocator || !step.steps?.length) {
1163
+ throw new Error("repeat_while_visible requires locator and steps");
1164
+ }
1165
+ const maxIterations = step.max_iterations ?? 100;
1166
+ const nestedScenario: DemoScenario = { ...scenario, steps: step.steps };
1167
+ let iteration = 0;
1168
+ while (iteration < maxIterations) {
1169
+ if (!(await isLocatorVisible(page, resolvedLocator))) {
1170
+ break;
1171
+ }
1172
+ iteration++;
1173
+ logger.info(`[repeat_while_visible] iteration ${iteration}`);
1174
+ const nested = await runScenarioOnExistingPage(page, nestedScenario, mergedContext, {
1175
+ ...options,
1176
+ skipBootstrap: true,
1177
+ endStep: undefined,
1178
+ });
1179
+ if (!nested.success) {
1180
+ throw new Error(nested.error ?? `repeat_while_visible nested steps failed on iteration ${iteration}`);
1181
+ }
1182
+ }
1183
+ if (iteration >= maxIterations && (await isLocatorVisible(page, resolvedLocator))) {
1184
+ throw new Error(`repeat_while_visible exceeded max_iterations (${maxIterations})`);
1185
+ }
1186
+ break;
1187
+ }
1188
+ case "cut_video": {
1189
+ if (!step.steps?.length) {
1190
+ throw new Error("cut_video requires steps");
1191
+ }
1192
+ if (step.placeholder) {
1193
+ const phScenario: DemoScenario = { ...scenario, steps: [step.placeholder] };
1194
+ const phResult = await runScenarioOnExistingPage(page, phScenario, mergedContext, {
1195
+ ...options,
1196
+ skipBootstrap: true,
1197
+ endStep: 1,
1198
+ markStepsExcluded: false,
1199
+ });
1200
+ stepResults.push(...phResult.stepResults);
1201
+ if (!phResult.success) {
1202
+ throw new Error(phResult.error ?? "cut_video placeholder step failed");
1203
+ }
1204
+ }
1205
+ const excludeStartMs = Date.now();
1206
+ const nestedScenario: DemoScenario = { ...scenario, steps: step.steps };
1207
+ const nested = await runScenarioOnExistingPage(page, nestedScenario, mergedContext, {
1208
+ ...options,
1209
+ skipBootstrap: true,
1210
+ endStep: undefined,
1211
+ markStepsExcluded: true,
1212
+ });
1213
+ const excludeEndMs = Date.now();
1214
+ if (excludeRanges && excludeEndMs > excludeStartMs) {
1215
+ excludeRanges.push({ startMs: excludeStartMs, endMs: excludeEndMs });
1216
+ }
1217
+ stepResults.push(
1218
+ ...nested.stepResults.map((sr) => ({
1219
+ ...sr,
1220
+ excluded: true,
1221
+ }))
1222
+ );
1223
+ if (!nested.success) {
1224
+ throw new Error(nested.error ?? "cut_video nested steps failed");
1225
+ }
1226
+ skipDefaultStepResult = true;
1227
+ break;
1228
+ }
1229
+ default:
1230
+ throw new Error(`Unhandled action: ${(step as DemoStep).action}`);
1231
+
1232
+ }
1233
+
1234
+ if (videoConfig && resolvedCaption) {
1235
+ const captionStyle = step.caption_style ?? "default";
1236
+ await injectCaptionOverlay(page, captionStyle);
1237
+ const parallel = videoConfig.caption_parallel === true;
1238
+ await showCaption(page, resolvedCaption, {
1239
+ parallelHold: parallel,
1240
+ holdMs: CAPTION_HOLD_MS,
1241
+ fadeMs: CAPTION_FADE_MS,
1242
+ style: captionStyle,
1243
+ });
1244
+ if (!parallel) {
1245
+ await new Promise((r) => setTimeout(r, CAPTION_HOLD_MS));
1246
+ }
1247
+ } else if (videoConfig && !videoConfig.caption_parallel) {
1248
+ await hideCaption(page);
1249
+ }
1250
+ } catch (err) {
1251
+ stepSuccess = false;
1252
+ success = false;
1253
+ stepError = err instanceof Error ? err.message : String(err);
1254
+ lastError = stepError;
1255
+ logger.error(formatStepFailedLogLine(i + 1, stepError));
1256
+ if (debugDir) {
1257
+ try {
1258
+ ensureDir(debugDir);
1259
+ const shotPath = join(debugDir, `${scenarioLabel}-step-${i + 1}.png`);
1260
+ await page.screenshot({ path: shotPath, fullPage: true });
1261
+ logger.info(`[debug] failure screenshot: ${shotPath}`);
1262
+ } catch (shotErr) {
1263
+ logger.warn(
1264
+ "Failed to save debug screenshot:",
1265
+ shotErr instanceof Error ? shotErr.message : shotErr
1266
+ );
1267
+ }
1268
+ }
1269
+ }
1270
+
1271
+ const endTimeMs = Date.now();
1272
+ if (videoExcludeStep && excludeRanges && endTimeMs > startTimeMs) {
1273
+ excludeRanges.push({ startMs: startTimeMs, endMs: endTimeMs });
1274
+ }
1275
+
1276
+ if (!skipDefaultStepResult) {
1277
+ const stepResult: StepResult = {
1278
+ stepIndex: i,
1279
+ action: step.action,
1280
+ startTimeMs,
1281
+ endTimeMs,
1282
+ success: stepSuccess,
1283
+ };
1284
+ if (resolvedCaption !== undefined) {
1285
+ stepResult.caption = resolvedCaption;
1286
+ }
1287
+ if (step.action === "log" && step.message) {
1288
+ stepResult.message = resolveVariables(step.message, mergedContext);
1289
+ }
1290
+ if (stepError !== undefined) {
1291
+ stepResult.error = stepError;
1292
+ }
1293
+ if (videoExcludeStep || markStepsExcluded) {
1294
+ stepResult.excluded = true;
1295
+ }
1296
+ stepResults.push(stepResult);
1297
+ }
1298
+
1299
+ const delayMs = scenario.step_delay_ms ?? DEFAULT_PACING.step_delay_ms;
1300
+ if (delayMs > 0) {
1301
+ await new Promise((r) => setTimeout(r, delayMs));
1302
+ }
1303
+
1304
+ if (!stepSuccess && failFast) {
1305
+ break;
1306
+ }
1307
+ }
1308
+
1309
+ return { success, stepResults, error: lastError };
1310
+ }
1311
+
1312
+ /**
1313
+ * Run a demo scenario: launch browser, execute steps, resolve variables, capture IDs from URLs.
1314
+ * Returns RunResult with step timestamps and captions for post-processing.
1315
+ */
1316
+ export async function runScenario(
1317
+ scenario: DemoScenario,
1318
+ context: RunContext,
1319
+ options: RunScenarioOptions = {}
1320
+ ): Promise<RunResult> {
1321
+ const logger = options.logger ?? createDemoLogger("info");
1322
+ const cwd = options.cwd ?? process.cwd();
1323
+
1324
+ const loadStateRaw = options.storageStatePath ?? scenario.storage_state;
1325
+ const loadStatePath = loadStateRaw ? resolve(cwd, loadStateRaw) : undefined;
1326
+ const saveStateRaw = options.saveStorageStatePath ?? scenario.save_storage_state;
1327
+ const saveStatePath = saveStateRaw ? resolve(cwd, saveStateRaw) : undefined;
1328
+
1329
+ const variables = { ...context.variables } as Record<string, string>;
1330
+ const mergedContext: RunContext = {
1331
+ ...context,
1332
+ variables,
1333
+ };
1334
+
1335
+ let success = false;
1336
+ let stepResults: StepResult[] = [];
1337
+ let lastError: string | undefined;
1338
+ let contextStartMs = 0;
1339
+ const excludeRanges: VideoExcludeRange[] = [];
1340
+ const videoConfig = scenario.video?.enabled ? scenario.video : undefined;
1341
+ const headless = options.headless ?? false;
1342
+ const DEFAULT_VIEWPORT = { width: 1920, height: 1080 };
1343
+ // video.viewport = browser render size (must fit the physical screen for headed mode).
1344
+ // video.size = final output video resolution (ffmpeg upscales if different from viewport).
1345
+ // When viewport is omitted, falls back to size, then DEFAULT_VIEWPORT.
1346
+ const viewport = videoConfig?.viewport ?? videoConfig?.size ?? DEFAULT_VIEWPORT;
1347
+ const videoOutputSize = videoConfig?.size ?? DEFAULT_VIEWPORT;
1348
+ let recordVideoDir: string | undefined;
1349
+ let videoOutputDir: string | undefined;
1350
+ let videoFilename: string | undefined;
1351
+ let videoPath: string | undefined;
1352
+
1353
+ if (videoConfig) {
1354
+ videoOutputDir = resolve(cwd, videoConfig.dir);
1355
+ ensureDir(videoOutputDir);
1356
+ if (videoConfig.filename) {
1357
+ recordVideoDir = join(tmpdir(), `demo-maker-${Date.now()}-${Math.random().toString(36).slice(2)}`);
1358
+ ensureDir(recordVideoDir);
1359
+ videoFilename = resolveTemplateFunctions(videoConfig.filename);
1360
+ } else {
1361
+ recordVideoDir = videoOutputDir;
1362
+ }
1363
+ }
1364
+ const screenshotsDirRaw = scenario.screenshots?.dir;
1365
+ if (screenshotsDirRaw) {
1366
+ ensureDir(resolve(cwd, screenshotsDirRaw));
1367
+ }
1368
+
1369
+ const slowMo = scenario.slow_mo_ms ?? DEFAULT_PACING.slow_mo_ms;
1370
+ // --start-maximized requires viewport: null to work on macOS; only use when not recording video
1371
+ // (video recording requires a fixed viewport size).
1372
+ const maximize = !headless && !recordVideoDir;
1373
+ const browser = await chromium.launch({
1374
+ headless,
1375
+ slowMo,
1376
+ args: maximize ? ["--start-maximized"] : [],
1377
+ });
1378
+ try {
1379
+ const contextOptions: Parameters<typeof browser.newContext>[0] = {
1380
+ baseURL: scenario.base_url,
1381
+ viewport: maximize ? null : viewport,
1382
+ };
1383
+ if (scenario.device_scale_factor && scenario.device_scale_factor > 0) {
1384
+ contextOptions.deviceScaleFactor = scenario.device_scale_factor;
1385
+ }
1386
+ if (recordVideoDir) {
1387
+ contextOptions.recordVideo = {
1388
+ dir: recordVideoDir,
1389
+ size: viewport,
1390
+ };
1391
+ }
1392
+ if (loadStatePath) {
1393
+ contextOptions.storageState = loadStatePath;
1394
+ logger.info(`Loading session state from: ${loadStatePath}`);
1395
+ }
1396
+
1397
+ const browserContext = await browser.newContext(contextOptions);
1398
+ contextStartMs = Date.now();
1399
+ const page = await browserContext.newPage();
1400
+
1401
+ const runResult = await runScenarioOnExistingPage(page, scenario, mergedContext, {
1402
+ logger,
1403
+ cwd,
1404
+ videoConfig,
1405
+ headless,
1406
+ failFast: options.failFast,
1407
+ endStep: options.endStep,
1408
+ debugDir: options.debugDir,
1409
+ scenarioLabel: options.scenarioLabel,
1410
+ excludeRanges,
1411
+ urlCaptureHooks: options.urlCaptureHooks,
1412
+ });
1413
+ success = runResult.success;
1414
+ stepResults = runResult.stepResults;
1415
+ lastError = runResult.error;
1416
+
1417
+ if (options.pauseAtEnd && !headless) {
1418
+ await waitForApproval("Paused at end of run. Press Enter to close the browser...\n");
1419
+ }
1420
+
1421
+ if (saveStatePath) {
1422
+ await browserContext.storageState({ path: saveStatePath });
1423
+ logger.info(`Session state saved to: ${saveStatePath}`);
1424
+ }
1425
+
1426
+ await browserContext.close();
1427
+
1428
+ if (recordVideoDir) {
1429
+ const video = page.video();
1430
+ if (video) {
1431
+ try {
1432
+ const recordedPath = await video.path();
1433
+ if (recordedPath) {
1434
+ if (videoFilename && videoOutputDir) {
1435
+ const destPath = join(videoOutputDir, videoFilename);
1436
+ copyFileSync(recordedPath, destPath);
1437
+ videoPath = destPath;
1438
+ } else {
1439
+ videoPath = recordedPath;
1440
+ }
1441
+ }
1442
+ } catch (e) {
1443
+ logger.warn("Failed to get or save video path:", e instanceof Error ? e.message : e);
1444
+ }
1445
+ }
1446
+ }
1447
+
1448
+ const excludeSec = excludeRangesToSec(excludeRanges, contextStartMs);
1449
+
1450
+ if (videoPath && excludeSec.length > 0) {
1451
+ const last = stepResults[stepResults.length - 1];
1452
+ const durationSec = last ? Math.max(0, (last.endTimeMs - contextStartMs) / 1000) : 0;
1453
+ const splicedPath = join(tmpdir(), `demo-splice-${Date.now()}.webm`);
1454
+ const spliceResult = spliceVideoExcludingRanges(videoPath, splicedPath, durationSec, excludeSec);
1455
+ if (spliceResult.ok && (spliceResult.removedSec ?? 0) > 0) {
1456
+ copyFileSync(splicedPath, videoPath);
1457
+ unlinkSync(splicedPath);
1458
+ logger.info(
1459
+ `Spliced video: removed ${(spliceResult.removedSec ?? 0).toFixed(1)}s of excluded content.`
1460
+ );
1461
+ } else if (!spliceResult.ok) {
1462
+ logger.warn(
1463
+ "ffmpeg splice failed (is ffmpeg installed?):",
1464
+ spliceResult.stderr?.slice(-500) ?? "unknown error"
1465
+ );
1466
+ } else if (spliceResult.ok) {
1467
+ try {
1468
+ unlinkSync(splicedPath);
1469
+ } catch {
1470
+ /* temp file may not exist */
1471
+ }
1472
+ }
1473
+ }
1474
+
1475
+ if (videoPath && videoConfig?.start_step != null && videoConfig?.end_step != null) {
1476
+ const startStep = videoConfig.start_step;
1477
+ const endStep = videoConfig.end_step;
1478
+ const n = scenario.steps.length;
1479
+ if (startStep >= 1 && endStep >= startStep && endStep <= n) {
1480
+ const startIdx = startStep - 1;
1481
+ const endIdx = endStep - 1;
1482
+ const rawStartSec = Math.max(0, (stepResults[startIdx]!.startTimeMs - contextStartMs) / 1000);
1483
+ const rawEndSec = (stepResults[endIdx]!.endTimeMs - contextStartMs) / 1000;
1484
+ const startSec = excludeSec.length > 0 ? remapTimelineSec(rawStartSec, excludeSec) : rawStartSec;
1485
+ const endSec = excludeSec.length > 0 ? remapTimelineSec(rawEndSec, excludeSec) : rawEndSec;
1486
+ if (endSec > startSec) {
1487
+ const trimmedPath = join(tmpdir(), `demo-trim-${Date.now()}-${resolveTemplateFunctions(videoConfig.filename ?? "out.webm")}`);
1488
+ const ff = spawnSync("ffmpeg", [
1489
+ "-y",
1490
+ "-i", videoPath,
1491
+ "-ss", String(startSec),
1492
+ "-to", String(endSec),
1493
+ "-c", "copy",
1494
+ trimmedPath,
1495
+ ], { encoding: "utf8" });
1496
+ if (ff.status === 0) {
1497
+ copyFileSync(trimmedPath, videoPath);
1498
+ unlinkSync(trimmedPath);
1499
+ logger.info(`Trimmed video to steps ${startStep}-${endStep} (${startSec.toFixed(1)}s–${endSec.toFixed(1)}s).`);
1500
+ } else {
1501
+ logger.warn("ffmpeg trim failed (is ffmpeg installed?):", ff.stderr?.slice(-500) ?? ff.error);
1502
+ }
1503
+ }
1504
+ }
1505
+ }
1506
+
1507
+ // Re-encode to H.264 MP4 when the output filename ends in .mp4.
1508
+ // Playwright always writes VP8 WebM at a hardcoded 1 Mbit/s, which degrades badly on
1509
+ // YouTube. Re-encoding with libx264 CRF 18 produces near-lossless quality and the
1510
+ // yuv420p/faststart flags ensure compatibility with all players and YouTube's ingestion.
1511
+ // When video.viewport is smaller than video.size (e.g. MacBook headed mode), ffmpeg also
1512
+ // upscales to the target resolution using the high-quality lanczos scaler.
1513
+ // When audio.background is set, the music track is mixed in (looped to video length, trimmed with -shortest).
1514
+ if (videoPath && videoPath.endsWith(".mp4")) {
1515
+ const playbackSpeed = scenario.video?.playback_speed ?? 1;
1516
+ const needsUpscale =
1517
+ viewport.width !== videoOutputSize.width || viewport.height !== videoOutputSize.height;
1518
+ const vfFilters: string[] = [];
1519
+ if (playbackSpeed !== 1) {
1520
+ vfFilters.push(`setpts=PTS/${playbackSpeed}`);
1521
+ }
1522
+ if (needsUpscale) {
1523
+ vfFilters.push(`scale=${videoOutputSize.width}:${videoOutputSize.height}:flags=lanczos`);
1524
+ }
1525
+ const videoFilter = vfFilters.length > 0 ? ["-vf", vfFilters.join(",")] : [];
1526
+
1527
+ const audioPath = scenario.audio?.background
1528
+ ? resolve(cwd, scenario.audio.background)
1529
+ : undefined;
1530
+ const audioInput = audioPath
1531
+ ? ["-stream_loop", "-1", "-i", audioPath]
1532
+ : [];
1533
+ const audioCodec = audioPath
1534
+ ? ["-c:a", "aac", "-b:a", "192k", "-shortest"]
1535
+ : [];
1536
+
1537
+ const encodedPath = join(tmpdir(), `demo-encoded-${Date.now()}.mp4`);
1538
+ const ff = spawnSync("ffmpeg", [
1539
+ "-y",
1540
+ "-i", videoPath,
1541
+ ...audioInput,
1542
+ ...videoFilter,
1543
+ "-c:v", "libx264",
1544
+ "-crf", "18",
1545
+ "-preset", "slow",
1546
+ "-pix_fmt", "yuv420p",
1547
+ "-movflags", "+faststart",
1548
+ ...audioCodec,
1549
+ encodedPath,
1550
+ ], { encoding: "utf8" });
1551
+ if (ff.status === 0) {
1552
+ copyFileSync(encodedPath, videoPath);
1553
+ unlinkSync(encodedPath);
1554
+ const parts: string[] = [];
1555
+ if (playbackSpeed !== 1) parts.push(`Playback speed ${playbackSpeed}×`);
1556
+ if (needsUpscale) parts.push(`Upscaled ${viewport.width}x${viewport.height} → ${videoOutputSize.width}x${videoOutputSize.height}`);
1557
+ if (audioPath) parts.push("Mixed background audio");
1558
+ logger.info(`Re-encoded to H.264 MP4 (CRF 18).${parts.length ? " " + parts.join(". ") + "." : ""}`);
1559
+ } else {
1560
+ logger.warn("ffmpeg H.264 re-encode failed — keeping original file. Is ffmpeg installed?", ff.stderr?.slice(-500) ?? ff.error);
1561
+ }
1562
+ }
1563
+ } finally {
1564
+ await browser.close();
1565
+ }
1566
+
1567
+ const result: RunResult = { success, stepResults };
1568
+ if (lastError !== undefined) {
1569
+ result.error = lastError;
1570
+ }
1571
+ if (videoPath !== undefined) {
1572
+ result.videoPath = videoPath;
1573
+ const timing = buildTimingExport(scenario, stepResults, contextStartMs, videoPath, excludeRanges);
1574
+ try {
1575
+ result.timingPath = writeTimingJson(timing, videoPath);
1576
+ logger.info(`Timing saved: ${result.timingPath}`);
1577
+ } catch (e) {
1578
+ logger.warn("Failed to write timing.json:", e instanceof Error ? e.message : e);
1579
+ }
1580
+ }
1581
+ return result;
1582
+ }