@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
package/src/parse.ts
ADDED
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse scenario YAML into typed DemoScenario. No variable substitution; leave {{ }} as-is.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { parse as parseYaml } from "yaml";
|
|
6
|
+
import type {
|
|
7
|
+
ActionType,
|
|
8
|
+
AudioConfig,
|
|
9
|
+
AuthConfig,
|
|
10
|
+
DemoStep,
|
|
11
|
+
Locator,
|
|
12
|
+
ParsedScenario,
|
|
13
|
+
ScreenshotConfig,
|
|
14
|
+
StepImport,
|
|
15
|
+
StepOrImport,
|
|
16
|
+
UrlCaptureRule,
|
|
17
|
+
VideoConfig,
|
|
18
|
+
} from "./types";
|
|
19
|
+
import {
|
|
20
|
+
describeInvalidLocator,
|
|
21
|
+
formatStepParseError,
|
|
22
|
+
type ParseScenarioContext,
|
|
23
|
+
} from "./parse-errors";
|
|
24
|
+
|
|
25
|
+
export interface ParseScenarioOptions {
|
|
26
|
+
/** Path to the YAML file (shown in validation errors). */
|
|
27
|
+
sourceFile?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const ACTION_TYPES: ActionType[] = [
|
|
31
|
+
"navigate",
|
|
32
|
+
"click",
|
|
33
|
+
"type",
|
|
34
|
+
"fill",
|
|
35
|
+
"wait",
|
|
36
|
+
"expect_visible",
|
|
37
|
+
"pause",
|
|
38
|
+
"log",
|
|
39
|
+
"screenshot",
|
|
40
|
+
"title_card",
|
|
41
|
+
"transition",
|
|
42
|
+
"repeat_while_visible",
|
|
43
|
+
"cut_video",
|
|
44
|
+
"drag",
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
function parseLocatorName(value: unknown): string | RegExp {
|
|
48
|
+
if (value instanceof RegExp) {
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
if (typeof value !== "string") {
|
|
52
|
+
return String(value ?? "");
|
|
53
|
+
}
|
|
54
|
+
// String that looks like /pattern/flags -> RegExp
|
|
55
|
+
const match = value.match(/^\/(.+)\/([gimsuy]*)$/);
|
|
56
|
+
if (match) {
|
|
57
|
+
try {
|
|
58
|
+
return new RegExp(match[1]!, match[2] ?? undefined);
|
|
59
|
+
} catch {
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function mapLocator(raw: unknown): Locator | undefined {
|
|
67
|
+
if (!raw || typeof raw !== "object") {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
const obj = raw as Record<string, unknown>;
|
|
71
|
+
|
|
72
|
+
if (typeof obj.role === "string") {
|
|
73
|
+
const name = obj.name === undefined ? "" : parseLocatorName(obj.name);
|
|
74
|
+
return { kind: "role", role: obj.role, name };
|
|
75
|
+
}
|
|
76
|
+
if (typeof obj.label !== "undefined") {
|
|
77
|
+
return { kind: "label", label: parseLocatorName(obj.label) };
|
|
78
|
+
}
|
|
79
|
+
if (typeof obj["aria-label"] === "string") {
|
|
80
|
+
return { kind: "label", label: parseLocatorName(obj["aria-label"]) };
|
|
81
|
+
}
|
|
82
|
+
if (typeof obj.text === "string") {
|
|
83
|
+
return { kind: "text", text: obj.text };
|
|
84
|
+
}
|
|
85
|
+
if (typeof obj.placeholder === "string") {
|
|
86
|
+
return { kind: "placeholder", placeholder: obj.placeholder };
|
|
87
|
+
}
|
|
88
|
+
if (typeof obj.title === "string") {
|
|
89
|
+
return { kind: "title", title: obj.title };
|
|
90
|
+
}
|
|
91
|
+
if (typeof obj.selector === "string") {
|
|
92
|
+
return { kind: "selector", selector: obj.selector };
|
|
93
|
+
}
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function stepError(
|
|
98
|
+
ctx: ParseScenarioContext,
|
|
99
|
+
index: number,
|
|
100
|
+
message: string,
|
|
101
|
+
raw?: Record<string, unknown>
|
|
102
|
+
): Error {
|
|
103
|
+
return new Error(formatStepParseError(ctx, index, message, raw));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function mapStep(raw: unknown, index: number, ctx: ParseScenarioContext): DemoStep {
|
|
107
|
+
if (!raw || typeof raw !== "object") {
|
|
108
|
+
throw stepError(ctx, index, "expected an object", undefined);
|
|
109
|
+
}
|
|
110
|
+
const obj = raw as Record<string, unknown>;
|
|
111
|
+
|
|
112
|
+
const action = obj.action;
|
|
113
|
+
if (typeof action !== "string" || !ACTION_TYPES.includes(action as ActionType)) {
|
|
114
|
+
throw stepError(
|
|
115
|
+
ctx,
|
|
116
|
+
index,
|
|
117
|
+
`invalid action "${String(action)}". Must be one of: ${ACTION_TYPES.join(", ")}`,
|
|
118
|
+
obj
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const step: DemoStep = {
|
|
123
|
+
action: action as ActionType,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
if (typeof obj.caption === "string") {
|
|
127
|
+
if (obj.caption.trim() === "") {
|
|
128
|
+
throw stepError(ctx, index, "caption must be non-empty when set", obj);
|
|
129
|
+
}
|
|
130
|
+
step.caption = obj.caption;
|
|
131
|
+
}
|
|
132
|
+
if (obj.caption_style === "chapter" || obj.caption_style === "default") {
|
|
133
|
+
step.caption_style = obj.caption_style;
|
|
134
|
+
}
|
|
135
|
+
if (typeof obj.url === "string") {
|
|
136
|
+
step.url = obj.url;
|
|
137
|
+
}
|
|
138
|
+
if (typeof obj.value === "string") {
|
|
139
|
+
step.value = obj.value;
|
|
140
|
+
}
|
|
141
|
+
if (typeof obj.note === "string") {
|
|
142
|
+
step.note = obj.note;
|
|
143
|
+
}
|
|
144
|
+
if (typeof obj.message === "string") {
|
|
145
|
+
step.message = obj.message;
|
|
146
|
+
}
|
|
147
|
+
if (typeof obj.text === "string") {
|
|
148
|
+
step.text = obj.text;
|
|
149
|
+
}
|
|
150
|
+
if (typeof obj.hold_ms === "number") {
|
|
151
|
+
step.hold_ms = obj.hold_ms;
|
|
152
|
+
}
|
|
153
|
+
if (typeof obj.fade_ms === "number") {
|
|
154
|
+
step.fade_ms = obj.fade_ms;
|
|
155
|
+
}
|
|
156
|
+
if (obj.screenshot !== undefined) {
|
|
157
|
+
if (step.action !== "screenshot") {
|
|
158
|
+
throw stepError(ctx, index, 'screenshot is only allowed on action "screenshot"', obj);
|
|
159
|
+
}
|
|
160
|
+
if (!obj.screenshot || typeof obj.screenshot !== "object") {
|
|
161
|
+
throw stepError(ctx, index, "screenshot must be an object", obj);
|
|
162
|
+
}
|
|
163
|
+
const sc = obj.screenshot as Record<string, unknown>;
|
|
164
|
+
const screenshot: DemoStep["screenshot"] = {};
|
|
165
|
+
if (typeof sc.file === "string" && sc.file.trim()) {
|
|
166
|
+
screenshot.file = sc.file.trim();
|
|
167
|
+
}
|
|
168
|
+
if (sc.full_page === true) {
|
|
169
|
+
screenshot.full_page = true;
|
|
170
|
+
}
|
|
171
|
+
if (sc.omit_background === true) {
|
|
172
|
+
screenshot.omit_background = true;
|
|
173
|
+
}
|
|
174
|
+
if (sc.scale === "css" || sc.scale === "device") {
|
|
175
|
+
screenshot.scale = sc.scale;
|
|
176
|
+
} else if (sc.scale !== undefined) {
|
|
177
|
+
throw stepError(ctx, index, 'screenshot.scale must be "css" or "device"', obj);
|
|
178
|
+
}
|
|
179
|
+
step.screenshot = screenshot;
|
|
180
|
+
}
|
|
181
|
+
if (typeof obj.timeout_ms === "number") {
|
|
182
|
+
step.timeout_ms = obj.timeout_ms;
|
|
183
|
+
}
|
|
184
|
+
if (obj.until === "url") {
|
|
185
|
+
step.until = "url";
|
|
186
|
+
if (typeof obj.url_pattern === "string") {
|
|
187
|
+
step.url_pattern = obj.url_pattern;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (obj.until === "hidden") {
|
|
191
|
+
step.until = "hidden";
|
|
192
|
+
}
|
|
193
|
+
if (typeof obj.refresh_every_ms === "number") {
|
|
194
|
+
step.refresh_every_ms = obj.refresh_every_ms;
|
|
195
|
+
}
|
|
196
|
+
if (typeof obj.offset_x === "number") {
|
|
197
|
+
step.offset_x = obj.offset_x;
|
|
198
|
+
}
|
|
199
|
+
if (typeof obj.offset_y === "number") {
|
|
200
|
+
step.offset_y = obj.offset_y;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const locator = mapLocator(obj.locator);
|
|
204
|
+
if (locator) {
|
|
205
|
+
step.locator = locator;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (obj.select_confirm === true) {
|
|
209
|
+
step.select_confirm = true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (obj.clear_before_type === false) {
|
|
213
|
+
if (step.action !== "type") {
|
|
214
|
+
throw stepError(ctx, index, 'clear_before_type: false is only allowed on action "type"', obj);
|
|
215
|
+
}
|
|
216
|
+
step.clear_before_type = false;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (typeof obj.typing_delay_ms === "number") {
|
|
220
|
+
if (step.action !== "type") {
|
|
221
|
+
throw stepError(ctx, index, 'typing_delay_ms is only allowed on action "type"', obj);
|
|
222
|
+
}
|
|
223
|
+
if (obj.typing_delay_ms < 0) {
|
|
224
|
+
throw stepError(ctx, index, "typing_delay_ms must be >= 0", obj);
|
|
225
|
+
}
|
|
226
|
+
step.typing_delay_ms = obj.typing_delay_ms;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (obj.steps !== undefined) {
|
|
230
|
+
if (step.action !== "repeat_while_visible" && step.action !== "cut_video") {
|
|
231
|
+
throw stepError(ctx, index, 'steps is only allowed on action "repeat_while_visible" or "cut_video"', obj);
|
|
232
|
+
}
|
|
233
|
+
if (!Array.isArray(obj.steps) || obj.steps.length === 0) {
|
|
234
|
+
throw stepError(
|
|
235
|
+
ctx,
|
|
236
|
+
index,
|
|
237
|
+
step.action === "cut_video"
|
|
238
|
+
? 'cut_video requires a non-empty "steps" array'
|
|
239
|
+
: 'repeat_while_visible requires a non-empty "steps" array',
|
|
240
|
+
obj
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
step.steps = obj.steps.map((s, j) => mapStep(s, index * 1000 + j + 1, ctx));
|
|
244
|
+
}
|
|
245
|
+
if (obj.placeholder !== undefined) {
|
|
246
|
+
if (step.action !== "cut_video") {
|
|
247
|
+
throw stepError(ctx, index, 'placeholder is only allowed on action "cut_video"', obj);
|
|
248
|
+
}
|
|
249
|
+
if (!obj.placeholder || typeof obj.placeholder !== "object") {
|
|
250
|
+
throw stepError(ctx, index, "placeholder must be an object with action", obj);
|
|
251
|
+
}
|
|
252
|
+
const ph = mapStep(obj.placeholder, index * 1000 + 900, ctx);
|
|
253
|
+
if (ph.action !== "title_card") {
|
|
254
|
+
throw stepError(ctx, index, 'cut_video placeholder must use action "title_card"', obj);
|
|
255
|
+
}
|
|
256
|
+
step.placeholder = ph;
|
|
257
|
+
}
|
|
258
|
+
if (obj.video_exclude === true) {
|
|
259
|
+
step.video_exclude = true;
|
|
260
|
+
}
|
|
261
|
+
if (typeof obj.max_iterations === "number") {
|
|
262
|
+
if (step.action !== "repeat_while_visible") {
|
|
263
|
+
throw stepError(ctx, index, 'max_iterations is only allowed on action "repeat_while_visible"', obj);
|
|
264
|
+
}
|
|
265
|
+
if (!Number.isInteger(obj.max_iterations) || obj.max_iterations < 1) {
|
|
266
|
+
throw stepError(ctx, index, "max_iterations must be an integer >= 1", obj);
|
|
267
|
+
}
|
|
268
|
+
step.max_iterations = obj.max_iterations;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (obj.settle_until !== undefined) {
|
|
272
|
+
if (step.action !== "click") {
|
|
273
|
+
throw stepError(ctx, index, 'settle_until is only allowed on action "click"', obj);
|
|
274
|
+
}
|
|
275
|
+
if (!obj.settle_until || typeof obj.settle_until !== "object") {
|
|
276
|
+
throw stepError(ctx, index, 'settle_until must be an object with "text" or "locator"', obj);
|
|
277
|
+
}
|
|
278
|
+
const su = obj.settle_until as Record<string, unknown>;
|
|
279
|
+
const textRaw = su.text;
|
|
280
|
+
const hasText = typeof textRaw === "string" && textRaw.trim() !== "";
|
|
281
|
+
const settleLoc = mapLocator(su.locator);
|
|
282
|
+
const hasLoc = settleLoc !== undefined;
|
|
283
|
+
if (hasText === hasLoc) {
|
|
284
|
+
throw stepError(
|
|
285
|
+
ctx,
|
|
286
|
+
index,
|
|
287
|
+
'settle_until requires exactly one of "text" (non-empty string) or "locator"',
|
|
288
|
+
obj
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
if (hasText) {
|
|
292
|
+
step.settle_until = { text: (textRaw as string).trim() };
|
|
293
|
+
} else {
|
|
294
|
+
step.settle_until = { locator: settleLoc! };
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Validation per action
|
|
299
|
+
if (step.action === "navigate" && !step.url) {
|
|
300
|
+
throw stepError(ctx, index, 'action "navigate" requires "url"', obj);
|
|
301
|
+
}
|
|
302
|
+
if (
|
|
303
|
+
(step.action === "click" || step.action === "type" || step.action === "fill" || step.action === "expect_visible" || step.action === "drag") &&
|
|
304
|
+
!step.locator
|
|
305
|
+
) {
|
|
306
|
+
const locHint = describeInvalidLocator(obj.locator);
|
|
307
|
+
const msg = locHint ?? `action "${step.action}" requires "locator"`;
|
|
308
|
+
throw stepError(ctx, index, msg, obj);
|
|
309
|
+
}
|
|
310
|
+
if (step.action === "wait" && !step.until && step.timeout_ms === undefined) {
|
|
311
|
+
throw stepError(ctx, index, 'action "wait" requires "until" with "url_pattern" or "locator", or "timeout_ms"', obj);
|
|
312
|
+
}
|
|
313
|
+
if (step.action === "wait" && step.until === "url" && !step.url_pattern) {
|
|
314
|
+
throw stepError(ctx, index, 'action "wait" with until "url" requires "url_pattern"', obj);
|
|
315
|
+
}
|
|
316
|
+
if (step.action === "wait" && step.until === "hidden" && !step.locator) {
|
|
317
|
+
throw stepError(ctx, index, 'action "wait" with until "hidden" requires "locator"', obj);
|
|
318
|
+
}
|
|
319
|
+
if (step.refresh_every_ms != null) {
|
|
320
|
+
if (step.action !== "wait" || step.until !== "hidden") {
|
|
321
|
+
throw stepError(
|
|
322
|
+
ctx,
|
|
323
|
+
index,
|
|
324
|
+
'refresh_every_ms is only allowed on action "wait" with until "hidden"',
|
|
325
|
+
obj
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
if (!Number.isInteger(step.refresh_every_ms) || step.refresh_every_ms < 500) {
|
|
329
|
+
throw stepError(ctx, index, "refresh_every_ms must be an integer >= 500", obj);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (step.action === "log" && !step.message) {
|
|
333
|
+
throw stepError(ctx, index, 'action "log" requires "message"', obj);
|
|
334
|
+
}
|
|
335
|
+
if (step.action === "title_card" && (!step.text || step.text.trim() === "")) {
|
|
336
|
+
throw stepError(ctx, index, 'action "title_card" requires non-empty "text"', obj);
|
|
337
|
+
}
|
|
338
|
+
if (step.action === "screenshot") {
|
|
339
|
+
// screenshot can be page-level (no locator) or element-level (with locator)
|
|
340
|
+
// screenshot options object is optional; defaults handled in runner
|
|
341
|
+
}
|
|
342
|
+
if (step.select_confirm && step.action !== "fill" && step.action !== "type") {
|
|
343
|
+
throw stepError(ctx, index, 'select_confirm is only allowed on action "fill" or "type"', obj);
|
|
344
|
+
}
|
|
345
|
+
if (step.action === "repeat_while_visible") {
|
|
346
|
+
if (!step.locator) {
|
|
347
|
+
const locHint = describeInvalidLocator(obj.locator);
|
|
348
|
+
throw stepError(ctx, index, locHint ?? 'repeat_while_visible requires "locator"', obj);
|
|
349
|
+
}
|
|
350
|
+
if (!step.steps || step.steps.length === 0) {
|
|
351
|
+
throw stepError(ctx, index, 'repeat_while_visible requires a non-empty "steps" array', obj);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (step.action === "cut_video") {
|
|
355
|
+
if (!step.steps || step.steps.length === 0) {
|
|
356
|
+
throw stepError(ctx, index, 'cut_video requires a non-empty "steps" array', obj);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (step.video_exclude && step.action === "cut_video") {
|
|
360
|
+
throw stepError(ctx, index, "video_exclude is not allowed on cut_video (nested steps are excluded automatically)", obj);
|
|
361
|
+
}
|
|
362
|
+
if (step.action === "drag") {
|
|
363
|
+
if (typeof step.offset_x !== "number" || !Number.isFinite(step.offset_x)) {
|
|
364
|
+
throw stepError(ctx, index, 'action "drag" requires numeric "offset_x"', obj);
|
|
365
|
+
}
|
|
366
|
+
if (step.offset_y === undefined) {
|
|
367
|
+
step.offset_y = 0;
|
|
368
|
+
} else if (!Number.isFinite(step.offset_y)) {
|
|
369
|
+
throw stepError(ctx, index, 'action "drag" requires finite numeric "offset_y" when set', obj);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return step;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function isStepImport(raw: unknown): raw is StepImport {
|
|
377
|
+
if (!raw || typeof raw !== "object") {
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
const obj = raw as Record<string, unknown>;
|
|
381
|
+
return typeof obj.import === "string" && obj.import.trim() !== "" && obj.action === undefined;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function mapStepOrImport(raw: unknown, index: number, ctx: ParseScenarioContext): StepOrImport {
|
|
385
|
+
if (!raw || typeof raw !== "object") {
|
|
386
|
+
throw stepError(ctx, index, "expected an object", undefined);
|
|
387
|
+
}
|
|
388
|
+
const obj = raw as Record<string, unknown>;
|
|
389
|
+
if (isStepImport(obj)) {
|
|
390
|
+
return { import: (obj.import as string).trim() };
|
|
391
|
+
}
|
|
392
|
+
return mapStep(raw, index, ctx);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function mapAuth(raw: unknown): AuthConfig | undefined {
|
|
396
|
+
if (!raw || typeof raw !== "object") {
|
|
397
|
+
return undefined;
|
|
398
|
+
}
|
|
399
|
+
const obj = raw as Record<string, unknown>;
|
|
400
|
+
if (typeof obj.email_env !== "string" || typeof obj.password_env !== "string") {
|
|
401
|
+
return undefined;
|
|
402
|
+
}
|
|
403
|
+
return { email_env: obj.email_env, password_env: obj.password_env };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function mapVideo(raw: unknown): VideoConfig | undefined {
|
|
407
|
+
if (!raw || typeof raw !== "object") {
|
|
408
|
+
return undefined;
|
|
409
|
+
}
|
|
410
|
+
const obj = raw as Record<string, unknown>;
|
|
411
|
+
const enabled = obj.enabled === true;
|
|
412
|
+
const dir = typeof obj.dir === "string" ? obj.dir.trim() : "";
|
|
413
|
+
if (!enabled || !dir) {
|
|
414
|
+
return undefined;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const config: VideoConfig = { enabled: true, dir };
|
|
418
|
+
if (typeof obj.filename === "string" && obj.filename.trim()) {
|
|
419
|
+
config.filename = obj.filename.trim();
|
|
420
|
+
}
|
|
421
|
+
if (obj.size && typeof obj.size === "object" && typeof (obj.size as Record<string, unknown>).width === "number" && typeof (obj.size as Record<string, unknown>).height === "number") {
|
|
422
|
+
const size = obj.size as { width: number; height: number };
|
|
423
|
+
config.size = { width: size.width, height: size.height };
|
|
424
|
+
}
|
|
425
|
+
if (obj.viewport && typeof obj.viewport === "object" && typeof (obj.viewport as Record<string, unknown>).width === "number" && typeof (obj.viewport as Record<string, unknown>).height === "number") {
|
|
426
|
+
const vp = obj.viewport as { width: number; height: number };
|
|
427
|
+
config.viewport = { width: vp.width, height: vp.height };
|
|
428
|
+
}
|
|
429
|
+
if (typeof obj.start_step === "number" && obj.start_step >= 1) {
|
|
430
|
+
config.start_step = obj.start_step;
|
|
431
|
+
}
|
|
432
|
+
if (typeof obj.end_step === "number" && obj.end_step >= 1) {
|
|
433
|
+
config.end_step = obj.end_step;
|
|
434
|
+
}
|
|
435
|
+
if (obj.caption_parallel === true) {
|
|
436
|
+
config.caption_parallel = true;
|
|
437
|
+
}
|
|
438
|
+
if (obj.playback_speed !== undefined) {
|
|
439
|
+
if (
|
|
440
|
+
typeof obj.playback_speed !== "number" ||
|
|
441
|
+
!Number.isFinite(obj.playback_speed) ||
|
|
442
|
+
obj.playback_speed <= 0 ||
|
|
443
|
+
obj.playback_speed > 4
|
|
444
|
+
) {
|
|
445
|
+
throw new Error("video.playback_speed must be a finite number > 0 and <= 4");
|
|
446
|
+
}
|
|
447
|
+
config.playback_speed = obj.playback_speed;
|
|
448
|
+
}
|
|
449
|
+
return config;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function mapAudio(raw: unknown): AudioConfig | undefined {
|
|
453
|
+
if (!raw || typeof raw !== "object") {
|
|
454
|
+
return undefined;
|
|
455
|
+
}
|
|
456
|
+
const obj = raw as Record<string, unknown>;
|
|
457
|
+
if (typeof obj.background !== "string" || !obj.background.trim()) {
|
|
458
|
+
return undefined;
|
|
459
|
+
}
|
|
460
|
+
return { background: obj.background.trim() };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function mapScreenshots(raw: unknown): ScreenshotConfig | undefined {
|
|
464
|
+
if (!raw || typeof raw !== "object") {
|
|
465
|
+
return undefined;
|
|
466
|
+
}
|
|
467
|
+
const obj = raw as Record<string, unknown>;
|
|
468
|
+
const dir = typeof obj.dir === "string" ? obj.dir.trim() : "";
|
|
469
|
+
if (!dir) {
|
|
470
|
+
return undefined;
|
|
471
|
+
}
|
|
472
|
+
return { dir };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function mapUrlCapture(raw: unknown): UrlCaptureRule[] | undefined {
|
|
476
|
+
if (raw === undefined || raw === null) {
|
|
477
|
+
return undefined;
|
|
478
|
+
}
|
|
479
|
+
if (!Array.isArray(raw)) {
|
|
480
|
+
throw new Error('"url_capture" must be an array of { variable, pattern }');
|
|
481
|
+
}
|
|
482
|
+
const rules: UrlCaptureRule[] = [];
|
|
483
|
+
for (let i = 0; i < raw.length; i++) {
|
|
484
|
+
const item = raw[i];
|
|
485
|
+
if (!item || typeof item !== "object") {
|
|
486
|
+
throw new Error(`url_capture[${i}] must be an object`);
|
|
487
|
+
}
|
|
488
|
+
const rec = item as Record<string, unknown>;
|
|
489
|
+
if (typeof rec.variable !== "string" || !rec.variable.trim()) {
|
|
490
|
+
throw new Error(`url_capture[${i}].variable must be a non-empty string`);
|
|
491
|
+
}
|
|
492
|
+
if (typeof rec.pattern !== "string" || !rec.pattern) {
|
|
493
|
+
throw new Error(`url_capture[${i}].pattern must be a non-empty string (RegExp source)`);
|
|
494
|
+
}
|
|
495
|
+
try {
|
|
496
|
+
// Validate pattern compiles
|
|
497
|
+
void new RegExp(rec.pattern);
|
|
498
|
+
} catch (err) {
|
|
499
|
+
throw new Error(
|
|
500
|
+
`url_capture[${i}].pattern is not a valid RegExp: ${err instanceof Error ? err.message : String(err)}`
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
rules.push({ variable: rec.variable.trim(), pattern: rec.pattern });
|
|
504
|
+
}
|
|
505
|
+
return rules.length > 0 ? rules : undefined;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Parse YAML content into a ParsedScenario (steps may contain import directives).
|
|
510
|
+
* Does not resolve variables; value/url may still contain {{ ... }}.
|
|
511
|
+
* Use loadScenario to flatten imports and get a DemoScenario for the runner.
|
|
512
|
+
*/
|
|
513
|
+
export function parseScenario(yamlContent: string, options: ParseScenarioOptions = {}): ParsedScenario {
|
|
514
|
+
let parsed: unknown;
|
|
515
|
+
try {
|
|
516
|
+
parsed = parseYaml(yamlContent);
|
|
517
|
+
} catch (err) {
|
|
518
|
+
throw new Error(
|
|
519
|
+
`Invalid YAML: ${err instanceof Error ? err.message : String(err)}`
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (!parsed || typeof parsed !== "object") {
|
|
524
|
+
throw new Error("Scenario YAML must be an object");
|
|
525
|
+
}
|
|
526
|
+
const obj = parsed as Record<string, unknown>;
|
|
527
|
+
|
|
528
|
+
const name = obj.name;
|
|
529
|
+
const steps = obj.steps;
|
|
530
|
+
if (typeof name !== "string" || !name.trim()) {
|
|
531
|
+
throw new Error('Scenario must have a non-empty "name"');
|
|
532
|
+
}
|
|
533
|
+
if (!Array.isArray(steps)) {
|
|
534
|
+
throw new Error('Scenario must have "steps" as an array');
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const stepEntries = steps.filter(
|
|
538
|
+
(s): s is Record<string, unknown> =>
|
|
539
|
+
s != null && typeof s === "object" && (typeof (s as Record<string, unknown>).action === "string" || isStepImport(s))
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
const parseCtx: ParseScenarioContext = {
|
|
543
|
+
sourceFile: options.sourceFile,
|
|
544
|
+
scenarioName: typeof name === "string" ? name.trim() : undefined,
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
const scenario: ParsedScenario = {
|
|
548
|
+
name: name.trim(),
|
|
549
|
+
description: typeof obj.description === "string" ? obj.description : "",
|
|
550
|
+
base_url: typeof obj.base_url === "string" ? obj.base_url : "http://localhost:2000",
|
|
551
|
+
steps: stepEntries.map((s, i) => mapStepOrImport(s, i, parseCtx)),
|
|
552
|
+
};
|
|
553
|
+
if (typeof obj.title === "string" && obj.title.trim()) {
|
|
554
|
+
scenario.title = obj.title.trim();
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (obj.imports !== undefined) {
|
|
558
|
+
if (!Array.isArray(obj.imports)) {
|
|
559
|
+
throw new Error('"imports" must be an array of strings');
|
|
560
|
+
}
|
|
561
|
+
const importPaths = obj.imports.filter((p): p is string => typeof p === "string").map((p) => p.trim()).filter(Boolean);
|
|
562
|
+
if (importPaths.length > 0) {
|
|
563
|
+
scenario.imports = importPaths;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (typeof obj.seed_data === "string") {
|
|
568
|
+
scenario.seed_data = obj.seed_data;
|
|
569
|
+
}
|
|
570
|
+
const auth = mapAuth(obj.auth);
|
|
571
|
+
if (auth) {
|
|
572
|
+
scenario.auth = auth;
|
|
573
|
+
}
|
|
574
|
+
const video = mapVideo(obj.video);
|
|
575
|
+
if (video) {
|
|
576
|
+
scenario.video = video;
|
|
577
|
+
}
|
|
578
|
+
const screenshots = mapScreenshots(obj.screenshots);
|
|
579
|
+
if (screenshots) {
|
|
580
|
+
scenario.screenshots = screenshots;
|
|
581
|
+
}
|
|
582
|
+
const audio = mapAudio(obj.audio);
|
|
583
|
+
if (audio) {
|
|
584
|
+
scenario.audio = audio;
|
|
585
|
+
}
|
|
586
|
+
if (typeof obj.device_scale_factor === "number" && obj.device_scale_factor > 0) {
|
|
587
|
+
scenario.device_scale_factor = obj.device_scale_factor;
|
|
588
|
+
}
|
|
589
|
+
if (typeof obj.step_delay_ms === "number" && obj.step_delay_ms >= 0) {
|
|
590
|
+
scenario.step_delay_ms = obj.step_delay_ms;
|
|
591
|
+
}
|
|
592
|
+
if (typeof obj.slow_mo_ms === "number" && obj.slow_mo_ms >= 0) {
|
|
593
|
+
scenario.slow_mo_ms = obj.slow_mo_ms;
|
|
594
|
+
}
|
|
595
|
+
if (typeof obj.typing_delay_ms === "number" && obj.typing_delay_ms >= 0) {
|
|
596
|
+
scenario.typing_delay_ms = obj.typing_delay_ms;
|
|
597
|
+
}
|
|
598
|
+
if (typeof obj.post_click_delay_ms === "number" && obj.post_click_delay_ms >= 0) {
|
|
599
|
+
scenario.post_click_delay_ms = obj.post_click_delay_ms;
|
|
600
|
+
}
|
|
601
|
+
if (typeof obj.navigate_hold_ms === "number" && obj.navigate_hold_ms >= 0) {
|
|
602
|
+
scenario.navigate_hold_ms = obj.navigate_hold_ms;
|
|
603
|
+
}
|
|
604
|
+
if (typeof obj.pre_action_delay_ms === "number" && obj.pre_action_delay_ms >= 0) {
|
|
605
|
+
scenario.pre_action_delay_ms = obj.pre_action_delay_ms;
|
|
606
|
+
}
|
|
607
|
+
if (typeof obj.action_timeout_ms === "number" && obj.action_timeout_ms > 0) {
|
|
608
|
+
scenario.action_timeout_ms = obj.action_timeout_ms;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const meta = obj.metadata;
|
|
612
|
+
if (meta !== undefined && meta !== null && typeof meta === "object" && !Array.isArray(meta)) {
|
|
613
|
+
scenario.metadata = { ...(meta as Record<string, unknown>) };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const urlCapture = mapUrlCapture(obj.url_capture);
|
|
617
|
+
if (urlCapture) {
|
|
618
|
+
scenario.url_capture = urlCapture;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
return scenario;
|
|
622
|
+
}
|