@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,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map Playwright recorder action selector strings and generated code to demo-maker Locator.
|
|
3
|
+
* Prefer parsing the generated code string (e.g. getByRole('button', { name: 'Accept' }))
|
|
4
|
+
* so we get the exact button/link/text filter.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Locator } from "./types";
|
|
8
|
+
|
|
9
|
+
const RECORDED_FALLBACK = "(recorded)";
|
|
10
|
+
|
|
11
|
+
const warnedKeys = new Set<string>();
|
|
12
|
+
function warnOnce(key: string, ...args: unknown[]): void {
|
|
13
|
+
if (warnedKeys.has(key)) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
warnedKeys.add(key);
|
|
17
|
+
// eslint-disable-next-line no-console
|
|
18
|
+
console.warn(...args);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function truncate(s: string, max = 300): string {
|
|
22
|
+
const t = s.trim();
|
|
23
|
+
if (t.length <= max) {
|
|
24
|
+
return t;
|
|
25
|
+
}
|
|
26
|
+
return t.slice(0, Math.max(0, max - 3)) + "...";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isRecordedFallbackLocator(locator: Locator): boolean {
|
|
30
|
+
return (
|
|
31
|
+
(locator.kind === "text" && locator.text === RECORDED_FALLBACK) ||
|
|
32
|
+
(locator.kind === "role" && typeof locator.name === "string" && locator.name === RECORDED_FALLBACK)
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseNameFromOptionsObjectLiteral(options: string): string | RegExp | null {
|
|
37
|
+
// Very small, pragmatic parser for the recorder output.
|
|
38
|
+
// Examples:
|
|
39
|
+
// { name: 'Accept', exact: true }
|
|
40
|
+
// { exact: true, name: "Accept" }
|
|
41
|
+
// { name: /Sign in/i }
|
|
42
|
+
const opts = options.trim();
|
|
43
|
+
if (!opts) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const nameStringMatch = opts.match(/(?:^|[,{]\s*)name\s*:\s*["']([^"']*)["']\s*(?:,|$)/);
|
|
48
|
+
if (nameStringMatch) {
|
|
49
|
+
return nameStringMatch[1] ?? "";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const nameRegexMatch = opts.match(/(?:^|[,{]\s*)name\s*:\s*\/([^/]+)\/([i]*)\s*(?:,|$)/);
|
|
53
|
+
if (nameRegexMatch) {
|
|
54
|
+
const pattern = (nameRegexMatch[1] ?? "").trim();
|
|
55
|
+
const flags = (nameRegexMatch[2] ?? "").trim();
|
|
56
|
+
if (!pattern) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return new RegExp(pattern, flags);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Extract aria-label or label= from Playwright internal selector strings.
|
|
67
|
+
*/
|
|
68
|
+
export function extractLabelFromSelector(selector: string | undefined): string | null {
|
|
69
|
+
const s = (selector ?? "").trim();
|
|
70
|
+
if (!s) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const tryMatch = (haystack: string): string | null => {
|
|
75
|
+
const labelMatch =
|
|
76
|
+
haystack.match(/label=(?:"([^"]*)"|'([^']*)'|([^\s\]]+))/i) ??
|
|
77
|
+
haystack.match(/internal:attr\s*=\s*\[\s*aria-label\s*=\s*(?:"([^"]*)"|'([^']*)')\s*]/i) ??
|
|
78
|
+
haystack.match(/internal:attr\s*=\s*\[\s*aria-labelledby\s*=\s*(?:"([^"]*)"|'([^']*)')\s*]/i);
|
|
79
|
+
if (!labelMatch) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
const label = (labelMatch[1] ?? labelMatch[2] ?? labelMatch[3] ?? "").trim();
|
|
83
|
+
return label || null;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const chunks = s.split(">>").map((c) => c.trim()).filter(Boolean);
|
|
87
|
+
for (const c of chunks.length ? chunks : [s]) {
|
|
88
|
+
const label = tryMatch(c);
|
|
89
|
+
if (label) {
|
|
90
|
+
return label;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return tryMatch(s);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Parse Playwright-generated code string to extract our Locator.
|
|
98
|
+
* Code looks like: "await page.getByRole('button', { name: 'Accept' }).click();"
|
|
99
|
+
* or getByLabel('Email'), getByText('Sign in'), getByPlaceholder('...'), getByTitle('...').
|
|
100
|
+
* Returns null if no known locator pattern found.
|
|
101
|
+
*/
|
|
102
|
+
export function parseCodeToLocator(code: string | undefined): Locator | null {
|
|
103
|
+
// Normalize: collapse newlines so we match when code is multi-line
|
|
104
|
+
const s = (code ?? "").replace(/\s+/g, " ").trim();
|
|
105
|
+
if (!s) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// getByLabel('Email') or getByLabel("Email") — before getByRole (more specific for form fields)
|
|
110
|
+
const getByLabelMatch = s.match(/\.getByLabel\s*\(\s*["']([^"']*)["']\s*\)/);
|
|
111
|
+
if (getByLabelMatch) {
|
|
112
|
+
const label = getByLabelMatch[1]!.trim();
|
|
113
|
+
if (label) {
|
|
114
|
+
return { kind: "label", label };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// getByRole('button', { name: 'Accept', ... }) or getByRole('button') or with regex name: /Sign in/i
|
|
119
|
+
const getByRoleMatch = s.match(/\.getByRole\s*\(\s*["']([^"']+)["']\s*(?:,\s*\{([^}]*)\}\s*)?\)/);
|
|
120
|
+
if (getByRoleMatch) {
|
|
121
|
+
const role = (getByRoleMatch[1] ?? "").toLowerCase().trim();
|
|
122
|
+
const options = (getByRoleMatch[2] ?? "").trim();
|
|
123
|
+
if (role) {
|
|
124
|
+
const name = options ? parseNameFromOptionsObjectLiteral(options) : null;
|
|
125
|
+
if (name instanceof RegExp) {
|
|
126
|
+
return { kind: "role", role, name };
|
|
127
|
+
}
|
|
128
|
+
const nameStr = typeof name === "string" ? name.trim() : "";
|
|
129
|
+
return { kind: "role", role, name: nameStr || RECORDED_FALLBACK };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// getByPlaceholder('Enter email')
|
|
134
|
+
const getByPlaceholderMatch = s.match(/\.getByPlaceholder\s*\(\s*["']([^"']*)["']\s*\)/);
|
|
135
|
+
if (getByPlaceholderMatch) {
|
|
136
|
+
const placeholder = getByPlaceholderMatch[1]!.trim();
|
|
137
|
+
if (placeholder) {
|
|
138
|
+
return { kind: "placeholder", placeholder };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// getByText('Sign in') or getByText("Sign in")
|
|
143
|
+
const getByTextMatch = s.match(/\.getByText\s*\(\s*["']([^"']*)["']\s*\)/);
|
|
144
|
+
if (getByTextMatch) {
|
|
145
|
+
const text = getByTextMatch[1]!.trim();
|
|
146
|
+
if (text) {
|
|
147
|
+
return { kind: "text", text };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// getByTitle('Close')
|
|
152
|
+
const getByTitleMatch = s.match(/\.getByTitle\s*\(\s*["']([^"']*)["']\s*\)/);
|
|
153
|
+
if (getByTitleMatch) {
|
|
154
|
+
const title = getByTitleMatch[1]!.trim();
|
|
155
|
+
if (title) {
|
|
156
|
+
return { kind: "title", title };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Playwright action from recorder (actionAdded event data.action). */
|
|
164
|
+
export interface PlaywrightRecordedAction {
|
|
165
|
+
name: string;
|
|
166
|
+
selector?: string;
|
|
167
|
+
text?: string;
|
|
168
|
+
options?: string[];
|
|
169
|
+
signals?: unknown[];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Convert a Playwright recorder selector string to our Locator.
|
|
174
|
+
* Playwright selectors can be internal format (e.g. role=button[name="Accept"] or internal:...).
|
|
175
|
+
* We try simple patterns; otherwise use text locator so the step is preserved.
|
|
176
|
+
*/
|
|
177
|
+
export function playwrightSelectorToLocator(selector: string | undefined): Locator {
|
|
178
|
+
const s = (selector ?? "").trim();
|
|
179
|
+
if (!s) {
|
|
180
|
+
return { kind: "text", text: RECORDED_FALLBACK };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Recorder selectors can be chained: "internal:role=button >> internal:text=\"Save\" >> nth=0"
|
|
184
|
+
// Prefer the most specific chunk that yields a stable locator.
|
|
185
|
+
const chunks = s.split(">>").map((c) => c.trim()).filter(Boolean);
|
|
186
|
+
const all = chunks.length ? chunks.join(" >> ") : s;
|
|
187
|
+
|
|
188
|
+
// internal:text="Sign in"
|
|
189
|
+
for (const c of chunks.length ? chunks : [s]) {
|
|
190
|
+
const internalTextMatch = c.match(/internal:text\s*=\s*(?:"([^"]*)"|'([^']*)')/i);
|
|
191
|
+
if (internalTextMatch) {
|
|
192
|
+
const text = (internalTextMatch[1] ?? internalTextMatch[2] ?? "").trim();
|
|
193
|
+
if (text) {
|
|
194
|
+
return { kind: "text", text };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const labelFromSelector = extractLabelFromSelector(s);
|
|
200
|
+
if (labelFromSelector) {
|
|
201
|
+
return { kind: "label", label: labelFromSelector };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// placeholder=...
|
|
205
|
+
const placeholderMatch =
|
|
206
|
+
all.match(/placeholder=(?:"([^"]*)"|'([^']*)'|([^\s\]]+))/i) ??
|
|
207
|
+
all.match(/internal:attr\s*=\s*\[\s*placeholder\s*=\s*(?:"([^"]*)"|'([^']*)')\s*]/i);
|
|
208
|
+
if (placeholderMatch) {
|
|
209
|
+
const placeholder = (placeholderMatch[1] ?? placeholderMatch[2] ?? placeholderMatch[3] ?? "").trim();
|
|
210
|
+
if (placeholder) {
|
|
211
|
+
return { kind: "placeholder", placeholder };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// title=... (e.g. internal:attr=[title="Close"])
|
|
216
|
+
const titleMatch =
|
|
217
|
+
all.match(/title=(?:"([^"]*)"|'([^']*)'|([^\s\]]+))/i) ??
|
|
218
|
+
all.match(/internal:attr\s*=\s*\[\s*title\s*=\s*(?:"([^"]*)"|'([^']*)')\s*]/i);
|
|
219
|
+
if (titleMatch) {
|
|
220
|
+
const title = (titleMatch[1] ?? titleMatch[2] ?? titleMatch[3] ?? "").trim();
|
|
221
|
+
if (title) {
|
|
222
|
+
return { kind: "title", title };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Playwright internal format sometimes has role=button[name="..."] style (from asLocator)
|
|
227
|
+
// or getByRole('button', { name: '...' }) in code - selector might be internal:attr=...
|
|
228
|
+
// Try aria-like: role=button[name="Accept"] or similar
|
|
229
|
+
const roleNameMatch = all.match(/role=([a-z]+)(?:\[name=(?:"([^"]*)"|'([^']*)')])?/i);
|
|
230
|
+
if (roleNameMatch) {
|
|
231
|
+
const role = roleNameMatch[1]!.toLowerCase();
|
|
232
|
+
const name = (roleNameMatch[2] ?? roleNameMatch[3] ?? "").trim();
|
|
233
|
+
if (role && name) {
|
|
234
|
+
return { kind: "role", role, name };
|
|
235
|
+
}
|
|
236
|
+
if (role) {
|
|
237
|
+
// If role has no accessible name, try to salvage has-text/text filters before falling back.
|
|
238
|
+
const hasTextMatch = all.match(/has-text\s*=\s*(?:"([^"]*)"|'([^']*)'|([^>\]]+))/i);
|
|
239
|
+
const text = (hasTextMatch?.[1] ?? hasTextMatch?.[2] ?? hasTextMatch?.[3] ?? "").replace(/^["']|["']$/g, "").trim();
|
|
240
|
+
if (text) {
|
|
241
|
+
return { kind: "text", text };
|
|
242
|
+
}
|
|
243
|
+
return { kind: "role", role, name: RECORDED_FALLBACK };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// text=... or has-text=...
|
|
248
|
+
const textMatch = all.match(/(?:text|has-text)=(.+)/i);
|
|
249
|
+
if (textMatch) {
|
|
250
|
+
const text = textMatch[1]!.replace(/^["']|["']$/g, "").trim();
|
|
251
|
+
if (text) {
|
|
252
|
+
return { kind: "text", text };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Fallback: use selector as text so step is not dropped; user can refine in YAML
|
|
257
|
+
return { kind: "text", text: all.length > 60 ? all.slice(0, 57) + "..." : all };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Resolve locator: prefer label from selector (aria-label), then generated code, then selector parsing.
|
|
262
|
+
*/
|
|
263
|
+
function resolveLocator(code: string | undefined, selector: string | undefined): Locator {
|
|
264
|
+
const labelFromSelector = extractLabelFromSelector(selector);
|
|
265
|
+
if (labelFromSelector) {
|
|
266
|
+
return { kind: "label", label: labelFromSelector };
|
|
267
|
+
}
|
|
268
|
+
const fromCode = parseCodeToLocator(code);
|
|
269
|
+
if (fromCode) {
|
|
270
|
+
return fromCode;
|
|
271
|
+
}
|
|
272
|
+
return playwrightSelectorToLocator(selector);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Map a Playwright recorded action (from actionAdded event) to a DemoStep-like object, or null to skip.
|
|
277
|
+
* Pass the generated code string when available so we extract role/name, label, text, etc.
|
|
278
|
+
*/
|
|
279
|
+
export function playwrightActionToDemoStep(
|
|
280
|
+
action: PlaywrightRecordedAction,
|
|
281
|
+
code?: string
|
|
282
|
+
): { action: "click" | "type"; locator?: Locator; value?: string } | null {
|
|
283
|
+
const name = action.name;
|
|
284
|
+
const selector = action.selector;
|
|
285
|
+
const locator = resolveLocator(code, selector);
|
|
286
|
+
|
|
287
|
+
if (isRecordedFallbackLocator(locator)) {
|
|
288
|
+
const key = `${name}|${locator.kind}|${typeof (locator as any).name === "string" ? (locator as any).name : ""}|${selector ?? ""}|${
|
|
289
|
+
code ?? ""
|
|
290
|
+
}`;
|
|
291
|
+
warnOnce(
|
|
292
|
+
key,
|
|
293
|
+
"[demo-maker][record] locator fallback to (recorded). Improve selector patterns or app a11y (missing accessible name/text).",
|
|
294
|
+
{
|
|
295
|
+
action: name,
|
|
296
|
+
selector: selector ? truncate(selector) : "",
|
|
297
|
+
code: code ? truncate(code) : "",
|
|
298
|
+
locator,
|
|
299
|
+
}
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
switch (name) {
|
|
304
|
+
case "click":
|
|
305
|
+
return { action: "click", locator };
|
|
306
|
+
case "fill":
|
|
307
|
+
return {
|
|
308
|
+
action: "type",
|
|
309
|
+
locator,
|
|
310
|
+
value: (action.text ?? "").trim(),
|
|
311
|
+
};
|
|
312
|
+
case "press":
|
|
313
|
+
return { action: "click", locator };
|
|
314
|
+
case "check":
|
|
315
|
+
case "uncheck":
|
|
316
|
+
return { action: "click", locator };
|
|
317
|
+
case "select":
|
|
318
|
+
return {
|
|
319
|
+
action: "type",
|
|
320
|
+
locator,
|
|
321
|
+
value: (action.options ?? [])[0] ?? "",
|
|
322
|
+
};
|
|
323
|
+
case "hover":
|
|
324
|
+
return { action: "click", locator };
|
|
325
|
+
default:
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI for recording: live record, continue-from (replay then record), or import from JSON.
|
|
3
|
+
* Usage:
|
|
4
|
+
* npm run demo:record -- --save-to path.yaml [--save-json path.json] [--url URL] [--base-url URL]
|
|
5
|
+
* npm run demo:record -- --continue-from scenario.yaml --save-to path.yaml [--save-json path.json] [--fast] [--env-file path]
|
|
6
|
+
* npm run demo:record -- --from recording.json --save-to scenario.yaml [--base-url URL]
|
|
7
|
+
* npm run demo:import -- recording.json -o scenario.yaml [--base-url URL]
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
11
|
+
import { resolve } from "path";
|
|
12
|
+
import { runLiveRecord } from "./recorder";
|
|
13
|
+
import { recordingJsonToScenario, scenarioToYaml } from "./recording-to-yaml";
|
|
14
|
+
|
|
15
|
+
function failIfExists(cwd: string, filePath: string, label: string): boolean {
|
|
16
|
+
const abs = resolve(cwd, filePath);
|
|
17
|
+
if (existsSync(abs)) {
|
|
18
|
+
console.error(`${label} already exists, refusing to overwrite:`, abs);
|
|
19
|
+
console.error("Delete/rename the file, or choose a different output path.");
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parsePositiveInt(raw: string | undefined): number | undefined {
|
|
26
|
+
if (raw === undefined || raw === "") {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
const n = Number.parseInt(raw, 10);
|
|
30
|
+
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseArgs(argv: string[]) {
|
|
34
|
+
const args = argv.slice(2);
|
|
35
|
+
const positionals = args.filter((a) => !a.startsWith("--"));
|
|
36
|
+
const saveTo =
|
|
37
|
+
args.find((a) => a.startsWith("--save-to="))?.slice("--save-to=".length) ??
|
|
38
|
+
(args.includes("--save-to") && args[args.indexOf("--save-to") + 1] ? args[args.indexOf("--save-to") + 1] : undefined);
|
|
39
|
+
const saveJson = args.find((a) => a.startsWith("--save-json="))?.slice("--save-json=".length);
|
|
40
|
+
const url = args.find((a) => a.startsWith("--url="))?.slice("--url=".length);
|
|
41
|
+
const from = args.find((a) => a.startsWith("--from="))?.slice("--from=".length);
|
|
42
|
+
const continueFrom =
|
|
43
|
+
args.find((a) => a.startsWith("--continue-from="))?.slice("--continue-from=".length) ??
|
|
44
|
+
(args.includes("--continue-from") && args[args.indexOf("--continue-from") + 1]
|
|
45
|
+
? args[args.indexOf("--continue-from") + 1]
|
|
46
|
+
: undefined);
|
|
47
|
+
const fast = args.includes("--fast");
|
|
48
|
+
const envFile =
|
|
49
|
+
args.find((a) => a.startsWith("--env-file="))?.slice("--env-file=".length) ??
|
|
50
|
+
(args.includes("--env-file") && args[args.indexOf("--env-file") + 1]
|
|
51
|
+
? args[args.indexOf("--env-file") + 1]
|
|
52
|
+
: undefined);
|
|
53
|
+
const baseUrl =
|
|
54
|
+
args.find((a) => a.startsWith("--base-url="))?.slice("--base-url=".length) ??
|
|
55
|
+
(args.includes("--base-url") && args[args.indexOf("--base-url") + 1]
|
|
56
|
+
? args[args.indexOf("--base-url") + 1]
|
|
57
|
+
: undefined);
|
|
58
|
+
const endStep =
|
|
59
|
+
parsePositiveInt(args.find((a) => a.startsWith("--end-step="))?.slice("--end-step=".length)) ??
|
|
60
|
+
(args.includes("--end-step") ? parsePositiveInt(args[args.indexOf("--end-step") + 1]) : undefined);
|
|
61
|
+
const oEq = args.find((a) => a.startsWith("-o="));
|
|
62
|
+
const oIdx = args.indexOf("-o");
|
|
63
|
+
const output = oEq ? oEq.slice(3) : oIdx >= 0 && args[oIdx + 1] ? args[oIdx + 1] : undefined;
|
|
64
|
+
const importMode = args.includes("--import");
|
|
65
|
+
const jsonFile = importMode ? positionals[0] : from ?? undefined;
|
|
66
|
+
return {
|
|
67
|
+
saveTo,
|
|
68
|
+
saveJson,
|
|
69
|
+
url,
|
|
70
|
+
from,
|
|
71
|
+
baseUrl,
|
|
72
|
+
continueFrom,
|
|
73
|
+
fast,
|
|
74
|
+
envFile,
|
|
75
|
+
endStep,
|
|
76
|
+
importMode,
|
|
77
|
+
jsonFile,
|
|
78
|
+
outFile: output,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function recordMain(argv: string[] = process.argv): Promise<number> {
|
|
83
|
+
const cwd = process.cwd();
|
|
84
|
+
const { saveTo, saveJson, url, from, baseUrl, continueFrom, fast, envFile, endStep, importMode, jsonFile, outFile } =
|
|
85
|
+
parseArgs(argv);
|
|
86
|
+
|
|
87
|
+
const doImport = from || (importMode && jsonFile && outFile);
|
|
88
|
+
if (doImport) {
|
|
89
|
+
const jsonPath = from ?? jsonFile;
|
|
90
|
+
if (!jsonPath) {
|
|
91
|
+
console.error("Usage: demo:import <recording.json> -o <scenario.yaml> [--base-url URL]");
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
94
|
+
const outputPath = outFile ?? saveTo;
|
|
95
|
+
if (!outputPath) {
|
|
96
|
+
console.error("Usage: demo:import <recording.json> -o <scenario.yaml> [--base-url URL]");
|
|
97
|
+
return 1;
|
|
98
|
+
}
|
|
99
|
+
if (failIfExists(cwd, outputPath, "Output YAML")) {
|
|
100
|
+
return 1;
|
|
101
|
+
}
|
|
102
|
+
let jsonContent: string;
|
|
103
|
+
try {
|
|
104
|
+
jsonContent = readFileSync(resolve(cwd, jsonPath), "utf-8");
|
|
105
|
+
} catch (e) {
|
|
106
|
+
console.error("Failed to read JSON:", e instanceof Error ? e.message : e);
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const scenario = recordingJsonToScenario(jsonContent, {
|
|
111
|
+
...(baseUrl !== undefined ? { base_url: baseUrl } : {}),
|
|
112
|
+
});
|
|
113
|
+
const yamlContent = scenarioToYaml(scenario);
|
|
114
|
+
writeFileSync(resolve(cwd, outputPath), yamlContent, "utf-8");
|
|
115
|
+
console.log("Import: saved YAML:", resolve(cwd, outputPath));
|
|
116
|
+
return 0;
|
|
117
|
+
} catch (e) {
|
|
118
|
+
console.error("Failed to convert:", e instanceof Error ? e.message : e);
|
|
119
|
+
return 1;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!saveTo) {
|
|
124
|
+
console.error("Usage (live record, opens browser):");
|
|
125
|
+
console.error(
|
|
126
|
+
" npm run demo:record -- --save-to <path.yaml> [--save-json path.json] [--url URL] [--base-url URL]"
|
|
127
|
+
);
|
|
128
|
+
console.error("Usage (continue from existing YAML, replay then record):");
|
|
129
|
+
console.error(
|
|
130
|
+
" npm run demo:record -- --continue-from <scenario.yaml> --save-to <path.yaml> [--save-json path.json] [--fast] [--env-file path] [--end-step N]"
|
|
131
|
+
);
|
|
132
|
+
console.error("Usage (import JSON to YAML, no browser):");
|
|
133
|
+
console.error(" npm run demo:record -- --from <recording.json> --save-to <scenario.yaml> [--base-url URL]");
|
|
134
|
+
console.error(" npm run demo:import -- <recording.json> -o <scenario.yaml> [--base-url URL]");
|
|
135
|
+
return 1;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!continueFrom && failIfExists(cwd, saveTo, "Output YAML")) {
|
|
139
|
+
return 1;
|
|
140
|
+
}
|
|
141
|
+
if (saveJson !== undefined && !continueFrom && failIfExists(cwd, saveJson, "Output JSON")) {
|
|
142
|
+
return 1;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
console.log(
|
|
147
|
+
continueFrom
|
|
148
|
+
? "Starting recorder with replay (browser will open). Replay runs first, then recording. Press Ctrl+C to stop and save."
|
|
149
|
+
: "Starting live recorder (browser will open). Press Ctrl+C to stop and save."
|
|
150
|
+
);
|
|
151
|
+
const recordOptions: Parameters<typeof runLiveRecord>[0] = { saveTo, cwd };
|
|
152
|
+
if (saveJson !== undefined) {
|
|
153
|
+
recordOptions.saveJson = saveJson;
|
|
154
|
+
}
|
|
155
|
+
if (url !== undefined) {
|
|
156
|
+
recordOptions.url = url;
|
|
157
|
+
} else if (baseUrl !== undefined) {
|
|
158
|
+
recordOptions.url = baseUrl;
|
|
159
|
+
}
|
|
160
|
+
if (continueFrom !== undefined) {
|
|
161
|
+
recordOptions.continueFrom = continueFrom;
|
|
162
|
+
}
|
|
163
|
+
if (fast) {
|
|
164
|
+
recordOptions.fast = true;
|
|
165
|
+
}
|
|
166
|
+
if (envFile !== undefined) {
|
|
167
|
+
recordOptions.envFile = envFile;
|
|
168
|
+
}
|
|
169
|
+
if (endStep !== undefined) {
|
|
170
|
+
recordOptions.endStep = endStep;
|
|
171
|
+
}
|
|
172
|
+
await runLiveRecord(recordOptions);
|
|
173
|
+
return 0;
|
|
174
|
+
} catch (e) {
|
|
175
|
+
console.error("Recording failed:", e instanceof Error ? e.message : e);
|
|
176
|
+
return 1;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|