state-scenarios-cli 0.4.0 → 0.5.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/README.md +1 -1
- package/dist/cli.js +34 -10
- package/dist/cli.js.map +1 -1
- package/dist/{record-WEZRD2VT.js → record-HXIYGMWA.js} +12 -6
- package/dist/record-HXIYGMWA.js.map +1 -0
- package/dist/record.d.ts +7 -3
- package/package.json +1 -1
- package/skills/state-scenarios/references/media.md +12 -2
- package/dist/record-WEZRD2VT.js.map +0 -1
package/README.md
CHANGED
|
@@ -16,5 +16,5 @@ npx state-scenarios suggest scenarios --src src --schema scenario.schema.json
|
|
|
16
16
|
- `shoot` opens every scenario in Chromium, waits until it's ready, and saves a screenshot. It fails on unmocked requests, uncaught exceptions and `console.error` calls, and writes `<out>/<name>.md` for each failure with the fix. It needs `@playwright/test`.
|
|
17
17
|
- `doctor` checks the wiring one step at a time and prints each fix. `mcp` lets a coding agent open, drive and save scenarios. `guide` prints the shipped agent skill.
|
|
18
18
|
- Exit codes: `1` a scenario failed, `2` bad arguments or scenarios, `3` something needed is missing or down. Errors print a stable code, e.g. `[DEV_SERVER_DOWN]`.
|
|
19
|
-
- `record` opens each scenario named in `--steps`, runs its steps with a visible cursor, and saves an MP4, GIF or WebM, optionally cropped to `--element
|
|
19
|
+
- `record` opens each scenario named in `--steps`, runs its steps with a visible cursor, and saves an MP4, GIF or WebM, optionally cropped to `--element` (or per scenario with `{ element, steps }`). `--fps`, `--width` and `--hold <ms>` tune size and the final frame. MP4, GIF, cropping, `--fps` and `--width` need ffmpeg.
|
|
20
20
|
- `suggest` asks Claude, or another model behind `ANTHROPIC_BASE_URL` via `--model`, which UI states lack scenarios, and drafts them for review.
|
package/dist/cli.js
CHANGED
|
@@ -36,8 +36,10 @@ var HELP = `Usage: state-scenarios <command> <scenarios-dir> [options]
|
|
|
36
36
|
Each failed scenario gets <out>/<name>.md: what failed and how to fix it
|
|
37
37
|
record Open each scenario that has steps, run them, and save a video with a visible cursor.
|
|
38
38
|
--base-url and --steps (required): a module whose default export maps scenario names
|
|
39
|
-
to async (page) => {} steps
|
|
40
|
-
mp4, gif
|
|
39
|
+
to async (page) => {} steps, or to { element, steps } to crop that one scenario.
|
|
40
|
+
--format mp4 (default), gif or webm; --out, --only, --element, --fps (gif default 12),
|
|
41
|
+
--width <px> to downscale, --hold <ms> on the last frame (default 800).
|
|
42
|
+
mp4, gif, --element, --fps and --width need ffmpeg on PATH (or FFMPEG_PATH)
|
|
41
43
|
suggest Ask Claude which UI states lack scenarios and draft them into --out (default <dir>/proposed).
|
|
42
44
|
--src dir[,dir] (required), --schema file, --max n, --model (default $STATE_SCENARIOS_MODEL or claude-opus-5-5).
|
|
43
45
|
Needs ANTHROPIC_API_KEY or \`ant auth login\`; ANTHROPIC_BASE_URL points it at a compatible proxy.
|
|
@@ -64,6 +66,9 @@ var { positionals, values } = parseArgs({
|
|
|
64
66
|
timezone: { type: "string" },
|
|
65
67
|
steps: { type: "string" },
|
|
66
68
|
format: { type: "string" },
|
|
69
|
+
fps: { type: "string" },
|
|
70
|
+
width: { type: "string" },
|
|
71
|
+
hold: { type: "string" },
|
|
67
72
|
json: { type: "boolean" },
|
|
68
73
|
src: { type: "string" },
|
|
69
74
|
schema: { type: "string" },
|
|
@@ -75,10 +80,25 @@ var { positionals, values } = parseArgs({
|
|
|
75
80
|
help: { type: "boolean", short: "h" }
|
|
76
81
|
}
|
|
77
82
|
});
|
|
83
|
+
var Steps = z.function({ input: [z.custom()], output: z.unknown() });
|
|
78
84
|
var StepsSchema = z.record(
|
|
79
85
|
z.string(),
|
|
80
|
-
z.
|
|
86
|
+
z.union([
|
|
87
|
+
Steps.transform((steps) => ({ steps, element: void 0 })),
|
|
88
|
+
z.object({
|
|
89
|
+
element: z.string().optional(),
|
|
90
|
+
steps: Steps
|
|
91
|
+
})
|
|
92
|
+
])
|
|
81
93
|
);
|
|
94
|
+
var positive = (flag) => {
|
|
95
|
+
const text = values[flag];
|
|
96
|
+
const n = Number(text);
|
|
97
|
+
if (text !== void 0 && !(Number.isFinite(n) && n > 0)) {
|
|
98
|
+
throw new CliError("INVALID_ARGUMENT", `--${flag} must be a positive number, not "${text}"`);
|
|
99
|
+
}
|
|
100
|
+
return text === void 0 ? void 0 : n;
|
|
101
|
+
};
|
|
82
102
|
var [command, dir = command === "init" ? "src/scenarios" : void 0] = positionals;
|
|
83
103
|
if (command === "guide") {
|
|
84
104
|
const { guide } = await import("./skills-NQRJHKHG.js");
|
|
@@ -273,12 +293,15 @@ ${" ".repeat(33)}${r.url}` : ""}`
|
|
|
273
293
|
for (const n of names) {
|
|
274
294
|
if (!catalog.get(n)) throw new CliError("UNKNOWN_SCENARIO", `--steps names unknown scenario "${n}"`);
|
|
275
295
|
}
|
|
296
|
+
const fps = positive("fps");
|
|
297
|
+
const width = positive("width");
|
|
298
|
+
const hold = positive("hold") ?? 800;
|
|
276
299
|
const out = values.out ?? "scenario-videos";
|
|
277
300
|
mkdirSync(out, { recursive: true });
|
|
278
301
|
const raw = mkdtempSync(join(tmpdir(), "state-scenarios-"));
|
|
279
302
|
await checkServer(baseURL);
|
|
280
303
|
const { openScenario, showCursor } = await import("state-scenarios/playwright");
|
|
281
|
-
const { encodeVideo, visibleCrop } = await import("./record-
|
|
304
|
+
const { encodeVideo, visibleCrop } = await import("./record-HXIYGMWA.js");
|
|
282
305
|
const browser = await launchChromium({ slowMo: 150 });
|
|
283
306
|
let exitCode = 0;
|
|
284
307
|
const cropTo = async (page, selector, viewport) => {
|
|
@@ -288,15 +311,16 @@ ${" ".repeat(33)}${r.url}` : ""}`
|
|
|
288
311
|
() => null
|
|
289
312
|
);
|
|
290
313
|
const visible = box && visibleCrop(box, viewport);
|
|
291
|
-
if (!visible) throw new CliError("ELEMENT_NOT_FOUND",
|
|
314
|
+
if (!visible) throw new CliError("ELEMENT_NOT_FOUND", `element "${selector}" isn't visible`);
|
|
292
315
|
if (visible.clipped) {
|
|
293
316
|
console.log(
|
|
294
|
-
`
|
|
317
|
+
` element "${selector}" (${Math.round(box.width)}x${Math.round(box.height)}) is larger than the viewport; the video shows the visible part. Use a larger --viewport to fit it.`
|
|
295
318
|
);
|
|
296
319
|
}
|
|
297
320
|
return visible.crop;
|
|
298
321
|
};
|
|
299
|
-
for (const [name,
|
|
322
|
+
for (const [name, entry] of Object.entries(steps).filter(([n]) => names.includes(n))) {
|
|
323
|
+
const { element = values.element, steps: run } = entry;
|
|
300
324
|
const resolved = catalog.resolve(catalog.get(name) ?? { name });
|
|
301
325
|
const options = contextOptions(resolved, values);
|
|
302
326
|
const context = await browser.newContext({
|
|
@@ -310,16 +334,16 @@ ${" ".repeat(33)}${r.url}` : ""}`
|
|
|
310
334
|
try {
|
|
311
335
|
await showCursor(page);
|
|
312
336
|
await openScenario(page, name, { path: resolved.path, strict: !values["no-strict"], panel: false });
|
|
313
|
-
const crop =
|
|
337
|
+
const crop = element ? await cropTo(page, element, options.viewport) : void 0;
|
|
314
338
|
const start = (Date.now() - began) / 1e3;
|
|
315
339
|
await Promise.resolve(run(page)).catch((err) => {
|
|
316
340
|
throw new CliError("STEPS_FAILED", `steps failed: ${err.message.split("\n")[0]}`);
|
|
317
341
|
});
|
|
318
|
-
await page.waitForTimeout(
|
|
342
|
+
await page.waitForTimeout(hold);
|
|
319
343
|
await context.close();
|
|
320
344
|
const video = page.video();
|
|
321
345
|
if (!video) throw new Error("Playwright recorded no video");
|
|
322
|
-
encodeVideo({ input: await video.path(), output: file, format, start, crop });
|
|
346
|
+
encodeVideo({ input: await video.path(), output: file, format, start, crop, fps, width });
|
|
323
347
|
console.log(`\u2713 ${name.padEnd(32)} ${file}`);
|
|
324
348
|
} catch (err) {
|
|
325
349
|
exitCode = Math.max(exitCode, err instanceof CliError ? err.exitCode : 1);
|
package/dist/cli.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\nimport type { Page } from '@playwright/test';\nimport { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';\nimport { tmpdir } from 'node:os';\nimport { join, relative, resolve } from 'node:path';\nimport { pathToFileURL } from 'node:url';\nimport { parseArgs } from 'node:util';\nimport { type Catalog, createCatalog, scenarioJsonSchema, type ScenarioRule } from 'state-scenarios';\nimport { z } from 'zod';\nimport { checkServer, contextOptions, launchChromium, scenarioLink } from './browser.js';\nimport { CliError } from './errors.js';\n\nconst HELP = `Usage: state-scenarios <command> <scenarios-dir> [options]\n\n init Create <dir> (default src/scenarios) with default.json, a sibling scenario.schema.json,\n print the wiring snippet, install the agent skills into .agents/skills and .claude/skills,\n and register the MCP server in .mcp.json. --no-skills skips the last two; --base-url sets the\n URL the MCP server opens (default http://localhost:5173, or :3000 for Next.js)\n guide Print the agent skill for this version: guide [topic]. guide alone lists the topics\n doctor Check the wiring one step at a time and print the fix for each problem: scenarios, dev server,\n MSW worker, session start, ready selector, save route, ffmpeg, skills and MCP. --base-url (required)\n mcp Serve an MCP server on stdio for coding agents: list, open and drive scenarios, check ready\n selectors, and save what the app shows as a scenario. --base-url (required)\n validate Check every scenario parses, resolves and passes --rules\n list Print scenarios with links (--base-url) for people and agents; --json for machines\n shoot Open each scenario in Chromium, wait for its ready selector, screenshot it,\n and report requests it didn't mock. --base-url (required), --out, --only a,b, --no-strict,\n --element <selector> to capture one element instead of the full page.\n Each failed scenario gets <out>/<name>.md: what failed and how to fix it\n record Open each scenario that has steps, run them, and save a video with a visible cursor.\n --base-url and --steps (required): a module whose default export maps scenario names\n to async (page) => {} steps. --format mp4 (default), gif or webm; --out, --only, --element.\n mp4, gif and --element need ffmpeg on PATH (or FFMPEG_PATH)\n suggest Ask Claude which UI states lack scenarios and draft them into --out (default <dir>/proposed).\n --src dir[,dir] (required), --schema file, --max n, --model (default $STATE_SCENARIOS_MODEL or claude-opus-5-5).\n Needs ANTHROPIC_API_KEY or \\`ant auth login\\`; ANTHROPIC_BASE_URL points it at a compatible proxy.\n --env-file file loads those variables first.\n promote Move <dir>/proposed/*.json into <dir> after validate. --dry-run lists moves only.\n\nCommon: --rules file module exporting \\`rules\\` (ScenarioRule[])\n --json machine-readable output (list, shoot, doctor)`;\n\nconst MAX_SOURCE_CHARS = 800_000;\n\nconst { positionals, values } = parseArgs({\n allowPositionals: true,\n options: {\n rules: { type: 'string' },\n 'base-url': { type: 'string' },\n out: { type: 'string' },\n only: { type: 'string' },\n 'no-strict': { type: 'boolean' },\n 'no-panel': { type: 'boolean' },\n viewport: { type: 'string' },\n scale: { type: 'string' },\n 'color-scheme': { type: 'string' },\n element: { type: 'string' },\n locale: { type: 'string' },\n timezone: { type: 'string' },\n steps: { type: 'string' },\n format: { type: 'string' },\n json: { type: 'boolean' },\n src: { type: 'string' },\n schema: { type: 'string' },\n max: { type: 'string' },\n model: { type: 'string' },\n 'env-file': { type: 'string' },\n 'dry-run': { type: 'boolean' },\n 'no-skills': { type: 'boolean' },\n help: { type: 'boolean', short: 'h' },\n },\n});\n\n/** `--steps` module: scenario name to the steps recorded after it opens. */\nconst StepsSchema = z.record(\n z.string(),\n z.function({ input: [z.custom<Page>()], output: z.unknown() }),\n);\n\nconst [command, dir = command === 'init' ? 'src/scenarios' : undefined] = positionals;\n\nif (command === 'guide') {\n const { guide } = await import('./skills.js');\n\n try {\n console.log(guide(positionals[1]));\n process.exit(0);\n } catch (err) {\n console.error(err instanceof Error ? err.message : String(err));\n process.exit(2);\n }\n}\n\nif (values.help || !command || !dir) {\n console.log(HELP);\n process.exit(command ? 2 : 0);\n}\n\n/** Read the scenarios folder; called again by long-running commands to see new files. */\nconst loadCatalog = (rules: ScenarioRule[]): Catalog => {\n try {\n return createCatalog(\n readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) =>\n JSON.parse(readFileSync(join(dir, f), 'utf8'))\n ),\n { rules },\n );\n } catch (err) {\n throw new CliError('INVALID_SCENARIO', err instanceof Error ? err.message : String(err));\n }\n};\n\nconst requireBaseUrl = () => {\n const url = values['base-url'];\n\n if (!url) throw new CliError('INVALID_ARGUMENT', `${command} needs --base-url, e.g. http://localhost:5173`);\n\n return url;\n};\n\ntry {\n if (command === 'init') {\n const { initScenarios } = await import('./init.js');\n const { stack } = initScenarios(dir);\n\n if (!values['no-skills']) {\n const { installSkills, registerMcp } = await import('./skills.js');\n\n const url = values['base-url']\n ?? (stack === 'next' ? 'http://localhost:3000' : 'http://localhost:5173');\n\n const args = ['mcp', dir, '--base-url', url];\n console.log('');\n\n for (const line of [...installSkills(process.cwd()), ...registerMcp(process.cwd(), args)]) {\n console.log(line);\n }\n\n console.log(`\nCodex: codex mcp add state-scenarios -- npx state-scenarios ${args.join(' ')}\nFor agents that need a pointer, add to AGENTS.md (or CLAUDE.md):\n UI states use state-scenarios; read .agents/skills/state-scenarios/SKILL.md before adding or checking one.`);\n }\n\n process.exit(0);\n }\n\n const rules: ScenarioRule[] = values.rules\n ? (await import(pathToFileURL(resolve(values.rules)).href)).rules\n : [];\n\n if (command === 'promote') {\n const { promoteScenarios } = await import('./promote.js');\n const result = promoteScenarios(dir, { rules, dryRun: !!values['dry-run'] });\n process.exit(result.skipped.length ? 1 : 0);\n }\n\n if (command === 'mcp') {\n const { serveMcp } = await import('./mcp.js');\n\n const { version } = z.object({ version: z.string() }).parse(\n JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')),\n );\n\n await serveMcp({\n dir,\n baseURL: requireBaseUrl(),\n loadCatalog: () => loadCatalog(rules),\n flags: values,\n version,\n });\n process.exit(0);\n }\n\n if (command === 'doctor') {\n const { runDoctor } = await import('./doctor.js');\n const { EXIT_CODES, ERROR_CLASSES } = await import('./errors.js');\n\n const checks = await runDoctor({\n dir,\n baseURL: requireBaseUrl(),\n loadCatalog: () => loadCatalog(rules),\n flags: values,\n });\n\n if (values.json) console.log(JSON.stringify(checks, null, 2));\n else {\n for (const c of checks) {\n console.log(\n `${{ ok: '✓', warn: '!', fail: '✗' }[c.status]} ${c.name.padEnd(16)} ${\n c.code ? `[${c.code}] ` : ''\n }${c.detail}`,\n );\n\n if (c.fix && c.status !== 'ok') console.log(` ${' '.repeat(16)} fix: ${c.fix}`);\n }\n }\n\n const failed = checks.filter((c) => c.status === 'fail');\n process.exit(Math.max(0, ...failed.map((c) => (c.code ? EXIT_CODES[ERROR_CLASSES[c.code]] : 1))));\n }\n\n const catalog = loadCatalog(rules);\n\n switch (command) {\n case 'validate': {\n const problems = catalog.check();\n\n for (const p of problems) console.error(`✗ ${p}\\n`);\n console.log(`${catalog.list.length - problems.length}/${catalog.list.length} scenarios valid`);\n process.exit(problems.length ? 1 : 0);\n }\n\n case 'list': {\n const base = values['base-url'];\n\n const rows = catalog.list.map((s) => ({\n name: s.name,\n description: s.description ?? '',\n tags: s.tags ?? [],\n ready: s.ready ?? null,\n url: base\n ? `${base.replace(/\\/$/, '')}${catalog.resolve(s).path ?? '/'}?scenario=${\n encodeURIComponent(s.name)\n }`\n : null,\n }));\n\n if (values.json) console.log(JSON.stringify(rows, null, 2));\n else {\n for (const r of rows) {\n console.log(\n `${r.name.padEnd(32)} ${r.description}${r.url ? `\\n${' '.repeat(33)}${r.url}` : ''}`,\n );\n }\n }\n\n break;\n }\n\n case 'shoot': {\n const baseURL = requireBaseUrl();\n const out = values.out ?? 'scenario-shots';\n const only = values.only?.split(',');\n\n for (const n of only ?? []) {\n if (!catalog.get(n)) throw new CliError('UNKNOWN_SCENARIO', `--only names unknown scenario \"${n}\"`);\n }\n\n mkdirSync(out, { recursive: true });\n await checkServer(baseURL);\n\n const { inspectScenario, failurePage } = await import('./inspect.js');\n const browser = await launchChromium();\n const report = [];\n\n for (const s of catalog.list.filter((s) => !only || only.includes(s.name))) {\n const scenario = catalog.resolve(s);\n const context = await browser.newContext({ baseURL, ...contextOptions(scenario, values) });\n const page = await context.newPage();\n page.setDefaultTimeout(10_000);\n\n // Uncaught exceptions and console errors fail the run too.\n const result = await inspectScenario(page, scenario, {\n link: scenarioLink(baseURL, scenario),\n strict: !values['no-strict'],\n panel: !values['no-panel'],\n });\n\n const screenshot = join(out, `${s.name}.png`);\n let captured = false;\n\n if (values.element && !result.error) {\n try {\n await page.locator(values.element).first().screenshot({ path: screenshot });\n captured = true;\n } catch (err) {\n result.codes.push('ELEMENT_NOT_FOUND');\n result.ok = false;\n result.error = `--element: ${err instanceof Error ? err.message.split('\\n')[0] : String(err)}`;\n }\n }\n\n // The full page shows what went wrong when the scenario or the element failed.\n if (!captured) await page.screenshot({ path: screenshot, fullPage: true });\n\n // A page per failure, written for whoever fixes it; a stale one would mislead.\n const details = join(out, `${s.name}.md`);\n rmSync(details, { force: true });\n\n if (!result.ok) {\n writeFileSync(details, failurePage(result, { screenshot, appOrigin: new URL(baseURL).origin }));\n }\n\n report.push({ ...result, screenshot, details: result.ok ? null : details });\n await context.close();\n }\n\n await browser.close();\n\n if (values.json) console.log(JSON.stringify(report, null, 2));\n else {\n for (const r of report) {\n console.log(`${r.ok ? '✓' : '✗'} ${r.name.padEnd(32)} ${r.screenshot}`);\n\n if (r.ok) continue;\n console.log(` ${r.codes.join(', ')}${r.error ? `: ${r.error}` : ''}`);\n\n for (const u of r.unhandled) console.log(` unmocked: ${u}`);\n\n for (const e of r.pageErrors) console.log(` uncaught: ${e}`);\n\n for (const e of r.consoleErrors) console.log(` console.error: ${e}`);\n console.log(` ❯ details ${r.details}`);\n }\n }\n\n process.exit(report.every((r) => r.ok) ? 0 : 1);\n }\n\n case 'record': {\n const baseURL = requireBaseUrl();\n\n if (!values.steps) {\n throw new CliError('INVALID_ARGUMENT', 'record needs --steps, e.g. --steps scenarios/steps.ts');\n }\n\n const format = values.format ?? 'mp4';\n\n if (format !== 'mp4' && format !== 'gif' && format !== 'webm') {\n throw new CliError('INVALID_ARGUMENT', `--format must be mp4, gif or webm, not \"${format}\"`);\n }\n\n const steps = StepsSchema.parse((await import(pathToFileURL(resolve(values.steps)).href)).default);\n const only = values.only?.split(',');\n const names = Object.keys(steps).filter((n) => !only || only.includes(n));\n\n for (const n of names) {\n if (!catalog.get(n)) throw new CliError('UNKNOWN_SCENARIO', `--steps names unknown scenario \"${n}\"`);\n }\n\n const out = values.out ?? 'scenario-videos';\n mkdirSync(out, { recursive: true });\n const raw = mkdtempSync(join(tmpdir(), 'state-scenarios-'));\n\n await checkServer(baseURL);\n const { openScenario, showCursor } = await import('state-scenarios/playwright');\n const { encodeVideo, visibleCrop } = await import('./record.js');\n // slowMo gives the drawn cursor time to glide to each target before the click lands.\n const browser = await launchChromium({ slowMo: 150 });\n let exitCode = 0;\n\n /**\n * Where the element sits in the video once the scenario is ready. Scrolled into view first:\n * video holds only the viewport. Measured before the steps run, so steps that scroll the page\n * move the element out of the crop.\n */\n const cropTo = async (page: Page, selector: string, viewport: { width: number; height: number; }) => {\n const element = page.locator(selector).first();\n\n const box = await element.scrollIntoViewIfNeeded({ timeout: 5000 }).then(\n () => element.boundingBox(),\n () => null,\n );\n\n const visible = box && visibleCrop(box, viewport);\n\n if (!visible) throw new CliError('ELEMENT_NOT_FOUND', `--element \"${selector}\" isn't visible`);\n\n if (visible.clipped) {\n console.log(\n ` --element \"${selector}\" (${Math.round(box.width)}x${\n Math.round(box.height)\n }) is larger than the viewport; `\n + `the video shows the visible part. Use a larger --viewport to fit it.`,\n );\n }\n\n return visible.crop;\n };\n\n for (const [name, run] of Object.entries(steps).filter(([n]) => names.includes(n))) {\n const resolved = catalog.resolve(catalog.get(name) ?? { name });\n const options = contextOptions(resolved, values);\n\n // Playwright records video at CSS-pixel size whatever the scale, so use a larger --viewport for sharper video.\n const context = await browser.newContext({\n baseURL,\n ...options,\n recordVideo: { dir: raw, size: options.viewport },\n });\n\n const page = await context.newPage();\n const began = Date.now();\n const file = join(out, `${name}.${format}`);\n\n try {\n await showCursor(page);\n await openScenario(page, name, { path: resolved.path, strict: !values['no-strict'], panel: false });\n const crop = values.element ? await cropTo(page, values.element, options.viewport) : undefined;\n\n const start = (Date.now() - began) / 1000;\n await Promise.resolve(run(page)).catch((err: Error) => {\n throw new CliError('STEPS_FAILED', `steps failed: ${err.message.split('\\n')[0]}`);\n });\n await page.waitForTimeout(800); // hold the final frame\n await context.close();\n\n const video = page.video();\n\n if (!video) throw new Error('Playwright recorded no video');\n encodeVideo({ input: await video.path(), output: file, format, start, crop });\n console.log(`✓ ${name.padEnd(32)} ${file}`);\n } catch (err) {\n exitCode = Math.max(exitCode, err instanceof CliError ? err.exitCode : 1);\n await context.close();\n const code = err instanceof CliError ? `[${err.code}] ` : '';\n console.log(\n `✗ ${name.padEnd(32)} ${code}${err instanceof Error ? err.message.split('\\n')[0] : String(err)}`,\n );\n }\n }\n\n await browser.close();\n rmSync(raw, { recursive: true, force: true });\n process.exit(exitCode);\n }\n\n case 'suggest': {\n if (!values.src) {\n throw new CliError('INVALID_ARGUMENT', 'suggest needs --src, e.g. --src src/pages,src/components');\n }\n\n const sources = values.src.split(',').flatMap((d) => walk(d));\n const size = sources.reduce((n, f) => n + f.content.length, 0);\n\n if (size > MAX_SOURCE_CHARS) {\n throw new Error(\n `--src is ${size.toLocaleString()} characters; narrow it to the screens you want covered`,\n );\n }\n\n const jsonSchema = values.schema\n ? JSON.parse(readFileSync(values.schema, 'utf8'))\n : scenarioJsonSchema();\n\n if (values['env-file']) process.loadEnvFile(values['env-file']);\n const { default: Anthropic } = await import('@anthropic-ai/sdk');\n const model = values.model ?? process.env.STATE_SCENARIOS_MODEL ?? undefined;\n const { suggestScenarios } = await import('./suggest.js');\n console.error(\n `Reading ${sources.length} files (${size.toLocaleString()} chars) and ${catalog.list.length} scenarios with ${\n model ?? 'claude-opus-5-5'\n }…`,\n );\n\n const result = await suggestScenarios(\n { sources, catalog, jsonSchema, max: Number(values.max ?? 8), model },\n {\n client: new Anthropic({\n baseURL: process.env.ANTHROPIC_BASE_URL || undefined,\n // Some Anthropic-compatible proxies require a session id and a user agent.\n defaultHeaders: {\n 'x-opencode-session': `state-scenarios-${Date.now()}`,\n 'User-Agent': 'state-scenarios/0.0 (suggest)',\n },\n }),\n },\n );\n\n console.log('\\nCandidate UI states (not exhaustive)\\n');\n\n for (const s of result.states) {\n const mark = s.covered_by ? '✓' : '✗';\n console.log(\n `${mark} ${`${s.area} / ${s.state}`.padEnd(56)} ${s.covered_by ?? `uncovered (${s.priority})`}`,\n );\n }\n\n const covered = result.states.filter((s) => s.covered_by).length;\n console.log(\n `\\n${result.states.length} states detected, ${covered} have scenarios, ${\n result.states.length - covered\n } uncovered`,\n );\n\n const out = values.out ?? join(dir, 'proposed');\n\n if (result.accepted.length) mkdirSync(out, { recursive: true });\n\n for (const { scenario, covers } of result.accepted) {\n const file = join(out, `${scenario.name}.json`);\n const $schema = values.schema ? relative(out, values.schema).replaceAll('\\\\', '/') : undefined;\n writeFileSync(file, `${JSON.stringify({ $schema, ...scenario }, null, 2)}\\n`);\n console.log(`+ ${file} (${covers})`);\n }\n\n for (const r of result.rejected) console.log(`- rejected proposal for ${r.covers}: ${r.error}`);\n\n if (result.accepted.length) {\n console.log(\n `\\nReview the drafts, then: state-scenarios promote ${dir}`,\n );\n }\n\n break;\n }\n\n default:\n console.error(`Unknown command \"${command}\"\\n\\n${HELP}`);\n process.exit(2);\n }\n} catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n const hint = /authentication/i.test(message) ? '\\nSet ANTHROPIC_API_KEY, or run `ant auth login`.' : '';\n const code = err instanceof CliError ? `[${err.code}] ` : '';\n console.error(`state-scenarios ${command}: ${code}${message}${hint}`);\n process.exit(err instanceof CliError ? err.exitCode : 1);\n}\n\nfunction walk(path: string): { path: string; content: string; }[] {\n if (statSync(path).isDirectory()) {\n return readdirSync(path)\n .filter((f) => !['node_modules', 'dist', 'scenarios'].includes(f))\n .flatMap((f) => walk(join(path, f)));\n }\n\n const isSource = /\\.(tsx?|jsx?|vue|svelte)$/.test(path) && !/\\.(d|test|spec)\\.\\w+$/.test(path);\n\n return isSource ? [{ path, content: readFileSync(path, 'utf8') }] : [];\n}\n"],"mappings":"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","names":["s"]}
|
|
1
|
+
{"version":3,"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\nimport type { Page } from '@playwright/test';\nimport { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';\nimport { tmpdir } from 'node:os';\nimport { join, relative, resolve } from 'node:path';\nimport { pathToFileURL } from 'node:url';\nimport { parseArgs } from 'node:util';\nimport { type Catalog, createCatalog, scenarioJsonSchema, type ScenarioRule } from 'state-scenarios';\nimport { z } from 'zod';\nimport { checkServer, contextOptions, launchChromium, scenarioLink } from './browser.js';\nimport { CliError } from './errors.js';\n\nconst HELP = `Usage: state-scenarios <command> <scenarios-dir> [options]\n\n init Create <dir> (default src/scenarios) with default.json, a sibling scenario.schema.json,\n print the wiring snippet, install the agent skills into .agents/skills and .claude/skills,\n and register the MCP server in .mcp.json. --no-skills skips the last two; --base-url sets the\n URL the MCP server opens (default http://localhost:5173, or :3000 for Next.js)\n guide Print the agent skill for this version: guide [topic]. guide alone lists the topics\n doctor Check the wiring one step at a time and print the fix for each problem: scenarios, dev server,\n MSW worker, session start, ready selector, save route, ffmpeg, skills and MCP. --base-url (required)\n mcp Serve an MCP server on stdio for coding agents: list, open and drive scenarios, check ready\n selectors, and save what the app shows as a scenario. --base-url (required)\n validate Check every scenario parses, resolves and passes --rules\n list Print scenarios with links (--base-url) for people and agents; --json for machines\n shoot Open each scenario in Chromium, wait for its ready selector, screenshot it,\n and report requests it didn't mock. --base-url (required), --out, --only a,b, --no-strict,\n --element <selector> to capture one element instead of the full page.\n Each failed scenario gets <out>/<name>.md: what failed and how to fix it\n record Open each scenario that has steps, run them, and save a video with a visible cursor.\n --base-url and --steps (required): a module whose default export maps scenario names\n to async (page) => {} steps, or to { element, steps } to crop that one scenario.\n --format mp4 (default), gif or webm; --out, --only, --element, --fps (gif default 12),\n --width <px> to downscale, --hold <ms> on the last frame (default 800).\n mp4, gif, --element, --fps and --width need ffmpeg on PATH (or FFMPEG_PATH)\n suggest Ask Claude which UI states lack scenarios and draft them into --out (default <dir>/proposed).\n --src dir[,dir] (required), --schema file, --max n, --model (default $STATE_SCENARIOS_MODEL or claude-opus-5-5).\n Needs ANTHROPIC_API_KEY or \\`ant auth login\\`; ANTHROPIC_BASE_URL points it at a compatible proxy.\n --env-file file loads those variables first.\n promote Move <dir>/proposed/*.json into <dir> after validate. --dry-run lists moves only.\n\nCommon: --rules file module exporting \\`rules\\` (ScenarioRule[])\n --json machine-readable output (list, shoot, doctor)`;\n\nconst MAX_SOURCE_CHARS = 800_000;\n\nconst { positionals, values } = parseArgs({\n allowPositionals: true,\n options: {\n rules: { type: 'string' },\n 'base-url': { type: 'string' },\n out: { type: 'string' },\n only: { type: 'string' },\n 'no-strict': { type: 'boolean' },\n 'no-panel': { type: 'boolean' },\n viewport: { type: 'string' },\n scale: { type: 'string' },\n 'color-scheme': { type: 'string' },\n element: { type: 'string' },\n locale: { type: 'string' },\n timezone: { type: 'string' },\n steps: { type: 'string' },\n format: { type: 'string' },\n fps: { type: 'string' },\n width: { type: 'string' },\n hold: { type: 'string' },\n json: { type: 'boolean' },\n src: { type: 'string' },\n schema: { type: 'string' },\n max: { type: 'string' },\n model: { type: 'string' },\n 'env-file': { type: 'string' },\n 'dry-run': { type: 'boolean' },\n 'no-skills': { type: 'boolean' },\n help: { type: 'boolean', short: 'h' },\n },\n});\n\nconst Steps = z.function({ input: [z.custom<Page>()], output: z.unknown() });\n\n/** `--steps` module: scenario name to the steps recorded after it opens, or to `{ element, steps }`. */\nconst StepsSchema = z.record(\n z.string(),\n z.union([\n Steps.transform((steps) => ({ steps, element: undefined })),\n z.object({\n element: z.string().optional(),\n steps: Steps,\n }),\n ]),\n);\n\n/** A number flag, undefined when not passed. */\nconst positive = (flag: 'fps' | 'width' | 'hold') => {\n const text = values[flag];\n const n = Number(text);\n\n if (text !== undefined && !(Number.isFinite(n) && n > 0)) {\n throw new CliError('INVALID_ARGUMENT', `--${flag} must be a positive number, not \"${text}\"`);\n }\n\n return text === undefined ? undefined : n;\n};\n\nconst [command, dir = command === 'init' ? 'src/scenarios' : undefined] = positionals;\n\nif (command === 'guide') {\n const { guide } = await import('./skills.js');\n\n try {\n console.log(guide(positionals[1]));\n process.exit(0);\n } catch (err) {\n console.error(err instanceof Error ? err.message : String(err));\n process.exit(2);\n }\n}\n\nif (values.help || !command || !dir) {\n console.log(HELP);\n process.exit(command ? 2 : 0);\n}\n\n/** Read the scenarios folder; called again by long-running commands to see new files. */\nconst loadCatalog = (rules: ScenarioRule[]): Catalog => {\n try {\n return createCatalog(\n readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) =>\n JSON.parse(readFileSync(join(dir, f), 'utf8'))\n ),\n { rules },\n );\n } catch (err) {\n throw new CliError('INVALID_SCENARIO', err instanceof Error ? err.message : String(err));\n }\n};\n\nconst requireBaseUrl = () => {\n const url = values['base-url'];\n\n if (!url) throw new CliError('INVALID_ARGUMENT', `${command} needs --base-url, e.g. http://localhost:5173`);\n\n return url;\n};\n\ntry {\n if (command === 'init') {\n const { initScenarios } = await import('./init.js');\n const { stack } = initScenarios(dir);\n\n if (!values['no-skills']) {\n const { installSkills, registerMcp } = await import('./skills.js');\n\n const url = values['base-url']\n ?? (stack === 'next' ? 'http://localhost:3000' : 'http://localhost:5173');\n\n const args = ['mcp', dir, '--base-url', url];\n console.log('');\n\n for (const line of [...installSkills(process.cwd()), ...registerMcp(process.cwd(), args)]) {\n console.log(line);\n }\n\n console.log(`\nCodex: codex mcp add state-scenarios -- npx state-scenarios ${args.join(' ')}\nFor agents that need a pointer, add to AGENTS.md (or CLAUDE.md):\n UI states use state-scenarios; read .agents/skills/state-scenarios/SKILL.md before adding or checking one.`);\n }\n\n process.exit(0);\n }\n\n const rules: ScenarioRule[] = values.rules\n ? (await import(pathToFileURL(resolve(values.rules)).href)).rules\n : [];\n\n if (command === 'promote') {\n const { promoteScenarios } = await import('./promote.js');\n const result = promoteScenarios(dir, { rules, dryRun: !!values['dry-run'] });\n process.exit(result.skipped.length ? 1 : 0);\n }\n\n if (command === 'mcp') {\n const { serveMcp } = await import('./mcp.js');\n\n const { version } = z.object({ version: z.string() }).parse(\n JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')),\n );\n\n await serveMcp({\n dir,\n baseURL: requireBaseUrl(),\n loadCatalog: () => loadCatalog(rules),\n flags: values,\n version,\n });\n process.exit(0);\n }\n\n if (command === 'doctor') {\n const { runDoctor } = await import('./doctor.js');\n const { EXIT_CODES, ERROR_CLASSES } = await import('./errors.js');\n\n const checks = await runDoctor({\n dir,\n baseURL: requireBaseUrl(),\n loadCatalog: () => loadCatalog(rules),\n flags: values,\n });\n\n if (values.json) console.log(JSON.stringify(checks, null, 2));\n else {\n for (const c of checks) {\n console.log(\n `${{ ok: '✓', warn: '!', fail: '✗' }[c.status]} ${c.name.padEnd(16)} ${\n c.code ? `[${c.code}] ` : ''\n }${c.detail}`,\n );\n\n if (c.fix && c.status !== 'ok') console.log(` ${' '.repeat(16)} fix: ${c.fix}`);\n }\n }\n\n const failed = checks.filter((c) => c.status === 'fail');\n process.exit(Math.max(0, ...failed.map((c) => (c.code ? EXIT_CODES[ERROR_CLASSES[c.code]] : 1))));\n }\n\n const catalog = loadCatalog(rules);\n\n switch (command) {\n case 'validate': {\n const problems = catalog.check();\n\n for (const p of problems) console.error(`✗ ${p}\\n`);\n console.log(`${catalog.list.length - problems.length}/${catalog.list.length} scenarios valid`);\n process.exit(problems.length ? 1 : 0);\n }\n\n case 'list': {\n const base = values['base-url'];\n\n const rows = catalog.list.map((s) => ({\n name: s.name,\n description: s.description ?? '',\n tags: s.tags ?? [],\n ready: s.ready ?? null,\n url: base\n ? `${base.replace(/\\/$/, '')}${catalog.resolve(s).path ?? '/'}?scenario=${\n encodeURIComponent(s.name)\n }`\n : null,\n }));\n\n if (values.json) console.log(JSON.stringify(rows, null, 2));\n else {\n for (const r of rows) {\n console.log(\n `${r.name.padEnd(32)} ${r.description}${r.url ? `\\n${' '.repeat(33)}${r.url}` : ''}`,\n );\n }\n }\n\n break;\n }\n\n case 'shoot': {\n const baseURL = requireBaseUrl();\n const out = values.out ?? 'scenario-shots';\n const only = values.only?.split(',');\n\n for (const n of only ?? []) {\n if (!catalog.get(n)) throw new CliError('UNKNOWN_SCENARIO', `--only names unknown scenario \"${n}\"`);\n }\n\n mkdirSync(out, { recursive: true });\n await checkServer(baseURL);\n\n const { inspectScenario, failurePage } = await import('./inspect.js');\n const browser = await launchChromium();\n const report = [];\n\n for (const s of catalog.list.filter((s) => !only || only.includes(s.name))) {\n const scenario = catalog.resolve(s);\n const context = await browser.newContext({ baseURL, ...contextOptions(scenario, values) });\n const page = await context.newPage();\n page.setDefaultTimeout(10_000);\n\n // Uncaught exceptions and console errors fail the run too.\n const result = await inspectScenario(page, scenario, {\n link: scenarioLink(baseURL, scenario),\n strict: !values['no-strict'],\n panel: !values['no-panel'],\n });\n\n const screenshot = join(out, `${s.name}.png`);\n let captured = false;\n\n if (values.element && !result.error) {\n try {\n await page.locator(values.element).first().screenshot({ path: screenshot });\n captured = true;\n } catch (err) {\n result.codes.push('ELEMENT_NOT_FOUND');\n result.ok = false;\n result.error = `--element: ${err instanceof Error ? err.message.split('\\n')[0] : String(err)}`;\n }\n }\n\n // The full page shows what went wrong when the scenario or the element failed.\n if (!captured) await page.screenshot({ path: screenshot, fullPage: true });\n\n // A page per failure, written for whoever fixes it; a stale one would mislead.\n const details = join(out, `${s.name}.md`);\n rmSync(details, { force: true });\n\n if (!result.ok) {\n writeFileSync(details, failurePage(result, { screenshot, appOrigin: new URL(baseURL).origin }));\n }\n\n report.push({ ...result, screenshot, details: result.ok ? null : details });\n await context.close();\n }\n\n await browser.close();\n\n if (values.json) console.log(JSON.stringify(report, null, 2));\n else {\n for (const r of report) {\n console.log(`${r.ok ? '✓' : '✗'} ${r.name.padEnd(32)} ${r.screenshot}`);\n\n if (r.ok) continue;\n console.log(` ${r.codes.join(', ')}${r.error ? `: ${r.error}` : ''}`);\n\n for (const u of r.unhandled) console.log(` unmocked: ${u}`);\n\n for (const e of r.pageErrors) console.log(` uncaught: ${e}`);\n\n for (const e of r.consoleErrors) console.log(` console.error: ${e}`);\n console.log(` ❯ details ${r.details}`);\n }\n }\n\n process.exit(report.every((r) => r.ok) ? 0 : 1);\n }\n\n case 'record': {\n const baseURL = requireBaseUrl();\n\n if (!values.steps) {\n throw new CliError('INVALID_ARGUMENT', 'record needs --steps, e.g. --steps scenarios/steps.ts');\n }\n\n const format = values.format ?? 'mp4';\n\n if (format !== 'mp4' && format !== 'gif' && format !== 'webm') {\n throw new CliError('INVALID_ARGUMENT', `--format must be mp4, gif or webm, not \"${format}\"`);\n }\n\n const steps = StepsSchema.parse((await import(pathToFileURL(resolve(values.steps)).href)).default);\n const only = values.only?.split(',');\n const names = Object.keys(steps).filter((n) => !only || only.includes(n));\n\n for (const n of names) {\n if (!catalog.get(n)) throw new CliError('UNKNOWN_SCENARIO', `--steps names unknown scenario \"${n}\"`);\n }\n\n const fps = positive('fps');\n const width = positive('width');\n const hold = positive('hold') ?? 800;\n const out = values.out ?? 'scenario-videos';\n mkdirSync(out, { recursive: true });\n const raw = mkdtempSync(join(tmpdir(), 'state-scenarios-'));\n\n await checkServer(baseURL);\n const { openScenario, showCursor } = await import('state-scenarios/playwright');\n const { encodeVideo, visibleCrop } = await import('./record.js');\n // slowMo gives the drawn cursor time to glide to each target before the click lands.\n const browser = await launchChromium({ slowMo: 150 });\n let exitCode = 0;\n\n /**\n * Where the element sits in the video once the scenario is ready. Scrolled into view first:\n * video holds only the viewport. Measured before the steps run, so steps that scroll the page\n * move the element out of the crop.\n */\n const cropTo = async (page: Page, selector: string, viewport: { width: number; height: number; }) => {\n const element = page.locator(selector).first();\n\n const box = await element.scrollIntoViewIfNeeded({ timeout: 5000 }).then(\n () => element.boundingBox(),\n () => null,\n );\n\n const visible = box && visibleCrop(box, viewport);\n\n if (!visible) throw new CliError('ELEMENT_NOT_FOUND', `element \"${selector}\" isn't visible`);\n\n if (visible.clipped) {\n console.log(\n ` element \"${selector}\" (${Math.round(box.width)}x${\n Math.round(box.height)\n }) is larger than the viewport; `\n + `the video shows the visible part. Use a larger --viewport to fit it.`,\n );\n }\n\n return visible.crop;\n };\n\n for (const [name, entry] of Object.entries(steps).filter(([n]) => names.includes(n))) {\n const { element = values.element, steps: run } = entry;\n const resolved = catalog.resolve(catalog.get(name) ?? { name });\n const options = contextOptions(resolved, values);\n\n // Playwright records video at CSS-pixel size whatever the scale, so use a larger --viewport for sharper video.\n const context = await browser.newContext({\n baseURL,\n ...options,\n recordVideo: { dir: raw, size: options.viewport },\n });\n\n const page = await context.newPage();\n const began = Date.now();\n const file = join(out, `${name}.${format}`);\n\n try {\n await showCursor(page);\n await openScenario(page, name, { path: resolved.path, strict: !values['no-strict'], panel: false });\n const crop = element ? await cropTo(page, element, options.viewport) : undefined;\n\n const start = (Date.now() - began) / 1000;\n await Promise.resolve(run(page)).catch((err: Error) => {\n throw new CliError('STEPS_FAILED', `steps failed: ${err.message.split('\\n')[0]}`);\n });\n await page.waitForTimeout(hold); // hold the final frame\n await context.close();\n\n const video = page.video();\n\n if (!video) throw new Error('Playwright recorded no video');\n encodeVideo({ input: await video.path(), output: file, format, start, crop, fps, width });\n console.log(`✓ ${name.padEnd(32)} ${file}`);\n } catch (err) {\n exitCode = Math.max(exitCode, err instanceof CliError ? err.exitCode : 1);\n await context.close();\n const code = err instanceof CliError ? `[${err.code}] ` : '';\n console.log(\n `✗ ${name.padEnd(32)} ${code}${err instanceof Error ? err.message.split('\\n')[0] : String(err)}`,\n );\n }\n }\n\n await browser.close();\n rmSync(raw, { recursive: true, force: true });\n process.exit(exitCode);\n }\n\n case 'suggest': {\n if (!values.src) {\n throw new CliError('INVALID_ARGUMENT', 'suggest needs --src, e.g. --src src/pages,src/components');\n }\n\n const sources = values.src.split(',').flatMap((d) => walk(d));\n const size = sources.reduce((n, f) => n + f.content.length, 0);\n\n if (size > MAX_SOURCE_CHARS) {\n throw new Error(\n `--src is ${size.toLocaleString()} characters; narrow it to the screens you want covered`,\n );\n }\n\n const jsonSchema = values.schema\n ? JSON.parse(readFileSync(values.schema, 'utf8'))\n : scenarioJsonSchema();\n\n if (values['env-file']) process.loadEnvFile(values['env-file']);\n const { default: Anthropic } = await import('@anthropic-ai/sdk');\n const model = values.model ?? process.env.STATE_SCENARIOS_MODEL ?? undefined;\n const { suggestScenarios } = await import('./suggest.js');\n console.error(\n `Reading ${sources.length} files (${size.toLocaleString()} chars) and ${catalog.list.length} scenarios with ${\n model ?? 'claude-opus-5-5'\n }…`,\n );\n\n const result = await suggestScenarios(\n { sources, catalog, jsonSchema, max: Number(values.max ?? 8), model },\n {\n client: new Anthropic({\n baseURL: process.env.ANTHROPIC_BASE_URL || undefined,\n // Some Anthropic-compatible proxies require a session id and a user agent.\n defaultHeaders: {\n 'x-opencode-session': `state-scenarios-${Date.now()}`,\n 'User-Agent': 'state-scenarios/0.0 (suggest)',\n },\n }),\n },\n );\n\n console.log('\\nCandidate UI states (not exhaustive)\\n');\n\n for (const s of result.states) {\n const mark = s.covered_by ? '✓' : '✗';\n console.log(\n `${mark} ${`${s.area} / ${s.state}`.padEnd(56)} ${s.covered_by ?? `uncovered (${s.priority})`}`,\n );\n }\n\n const covered = result.states.filter((s) => s.covered_by).length;\n console.log(\n `\\n${result.states.length} states detected, ${covered} have scenarios, ${\n result.states.length - covered\n } uncovered`,\n );\n\n const out = values.out ?? join(dir, 'proposed');\n\n if (result.accepted.length) mkdirSync(out, { recursive: true });\n\n for (const { scenario, covers } of result.accepted) {\n const file = join(out, `${scenario.name}.json`);\n const $schema = values.schema ? relative(out, values.schema).replaceAll('\\\\', '/') : undefined;\n writeFileSync(file, `${JSON.stringify({ $schema, ...scenario }, null, 2)}\\n`);\n console.log(`+ ${file} (${covers})`);\n }\n\n for (const r of result.rejected) console.log(`- rejected proposal for ${r.covers}: ${r.error}`);\n\n if (result.accepted.length) {\n console.log(\n `\\nReview the drafts, then: state-scenarios promote ${dir}`,\n );\n }\n\n break;\n }\n\n default:\n console.error(`Unknown command \"${command}\"\\n\\n${HELP}`);\n process.exit(2);\n }\n} catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n const hint = /authentication/i.test(message) ? '\\nSet ANTHROPIC_API_KEY, or run `ant auth login`.' : '';\n const code = err instanceof CliError ? `[${err.code}] ` : '';\n console.error(`state-scenarios ${command}: ${code}${message}${hint}`);\n process.exit(err instanceof CliError ? err.exitCode : 1);\n}\n\nfunction walk(path: string): { path: string; content: string; }[] {\n if (statSync(path).isDirectory()) {\n return readdirSync(path)\n .filter((f) => !['node_modules', 'dist', 'scenarios'].includes(f))\n .flatMap((f) => walk(join(path, f)));\n }\n\n const isSource = /\\.(tsx?|jsx?|vue|svelte)$/.test(path) && !/\\.(d|test|spec)\\.\\w+$/.test(path);\n\n return isSource ? [{ path, content: readFileSync(path, 'utf8') }] : [];\n}\n"],"mappings":"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","names":["s"]}
|
|
@@ -14,14 +14,20 @@ function visibleCrop(box, viewport) {
|
|
|
14
14
|
return { crop: { x, y, width, height }, clipped: width < box.width || height < box.height };
|
|
15
15
|
}
|
|
16
16
|
var even = (n) => Math.max(2, Math.floor(n / 2) * 2);
|
|
17
|
-
function ffmpegArgs({ input, output, format, start, crop }) {
|
|
18
|
-
const
|
|
17
|
+
function ffmpegArgs({ input, output, format, start, crop, fps, width }) {
|
|
18
|
+
const rate = fps ?? (format === "gif" ? 12 : void 0);
|
|
19
|
+
const filters = [
|
|
20
|
+
crop && `crop=${even(crop.width)}:${even(crop.height)}:${Math.round(crop.x)}:${Math.round(crop.y)}`,
|
|
21
|
+
rate && `fps=${rate}`,
|
|
22
|
+
// Quoted so the comma in min() isn't read as a filter separator.
|
|
23
|
+
width && `scale='trunc(min(${width},iw)/2)*2':-2:flags=lanczos`
|
|
24
|
+
].filter(Boolean);
|
|
19
25
|
const codec = {
|
|
20
26
|
mp4: ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "23", "-movflags", "+faststart"],
|
|
21
27
|
webm: ["-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "32"],
|
|
22
28
|
gif: []
|
|
23
29
|
}[format];
|
|
24
|
-
const vf = format === "gif" ? `${
|
|
30
|
+
const vf = format === "gif" ? `${filters.join(",")},split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer` : filters.join(",");
|
|
25
31
|
return [
|
|
26
32
|
"-y",
|
|
27
33
|
"-loglevel",
|
|
@@ -37,7 +43,7 @@ function ffmpegArgs({ input, output, format, start, crop }) {
|
|
|
37
43
|
];
|
|
38
44
|
}
|
|
39
45
|
function encodeVideo(options) {
|
|
40
|
-
if (options.format === "webm" && !options.crop) {
|
|
46
|
+
if (options.format === "webm" && !options.crop && !options.fps && !options.width) {
|
|
41
47
|
copyFileSync(options.input, options.output);
|
|
42
48
|
return;
|
|
43
49
|
}
|
|
@@ -46,7 +52,7 @@ function encodeVideo(options) {
|
|
|
46
52
|
if (result.error) {
|
|
47
53
|
throw new CliError(
|
|
48
54
|
"FFMPEG_MISSING",
|
|
49
|
-
`record needs ffmpeg for ${options.format}${options.
|
|
55
|
+
`record needs ffmpeg for ${options.format}${options.format === "webm" ? " with --element, --fps or --width" : ""}: install it (https://ffmpeg.org/download.html) or set FFMPEG_PATH`
|
|
50
56
|
);
|
|
51
57
|
}
|
|
52
58
|
if (result.status !== 0) throw new Error(`ffmpeg failed: ${result.stderr.trim()}`);
|
|
@@ -56,4 +62,4 @@ export {
|
|
|
56
62
|
ffmpegArgs,
|
|
57
63
|
visibleCrop
|
|
58
64
|
};
|
|
59
|
-
//# sourceMappingURL=record-
|
|
65
|
+
//# sourceMappingURL=record-HXIYGMWA.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/record.ts"],"sourcesContent":["import { spawnSync } from 'node:child_process';\nimport { copyFileSync } from 'node:fs';\nimport { CliError } from './errors.js';\n\nexport type VideoFormat = 'mp4' | 'gif' | 'webm';\n\nexport interface Crop {\n x: number;\n y: number;\n width: number;\n height: number;\n}\n\n/**\n * The part of an element's box the video shows. Video holds only the viewport, so a crop must\n * stay inside it; `clipped` says the element was larger than the visible area.\n */\nexport function visibleCrop(box: Crop, viewport: { width: number; height: number; }) {\n const x = Math.max(0, box.x);\n const y = Math.max(0, box.y);\n const width = Math.min(viewport.width, box.x + box.width) - x;\n const height = Math.min(viewport.height, box.y + box.height) - y;\n\n if (width < 2 || height < 2) return undefined;\n\n return { crop: { x, y, width, height }, clipped: width < box.width || height < box.height };\n}\n\nconst even = (n: number) => Math.max(2, Math.floor(n / 2) * 2);\n\n/**\n * ffmpeg arguments that trim the page load off the start, crop to an element, and encode.\n * MP4 is H.264 in yuv420p so it plays everywhere, including Safari and phones; GIF uses a\n * generated palette, which keeps it sharp at a fraction of the naive size.\n */\nexport function ffmpegArgs(\n { input, output, format, start, crop, fps, width }: {\n input: string;\n output: string;\n format: VideoFormat;\n /** Seconds to skip, e.g. the page loading before the steps begin. */\n start: number;\n crop?: Crop;\n /** Frames per second; GIFs default to 12. */\n fps?: number;\n /** Maximum output width in pixels, never upscaled; height follows the aspect ratio. */\n width?: number;\n },\n): string[] {\n const rate = fps ?? (format === 'gif' ? 12 : undefined);\n\n // yuv420p needs even sizes.\n const filters = [\n crop && `crop=${even(crop.width)}:${even(crop.height)}:${Math.round(crop.x)}:${Math.round(crop.y)}`,\n rate && `fps=${rate}`,\n // Quoted so the comma in min() isn't read as a filter separator.\n width && `scale='trunc(min(${width},iw)/2)*2':-2:flags=lanczos`,\n ].filter(Boolean);\n\n const codec = {\n mp4: ['-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '23', '-movflags', '+faststart'],\n webm: ['-c:v', 'libvpx-vp9', '-b:v', '0', '-crf', '32'],\n gif: [],\n }[format];\n\n const vf = format === 'gif'\n ? `${filters.join(',')},split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer`\n : filters.join(',');\n\n return [\n '-y',\n '-loglevel',\n 'error',\n '-i',\n input,\n '-ss',\n start.toFixed(2),\n ...(vf ? ['-vf', vf] : []),\n '-an',\n ...codec,\n output,\n ];\n}\n\n/**\n * Convert Playwright's WebM into the requested format. WebM without a crop, --fps or --width is\n * copied as recorded (page load included), so it works without ffmpeg.\n */\nexport function encodeVideo(options: Parameters<typeof ffmpegArgs>[0]) {\n if (options.format === 'webm' && !options.crop && !options.fps && !options.width) {\n copyFileSync(options.input, options.output);\n\n return;\n }\n\n // FFMPEG_PATH is the usual override when ffmpeg isn't on PATH (common on Windows).\n const ffmpeg = process.env.FFMPEG_PATH || 'ffmpeg';\n const result = spawnSync(ffmpeg, ffmpegArgs(options), { encoding: 'utf8' });\n\n if (result.error) {\n throw new CliError(\n 'FFMPEG_MISSING',\n `record needs ffmpeg for ${options.format}${\n options.format === 'webm' ? ' with --element, --fps or --width' : ''\n }: install it (https://ffmpeg.org/download.html) or set FFMPEG_PATH`,\n );\n }\n\n if (result.status !== 0) throw new Error(`ffmpeg failed: ${result.stderr.trim()}`);\n}\n"],"mappings":";;;;;AAAA,SAAS,iBAAiB;AAC1B,SAAS,oBAAoB;AAgBtB,SAAS,YAAY,KAAW,UAA8C;AACnF,QAAM,IAAI,KAAK,IAAI,GAAG,IAAI,CAAC;AAC3B,QAAM,IAAI,KAAK,IAAI,GAAG,IAAI,CAAC;AAC3B,QAAM,QAAQ,KAAK,IAAI,SAAS,OAAO,IAAI,IAAI,IAAI,KAAK,IAAI;AAC5D,QAAM,SAAS,KAAK,IAAI,SAAS,QAAQ,IAAI,IAAI,IAAI,MAAM,IAAI;AAE/D,MAAI,QAAQ,KAAK,SAAS,EAAG,QAAO;AAEpC,SAAO,EAAE,MAAM,EAAE,GAAG,GAAG,OAAO,OAAO,GAAG,SAAS,QAAQ,IAAI,SAAS,SAAS,IAAI,OAAO;AAC5F;AAEA,IAAM,OAAO,CAAC,MAAc,KAAK,IAAI,GAAG,KAAK,MAAM,IAAI,CAAC,IAAI,CAAC;AAOtD,SAAS,WACd,EAAE,OAAO,QAAQ,QAAQ,OAAO,MAAM,KAAK,MAAM,GAYvC;AACV,QAAM,OAAO,QAAQ,WAAW,QAAQ,KAAK;AAG7C,QAAM,UAAU;AAAA,IACd,QAAQ,QAAQ,KAAK,KAAK,KAAK,CAAC,IAAI,KAAK,KAAK,MAAM,CAAC,IAAI,KAAK,MAAM,KAAK,CAAC,CAAC,IAAI,KAAK,MAAM,KAAK,CAAC,CAAC;AAAA,IACjG,QAAQ,OAAO,IAAI;AAAA;AAAA,IAEnB,SAAS,oBAAoB,KAAK;AAAA,EACpC,EAAE,OAAO,OAAO;AAEhB,QAAM,QAAQ;AAAA,IACZ,KAAK,CAAC,QAAQ,WAAW,YAAY,WAAW,QAAQ,MAAM,aAAa,YAAY;AAAA,IACvF,MAAM,CAAC,QAAQ,cAAc,QAAQ,KAAK,QAAQ,IAAI;AAAA,IACtD,KAAK,CAAC;AAAA,EACR,EAAE,MAAM;AAER,QAAM,KAAK,WAAW,QAClB,GAAG,QAAQ,KAAK,GAAG,CAAC,gFACpB,QAAQ,KAAK,GAAG;AAEpB,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,MAAM,QAAQ,CAAC;AAAA,IACf,GAAI,KAAK,CAAC,OAAO,EAAE,IAAI,CAAC;AAAA,IACxB;AAAA,IACA,GAAG;AAAA,IACH;AAAA,EACF;AACF;AAMO,SAAS,YAAY,SAA2C;AACrE,MAAI,QAAQ,WAAW,UAAU,CAAC,QAAQ,QAAQ,CAAC,QAAQ,OAAO,CAAC,QAAQ,OAAO;AAChF,iBAAa,QAAQ,OAAO,QAAQ,MAAM;AAE1C;AAAA,EACF;AAGA,QAAM,SAAS,QAAQ,IAAI,eAAe;AAC1C,QAAM,SAAS,UAAU,QAAQ,WAAW,OAAO,GAAG,EAAE,UAAU,OAAO,CAAC;AAE1E,MAAI,OAAO,OAAO;AAChB,UAAM,IAAI;AAAA,MACR;AAAA,MACA,2BAA2B,QAAQ,MAAM,GACvC,QAAQ,WAAW,SAAS,sCAAsC,EACpE;AAAA,IACF;AAAA,EACF;AAEA,MAAI,OAAO,WAAW,EAAG,OAAM,IAAI,MAAM,kBAAkB,OAAO,OAAO,KAAK,CAAC,EAAE;AACnF;","names":[]}
|
package/dist/record.d.ts
CHANGED
|
@@ -26,16 +26,20 @@ export declare function visibleCrop(box: Crop, viewport: {
|
|
|
26
26
|
* MP4 is H.264 in yuv420p so it plays everywhere, including Safari and phones; GIF uses a
|
|
27
27
|
* generated palette, which keeps it sharp at a fraction of the naive size.
|
|
28
28
|
*/
|
|
29
|
-
export declare function ffmpegArgs({ input, output, format, start, crop }: {
|
|
29
|
+
export declare function ffmpegArgs({ input, output, format, start, crop, fps, width }: {
|
|
30
30
|
input: string;
|
|
31
31
|
output: string;
|
|
32
32
|
format: VideoFormat;
|
|
33
33
|
/** Seconds to skip, e.g. the page loading before the steps begin. */
|
|
34
34
|
start: number;
|
|
35
35
|
crop?: Crop;
|
|
36
|
+
/** Frames per second; GIFs default to 12. */
|
|
37
|
+
fps?: number;
|
|
38
|
+
/** Maximum output width in pixels, never upscaled; height follows the aspect ratio. */
|
|
39
|
+
width?: number;
|
|
36
40
|
}): string[];
|
|
37
41
|
/**
|
|
38
|
-
* Convert Playwright's WebM into the requested format.
|
|
39
|
-
* (page load included), so it works without ffmpeg.
|
|
42
|
+
* Convert Playwright's WebM into the requested format. WebM without a crop, --fps or --width is
|
|
43
|
+
* copied as recorded (page load included), so it works without ffmpeg.
|
|
40
44
|
*/
|
|
41
45
|
export declare function encodeVideo(options: Parameters<typeof ffmpegArgs>[0]): void;
|
package/package.json
CHANGED
|
@@ -26,6 +26,13 @@ export default {
|
|
|
26
26
|
await page.getByRole('searchbox').click();
|
|
27
27
|
await page.keyboard.type('Atlantis', { delay: 100 });
|
|
28
28
|
},
|
|
29
|
+
// { element, steps } crops this scenario's video to one component; it overrides --element.
|
|
30
|
+
'cart-empty': {
|
|
31
|
+
element: '.cart',
|
|
32
|
+
steps: async (page: Page) => {
|
|
33
|
+
await page.getByRole('link', { name: 'Browse products' }).hover();
|
|
34
|
+
},
|
|
35
|
+
},
|
|
29
36
|
};
|
|
30
37
|
```
|
|
31
38
|
|
|
@@ -35,15 +42,18 @@ npx state-scenarios record src/scenarios --base-url http://localhost:5173 --step
|
|
|
35
42
|
|
|
36
43
|
- The panel is hidden and a cursor is drawn (Playwright videos don't show the mouse). Actions are
|
|
37
44
|
slowed slightly so the cursor reaches each target before the click.
|
|
38
|
-
- The video starts once the scenario is ready and holds the last frame
|
|
45
|
+
- The video starts once the scenario is ready and holds the last frame for `--hold` ms (default
|
|
46
|
+
800), so there's no need for a `waitForTimeout` at the end of steps. Uncropped
|
|
39
47
|
`--format webm` is the exception: it keeps the page load, and needs no ffmpeg.
|
|
40
48
|
- `--format mp4` (default) plays in every browser and phone at a fraction of a GIF's size; use it
|
|
41
49
|
on websites. `--format gif` for READMEs and other image-only places. `--element` crops: the
|
|
42
50
|
element is scrolled into view first, and one larger than the viewport is cut to the visible part
|
|
43
51
|
(use a larger `--viewport`). Steps that scroll the page move the element out of the crop.
|
|
52
|
+
- For website GIFs, `--width 900` caps the width and `--fps 10` lowers the frame rate (GIFs default to
|
|
53
|
+
12); both cut file size.
|
|
44
54
|
- Video records at viewport size, so use a larger `--viewport` for sharper video; `--scale`
|
|
45
55
|
affects screenshots only.
|
|
46
|
-
- MP4, GIF and `--
|
|
56
|
+
- MP4, GIF, `--element`, `--fps` and `--width` need ffmpeg on `PATH`, or `FFMPEG_PATH`. WebM without them doesn't.
|
|
47
57
|
- In your own Playwright scripts, `showCursor(page)` from `state-scenarios/playwright` draws the
|
|
48
58
|
cursor; call it before `openScenario(page, name, { panel: false })`.
|
|
49
59
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/record.ts"],"sourcesContent":["import { spawnSync } from 'node:child_process';\nimport { copyFileSync } from 'node:fs';\nimport { CliError } from './errors.js';\n\nexport type VideoFormat = 'mp4' | 'gif' | 'webm';\n\nexport interface Crop {\n x: number;\n y: number;\n width: number;\n height: number;\n}\n\n/**\n * The part of an element's box the video shows. Video holds only the viewport, so a crop must\n * stay inside it; `clipped` says the element was larger than the visible area.\n */\nexport function visibleCrop(box: Crop, viewport: { width: number; height: number; }) {\n const x = Math.max(0, box.x);\n const y = Math.max(0, box.y);\n const width = Math.min(viewport.width, box.x + box.width) - x;\n const height = Math.min(viewport.height, box.y + box.height) - y;\n\n if (width < 2 || height < 2) return undefined;\n\n return { crop: { x, y, width, height }, clipped: width < box.width || height < box.height };\n}\n\nconst even = (n: number) => Math.max(2, Math.floor(n / 2) * 2);\n\n/**\n * ffmpeg arguments that trim the page load off the start, crop to an element, and encode.\n * MP4 is H.264 in yuv420p so it plays everywhere, including Safari and phones; GIF uses a\n * generated palette, which keeps it sharp at a fraction of the naive size.\n */\nexport function ffmpegArgs(\n { input, output, format, start, crop }: {\n input: string;\n output: string;\n format: VideoFormat;\n /** Seconds to skip, e.g. the page loading before the steps begin. */\n start: number;\n crop?: Crop;\n },\n): string[] {\n // yuv420p needs even sizes.\n const filters = crop\n ? [`crop=${even(crop.width)}:${even(crop.height)}:${Math.round(crop.x)}:${Math.round(crop.y)}`]\n : [];\n\n const codec = {\n mp4: ['-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '23', '-movflags', '+faststart'],\n webm: ['-c:v', 'libvpx-vp9', '-b:v', '0', '-crf', '32'],\n gif: [],\n }[format];\n\n const vf = format === 'gif'\n ? `${\n [...filters, 'fps=12'].join(',')\n },split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer`\n : filters.join(',');\n\n return [\n '-y',\n '-loglevel',\n 'error',\n '-i',\n input,\n '-ss',\n start.toFixed(2),\n ...(vf ? ['-vf', vf] : []),\n '-an',\n ...codec,\n output,\n ];\n}\n\n/**\n * Convert Playwright's WebM into the requested format. Uncropped WebM is copied as recorded\n * (page load included), so it works without ffmpeg.\n */\nexport function encodeVideo(options: Parameters<typeof ffmpegArgs>[0]) {\n if (options.format === 'webm' && !options.crop) {\n copyFileSync(options.input, options.output);\n\n return;\n }\n\n // FFMPEG_PATH is the usual override when ffmpeg isn't on PATH (common on Windows).\n const ffmpeg = process.env.FFMPEG_PATH || 'ffmpeg';\n const result = spawnSync(ffmpeg, ffmpegArgs(options), { encoding: 'utf8' });\n\n if (result.error) {\n throw new CliError(\n 'FFMPEG_MISSING',\n `record needs ffmpeg for ${options.format}${\n options.crop ? ' and --element' : ''\n }: install it (https://ffmpeg.org/download.html) or set FFMPEG_PATH`,\n );\n }\n\n if (result.status !== 0) throw new Error(`ffmpeg failed: ${result.stderr.trim()}`);\n}\n"],"mappings":";;;;;AAAA,SAAS,iBAAiB;AAC1B,SAAS,oBAAoB;AAgBtB,SAAS,YAAY,KAAW,UAA8C;AACnF,QAAM,IAAI,KAAK,IAAI,GAAG,IAAI,CAAC;AAC3B,QAAM,IAAI,KAAK,IAAI,GAAG,IAAI,CAAC;AAC3B,QAAM,QAAQ,KAAK,IAAI,SAAS,OAAO,IAAI,IAAI,IAAI,KAAK,IAAI;AAC5D,QAAM,SAAS,KAAK,IAAI,SAAS,QAAQ,IAAI,IAAI,IAAI,MAAM,IAAI;AAE/D,MAAI,QAAQ,KAAK,SAAS,EAAG,QAAO;AAEpC,SAAO,EAAE,MAAM,EAAE,GAAG,GAAG,OAAO,OAAO,GAAG,SAAS,QAAQ,IAAI,SAAS,SAAS,IAAI,OAAO;AAC5F;AAEA,IAAM,OAAO,CAAC,MAAc,KAAK,IAAI,GAAG,KAAK,MAAM,IAAI,CAAC,IAAI,CAAC;AAOtD,SAAS,WACd,EAAE,OAAO,QAAQ,QAAQ,OAAO,KAAK,GAQ3B;AAEV,QAAM,UAAU,OACZ,CAAC,QAAQ,KAAK,KAAK,KAAK,CAAC,IAAI,KAAK,KAAK,MAAM,CAAC,IAAI,KAAK,MAAM,KAAK,CAAC,CAAC,IAAI,KAAK,MAAM,KAAK,CAAC,CAAC,EAAE,IAC5F,CAAC;AAEL,QAAM,QAAQ;AAAA,IACZ,KAAK,CAAC,QAAQ,WAAW,YAAY,WAAW,QAAQ,MAAM,aAAa,YAAY;AAAA,IACvF,MAAM,CAAC,QAAQ,cAAc,QAAQ,KAAK,QAAQ,IAAI;AAAA,IACtD,KAAK,CAAC;AAAA,EACR,EAAE,MAAM;AAER,QAAM,KAAK,WAAW,QAClB,GACA,CAAC,GAAG,SAAS,QAAQ,EAAE,KAAK,GAAG,CACjC,gFACE,QAAQ,KAAK,GAAG;AAEpB,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,MAAM,QAAQ,CAAC;AAAA,IACf,GAAI,KAAK,CAAC,OAAO,EAAE,IAAI,CAAC;AAAA,IACxB;AAAA,IACA,GAAG;AAAA,IACH;AAAA,EACF;AACF;AAMO,SAAS,YAAY,SAA2C;AACrE,MAAI,QAAQ,WAAW,UAAU,CAAC,QAAQ,MAAM;AAC9C,iBAAa,QAAQ,OAAO,QAAQ,MAAM;AAE1C;AAAA,EACF;AAGA,QAAM,SAAS,QAAQ,IAAI,eAAe;AAC1C,QAAM,SAAS,UAAU,QAAQ,WAAW,OAAO,GAAG,EAAE,UAAU,OAAO,CAAC;AAE1E,MAAI,OAAO,OAAO;AAChB,UAAM,IAAI;AAAA,MACR;AAAA,MACA,2BAA2B,QAAQ,MAAM,GACvC,QAAQ,OAAO,mBAAmB,EACpC;AAAA,IACF;AAAA,EACF;AAEA,MAAI,OAAO,WAAW,EAAG,OAAM,IAAI,MAAM,kBAAkB,OAAO,OAAO,KAAK,CAAC,EAAE;AACnF;","names":[]}
|