@a1knowhow/demo-maker 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +104 -0
  3. package/SECURITY.md +33 -0
  4. package/bin/demo-maker.mjs +14 -0
  5. package/docs/README.md +35 -0
  6. package/docs/actions.md +160 -0
  7. package/docs/cli.md +73 -0
  8. package/docs/getting-started.md +220 -0
  9. package/docs/imports-and-fragments.md +78 -0
  10. package/docs/library-api.md +64 -0
  11. package/docs/locators.md +119 -0
  12. package/docs/recording.md +59 -0
  13. package/docs/repairing-scenarios.md +73 -0
  14. package/docs/scenario-schema.md +80 -0
  15. package/docs/url-capture.md +75 -0
  16. package/docs/variables.md +48 -0
  17. package/docs/video-and-pacing.md +109 -0
  18. package/examples/demo1-introduction.yaml +166 -0
  19. package/examples/demo2-github-actions.yaml +109 -0
  20. package/examples/files/demo1-introduction-run.log +50 -0
  21. package/examples/fragments/open-repository.yaml +15 -0
  22. package/examples/smoke.yaml +18 -0
  23. package/llms.txt +32 -0
  24. package/package.json +66 -0
  25. package/src/cli.ts +226 -0
  26. package/src/env.ts +112 -0
  27. package/src/export-chapters.ts +99 -0
  28. package/src/ffmpeg.ts +168 -0
  29. package/src/index.ts +54 -0
  30. package/src/load.ts +143 -0
  31. package/src/logger.ts +126 -0
  32. package/src/main.ts +68 -0
  33. package/src/parse-errors.ts +99 -0
  34. package/src/parse.ts +622 -0
  35. package/src/playwright-selector-to-locator.ts +328 -0
  36. package/src/record-cli.ts +179 -0
  37. package/src/recorder.ts +442 -0
  38. package/src/recording-to-yaml.ts +435 -0
  39. package/src/runner.ts +1582 -0
  40. package/src/selector-to-locator.ts +69 -0
  41. package/src/templates.ts +48 -0
  42. package/src/types.ts +319 -0
  43. package/src/url-capture.ts +105 -0
  44. package/src/variables.ts +64 -0
package/src/load.ts ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Load scenario from file with import expansion: resolves top-level imports and
3
+ * step-level import directives, flattens to a single steps array, detects circular imports.
4
+ */
5
+
6
+ import { readFileSync } from "fs";
7
+ import { dirname, resolve } from "path";
8
+ import type { DemoScenario, DemoStep, ParsedScenario, StepOrImport } from "./types";
9
+ import { parseScenario } from "./parse";
10
+
11
+ export interface LoadScenarioOptions {
12
+ /** Working directory for resolving the entry path. Default process.cwd(). */
13
+ cwd?: string;
14
+ /** Custom file reader (e.g. for tests). Default readFileSync. */
15
+ readFile?: (absolutePath: string) => string;
16
+ }
17
+
18
+ function isStepImport(s: StepOrImport): s is { import: string } {
19
+ return typeof s === "object" && s !== null && "import" in s && typeof (s as { import: string }).import === "string";
20
+ }
21
+
22
+ /**
23
+ * Load and expand a scenario from a file path. Resolves imports relative to each file's directory.
24
+ * Returns a DemoScenario with flattened steps (no import directives). Throws on circular import.
25
+ */
26
+ export function loadScenario(
27
+ entryPath: string,
28
+ options: LoadScenarioOptions = {},
29
+ visited: Set<string> = new Set()
30
+ ): DemoScenario {
31
+ const cwd = options.cwd ?? process.cwd();
32
+ const readFile = options.readFile ?? ((path: string) => readFileSync(path, "utf-8"));
33
+ const absolutePath = resolve(cwd, entryPath);
34
+
35
+ if (visited.has(absolutePath)) {
36
+ throw new Error(`Circular import: ${absolutePath}`);
37
+ }
38
+ visited.add(absolutePath);
39
+
40
+ let content: string;
41
+ try {
42
+ content = readFile(absolutePath);
43
+ } catch (err) {
44
+ visited.delete(absolutePath);
45
+ throw new Error(
46
+ `Failed to read scenario file ${absolutePath}: ${err instanceof Error ? err.message : String(err)}`
47
+ );
48
+ }
49
+
50
+ let parsed: ParsedScenario;
51
+ try {
52
+ parsed = parseScenario(content, { sourceFile: absolutePath });
53
+ } catch (err) {
54
+ visited.delete(absolutePath);
55
+ throw err;
56
+ }
57
+
58
+ const baseDir = dirname(absolutePath);
59
+ const effectiveSteps: DemoStep[] = [];
60
+
61
+ if (parsed.imports && parsed.imports.length > 0) {
62
+ for (const importPath of parsed.imports) {
63
+ const resolvedImport = resolve(baseDir, importPath);
64
+ const child = loadScenario(resolvedImport, { ...options, cwd: baseDir }, visited);
65
+ effectiveSteps.push(...child.steps);
66
+ }
67
+ }
68
+
69
+ for (const item of parsed.steps) {
70
+ if (isStepImport(item)) {
71
+ const resolvedImport = resolve(baseDir, item.import);
72
+ const child = loadScenario(resolvedImport, { ...options, cwd: baseDir }, visited);
73
+ effectiveSteps.push(...child.steps);
74
+ } else {
75
+ effectiveSteps.push(item);
76
+ }
77
+ }
78
+
79
+ visited.delete(absolutePath);
80
+
81
+ const scenario: DemoScenario = {
82
+ name: parsed.name,
83
+ description: parsed.description,
84
+ base_url: parsed.base_url,
85
+ steps: effectiveSteps,
86
+ };
87
+ if (parsed.title !== undefined) {
88
+ scenario.title = parsed.title;
89
+ }
90
+ if (parsed.seed_data !== undefined) {
91
+ scenario.seed_data = parsed.seed_data;
92
+ }
93
+ if (parsed.auth !== undefined) {
94
+ scenario.auth = parsed.auth;
95
+ }
96
+ if (parsed.video !== undefined) {
97
+ scenario.video = parsed.video;
98
+ }
99
+ if (parsed.screenshots !== undefined) {
100
+ scenario.screenshots = parsed.screenshots;
101
+ }
102
+ if (parsed.device_scale_factor !== undefined) {
103
+ scenario.device_scale_factor = parsed.device_scale_factor;
104
+ }
105
+ if (parsed.audio !== undefined) {
106
+ scenario.audio = parsed.audio;
107
+ }
108
+ if (parsed.action_timeout_ms !== undefined) {
109
+ scenario.action_timeout_ms = parsed.action_timeout_ms;
110
+ }
111
+ if (parsed.storage_state !== undefined) {
112
+ scenario.storage_state = parsed.storage_state;
113
+ }
114
+ if (parsed.save_storage_state !== undefined) {
115
+ scenario.save_storage_state = parsed.save_storage_state;
116
+ }
117
+ if (parsed.step_delay_ms !== undefined) {
118
+ scenario.step_delay_ms = parsed.step_delay_ms;
119
+ }
120
+ if (parsed.slow_mo_ms !== undefined) {
121
+ scenario.slow_mo_ms = parsed.slow_mo_ms;
122
+ }
123
+ if (parsed.typing_delay_ms !== undefined) {
124
+ scenario.typing_delay_ms = parsed.typing_delay_ms;
125
+ }
126
+ if (parsed.post_click_delay_ms !== undefined) {
127
+ scenario.post_click_delay_ms = parsed.post_click_delay_ms;
128
+ }
129
+ if (parsed.navigate_hold_ms !== undefined) {
130
+ scenario.navigate_hold_ms = parsed.navigate_hold_ms;
131
+ }
132
+ if (parsed.pre_action_delay_ms !== undefined) {
133
+ scenario.pre_action_delay_ms = parsed.pre_action_delay_ms;
134
+ }
135
+ if (parsed.metadata !== undefined) {
136
+ scenario.metadata = parsed.metadata;
137
+ }
138
+ if (parsed.url_capture !== undefined) {
139
+ scenario.url_capture = parsed.url_capture;
140
+ }
141
+
142
+ return scenario;
143
+ }
package/src/logger.ts ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Node-safe logger for the demo-maker. No SvelteKit or $app imports.
3
+ */
4
+
5
+ import type { IDemoLogger } from "./types";
6
+
7
+ /** Local time `HH:mm:ss` for step progress lines. */
8
+ export function formatLocalTimeHHMMSS(d = new Date()): string {
9
+ const pad = (n: number) => String(n).padStart(2, "0");
10
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
11
+ }
12
+
13
+ function ansiColorEnabled(): boolean {
14
+ if (process.env.NO_COLOR != null && process.env.NO_COLOR !== "") {
15
+ return false;
16
+ }
17
+ if (process.env.FORCE_COLOR === "1" || process.env.FORCE_COLOR === "true") {
18
+ return true;
19
+ }
20
+ return process.stdout.isTTY === true;
21
+ }
22
+
23
+ /** Widest `ActionType` string (`expect_visible`) — pad so hint column lines up. */
24
+ const STEP_ACTION_COLUMN_WIDTH = 14;
25
+
26
+ function paddedStepCounter(stepIndex: number, stepTotal: number): string {
27
+ const w = Math.max(1, String(stepTotal).length);
28
+ return `${String(stepIndex).padStart(w, " ")}/${stepTotal}`;
29
+ }
30
+
31
+ function paddedActionVerb(action: string): string {
32
+ return action.padEnd(STEP_ACTION_COLUMN_WIDTH, " ");
33
+ }
34
+
35
+ const ANSI = {
36
+ reset: "\x1b[0m",
37
+ /** Timestamp */
38
+ time: "\x1b[2;36m",
39
+ /** Punctuation: ` - ` and `: ` between segments */
40
+ sep: "\x1b[2;90m",
41
+ /** `Step i/n` */
42
+ step: "\x1b[1;97m",
43
+ /** Action verb: click, fill, wait, … */
44
+ action: "\x1b[33m",
45
+ /** Locator / URL / caption tail after action */
46
+ detail: "\x1b[35m",
47
+ /** Errors */
48
+ err: "\x1b[31m",
49
+ } as const;
50
+
51
+ export interface StepProgressLogParts {
52
+ stepIndex: number;
53
+ stepTotal: number;
54
+ action: string;
55
+ hint?: string;
56
+ }
57
+
58
+ /**
59
+ * One-line step progress for the terminal: `22:45:59 - Step 8/37: wait - 3500ms`.
60
+ * Uses ANSI colors when stdout is a TTY and `NO_COLOR` is unset.
61
+ */
62
+ export function formatStepProgressLogLine(parts: StepProgressLogParts): string {
63
+ const ts = formatLocalTimeHHMMSS();
64
+ const { stepIndex, stepTotal, action, hint } = parts;
65
+ const hintTrim = hint?.trim();
66
+ const hasHint = hintTrim !== undefined && hintTrim !== "";
67
+
68
+ const stepPart = paddedStepCounter(stepIndex, stepTotal);
69
+ const actionPart = paddedActionVerb(action);
70
+
71
+ if (!ansiColorEnabled()) {
72
+ const core = `Step ${stepPart}: ${actionPart}`;
73
+ return `${ts} - ${core}${hasHint ? ` - ${hintTrim}` : ""}`;
74
+ }
75
+
76
+ const { reset, time, sep, step, action: actStyle, detail } = ANSI;
77
+ let out = `${time}${ts}${reset}${sep} - ${reset}${step}Step ${stepPart}${reset}${sep}: ${reset}${actStyle}${actionPart}${reset}`;
78
+ if (hasHint) {
79
+ out += `${sep} - ${reset}${detail}${hintTrim}${reset}`;
80
+ }
81
+ return out;
82
+ }
83
+
84
+ export function formatStepFailedLogLine(stepIndex: number, message: string): string {
85
+ const ts = formatLocalTimeHHMMSS();
86
+ if (!ansiColorEnabled()) {
87
+ return `${ts} - Step ${stepIndex} failed: ${message}`;
88
+ }
89
+ const { reset, time, sep, step, err } = ANSI;
90
+ return `${time}${ts}${reset}${sep} - ${reset}${step}Step ${stepIndex} failed${reset}${sep}: ${reset}${err}${message}${reset}`;
91
+ }
92
+
93
+ const LEVEL_ORDER: Record<string, number> = {
94
+ debug: 0,
95
+ info: 1,
96
+ warn: 2,
97
+ error: 3,
98
+ };
99
+
100
+ export function createDemoLogger(
101
+ level: "debug" | "info" | "warn" | "error" = "info"
102
+ ): IDemoLogger {
103
+ const minLevel = LEVEL_ORDER[level] ?? LEVEL_ORDER.info ?? 0;
104
+
105
+ function shouldLog(msgLevel: string): boolean {
106
+ return (LEVEL_ORDER[msgLevel] ?? 0) >= minLevel;
107
+ }
108
+
109
+ return {
110
+ log(...args: unknown[]) {
111
+ console.log(...args);
112
+ },
113
+ info(...args: unknown[]) {
114
+ if (shouldLog("info")) console.info(...args);
115
+ },
116
+ warn(...args: unknown[]) {
117
+ if (shouldLog("warn")) console.warn(...args);
118
+ },
119
+ error(...args: unknown[]) {
120
+ if (shouldLog("error")) console.error(...args);
121
+ },
122
+ debug(...args: unknown[]) {
123
+ if (shouldLog("debug")) console.debug(...args);
124
+ },
125
+ };
126
+ }
package/src/main.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Unified CLI: demo-maker <run|check|record|import|export-chapters> ...
3
+ */
4
+
5
+ import { runMain } from "./cli";
6
+ import { recordMain } from "./record-cli";
7
+ import { exportChaptersMain } from "./export-chapters";
8
+
9
+ function printHelp(): void {
10
+ console.log(`demo-maker — demo videos as code (YAML + Playwright → MP4)
11
+
12
+ Usage:
13
+ demo-maker run <scenario.yaml> [options]
14
+ demo-maker check <scenario.yaml> [options] # same as run --fail-fast --fast
15
+ demo-maker record [options]
16
+ demo-maker import <recording.json> -o <scenario.yaml>
17
+ demo-maker export-chapters <timing.json|dir>
18
+
19
+ Run options include:
20
+ --env-file=PATH --base-url=URL --headless --fast --fail-fast
21
+ --end-step=N --pause-at-end --storage-state=PATH --save-storage-state=PATH
22
+ --debug-dir=PATH --capture-from-url VAR=regex --url-capture-file=path.json
23
+
24
+ Requires: Node 20+, Playwright Chromium, ffmpeg on PATH for MP4/splice.
25
+ `);
26
+ }
27
+
28
+ export async function main(argv: string[] = process.argv): Promise<number> {
29
+ const args = argv.slice(2);
30
+ const cmd = args[0];
31
+
32
+ if (!cmd || cmd === "-h" || cmd === "--help" || cmd === "help") {
33
+ printHelp();
34
+ return cmd ? 0 : 1;
35
+ }
36
+
37
+ if (cmd === "run") {
38
+ return runMain(["node", "demo-maker", ...args.slice(1)]);
39
+ }
40
+ if (cmd === "check") {
41
+ return runMain(["node", "demo-maker", ...args.slice(1), "--fail-fast", "--fast"]);
42
+ }
43
+ if (cmd === "record") {
44
+ return recordMain(["node", "demo-maker", ...args.slice(1)]);
45
+ }
46
+ if (cmd === "import") {
47
+ return recordMain(["node", "demo-maker", "--import", ...args.slice(1)]);
48
+ }
49
+ if (cmd === "export-chapters") {
50
+ return exportChaptersMain(["node", "demo-maker", ...args.slice(1)]);
51
+ }
52
+
53
+ // Back-compat: first arg is a yaml path (old ui script style)
54
+ if (cmd.endsWith(".yaml") || cmd.endsWith(".yml")) {
55
+ return runMain(argv);
56
+ }
57
+
58
+ console.error(`Unknown command: ${cmd}`);
59
+ printHelp();
60
+ return 1;
61
+ }
62
+
63
+ main()
64
+ .then((code) => process.exit(code))
65
+ .catch((err) => {
66
+ console.error(err);
67
+ process.exit(1);
68
+ });
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Format parse/validation errors with file and step context for composed scenarios.
3
+ */
4
+
5
+ const SUMMARY_MAX = 120;
6
+
7
+ export interface ParseScenarioContext {
8
+ /** Absolute or relative path to the YAML file being parsed. */
9
+ sourceFile?: string;
10
+ /** Scenario `name:` from the same file. */
11
+ scenarioName?: string;
12
+ }
13
+
14
+ function truncate(value: string, max = SUMMARY_MAX): string {
15
+ const oneLine = value.replace(/\s+/g, " ").trim();
16
+ if (oneLine.length <= max) {
17
+ return oneLine;
18
+ }
19
+ return `${oneLine.slice(0, max - 1)}…`;
20
+ }
21
+
22
+ /** One-line summary of raw YAML step fields for error messages. */
23
+ export function summarizeRawStep(raw: Record<string, unknown>): string {
24
+ const parts: string[] = [];
25
+ if (typeof raw.action === "string") {
26
+ parts.push(`action=${raw.action}`);
27
+ }
28
+ if (typeof raw.import === "string") {
29
+ parts.push(`import=${raw.import}`);
30
+ }
31
+ if (typeof raw.url === "string") {
32
+ parts.push(`url=${truncate(raw.url)}`);
33
+ }
34
+ if (typeof raw.value === "string") {
35
+ parts.push(`value=${truncate(raw.value)}`);
36
+ }
37
+ if (typeof raw.message === "string") {
38
+ parts.push(`message=${truncate(raw.message)}`);
39
+ }
40
+ if (typeof raw.caption === "string") {
41
+ parts.push(`caption=${truncate(raw.caption)}`);
42
+ }
43
+ if (typeof raw.text === "string") {
44
+ parts.push(`text=${truncate(raw.text)}`);
45
+ }
46
+ if (raw.locator !== undefined) {
47
+ try {
48
+ parts.push(`locator=${truncate(JSON.stringify(raw.locator))}`);
49
+ } catch {
50
+ parts.push("locator=<unserializable>");
51
+ }
52
+ }
53
+ return parts.join(", ");
54
+ }
55
+
56
+ const VALID_LOCATOR_KEYS = new Set(["role", "label", "aria-label", "text", "placeholder", "title", "selector"]);
57
+
58
+ /** Hint when `locator:` is present but did not map to a supported shape. */
59
+ export function describeInvalidLocator(raw: unknown): string | undefined {
60
+ if (raw === undefined || raw === null) {
61
+ return undefined;
62
+ }
63
+ if (typeof raw !== "object") {
64
+ return `locator must be an object, got ${typeof raw}`;
65
+ }
66
+ const keys = Object.keys(raw as Record<string, unknown>);
67
+ if (keys.length === 0) {
68
+ return 'locator object is empty (use role, label, aria-label, text, placeholder, title, or selector)';
69
+ }
70
+ const unknown = keys.filter((k) => !VALID_LOCATOR_KEYS.has(k));
71
+ if (unknown.length > 0) {
72
+ return `unrecognized locator key(s): ${unknown.join(", ")} (use role, label, aria-label, text, placeholder, title, or selector)`;
73
+ }
74
+ return undefined;
75
+ }
76
+
77
+ export function formatStepParseError(
78
+ ctx: ParseScenarioContext,
79
+ stepIndex: number,
80
+ message: string,
81
+ rawStep?: Record<string, unknown>
82
+ ): string {
83
+ const stepNum = stepIndex + 1;
84
+ const where: string[] = [`Step ${stepNum}`];
85
+ if (ctx.sourceFile) {
86
+ where.push(`in ${ctx.sourceFile}`);
87
+ }
88
+ if (ctx.scenarioName) {
89
+ where.push(`(scenario "${ctx.scenarioName}")`);
90
+ }
91
+ let out = `${where.join(" ")}: ${message}`;
92
+ if (rawStep) {
93
+ const summary = summarizeRawStep(rawStep);
94
+ if (summary) {
95
+ out += `\n step: ${summary}`;
96
+ }
97
+ }
98
+ return out;
99
+ }