@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
@@ -0,0 +1,435 @@
1
+ /**
2
+ * Convert recorded JSON (our recorder format or Puppeteer Replay / Chrome DevTools Recorder)
3
+ * to demo-maker YAML scenario.
4
+ */
5
+
6
+ import { stringify as yamlStringify } from "yaml";
7
+ import { dirname, relative } from "path";
8
+ import type { DemoScenario, DemoStep, Locator, ParsedScenario, StepOrImport } from "./types";
9
+ import { replaySelectorsToLocator } from "./selector-to-locator";
10
+
11
+ /** Scenario with steps that may include import directives; used when merging base YAML + recorded steps. */
12
+ export type MergedScenario = Omit<ParsedScenario, "steps"> & { steps: StepOrImport[] };
13
+
14
+ /** Our recorder's JSON shape: steps already in demo-maker style. */
15
+ export interface OurRecordedScenario {
16
+ title: string;
17
+ base_url?: string;
18
+ steps: Array<{
19
+ action: string;
20
+ url?: string;
21
+ locator?: Locator;
22
+ value?: string;
23
+ timeout_ms?: number;
24
+ until?: string;
25
+ url_pattern?: string;
26
+ caption?: string;
27
+ note?: string;
28
+ }>;
29
+ }
30
+
31
+ /** Replay step (subset we care about). */
32
+ interface ReplayStep {
33
+ type: string;
34
+ url?: string;
35
+ selectors?: string[];
36
+ value?: string;
37
+ timeout?: number;
38
+ operator?: string;
39
+ count?: number;
40
+ visible?: boolean;
41
+ }
42
+
43
+ /** Replay UserFlow. */
44
+ interface ReplayUserFlow {
45
+ title?: string;
46
+ steps?: ReplayStep[];
47
+ }
48
+
49
+ const OUR_ACTIONS = new Set(["navigate", "click", "type", "fill", "wait", "expect_visible", "pause"]);
50
+ const REPLAY_NAVIGATE = "navigate";
51
+ const REPLAY_CLICK = "click";
52
+ const REPLAY_DOUBLE_CLICK = "doubleClick";
53
+ const REPLAY_CHANGE = "change";
54
+ const REPLAY_WAIT_FOR_ELEMENT = "waitForElement";
55
+
56
+ function isOurFormat(obj: unknown): obj is OurRecordedScenario {
57
+ if (!obj || typeof obj !== "object") {
58
+ return false;
59
+ }
60
+ const o = obj as Record<string, unknown>;
61
+ if (!Array.isArray(o.steps)) {
62
+ return false;
63
+ }
64
+ return (o.steps as unknown[]).every(
65
+ (s) => s && typeof s === "object" && typeof (s as Record<string, unknown>).action === "string"
66
+ );
67
+ }
68
+
69
+ function isReplayFormat(obj: unknown): obj is ReplayUserFlow {
70
+ if (!obj || typeof obj !== "object") {
71
+ return false;
72
+ }
73
+ const o = obj as Record<string, unknown>;
74
+ if (!Array.isArray(o.steps)) {
75
+ return false;
76
+ }
77
+ return (o.steps as unknown[]).every(
78
+ (s) => s && typeof s === "object" && typeof (s as Record<string, unknown>).type === "string"
79
+ );
80
+ }
81
+
82
+ function replayStepToDemoStep(step: ReplayStep, baseUrl: string): DemoStep | null {
83
+ const type = step.type;
84
+ if (type === REPLAY_NAVIGATE && typeof step.url === "string") {
85
+ let url = step.url;
86
+ try {
87
+ const u = new URL(url);
88
+ if (baseUrl && u.origin === new URL(baseUrl).origin) {
89
+ url = u.pathname + u.search;
90
+ }
91
+ } catch {
92
+ // leave url as-is
93
+ }
94
+ return { action: "navigate", url };
95
+ }
96
+ if (type === REPLAY_CLICK || type === REPLAY_DOUBLE_CLICK) {
97
+ const result = replaySelectorsToLocator(step.selectors ?? []);
98
+ if (!result) {
99
+ return null;
100
+ }
101
+ const stepOut: DemoStep = { action: "click", locator: result.locator };
102
+ if (result.note) {
103
+ stepOut.note = result.note;
104
+ }
105
+ return stepOut;
106
+ }
107
+ if (type === REPLAY_CHANGE) {
108
+ const result = replaySelectorsToLocator(step.selectors ?? []);
109
+ if (!result) {
110
+ return null;
111
+ }
112
+ const stepOut: DemoStep = {
113
+ action: "type",
114
+ locator: result.locator,
115
+ value: typeof step.value === "string" ? step.value : "",
116
+ };
117
+ if (result.note) {
118
+ stepOut.note = result.note;
119
+ }
120
+ return stepOut;
121
+ }
122
+ if (type === REPLAY_WAIT_FOR_ELEMENT) {
123
+ const result = replaySelectorsToLocator(step.selectors ?? []);
124
+ if (!result) {
125
+ return null;
126
+ }
127
+ const stepOut: DemoStep = { action: "expect_visible", locator: result.locator };
128
+ if (result.note) {
129
+ stepOut.note = result.note;
130
+ }
131
+ return stepOut;
132
+ }
133
+ return null;
134
+ }
135
+
136
+ function ourStepToDemoStep(raw: OurRecordedScenario["steps"][0]): DemoStep | null {
137
+ const action = raw.action;
138
+ if (!OUR_ACTIONS.has(action)) {
139
+ return null;
140
+ }
141
+ const step: DemoStep = { action: action as DemoStep["action"] };
142
+ if (raw.url !== undefined) {
143
+ step.url = raw.url;
144
+ }
145
+ if (raw.locator !== undefined) {
146
+ step.locator = raw.locator;
147
+ }
148
+ if (raw.value !== undefined) {
149
+ step.value = raw.value;
150
+ }
151
+ if (raw.timeout_ms !== undefined) {
152
+ step.timeout_ms = raw.timeout_ms;
153
+ }
154
+ if (raw.until !== undefined) {
155
+ step.until = raw.until as "url";
156
+ }
157
+ if (raw.url_pattern !== undefined) {
158
+ step.url_pattern = raw.url_pattern;
159
+ }
160
+ if (raw.caption !== undefined) {
161
+ step.caption = raw.caption;
162
+ }
163
+ if (raw.note !== undefined) {
164
+ step.note = raw.note;
165
+ }
166
+ return step;
167
+ }
168
+
169
+ /**
170
+ * Parse JSON content (our format or Replay) and return a DemoScenario.
171
+ */
172
+ export function recordingJsonToScenario(
173
+ jsonContent: string,
174
+ options: { base_url?: string } = {}
175
+ ): DemoScenario {
176
+ let parsed: unknown;
177
+ try {
178
+ parsed = JSON.parse(jsonContent);
179
+ } catch (e) {
180
+ throw new Error(`Invalid JSON: ${e instanceof Error ? e.message : String(e)}`);
181
+ }
182
+
183
+ const base_url = options.base_url ?? "http://localhost:2000";
184
+ let name = "recorded-scenario";
185
+ let description = "";
186
+ const steps: DemoStep[] = [];
187
+
188
+ let resolvedBaseUrl = base_url;
189
+ if (isOurFormat(parsed)) {
190
+ name = parsed.title?.trim() || name;
191
+ if (parsed.base_url && options.base_url === undefined) {
192
+ resolvedBaseUrl = parsed.base_url;
193
+ }
194
+ for (const raw of parsed.steps) {
195
+ const step = ourStepToDemoStep(raw);
196
+ if (step) {
197
+ steps.push(step);
198
+ }
199
+ }
200
+ } else if (isReplayFormat(parsed)) {
201
+ const flow = parsed as ReplayUserFlow;
202
+ name = flow.title?.trim() || name;
203
+ const base = options.base_url ?? base_url;
204
+ for (const replayStep of flow.steps ?? []) {
205
+ const step = replayStepToDemoStep(replayStep, base);
206
+ if (step) {
207
+ steps.push(step);
208
+ }
209
+ }
210
+ } else {
211
+ throw new Error("Unknown recording format: expected our format or Puppeteer Replay UserFlow");
212
+ }
213
+
214
+ return {
215
+ name,
216
+ description,
217
+ base_url: resolvedBaseUrl,
218
+ steps,
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Serialize DemoScenario to YAML string.
224
+ */
225
+ export function scenarioToYaml(scenario: DemoScenario): string {
226
+ const obj: Record<string, unknown> = {
227
+ name: scenario.name,
228
+ ...(scenario.title !== undefined && scenario.title.trim() ? { title: scenario.title } : {}),
229
+ description: scenario.description,
230
+ base_url: scenario.base_url,
231
+ ...(scenario.video !== undefined ? { video: scenario.video } : {}),
232
+ ...(scenario.step_delay_ms !== undefined ? { step_delay_ms: scenario.step_delay_ms } : {}),
233
+ ...(scenario.slow_mo_ms !== undefined ? { slow_mo_ms: scenario.slow_mo_ms } : {}),
234
+ ...(scenario.typing_delay_ms !== undefined ? { typing_delay_ms: scenario.typing_delay_ms } : {}),
235
+ ...(scenario.post_click_delay_ms !== undefined ? { post_click_delay_ms: scenario.post_click_delay_ms } : {}),
236
+ ...(scenario.navigate_hold_ms !== undefined ? { navigate_hold_ms: scenario.navigate_hold_ms } : {}),
237
+ ...(scenario.pre_action_delay_ms !== undefined ? { pre_action_delay_ms: scenario.pre_action_delay_ms } : {}),
238
+ steps: scenario.steps.map((s) => {
239
+ const step: Record<string, unknown> = { action: s.action };
240
+ if (s.caption !== undefined) {
241
+ step.caption = s.caption;
242
+ }
243
+ if (s.url !== undefined) {
244
+ step.url = s.url;
245
+ }
246
+ if (s.locator !== undefined) {
247
+ step.locator = locatorToPlain(s.locator);
248
+ }
249
+ if (s.value !== undefined) {
250
+ step.value = s.value;
251
+ }
252
+ if (s.timeout_ms !== undefined) {
253
+ step.timeout_ms = s.timeout_ms;
254
+ }
255
+ if (s.until !== undefined) {
256
+ step.until = s.until;
257
+ }
258
+ if (s.url_pattern !== undefined) {
259
+ step.url_pattern = s.url_pattern;
260
+ }
261
+ if (s.note !== undefined) {
262
+ step.note = s.note;
263
+ }
264
+ return step;
265
+ }),
266
+ };
267
+ let out = yamlStringify(obj, { lineWidth: 0 });
268
+ const delayComments: Array<{ key: string; comment: string }> = [
269
+ { key: "step_delay_ms", comment: "# Delay after each step (pacing)" },
270
+ { key: "slow_mo_ms", comment: "# Delay after each browser action (smoother motion)" },
271
+ { key: "typing_delay_ms", comment: "# Delay between keystrokes (human-like typing)" },
272
+ { key: "post_click_delay_ms", comment: "# Delay after click (show result before next)" },
273
+ { key: "navigate_hold_ms", comment: "# Wait after navigate (hold on new page)" },
274
+ { key: "pre_action_delay_ms", comment: "# Delay before navigate/click (video: more frames of current page)" },
275
+ ];
276
+ for (const { key, comment } of delayComments) {
277
+ out = out.replace(new RegExp(`\\n(${key}:)`, "g"), `\n${comment}\n$1`);
278
+ }
279
+ return out;
280
+ }
281
+
282
+ function isStepImport(s: StepOrImport): s is { import: string } {
283
+ return typeof s === "object" && s !== null && "import" in s && typeof (s as { import: string }).import === "string";
284
+ }
285
+
286
+ function stepOrImportToPlain(s: StepOrImport): Record<string, unknown> {
287
+ if (isStepImport(s)) {
288
+ return { import: s.import };
289
+ }
290
+ const step: Record<string, unknown> = { action: s.action };
291
+ if (s.caption !== undefined) {
292
+ step.caption = s.caption;
293
+ }
294
+ if (s.url !== undefined) {
295
+ step.url = s.url;
296
+ }
297
+ if (s.locator !== undefined) {
298
+ step.locator = locatorToPlain(s.locator);
299
+ }
300
+ if (s.value !== undefined) {
301
+ step.value = s.value;
302
+ }
303
+ if (s.timeout_ms !== undefined) {
304
+ step.timeout_ms = s.timeout_ms;
305
+ }
306
+ if (s.until !== undefined) {
307
+ step.until = s.until;
308
+ }
309
+ if (s.url_pattern !== undefined) {
310
+ step.url_pattern = s.url_pattern;
311
+ }
312
+ if (s.note !== undefined) {
313
+ step.note = s.note;
314
+ }
315
+ return step;
316
+ }
317
+
318
+ /**
319
+ * Default pacing for newly saved recorded scenarios (continue-from and live record).
320
+ */
321
+ export const RECORDED_SCENARIO_PACING = {
322
+ step_delay_ms: 500,
323
+ slow_mo_ms: 100,
324
+ typing_delay_ms: 50,
325
+ post_click_delay_ms: 250,
326
+ navigate_hold_ms: 2000,
327
+ pre_action_delay_ms: 300,
328
+ } as const;
329
+
330
+ /** Import path for YAML `imports:` relative to the saved scenario file. */
331
+ export function relativeImportPath(saveToPath: string, continueFromPath: string): string {
332
+ const rel = relative(dirname(saveToPath), continueFromPath);
333
+ return rel.split("\\").join("/");
334
+ }
335
+
336
+ /**
337
+ * Build a scenario YAML that references the replayed base via `imports` and contains
338
+ * only newly recorded steps (not inlined base steps).
339
+ */
340
+ export function composeRecordedOnlyScenario(
341
+ base: ParsedScenario,
342
+ extraSteps: DemoStep[],
343
+ opts: { importPath: string; name: string },
344
+ ): MergedScenario {
345
+ return {
346
+ name: opts.name,
347
+ description: "",
348
+ base_url: base.base_url,
349
+ imports: [opts.importPath],
350
+ steps: extraSteps,
351
+ ...RECORDED_SCENARIO_PACING,
352
+ };
353
+ }
354
+
355
+ /**
356
+ * Serialize a merged scenario (ParsedScenario with StepOrImport steps) to YAML.
357
+ * Preserves top-level imports and step-level import directives.
358
+ */
359
+ export function parsedScenarioToYaml(merged: MergedScenario): string {
360
+ const obj: Record<string, unknown> = {
361
+ name: merged.name,
362
+ };
363
+ if (merged.title !== undefined && merged.title.trim()) {
364
+ obj.title = merged.title;
365
+ }
366
+ obj.description = merged.description;
367
+ obj.base_url = merged.base_url;
368
+ obj.steps = merged.steps.map((s) => stepOrImportToPlain(s));
369
+ if (merged.imports !== undefined && merged.imports.length > 0) {
370
+ obj.imports = merged.imports;
371
+ }
372
+ if (merged.seed_data !== undefined) {
373
+ obj.seed_data = merged.seed_data;
374
+ }
375
+ if (merged.auth !== undefined) {
376
+ obj.auth = merged.auth;
377
+ }
378
+ if (merged.video !== undefined) {
379
+ obj.video = merged.video;
380
+ }
381
+ if (merged.step_delay_ms !== undefined) {
382
+ obj.step_delay_ms = merged.step_delay_ms;
383
+ }
384
+ if (merged.slow_mo_ms !== undefined) {
385
+ obj.slow_mo_ms = merged.slow_mo_ms;
386
+ }
387
+ if (merged.typing_delay_ms !== undefined) {
388
+ obj.typing_delay_ms = merged.typing_delay_ms;
389
+ }
390
+ if (merged.post_click_delay_ms !== undefined) {
391
+ obj.post_click_delay_ms = merged.post_click_delay_ms;
392
+ }
393
+ if (merged.navigate_hold_ms !== undefined) {
394
+ obj.navigate_hold_ms = merged.navigate_hold_ms;
395
+ }
396
+ if (merged.pre_action_delay_ms !== undefined) {
397
+ obj.pre_action_delay_ms = merged.pre_action_delay_ms;
398
+ }
399
+ let out = yamlStringify(obj, { lineWidth: 0 });
400
+ const delayComments: Array<{ key: string; comment: string }> = [
401
+ { key: "step_delay_ms", comment: "# Delay after each step (pacing)" },
402
+ { key: "slow_mo_ms", comment: "# Delay after each browser action (smoother motion)" },
403
+ { key: "typing_delay_ms", comment: "# Delay between keystrokes (human-like typing)" },
404
+ { key: "post_click_delay_ms", comment: "# Delay after click (show result before next)" },
405
+ { key: "navigate_hold_ms", comment: "# Wait after navigate (hold on new page)" },
406
+ { key: "pre_action_delay_ms", comment: "# Delay before navigate/click (video: more frames of current page)" },
407
+ ];
408
+ for (const { key, comment } of delayComments) {
409
+ out = out.replace(new RegExp(`\\n(${key}:)`, "g"), `\n${comment}\n$1`);
410
+ }
411
+ return out;
412
+ }
413
+
414
+ function locatorToPlain(loc: Locator): Record<string, unknown> {
415
+ switch (loc.kind) {
416
+ case "role":
417
+ return {
418
+ role: loc.role,
419
+ name:
420
+ loc.name instanceof RegExp ? `/${loc.name.source}/${loc.name.flags}` : loc.name,
421
+ };
422
+ case "label":
423
+ return { label: loc.label instanceof RegExp ? `/${loc.label.source}/${loc.label.flags}` : loc.label };
424
+ case "text":
425
+ return { text: loc.text };
426
+ case "placeholder":
427
+ return { placeholder: loc.placeholder };
428
+ case "title":
429
+ return { title: loc.title };
430
+ case "selector":
431
+ return { selector: loc.selector };
432
+ default:
433
+ return {};
434
+ }
435
+ }