@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.
- package/LICENSE +201 -0
- package/README.md +104 -0
- package/SECURITY.md +33 -0
- package/bin/demo-maker.mjs +14 -0
- package/docs/README.md +35 -0
- package/docs/actions.md +160 -0
- package/docs/cli.md +73 -0
- package/docs/getting-started.md +220 -0
- package/docs/imports-and-fragments.md +78 -0
- package/docs/library-api.md +64 -0
- package/docs/locators.md +119 -0
- package/docs/recording.md +59 -0
- package/docs/repairing-scenarios.md +73 -0
- package/docs/scenario-schema.md +80 -0
- package/docs/url-capture.md +75 -0
- package/docs/variables.md +48 -0
- package/docs/video-and-pacing.md +109 -0
- package/examples/demo1-introduction.yaml +166 -0
- package/examples/demo2-github-actions.yaml +109 -0
- package/examples/files/demo1-introduction-run.log +50 -0
- package/examples/fragments/open-repository.yaml +15 -0
- package/examples/smoke.yaml +18 -0
- package/llms.txt +32 -0
- package/package.json +66 -0
- package/src/cli.ts +226 -0
- package/src/env.ts +112 -0
- package/src/export-chapters.ts +99 -0
- package/src/ffmpeg.ts +168 -0
- package/src/index.ts +54 -0
- package/src/load.ts +143 -0
- package/src/logger.ts +126 -0
- package/src/main.ts +68 -0
- package/src/parse-errors.ts +99 -0
- package/src/parse.ts +622 -0
- package/src/playwright-selector-to-locator.ts +328 -0
- package/src/record-cli.ts +179 -0
- package/src/recorder.ts +442 -0
- package/src/recording-to-yaml.ts +435 -0
- package/src/runner.ts +1582 -0
- package/src/selector-to-locator.ts +69 -0
- package/src/templates.ts +48 -0
- package/src/types.ts +319 -0
- package/src/url-capture.ts +105 -0
- package/src/variables.ts +64 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse Puppeteer Replay / Chrome DevTools Recorder selectors into demo-maker Locator.
|
|
3
|
+
* Supports aria/ and text/; css/xpath get a fallback locator + note.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Locator } from "./types";
|
|
7
|
+
|
|
8
|
+
export interface SelectorToLocatorResult {
|
|
9
|
+
locator: Locator;
|
|
10
|
+
note?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Parse a single Replay selector string into our Locator if possible.
|
|
15
|
+
* - aria/role[name="..."] or aria/role[name='...'] -> { kind: "role", role, name }
|
|
16
|
+
* - text=... or text/... -> { kind: "text", text }
|
|
17
|
+
* - css=... / xpath=... -> fallback { kind: "text", text: selector } with note
|
|
18
|
+
*/
|
|
19
|
+
function parseOneSelector(selector: string): SelectorToLocatorResult | null {
|
|
20
|
+
const s = selector.trim();
|
|
21
|
+
if (!s) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// aria/button[name="Sign in"] or aria/button[name='Sign in']
|
|
26
|
+
const ariaMatch = s.match(/^aria\/([a-zA-Z]+)(?:\[name=(?:"([^"]*)"|'([^']*)')])?$/);
|
|
27
|
+
if (ariaMatch) {
|
|
28
|
+
const role = ariaMatch[1]!;
|
|
29
|
+
const name = ariaMatch[2] ?? ariaMatch[3] ?? "";
|
|
30
|
+
return { locator: { kind: "role", role, name } };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// text=Sign in or text/Sign in (some formats use /)
|
|
34
|
+
if (s.startsWith("text=")) {
|
|
35
|
+
return { locator: { kind: "text", text: s.slice(5).trim() } };
|
|
36
|
+
}
|
|
37
|
+
if (s.startsWith("text/")) {
|
|
38
|
+
return { locator: { kind: "text", text: s.slice(5).trim() } };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// css= or xpath=: no direct mapping; return fallback so step is not dropped
|
|
42
|
+
if (s.startsWith("css=") || s.startsWith("xpath=")) {
|
|
43
|
+
return {
|
|
44
|
+
locator: { kind: "text", text: `(replace: ${s})` },
|
|
45
|
+
note: `Original selector: ${s}. Replace locator in YAML.`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Given Replay selectors array, return our Locator + optional note.
|
|
54
|
+
* Tries each selector in order; returns first successful aria/ or text/ parse, or fallback for css/xpath.
|
|
55
|
+
*/
|
|
56
|
+
export function replaySelectorsToLocator(selectors: string[]): SelectorToLocatorResult | null {
|
|
57
|
+
if (!Array.isArray(selectors) || selectors.length === 0) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const sel of selectors) {
|
|
62
|
+
const parsed = parseOneSelector(typeof sel === "string" ? sel : String(sel));
|
|
63
|
+
if (parsed) {
|
|
64
|
+
return parsed;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return null;
|
|
69
|
+
}
|
package/src/templates.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template function resolution for demo scenario string values.
|
|
3
|
+
*
|
|
4
|
+
* Syntax: [fnName:"arg"]
|
|
5
|
+
* Currently supported:
|
|
6
|
+
* [date:"FORMAT"] — replaced with the current local date/time formatted per FORMAT.
|
|
7
|
+
*
|
|
8
|
+
* Supported format tokens:
|
|
9
|
+
* YYYY 4-digit year
|
|
10
|
+
* MM 2-digit month (01–12)
|
|
11
|
+
* DD 2-digit day (01–31)
|
|
12
|
+
* HH 2-digit hour, 24h (00–23)
|
|
13
|
+
* mm 2-digit minute (00–59)
|
|
14
|
+
*
|
|
15
|
+
* Example:
|
|
16
|
+
* resolveTemplateFunctions('homepage-demo-[date:"YYYY-MM-DD-HHmm"].webm')
|
|
17
|
+
* // → 'homepage-demo-2026-04-01-1432.webm'
|
|
18
|
+
*
|
|
19
|
+
* Unrecognised function names are left as-is.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const TEMPLATE_FUNCTION_REGEX = /\[(\w+):"([^"]*)"\]/g;
|
|
23
|
+
|
|
24
|
+
function pad2(n: number): string {
|
|
25
|
+
return String(n).padStart(2, "0");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function formatDate(fmt: string, now: Date): string {
|
|
29
|
+
return fmt
|
|
30
|
+
.replace("YYYY", String(now.getFullYear()))
|
|
31
|
+
.replace("MM", pad2(now.getMonth() + 1))
|
|
32
|
+
.replace("DD", pad2(now.getDate()))
|
|
33
|
+
.replace("HH", pad2(now.getHours()))
|
|
34
|
+
.replace("mm", pad2(now.getMinutes()));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolves [fnName:"arg"] template functions in a string.
|
|
39
|
+
* Unknown function names are preserved as-is.
|
|
40
|
+
*/
|
|
41
|
+
export function resolveTemplateFunctions(str: string): string {
|
|
42
|
+
return str.replace(TEMPLATE_FUNCTION_REGEX, (match, fn: string, arg: string) => {
|
|
43
|
+
if (fn === "date") {
|
|
44
|
+
return formatDate(arg, new Date());
|
|
45
|
+
}
|
|
46
|
+
return match;
|
|
47
|
+
});
|
|
48
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Domain types and interfaces for the demo-maker.
|
|
3
|
+
* Self-contained under ui/demo-maker; not for consumption by the rest of the app.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type ActionType =
|
|
7
|
+
| "navigate"
|
|
8
|
+
| "click"
|
|
9
|
+
| "type"
|
|
10
|
+
| "fill"
|
|
11
|
+
| "wait"
|
|
12
|
+
| "expect_visible"
|
|
13
|
+
| "pause"
|
|
14
|
+
| "log"
|
|
15
|
+
| "screenshot"
|
|
16
|
+
| "title_card"
|
|
17
|
+
| "transition"
|
|
18
|
+
| "repeat_while_visible"
|
|
19
|
+
| "cut_video"
|
|
20
|
+
| "drag";
|
|
21
|
+
|
|
22
|
+
/** Caption overlay style when `caption:` is set on a step. */
|
|
23
|
+
export type CaptionStyle = "default" | "chapter";
|
|
24
|
+
|
|
25
|
+
export type Locator =
|
|
26
|
+
| { kind: "role"; role: string; name: string | RegExp }
|
|
27
|
+
| { kind: "label"; label: string | RegExp }
|
|
28
|
+
| { kind: "text"; text: string }
|
|
29
|
+
| { kind: "placeholder"; placeholder: string }
|
|
30
|
+
| { kind: "title"; title: string }
|
|
31
|
+
| { kind: "selector"; selector: string };
|
|
32
|
+
|
|
33
|
+
export interface AuthConfig {
|
|
34
|
+
email_env: string;
|
|
35
|
+
password_env: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Capture a path segment from the browser URL into a scenario variable. */
|
|
39
|
+
export interface UrlCaptureRule {
|
|
40
|
+
/** Variable name for `{{ name }}` (e.g. workspace_id). */
|
|
41
|
+
variable: string;
|
|
42
|
+
/** RegExp source; the first capturing group is stored. */
|
|
43
|
+
pattern: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Video recording options. When enabled, the run is recorded and saved to the given folder. */
|
|
47
|
+
export interface VideoConfig {
|
|
48
|
+
/** Enable video recording for this scenario. Default false. */
|
|
49
|
+
enabled: boolean;
|
|
50
|
+
/** Output folder path for the recorded video (resolved relative to cwd when running). */
|
|
51
|
+
dir: string;
|
|
52
|
+
/** Optional output filename (e.g. "demo-login.webm"). If omitted, Playwright chooses the name. */
|
|
53
|
+
filename?: string;
|
|
54
|
+
/** Final output video resolution. Also used as the browser viewport unless `viewport` is set.
|
|
55
|
+
* Defaults to 1920x1080. */
|
|
56
|
+
size?: { width: number; height: number };
|
|
57
|
+
/** Browser viewport size during recording. Use this when the output `size` is larger than the
|
|
58
|
+
* physical screen (e.g. MacBook Retina ~1512 logical px wide). The browser renders at this
|
|
59
|
+
* smaller viewport, and ffmpeg upscales to `size` during the H.264 re-encode step.
|
|
60
|
+
* Defaults to `size` (no upscaling). */
|
|
61
|
+
viewport?: { width: number; height: number };
|
|
62
|
+
/** Optional 1-based start step (inclusive). When set with end_step, the recorded video is trimmed to this step range. E.g. skip login: start_step: 11, end_step: 15. */
|
|
63
|
+
start_step?: number;
|
|
64
|
+
/** Optional 1-based end step (inclusive). Use with start_step to trim the video. Requires ffmpeg. */
|
|
65
|
+
end_step?: number;
|
|
66
|
+
/**
|
|
67
|
+
* When true, after showing a step `caption` the runner does not wait `caption_hold_ms` before the
|
|
68
|
+
* next step — the caption fades out on a browser timer so automation can continue while it is visible.
|
|
69
|
+
* Default false: legacy behavior (hold blocks until the caption duration elapses).
|
|
70
|
+
*/
|
|
71
|
+
caption_parallel?: boolean;
|
|
72
|
+
/** Playback speed multiplier for final MP4 (1 = normal). Applied during H.264 re-encode before audio mux. */
|
|
73
|
+
playback_speed?: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Audio options mixed into the final video during the ffmpeg re-encode step. */
|
|
77
|
+
export interface AudioConfig {
|
|
78
|
+
/** Path to a background music file (mp3/wav/aac). Resolved relative to cwd. Loops/trims to video length. */
|
|
79
|
+
background: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface DemoScenario {
|
|
83
|
+
name: string;
|
|
84
|
+
/** Optional public / YouTube title; `name` stays the short internal id for CLI and filenames. */
|
|
85
|
+
title?: string;
|
|
86
|
+
description: string;
|
|
87
|
+
base_url: string;
|
|
88
|
+
seed_data?: string;
|
|
89
|
+
auth?: AuthConfig;
|
|
90
|
+
/**
|
|
91
|
+
* Optional directory for screenshots saved by `action: screenshot`.
|
|
92
|
+
* Resolved relative to the runner cwd (same rule as `video.dir`).
|
|
93
|
+
*/
|
|
94
|
+
screenshots?: ScreenshotConfig;
|
|
95
|
+
/**
|
|
96
|
+
* Optional device scale factor (DPR). When > 1, Playwright renders more pixels per CSS pixel,
|
|
97
|
+
* producing "retina" screenshots (and higher-res recordings when video is enabled).
|
|
98
|
+
* Default 1.
|
|
99
|
+
*/
|
|
100
|
+
device_scale_factor?: number;
|
|
101
|
+
/** Video recording: enable and set output folder (and optional filename). */
|
|
102
|
+
video?: VideoConfig;
|
|
103
|
+
/** Audio to mix into the final video. Requires ffmpeg and a .mp4 filename. */
|
|
104
|
+
audio?: AudioConfig;
|
|
105
|
+
/** Optional delay in ms after each step (for slower playback, e.g. demo videos). */
|
|
106
|
+
step_delay_ms?: number;
|
|
107
|
+
/** Playwright slowMo in ms: delay after each browser action (click, type, etc.) for smoother playback. */
|
|
108
|
+
slow_mo_ms?: number;
|
|
109
|
+
/** Delay in ms between keystrokes for type steps (human-like typing). Used with pressSequentially. */
|
|
110
|
+
typing_delay_ms?: number;
|
|
111
|
+
/** Delay in ms after each click (lets the result of the click be visible before next action). */
|
|
112
|
+
post_click_delay_ms?: number;
|
|
113
|
+
/** Wait in ms after each navigate (SPA hydration + hold on new page). Default 1500. */
|
|
114
|
+
navigate_hold_ms?: number;
|
|
115
|
+
/** Delay in ms before each navigate and before each click so the video has more frames of the current page before the cut (Playwright has no easing; at ~25 FPS the cut is instant). */
|
|
116
|
+
pre_action_delay_ms?: number;
|
|
117
|
+
/** Default Playwright action timeout in ms for click/navigate/type/fill/expect_visible steps.
|
|
118
|
+
* Per-step timeout_ms takes precedence. Default: 10000. */
|
|
119
|
+
action_timeout_ms?: number;
|
|
120
|
+
/** Path to a Playwright storageState JSON file to load before running (restores cookies/localStorage). Resolved relative to cwd. */
|
|
121
|
+
storage_state?: string;
|
|
122
|
+
/** Path to save the Playwright storageState JSON after the run completes (use with a login-only scenario to persist the session). Resolved relative to cwd. */
|
|
123
|
+
save_storage_state?: string;
|
|
124
|
+
/**
|
|
125
|
+
* Optional URL → variable capture rules (entry scenario only).
|
|
126
|
+
* On navigation, each rule's first regex group is written to `variables[variable]`.
|
|
127
|
+
*/
|
|
128
|
+
url_capture?: UrlCaptureRule[];
|
|
129
|
+
/** Flattened steps only (no import directives); after loadScenario. */
|
|
130
|
+
steps: DemoStep[];
|
|
131
|
+
/**
|
|
132
|
+
* Optional arbitrary key/value map from YAML (e.g. YouTube paste text, tags). Ignored by parser
|
|
133
|
+
* validation and by the Playwright runner; for humans or external tooling. Values may include
|
|
134
|
+
* nested objects, arrays, and strings (e.g. `{{ VAR }}` placeholders).
|
|
135
|
+
*/
|
|
136
|
+
metadata?: Record<string, unknown>;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Optional post-click readiness: wait for a visible UI signal after click. */
|
|
140
|
+
export type DemoStepSettleUntil = { text: string } | { locator: Locator };
|
|
141
|
+
|
|
142
|
+
export interface DemoStep {
|
|
143
|
+
caption?: string;
|
|
144
|
+
/** Overlay style for `caption:` — `chapter` uses a larger title-style bar. */
|
|
145
|
+
caption_style?: CaptionStyle;
|
|
146
|
+
action: ActionType;
|
|
147
|
+
url?: string;
|
|
148
|
+
locator?: Locator;
|
|
149
|
+
value?: string;
|
|
150
|
+
timeout_ms?: number;
|
|
151
|
+
until?: "url" | "hidden";
|
|
152
|
+
url_pattern?: string;
|
|
153
|
+
/** Only for `action: wait` with `until: hidden`. Reload the page on this interval while waiting. */
|
|
154
|
+
refresh_every_ms?: number;
|
|
155
|
+
note?: string;
|
|
156
|
+
/** Message to print to the console (supports {{ VAR }} interpolation). Used with action: log. */
|
|
157
|
+
message?: string;
|
|
158
|
+
/** Full-screen title text. Used with action: title_card. */
|
|
159
|
+
text?: string;
|
|
160
|
+
/** Hold duration in ms for title_card overlay, or black hold for transition (skips fade-back). Default 1800 for title_card. */
|
|
161
|
+
hold_ms?: number;
|
|
162
|
+
/** Fade duration in ms for title_card or transition. Default 400–500. */
|
|
163
|
+
fade_ms?: number;
|
|
164
|
+
/**
|
|
165
|
+
* Only for `action: screenshot`.
|
|
166
|
+
* - When the step has a `locator`, captures the element; otherwise captures the page.
|
|
167
|
+
* - If `file` is omitted, runner auto-names `step-XX.png`.
|
|
168
|
+
*/
|
|
169
|
+
screenshot?: ScreenshotOptions;
|
|
170
|
+
/**
|
|
171
|
+
* Only for `action: click`. After the click, wait until this UI signal is visible, then rAF paint.
|
|
172
|
+
* Omit for rAF-only settle (Playwright click already auto-waits for actionability).
|
|
173
|
+
*/
|
|
174
|
+
settle_until?: DemoStepSettleUntil;
|
|
175
|
+
/**
|
|
176
|
+
* Only for `action: type`. Ms between keystrokes; overrides scenario `typing_delay_ms`. Use 0 for long text.
|
|
177
|
+
*/
|
|
178
|
+
typing_delay_ms?: number;
|
|
179
|
+
/**
|
|
180
|
+
* Only for `fill` / `type` when the target is a `<select>`. Waits until an option matching
|
|
181
|
+
* `value` (visible label or `value` attribute) exists, selects, then polls until that choice is
|
|
182
|
+
* actually selected — avoids long timeouts when options load async (Playwright does not fully
|
|
183
|
+
* wait for dynamic options before select).
|
|
184
|
+
*/
|
|
185
|
+
select_confirm?: boolean;
|
|
186
|
+
/**
|
|
187
|
+
* Only for `action: type`. When false, skip `locator.clear()` before `pressSequentially` — use after
|
|
188
|
+
* inserting mentions/chips so the next keystrokes append instead of wiping the editor.
|
|
189
|
+
* Default: clear first (backward compatible).
|
|
190
|
+
*/
|
|
191
|
+
clear_before_type?: boolean;
|
|
192
|
+
/**
|
|
193
|
+
* Only for `repeat_while_visible` or `cut_video`. Nested steps run in order while `locator` matches a visible element,
|
|
194
|
+
* or are excluded from the final video (`cut_video`).
|
|
195
|
+
*/
|
|
196
|
+
steps?: DemoStep[];
|
|
197
|
+
/** Safety cap for `repeat_while_visible`. Default 100. */
|
|
198
|
+
max_iterations?: number;
|
|
199
|
+
/**
|
|
200
|
+
* Only for `cut_video`. Optional step (typically `title_card`) shown in the video immediately before the excluded block.
|
|
201
|
+
*/
|
|
202
|
+
placeholder?: DemoStep;
|
|
203
|
+
/**
|
|
204
|
+
* When true on any step, that step's wall-clock duration is removed from the final video via ffmpeg splice.
|
|
205
|
+
* Prefer `cut_video` with nested steps for multi-step excludes.
|
|
206
|
+
*/
|
|
207
|
+
video_exclude?: boolean;
|
|
208
|
+
/**
|
|
209
|
+
* Only for `action: drag`. Horizontal pixel offset from the locator center (negative = left).
|
|
210
|
+
*/
|
|
211
|
+
offset_x?: number;
|
|
212
|
+
/**
|
|
213
|
+
* Only for `action: drag`. Vertical pixel offset from the locator center. Default 0.
|
|
214
|
+
*/
|
|
215
|
+
offset_y?: number;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Wall-clock exclude interval collected during a run (absolute `Date.now()` values). */
|
|
219
|
+
export interface VideoExcludeRange {
|
|
220
|
+
startMs: number;
|
|
221
|
+
endMs: number;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export interface ScreenshotConfig {
|
|
225
|
+
/** Output folder path for screenshots (resolved relative to cwd when running). */
|
|
226
|
+
dir: string;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export interface ScreenshotOptions {
|
|
230
|
+
/** Output filename (e.g. "workflow-builder.png"). Defaults to `step-XX.png`. */
|
|
231
|
+
file?: string;
|
|
232
|
+
/** Capture full scrollable page (page screenshot only). Default false. */
|
|
233
|
+
full_page?: boolean;
|
|
234
|
+
/** For PNG screenshots only: make background transparent. Default false. */
|
|
235
|
+
omit_background?: boolean;
|
|
236
|
+
/**
|
|
237
|
+
* Screenshot scale. When `device_scale_factor` > 1, use "device" to keep the higher pixel density.
|
|
238
|
+
* Default "css".
|
|
239
|
+
*/
|
|
240
|
+
scale?: "css" | "device";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Step-level import directive; expanded in place by the loader. */
|
|
244
|
+
export interface StepImport {
|
|
245
|
+
import: string;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export type StepOrImport = DemoStep | StepImport;
|
|
249
|
+
|
|
250
|
+
/** Parsed scenario before loader expansion: steps may contain import directives; loader flattens to DemoScenario. */
|
|
251
|
+
export interface ParsedScenario extends Omit<DemoScenario, "steps"> {
|
|
252
|
+
/** Top-level imports (paths relative to this file's directory); used only by loader. */
|
|
253
|
+
imports?: string[];
|
|
254
|
+
steps: StepOrImport[];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export interface RunContext {
|
|
258
|
+
auth: { email: string; password: string };
|
|
259
|
+
variables?: Record<string, string>;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export interface StepResult {
|
|
263
|
+
stepIndex: number;
|
|
264
|
+
action: ActionType;
|
|
265
|
+
caption?: string;
|
|
266
|
+
message?: string;
|
|
267
|
+
startTimeMs: number;
|
|
268
|
+
endTimeMs: number;
|
|
269
|
+
success: boolean;
|
|
270
|
+
error?: string;
|
|
271
|
+
/** When true, this step's duration was cut from the final video. */
|
|
272
|
+
excluded?: boolean;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export interface TimingStepEntry {
|
|
276
|
+
/** 1-based step index in the scenario. */
|
|
277
|
+
index: number;
|
|
278
|
+
action: ActionType;
|
|
279
|
+
caption?: string;
|
|
280
|
+
message?: string;
|
|
281
|
+
startSec: number;
|
|
282
|
+
endSec: number;
|
|
283
|
+
/** When true, step was removed from the final video (omit from YouTube chapters). */
|
|
284
|
+
excluded?: boolean;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Exported next to the video for YouTube chapters and post-processing. */
|
|
288
|
+
export interface TimingExport {
|
|
289
|
+
scenarioName: string;
|
|
290
|
+
videoPath?: string;
|
|
291
|
+
durationSec: number;
|
|
292
|
+
steps: TimingStepEntry[];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export interface RunResult {
|
|
296
|
+
success: boolean;
|
|
297
|
+
error?: string;
|
|
298
|
+
stepResults: StepResult[];
|
|
299
|
+
/** Path to the saved video file, if video recording was enabled and completed. */
|
|
300
|
+
videoPath?: string;
|
|
301
|
+
/** Path to timing.json when video recording was enabled. */
|
|
302
|
+
timingPath?: string;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export interface IDemoLogger {
|
|
306
|
+
log(...args: unknown[]): void;
|
|
307
|
+
info(...args: unknown[]): void;
|
|
308
|
+
warn(...args: unknown[]): void;
|
|
309
|
+
error(...args: unknown[]): void;
|
|
310
|
+
debug(...args: unknown[]): void;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export interface IScenarioParser {
|
|
314
|
+
parse(yaml: string): DemoScenario;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export interface IDemoRunner {
|
|
318
|
+
run(scenario: DemoScenario, context: RunContext): Promise<RunResult>;
|
|
319
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pluggable URL → variable capture during scenario runs.
|
|
3
|
+
*
|
|
4
|
+
* On navigate (and after some steps), matching URL path segments can be stored as
|
|
5
|
+
* `{{ variable }}` values for later steps — e.g. capture an id from `/workspace/abc/…`.
|
|
6
|
+
*
|
|
7
|
+
* Library default: no hooks. Configure via scenario `url_capture`, CLI
|
|
8
|
+
* `--capture-from-url` / `--url-capture-file`, or `urlCaptureHooks` in options.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from "fs";
|
|
12
|
+
import { resolve } from "path";
|
|
13
|
+
import type { UrlCaptureRule } from "./types";
|
|
14
|
+
|
|
15
|
+
export type { UrlCaptureRule };
|
|
16
|
+
|
|
17
|
+
export type UrlCaptureHook = (url: string, captured: Record<string, string>) => void;
|
|
18
|
+
|
|
19
|
+
/** Build a hook from named regex rules (first capture group → variable). */
|
|
20
|
+
export function createRegexUrlCapture(rules: UrlCaptureRule[]): UrlCaptureHook {
|
|
21
|
+
const compiled = rules.map((r) => {
|
|
22
|
+
const variable = r.variable.trim();
|
|
23
|
+
if (!variable) {
|
|
24
|
+
throw new Error("url_capture rule variable must be non-empty");
|
|
25
|
+
}
|
|
26
|
+
const re = typeof r.pattern === "string" ? new RegExp(r.pattern) : r.pattern;
|
|
27
|
+
return { variable, re };
|
|
28
|
+
});
|
|
29
|
+
return (url, captured) => {
|
|
30
|
+
for (const { variable, re } of compiled) {
|
|
31
|
+
const m = url.match(re);
|
|
32
|
+
if (m?.[1]) {
|
|
33
|
+
captured[variable] = m[1];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Parse CLI form: `variable=pattern` (pattern is RegExp source). */
|
|
40
|
+
export function parseCaptureFromUrlArg(raw: string): UrlCaptureRule {
|
|
41
|
+
const eq = raw.indexOf("=");
|
|
42
|
+
if (eq <= 0) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`Invalid --capture-from-url "${raw}". Expected VARIABLE=regex-source (first group is captured).`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
const variable = raw.slice(0, eq).trim();
|
|
48
|
+
const pattern = raw.slice(eq + 1);
|
|
49
|
+
if (!variable || !pattern) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`Invalid --capture-from-url "${raw}". Expected VARIABLE=regex-source (first group is captured).`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return { variable, pattern };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Load rules from a JSON file: `[{ "variable": "id", "pattern": "/foo/([^/]+)" }, …]`. */
|
|
58
|
+
export function loadUrlCaptureRulesFile(filePath: string, cwd: string = process.cwd()): UrlCaptureRule[] {
|
|
59
|
+
const abs = resolve(cwd, filePath);
|
|
60
|
+
let parsed: unknown;
|
|
61
|
+
try {
|
|
62
|
+
parsed = JSON.parse(readFileSync(abs, "utf8"));
|
|
63
|
+
} catch (err) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`Failed to read url-capture file ${abs}: ${err instanceof Error ? err.message : String(err)}`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (!Array.isArray(parsed)) {
|
|
69
|
+
throw new Error(`url-capture file must be a JSON array: ${abs}`);
|
|
70
|
+
}
|
|
71
|
+
const rules: UrlCaptureRule[] = [];
|
|
72
|
+
for (let i = 0; i < parsed.length; i++) {
|
|
73
|
+
const item = parsed[i];
|
|
74
|
+
if (!item || typeof item !== "object") {
|
|
75
|
+
throw new Error(`url-capture[${i}] must be an object in ${abs}`);
|
|
76
|
+
}
|
|
77
|
+
const rec = item as Record<string, unknown>;
|
|
78
|
+
if (typeof rec.variable !== "string" || typeof rec.pattern !== "string") {
|
|
79
|
+
throw new Error(`url-capture[${i}] needs string "variable" and "pattern" in ${abs}`);
|
|
80
|
+
}
|
|
81
|
+
rules.push({ variable: rec.variable, pattern: rec.pattern });
|
|
82
|
+
}
|
|
83
|
+
return rules;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function applyUrlCapture(
|
|
87
|
+
url: string,
|
|
88
|
+
captured: Record<string, string>,
|
|
89
|
+
hooks: UrlCaptureHook[] | undefined
|
|
90
|
+
): void {
|
|
91
|
+
if (!hooks?.length) return;
|
|
92
|
+
for (const hook of hooks) {
|
|
93
|
+
hook(url, captured);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Merge scenario rules + CLI rules into hooks (CLI rules appended after scenario). */
|
|
98
|
+
export function buildUrlCaptureHooks(options: {
|
|
99
|
+
scenarioRules?: UrlCaptureRule[];
|
|
100
|
+
cliRules?: UrlCaptureRule[];
|
|
101
|
+
}): UrlCaptureHook[] | undefined {
|
|
102
|
+
const rules = [...(options.scenarioRules ?? []), ...(options.cliRules ?? [])];
|
|
103
|
+
if (rules.length === 0) return undefined;
|
|
104
|
+
return [createRegexUrlCapture(rules)];
|
|
105
|
+
}
|
package/src/variables.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Variable resolution for demo scenario placeholders (e.g. {{ auth.email }}, {{ workspace_id }}).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { RunContext } from "./types";
|
|
6
|
+
|
|
7
|
+
const PLACEHOLDER_REGEX = /\{\{\s*([^}]+)\s*\}\}/g;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolves nested keys like "auth.email" or flat "workspace_id" from context.
|
|
11
|
+
* context.auth.email, context.variables["workspace_id"], etc.
|
|
12
|
+
*/
|
|
13
|
+
function getNestedValue(
|
|
14
|
+
context: RunContext,
|
|
15
|
+
path: string
|
|
16
|
+
): string | undefined {
|
|
17
|
+
const trimmed = path.trim();
|
|
18
|
+
const parts = trimmed.split(".");
|
|
19
|
+
|
|
20
|
+
// Single segment: check context.variables first (e.g. workspace_id, document_id)
|
|
21
|
+
if (parts.length === 1 && context.variables?.[trimmed] !== undefined) {
|
|
22
|
+
const v = context.variables[trimmed];
|
|
23
|
+
return typeof v === "string" ? v : String(v);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let current: unknown = context;
|
|
27
|
+
for (const part of parts) {
|
|
28
|
+
if (current === null || current === undefined) {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
if (typeof current !== "object") {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
current = (current as Record<string, unknown>)[part];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (typeof current === "string") {
|
|
38
|
+
return current;
|
|
39
|
+
}
|
|
40
|
+
if (typeof current === "number" || typeof current === "boolean") {
|
|
41
|
+
return String(current);
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Replaces {{ path }} placeholders in template with values from context.
|
|
48
|
+
* Supports auth.email, auth.password, workspace_id, document_id, mcp.tavily_url, etc.
|
|
49
|
+
* Unresolved placeholders are left as-is.
|
|
50
|
+
*/
|
|
51
|
+
export function resolveVariables(template: string, context: RunContext): string {
|
|
52
|
+
return template.replace(PLACEHOLDER_REGEX, (_, path: string) => {
|
|
53
|
+
const value = getNestedValue(context, path.trim());
|
|
54
|
+
return value !== undefined ? value : `{{ ${path.trim()} }}`;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface IVariableResolver {
|
|
59
|
+
resolve(template: string, context: RunContext): string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const defaultVariableResolver: IVariableResolver = {
|
|
63
|
+
resolve: resolveVariables,
|
|
64
|
+
};
|