state-scenarios-cli 0.3.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 +10 -1
- package/dist/browser.d.ts +27 -0
- package/dist/chunk-GCYOLAFU.js +40 -0
- package/dist/chunk-GCYOLAFU.js.map +1 -0
- package/dist/chunk-O6QTNAAZ.js +67 -0
- package/dist/chunk-O6QTNAAZ.js.map +1 -0
- package/dist/chunk-YFNROT3R.js +115 -0
- package/dist/chunk-YFNROT3R.js.map +1 -0
- package/dist/cli.js +264 -44
- package/dist/cli.js.map +1 -1
- package/dist/doctor-URGIFA3W.js +200 -0
- package/dist/doctor-URGIFA3W.js.map +1 -0
- package/dist/doctor.d.ts +21 -0
- package/dist/errors-G7ODRBLT.js +11 -0
- package/dist/errors-G7ODRBLT.js.map +1 -0
- package/dist/errors.d.ts +33 -0
- package/dist/{init-VLZQEJRD.js → init-MRK4F5SV.js} +2 -1
- package/dist/init-MRK4F5SV.js.map +1 -0
- package/dist/init.d.ts +5 -1
- package/dist/inspect-LN4EACWA.js +13 -0
- package/dist/inspect-LN4EACWA.js.map +1 -0
- package/dist/inspect.d.ts +63 -0
- package/dist/mcp-HRQIWAIR.js +342 -0
- package/dist/mcp-HRQIWAIR.js.map +1 -0
- package/dist/mcp-protocol.d.ts +51 -0
- package/dist/mcp.d.ts +19 -0
- package/dist/record-HXIYGMWA.js +65 -0
- package/dist/record-HXIYGMWA.js.map +1 -0
- package/dist/record.d.ts +45 -0
- package/dist/skills-NQRJHKHG.js +80 -0
- package/dist/skills-NQRJHKHG.js.map +1 -0
- package/dist/skills.d.ts +13 -0
- package/package.json +5 -4
- package/skills/create-scenario-map/SKILL.md +88 -0
- package/skills/state-scenarios/SKILL.md +91 -0
- package/skills/state-scenarios/references/auth.md +52 -0
- package/skills/state-scenarios/references/capture.md +45 -0
- package/skills/state-scenarios/references/debugging.md +52 -0
- package/skills/state-scenarios/references/mcp.md +39 -0
- package/skills/state-scenarios/references/media.md +60 -0
- package/skills/state-scenarios/references/network.md +67 -0
- package/skills/state-scenarios/references/setup.md +94 -0
- package/skills/state-scenarios/references/verify.md +47 -0
- package/skills/state-scenarios/references/writing-scenarios.md +55 -0
- package/dist/init-VLZQEJRD.js.map +0 -1
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import {
|
|
2
|
+
checkServer,
|
|
3
|
+
contextOptions,
|
|
4
|
+
launchChromium,
|
|
5
|
+
scenarioLink
|
|
6
|
+
} from "./chunk-O6QTNAAZ.js";
|
|
7
|
+
import {
|
|
8
|
+
CliError
|
|
9
|
+
} from "./chunk-GCYOLAFU.js";
|
|
10
|
+
import {
|
|
11
|
+
inspectScenario,
|
|
12
|
+
watchErrors
|
|
13
|
+
} from "./chunk-YFNROT3R.js";
|
|
14
|
+
|
|
15
|
+
// src/mcp.ts
|
|
16
|
+
import { existsSync, writeFileSync } from "fs";
|
|
17
|
+
import { dirname, join, relative } from "path";
|
|
18
|
+
import { parseScenario } from "state-scenarios";
|
|
19
|
+
import { unhandledRequests } from "state-scenarios/playwright";
|
|
20
|
+
import { z as z2 } from "zod";
|
|
21
|
+
|
|
22
|
+
// src/mcp-protocol.ts
|
|
23
|
+
import { createInterface } from "readline";
|
|
24
|
+
import { parseJson } from "state-scenarios";
|
|
25
|
+
import { z } from "zod";
|
|
26
|
+
function tool(def) {
|
|
27
|
+
return {
|
|
28
|
+
name: def.name,
|
|
29
|
+
description: def.description,
|
|
30
|
+
inputSchema: z.toJSONSchema(def.input, { io: "input" }),
|
|
31
|
+
call: async (args) => def.run(def.input.parse(args))
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
var Message = z.object({
|
|
35
|
+
id: z.union([z.string(), z.number()]).optional(),
|
|
36
|
+
method: z.string(),
|
|
37
|
+
params: z.looseObject({}).optional()
|
|
38
|
+
});
|
|
39
|
+
var CallParams = z.object({ name: z.string(), arguments: z.json().optional() });
|
|
40
|
+
var ok = (id, result) => ({ jsonrpc: "2.0", id, result });
|
|
41
|
+
function createMcpHandler(tools, info) {
|
|
42
|
+
const byName = new Map(tools.map((t) => [t.name, t]));
|
|
43
|
+
let queue = Promise.resolve();
|
|
44
|
+
const serial = (work) => {
|
|
45
|
+
const run = queue.then(work);
|
|
46
|
+
queue = run.then(() => {
|
|
47
|
+
}, () => {
|
|
48
|
+
});
|
|
49
|
+
return run;
|
|
50
|
+
};
|
|
51
|
+
return async (raw) => {
|
|
52
|
+
const parsed = Message.safeParse(raw);
|
|
53
|
+
if (!parsed.success) {
|
|
54
|
+
return { jsonrpc: "2.0", id: null, error: { code: -32600, message: "Invalid request" } };
|
|
55
|
+
}
|
|
56
|
+
const { id, method, params } = parsed.data;
|
|
57
|
+
if (id === void 0) return null;
|
|
58
|
+
switch (method) {
|
|
59
|
+
case "initialize":
|
|
60
|
+
return ok(id, {
|
|
61
|
+
// Answer with the client's version: the subset used here is the same in all of them.
|
|
62
|
+
protocolVersion: z.string().catch("2025-06-18").parse(params?.protocolVersion),
|
|
63
|
+
capabilities: { tools: {} },
|
|
64
|
+
serverInfo: info
|
|
65
|
+
});
|
|
66
|
+
case "ping":
|
|
67
|
+
return ok(id, {});
|
|
68
|
+
case "tools/list":
|
|
69
|
+
return ok(id, {
|
|
70
|
+
tools: tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema }))
|
|
71
|
+
});
|
|
72
|
+
case "tools/call": {
|
|
73
|
+
const call = CallParams.safeParse(params);
|
|
74
|
+
const found = call.success ? byName.get(call.data.name) : void 0;
|
|
75
|
+
if (!call.success || !found) {
|
|
76
|
+
return { jsonrpc: "2.0", id, error: { code: -32602, message: "Unknown tool" } };
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
return ok(id, { content: await serial(() => found.call(call.data.arguments ?? {})) });
|
|
80
|
+
} catch (err) {
|
|
81
|
+
const code = err instanceof CliError ? `[${err.code}] ` : "";
|
|
82
|
+
const message = err instanceof z.ZodError ? z.prettifyError(err) : err instanceof Error ? err.message : String(err);
|
|
83
|
+
return ok(id, { content: [{ type: "text", text: `${code}${message}` }], isError: true });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
default:
|
|
87
|
+
return { jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` } };
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function serveStdio(handle) {
|
|
92
|
+
const lines = createInterface({ input: process.stdin });
|
|
93
|
+
lines.on("line", (line) => {
|
|
94
|
+
if (!line.trim()) return;
|
|
95
|
+
let message;
|
|
96
|
+
try {
|
|
97
|
+
message = parseJson(line);
|
|
98
|
+
} catch {
|
|
99
|
+
process.stdout.write(
|
|
100
|
+
`${JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } })}
|
|
101
|
+
`
|
|
102
|
+
);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
void handle(message).then((response) => {
|
|
106
|
+
if (response) process.stdout.write(`${JSON.stringify(response)}
|
|
107
|
+
`);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
return new Promise((resolve) => lines.on("close", resolve));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// src/mcp.ts
|
|
114
|
+
var MAX_SNAPSHOT = 2e4;
|
|
115
|
+
var text = (value) => ({ type: "text", text: value });
|
|
116
|
+
var json = (value) => text(JSON.stringify(value, null, 2));
|
|
117
|
+
function mcpTools({ dir, baseURL, loadCatalog, flags }) {
|
|
118
|
+
let browser;
|
|
119
|
+
let page;
|
|
120
|
+
let errors = { pageErrors: [], consoleErrors: [] };
|
|
121
|
+
const getPage = async () => {
|
|
122
|
+
if (page && !page.isClosed()) return page;
|
|
123
|
+
await checkServer(baseURL);
|
|
124
|
+
browser ??= await launchChromium();
|
|
125
|
+
const context = await browser.newContext({ baseURL, ...contextOptions(void 0, flags) });
|
|
126
|
+
page = await context.newPage();
|
|
127
|
+
page.setDefaultTimeout(1e4);
|
|
128
|
+
await page.addInitScript(() => {
|
|
129
|
+
addEventListener("DOMContentLoaded", () => {
|
|
130
|
+
const style = document.createElement("style");
|
|
131
|
+
style.textContent = "scenario-panel { display: none !important; }";
|
|
132
|
+
document.head.append(style);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
errors = watchErrors(page);
|
|
136
|
+
return page;
|
|
137
|
+
};
|
|
138
|
+
const status = async (p) => ({
|
|
139
|
+
url: p.url(),
|
|
140
|
+
scenario: await p.evaluate(() => globalThis.stateScenariosPage?.scenario() ?? null).catch(() => null),
|
|
141
|
+
unhandled: await unhandledRequests(p).catch(() => []),
|
|
142
|
+
pageErrors: errors.pageErrors,
|
|
143
|
+
consoleErrors: errors.consoleErrors
|
|
144
|
+
});
|
|
145
|
+
const observe = async (p) => {
|
|
146
|
+
const snapshot = await p.locator("body").ariaSnapshot();
|
|
147
|
+
const cut = snapshot.length > MAX_SNAPSHOT ? `${snapshot.slice(0, MAX_SNAPSHOT)}
|
|
148
|
+
\u2026 (cut)` : snapshot;
|
|
149
|
+
return [json(await status(p)), text(cut)];
|
|
150
|
+
};
|
|
151
|
+
const screenshot = async (p, selector) => ({
|
|
152
|
+
type: "image",
|
|
153
|
+
mimeType: "image/png",
|
|
154
|
+
data: (await (selector ? p.locator(selector).first().screenshot() : p.screenshot())).toString("base64")
|
|
155
|
+
});
|
|
156
|
+
const Selector = z2.string().min(1).describe(
|
|
157
|
+
'Playwright selector: text="Save", role=button[name="Save"], css, or [data-testid=x]'
|
|
158
|
+
);
|
|
159
|
+
const tools = [
|
|
160
|
+
tool({
|
|
161
|
+
name: "list_scenarios",
|
|
162
|
+
description: "Every scenario in the catalog: name, description, ready selector and the link that opens it.",
|
|
163
|
+
input: z2.object({}),
|
|
164
|
+
run: async () => {
|
|
165
|
+
const catalog = loadCatalog();
|
|
166
|
+
return [json(catalog.list.map((s) => ({
|
|
167
|
+
name: s.name,
|
|
168
|
+
description: s.description ?? "",
|
|
169
|
+
ready: s.ready ?? null,
|
|
170
|
+
link: scenarioLink(baseURL, catalog.resolve(s))
|
|
171
|
+
})))];
|
|
172
|
+
}
|
|
173
|
+
}),
|
|
174
|
+
tool({
|
|
175
|
+
name: "validate",
|
|
176
|
+
description: "Check every scenario parses, resolves its extends and passes the rules. Empty list means valid.",
|
|
177
|
+
input: z2.object({}),
|
|
178
|
+
run: async () => [json(loadCatalog().check())]
|
|
179
|
+
}),
|
|
180
|
+
tool({
|
|
181
|
+
name: "open_scenario",
|
|
182
|
+
description: "Open the app in a scenario (strict: unmocked requests get 501) and wait for its ready selector. Returns what went wrong, if anything, with a screenshot.",
|
|
183
|
+
input: z2.object({ name: z2.string().describe("Scenario name, or several joined with commas") }),
|
|
184
|
+
run: async ({ name }) => {
|
|
185
|
+
const catalog = loadCatalog();
|
|
186
|
+
const names = name.split(",").map((n) => n.trim());
|
|
187
|
+
const found = names.every((n) => catalog.get(n));
|
|
188
|
+
if (!found) {
|
|
189
|
+
throw new CliError(
|
|
190
|
+
"UNKNOWN_SCENARIO",
|
|
191
|
+
`Unknown scenario "${name}". Known: ${catalog.list.map((s) => s.name).join(", ")}`
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
const scenario = catalog.resolve(
|
|
195
|
+
catalog.get(names.join(",")) ?? { name: names.join(","), extends: names }
|
|
196
|
+
);
|
|
197
|
+
const p = await getPage();
|
|
198
|
+
errors.pageErrors.length = errors.consoleErrors.length = 0;
|
|
199
|
+
const result = await inspectScenario(p, scenario, { link: scenarioLink(baseURL, scenario) });
|
|
200
|
+
return [json(result), await screenshot(p)];
|
|
201
|
+
}
|
|
202
|
+
}),
|
|
203
|
+
tool({
|
|
204
|
+
name: "open_app",
|
|
205
|
+
description: "Open the app at a path without a scenario. record: true lets requests reach the real backend and records their JSON responses, so save_scenario can replay them offline.",
|
|
206
|
+
input: z2.object({ path: z2.string().startsWith("/").default("/"), record: z2.boolean().default(false) }),
|
|
207
|
+
run: async ({ path, record }) => {
|
|
208
|
+
const p = await getPage();
|
|
209
|
+
errors.pageErrors.length = errors.consoleErrors.length = 0;
|
|
210
|
+
const url = new URL(path, baseURL);
|
|
211
|
+
if (url.origin !== new URL(baseURL).origin) {
|
|
212
|
+
throw new CliError("INVALID_ARGUMENT", `path must stay on ${baseURL}, not "${path}"`);
|
|
213
|
+
}
|
|
214
|
+
url.searchParams.set("scenario", "");
|
|
215
|
+
if (record) url.searchParams.set("scenario-record", "");
|
|
216
|
+
await p.goto(url.toString());
|
|
217
|
+
return observe(p);
|
|
218
|
+
}
|
|
219
|
+
}),
|
|
220
|
+
tool({
|
|
221
|
+
name: "observe",
|
|
222
|
+
description: "The page as an accessibility tree (roles, names, text), plus URL, active scenario, unmocked requests and errors.",
|
|
223
|
+
input: z2.object({}),
|
|
224
|
+
run: async () => observe(await getPage())
|
|
225
|
+
}),
|
|
226
|
+
tool({
|
|
227
|
+
name: "screenshot",
|
|
228
|
+
description: "A PNG of the page, or of the first element matching selector.",
|
|
229
|
+
input: z2.object({ selector: Selector.optional() }),
|
|
230
|
+
run: async ({ selector }) => [await screenshot(await getPage(), selector)]
|
|
231
|
+
}),
|
|
232
|
+
tool({
|
|
233
|
+
name: "click",
|
|
234
|
+
description: "Click the first element matching selector.",
|
|
235
|
+
input: z2.object({ selector: Selector }),
|
|
236
|
+
run: async ({ selector }) => {
|
|
237
|
+
const p = await getPage();
|
|
238
|
+
await p.locator(selector).first().click();
|
|
239
|
+
return [json(await status(p))];
|
|
240
|
+
}
|
|
241
|
+
}),
|
|
242
|
+
tool({
|
|
243
|
+
name: "fill",
|
|
244
|
+
description: "Type a value into the input matching selector, replacing what it holds.",
|
|
245
|
+
input: z2.object({ selector: Selector, value: z2.string() }),
|
|
246
|
+
run: async ({ selector, value }) => {
|
|
247
|
+
const p = await getPage();
|
|
248
|
+
await p.locator(selector).first().fill(value);
|
|
249
|
+
return [json(await status(p))];
|
|
250
|
+
}
|
|
251
|
+
}),
|
|
252
|
+
tool({
|
|
253
|
+
name: "press",
|
|
254
|
+
description: "Press a key on the focused element, e.g. Enter, Escape, Tab, ArrowDown.",
|
|
255
|
+
input: z2.object({ key: z2.string().min(1) }),
|
|
256
|
+
run: async ({ key }) => {
|
|
257
|
+
const p = await getPage();
|
|
258
|
+
await p.keyboard.press(key);
|
|
259
|
+
return [json(await status(p))];
|
|
260
|
+
}
|
|
261
|
+
}),
|
|
262
|
+
tool({
|
|
263
|
+
name: "check_ready",
|
|
264
|
+
description: "Check a ready selector against the page now: whether it matches and is visible, plus distinctive selectors that would work. Use before save_scenario.",
|
|
265
|
+
input: z2.object({ selector: Selector }),
|
|
266
|
+
run: async ({ selector }) => {
|
|
267
|
+
const p = await getPage();
|
|
268
|
+
const report = await p.evaluate((s) => globalThis.stateScenariosPage?.checkReady(s), selector);
|
|
269
|
+
if (!report) {
|
|
270
|
+
throw new CliError(
|
|
271
|
+
"SESSION_NOT_STARTED",
|
|
272
|
+
"No scenario session on this page: is startScenarios running?"
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
return [json(report)];
|
|
276
|
+
}
|
|
277
|
+
}),
|
|
278
|
+
tool({
|
|
279
|
+
name: "save_scenario",
|
|
280
|
+
description: "Save what the app shows now (route, URL params, mocked and recorded responses, adapter state) as <dir>/<name>.json. extends writes only the differences from that scenario. Recorded secrets are redacted.",
|
|
281
|
+
input: z2.object({
|
|
282
|
+
name: z2.string().regex(/^[a-z0-9][a-z0-9-]*$/, "kebab-case, e.g. orders-empty"),
|
|
283
|
+
description: z2.string().optional(),
|
|
284
|
+
ready: Selector.optional().describe(
|
|
285
|
+
"Selector visible once this state shows; check it with check_ready first"
|
|
286
|
+
),
|
|
287
|
+
extends: z2.string().optional().describe('Base scenario, usually "default"'),
|
|
288
|
+
replace: z2.boolean().default(false).describe("Overwrite an existing file")
|
|
289
|
+
}),
|
|
290
|
+
run: async ({ replace, ...options }) => {
|
|
291
|
+
const p = await getPage();
|
|
292
|
+
const file = join(dir, `${options.name}.json`);
|
|
293
|
+
if (existsSync(file) && !replace) {
|
|
294
|
+
throw new CliError(
|
|
295
|
+
"INVALID_ARGUMENT",
|
|
296
|
+
`${file} already exists; pass replace: true to overwrite it`
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
const captured = await p.evaluate(
|
|
300
|
+
(o) => globalThis.stateScenariosPage?.capture(o),
|
|
301
|
+
options
|
|
302
|
+
);
|
|
303
|
+
if (!captured) {
|
|
304
|
+
throw new CliError(
|
|
305
|
+
"SESSION_NOT_STARTED",
|
|
306
|
+
"No scenario session on this page: is startScenarios running?"
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
parseScenario(captured);
|
|
310
|
+
const schema = join(dirname(dir), "scenario.schema.json");
|
|
311
|
+
const $schema = existsSync(schema) ? relative(dir, schema).replaceAll("\\", "/") : void 0;
|
|
312
|
+
writeFileSync(file, `${JSON.stringify({ $schema, ...captured }, null, 2)}
|
|
313
|
+
`);
|
|
314
|
+
const problems = loadCatalog().check().filter((m) => m.includes(`"${options.name}"`));
|
|
315
|
+
return [json({
|
|
316
|
+
saved: file,
|
|
317
|
+
problems,
|
|
318
|
+
extends: captured.extends ?? null,
|
|
319
|
+
path: captured.path ?? null,
|
|
320
|
+
url: captured.url ?? {},
|
|
321
|
+
network: (captured.network ?? []).map(
|
|
322
|
+
(e) => `${e.method ?? "GET"} ${e.path}${e.remove ? " (removed)" : ""}`
|
|
323
|
+
),
|
|
324
|
+
handlers: captured.handlers ?? [],
|
|
325
|
+
state: Object.keys(captured.state ?? {}),
|
|
326
|
+
ready: captured.ready ?? null
|
|
327
|
+
})];
|
|
328
|
+
}
|
|
329
|
+
})
|
|
330
|
+
];
|
|
331
|
+
return { tools, close: async () => browser?.close() };
|
|
332
|
+
}
|
|
333
|
+
async function serveMcp(options) {
|
|
334
|
+
const { tools, close } = mcpTools(options);
|
|
335
|
+
await serveStdio(createMcpHandler(tools, { name: "state-scenarios", version: options.version }));
|
|
336
|
+
await close();
|
|
337
|
+
}
|
|
338
|
+
export {
|
|
339
|
+
mcpTools,
|
|
340
|
+
serveMcp
|
|
341
|
+
};
|
|
342
|
+
//# sourceMappingURL=mcp-HRQIWAIR.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/mcp.ts","../src/mcp-protocol.ts"],"sourcesContent":["import type { Browser, Page } from '@playwright/test';\nimport { existsSync, writeFileSync } from 'node:fs';\nimport { dirname, join, relative } from 'node:path';\nimport { type Catalog, parseScenario, type ScenarioInput } from 'state-scenarios';\nimport { unhandledRequests } from 'state-scenarios/playwright';\nimport { z } from 'zod';\nimport { type BrowserFlags, checkServer, contextOptions, launchChromium, scenarioLink } from './browser.js';\nimport { CliError } from './errors.js';\nimport { inspectScenario, watchErrors } from './inspect.js';\nimport { type Content, createMcpHandler, serveStdio, tool } from './mcp-protocol.js';\n\nconst MAX_SNAPSHOT = 20_000;\n\nexport interface McpOptions {\n dir: string;\n baseURL: string;\n /** Reads the scenarios folder again, so files saved during the session show up. */\n loadCatalog: () => Catalog;\n flags: BrowserFlags;\n version: string;\n}\n\nconst text = (value: string): Content => ({ type: 'text', text: value });\n\nconst json = (value: Parameters<typeof JSON.stringify>[0]): Content => text(JSON.stringify(value, null, 2));\n\n/**\n * Tools that let a coding agent open the app in any scenario, look at it, drive it, and save\n * what it shows as a new scenario. One browser page per server; each agent starts its own server.\n */\nexport function mcpTools({ dir, baseURL, loadCatalog, flags }: McpOptions) {\n let browser: Browser | undefined;\n let page: Page | undefined;\n let errors: ReturnType<typeof watchErrors> = { pageErrors: [], consoleErrors: [] };\n\n const getPage = async () => {\n if (page && !page.isClosed()) return page;\n await checkServer(baseURL);\n browser ??= await launchChromium();\n const context = await browser.newContext({ baseURL, ...contextOptions(undefined, flags) });\n page = await context.newPage();\n page.setDefaultTimeout(10_000);\n // Agents read the page through the accessibility tree; the dev panel would only add noise.\n await page.addInitScript(() => {\n addEventListener('DOMContentLoaded', () => {\n const style = document.createElement('style');\n style.textContent = 'scenario-panel { display: none !important; }';\n document.head.append(style);\n });\n });\n errors = watchErrors(page);\n\n return page;\n };\n\n const status = async (p: Page) => ({\n url: p.url(),\n scenario: await p.evaluate(() => globalThis.stateScenariosPage?.scenario() ?? null).catch(() => null),\n unhandled: await unhandledRequests(p).catch(() => []),\n pageErrors: errors.pageErrors,\n consoleErrors: errors.consoleErrors,\n });\n\n const observe = async (p: Page): Promise<Content[]> => {\n const snapshot = await p.locator('body').ariaSnapshot();\n const cut = snapshot.length > MAX_SNAPSHOT ? `${snapshot.slice(0, MAX_SNAPSHOT)}\\n… (cut)` : snapshot;\n\n return [json(await status(p)), text(cut)];\n };\n\n const screenshot = async (p: Page, selector?: string): Promise<Content> => ({\n type: 'image',\n mimeType: 'image/png',\n data: (await (selector ? p.locator(selector).first().screenshot() : p.screenshot())).toString('base64'),\n });\n\n const Selector = z.string().min(1).describe(\n 'Playwright selector: text=\"Save\", role=button[name=\"Save\"], css, or [data-testid=x]',\n );\n\n const tools = [\n tool({\n name: 'list_scenarios',\n description:\n 'Every scenario in the catalog: name, description, ready selector and the link that opens it.',\n input: z.object({}),\n run: async () => {\n const catalog = loadCatalog();\n\n return [json(catalog.list.map((s) => ({\n name: s.name,\n description: s.description ?? '',\n ready: s.ready ?? null,\n link: scenarioLink(baseURL, catalog.resolve(s)),\n })))];\n },\n }),\n tool({\n name: 'validate',\n description:\n 'Check every scenario parses, resolves its extends and passes the rules. Empty list means valid.',\n input: z.object({}),\n run: async () => [json(loadCatalog().check())],\n }),\n tool({\n name: 'open_scenario',\n description:\n 'Open the app in a scenario (strict: unmocked requests get 501) and wait for its ready selector. Returns what went wrong, if anything, with a screenshot.',\n input: z.object({ name: z.string().describe('Scenario name, or several joined with commas') }),\n run: async ({ name }) => {\n const catalog = loadCatalog();\n const names = name.split(',').map((n) => n.trim());\n const found = names.every((n) => catalog.get(n));\n\n if (!found) {\n throw new CliError(\n 'UNKNOWN_SCENARIO',\n `Unknown scenario \"${name}\". Known: ${catalog.list.map((s) => s.name).join(', ')}`,\n );\n }\n\n const scenario = catalog.resolve(\n catalog.get(names.join(',')) ?? { name: names.join(','), extends: names },\n );\n\n const p = await getPage();\n errors.pageErrors.length = errors.consoleErrors.length = 0;\n const result = await inspectScenario(p, scenario, { link: scenarioLink(baseURL, scenario) });\n\n return [json(result), await screenshot(p)];\n },\n }),\n tool({\n name: 'open_app',\n description:\n 'Open the app at a path without a scenario. record: true lets requests reach the real backend and records their JSON responses, so save_scenario can replay them offline.',\n input: z.object({ path: z.string().startsWith('/').default('/'), record: z.boolean().default(false) }),\n run: async ({ path, record }) => {\n const p = await getPage();\n errors.pageErrors.length = errors.consoleErrors.length = 0;\n const url = new URL(path, baseURL);\n\n // `//host/...` is a path to URL parsing but leaves the app.\n if (url.origin !== new URL(baseURL).origin) {\n throw new CliError('INVALID_ARGUMENT', `path must stay on ${baseURL}, not \"${path}\"`);\n }\n\n url.searchParams.set('scenario', '');\n\n if (record) url.searchParams.set('scenario-record', '');\n await p.goto(url.toString());\n\n return observe(p);\n },\n }),\n tool({\n name: 'observe',\n description:\n 'The page as an accessibility tree (roles, names, text), plus URL, active scenario, unmocked requests and errors.',\n input: z.object({}),\n run: async () => observe(await getPage()),\n }),\n tool({\n name: 'screenshot',\n description: 'A PNG of the page, or of the first element matching selector.',\n input: z.object({ selector: Selector.optional() }),\n run: async ({ selector }) => [await screenshot(await getPage(), selector)],\n }),\n tool({\n name: 'click',\n description: 'Click the first element matching selector.',\n input: z.object({ selector: Selector }),\n run: async ({ selector }) => {\n const p = await getPage();\n await p.locator(selector).first().click();\n\n return [json(await status(p))];\n },\n }),\n tool({\n name: 'fill',\n description: 'Type a value into the input matching selector, replacing what it holds.',\n input: z.object({ selector: Selector, value: z.string() }),\n run: async ({ selector, value }) => {\n const p = await getPage();\n await p.locator(selector).first().fill(value);\n\n return [json(await status(p))];\n },\n }),\n tool({\n name: 'press',\n description: 'Press a key on the focused element, e.g. Enter, Escape, Tab, ArrowDown.',\n input: z.object({ key: z.string().min(1) }),\n run: async ({ key }) => {\n const p = await getPage();\n await p.keyboard.press(key);\n\n return [json(await status(p))];\n },\n }),\n tool({\n name: 'check_ready',\n description:\n 'Check a ready selector against the page now: whether it matches and is visible, plus distinctive selectors that would work. Use before save_scenario.',\n input: z.object({ selector: Selector }),\n run: async ({ selector }) => {\n const p = await getPage();\n const report = await p.evaluate((s) => globalThis.stateScenariosPage?.checkReady(s), selector);\n\n if (!report) {\n throw new CliError(\n 'SESSION_NOT_STARTED',\n 'No scenario session on this page: is startScenarios running?',\n );\n }\n\n return [json(report)];\n },\n }),\n tool({\n name: 'save_scenario',\n description:\n 'Save what the app shows now (route, URL params, mocked and recorded responses, adapter state) as <dir>/<name>.json. extends writes only the differences from that scenario. Recorded secrets are redacted.',\n input: z.object({\n name: z.string().regex(/^[a-z0-9][a-z0-9-]*$/, 'kebab-case, e.g. orders-empty'),\n description: z.string().optional(),\n ready: Selector.optional().describe(\n 'Selector visible once this state shows; check it with check_ready first',\n ),\n extends: z.string().optional().describe('Base scenario, usually \"default\"'),\n replace: z.boolean().default(false).describe('Overwrite an existing file'),\n }),\n run: async ({ replace, ...options }) => {\n const p = await getPage();\n const file = join(dir, `${options.name}.json`);\n\n if (existsSync(file) && !replace) {\n throw new CliError(\n 'INVALID_ARGUMENT',\n `${file} already exists; pass replace: true to overwrite it`,\n );\n }\n\n const captured: ScenarioInput | undefined = await p.evaluate(\n (o) => globalThis.stateScenariosPage?.capture(o),\n options,\n );\n\n if (!captured) {\n throw new CliError(\n 'SESSION_NOT_STARTED',\n 'No scenario session on this page: is startScenarios running?',\n );\n }\n\n parseScenario(captured);\n const schema = join(dirname(dir), 'scenario.schema.json');\n const $schema = existsSync(schema) ? relative(dir, schema).replaceAll('\\\\', '/') : undefined;\n writeFileSync(file, `${JSON.stringify({ $schema, ...captured }, null, 2)}\\n`);\n // Resolve with the catalog on disk, so a bad extends or rule shows now, not later.\n const problems = loadCatalog().check().filter((m) => m.includes(`\"${options.name}\"`));\n\n // A summary: captured state can be megabytes, too much for an agent's context. The file has it all.\n return [json({\n saved: file,\n problems,\n extends: captured.extends ?? null,\n path: captured.path ?? null,\n url: captured.url ?? {},\n network: (captured.network ?? []).map((e) =>\n `${e.method ?? 'GET'} ${e.path}${e.remove ? ' (removed)' : ''}`\n ),\n handlers: captured.handlers ?? [],\n state: Object.keys(captured.state ?? {}),\n ready: captured.ready ?? null,\n })];\n },\n }),\n ];\n\n return { tools, close: async () => browser?.close() };\n}\n\nexport async function serveMcp(options: McpOptions) {\n const { tools, close } = mcpTools(options);\n await serveStdio(createMcpHandler(tools, { name: 'state-scenarios', version: options.version }));\n await close();\n}\n","import { createInterface } from 'node:readline';\nimport { type Json, parseJson } from 'state-scenarios';\nimport { z } from 'zod';\nimport { CliError } from './errors.js';\n\nexport type Content = { type: 'text'; text: string; } | { type: 'image'; data: string; mimeType: string; };\n\nexport interface McpTool {\n name: string;\n description: string;\n inputSchema: z.core.JSONSchema.BaseSchema;\n call(args: Json): Promise<Content[]>;\n}\n\n/** Define a tool whose arguments are checked against `input` before `run` sees them. */\nexport function tool<S extends z.ZodObject>(\n def: { name: string; description: string; input: S; run(args: z.output<S>): Promise<Content[]>; },\n): McpTool {\n return {\n name: def.name,\n description: def.description,\n inputSchema: z.toJSONSchema(def.input, { io: 'input' }),\n call: async (args) => def.run(def.input.parse(args)),\n };\n}\n\nconst Message = z.object({\n id: z.union([z.string(), z.number()]).optional(),\n method: z.string(),\n params: z.looseObject({}).optional(),\n});\n\nconst CallParams = z.object({ name: z.string(), arguments: z.json().optional() });\n\nconst ok = <T>(id: string | number, result: T) => ({ jsonrpc: '2.0', id, result });\n\n/**\n * Minimal MCP over JSON-RPC: initialize, ping, tools/list and tools/call. Returns the\n * response to send, or null for notifications.\n */\nexport function createMcpHandler(tools: McpTool[], info: { name: string; version: string; }) {\n const byName = new Map(tools.map((t) => [t.name, t]));\n // Tools share one browser page: run calls one at a time, in arrival order, so an overlapping\n // navigation or click can't land in the middle of another call.\n let queue: Promise<void> = Promise.resolve();\n\n const serial = <T>(work: () => Promise<T>) => {\n const run = queue.then(work);\n queue = run.then(() => {}, () => {});\n\n return run;\n };\n\n return async (raw: Json) => {\n const parsed = Message.safeParse(raw);\n\n if (!parsed.success) {\n return { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Invalid request' } };\n }\n\n const { id, method, params } = parsed.data;\n\n if (id === undefined) return null; // a notification, e.g. notifications/initialized\n\n switch (method) {\n case 'initialize':\n return ok(id, {\n // Answer with the client's version: the subset used here is the same in all of them.\n protocolVersion: z.string().catch('2025-06-18').parse(params?.protocolVersion),\n capabilities: { tools: {} },\n serverInfo: info,\n });\n case 'ping':\n return ok(id, {});\n case 'tools/list':\n return ok(id, {\n tools: tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),\n });\n case 'tools/call': {\n const call = CallParams.safeParse(params);\n const found = call.success ? byName.get(call.data.name) : undefined;\n\n if (!call.success || !found) {\n return { jsonrpc: '2.0', id, error: { code: -32602, message: 'Unknown tool' } };\n }\n\n try {\n return ok(id, { content: await serial(() => found.call(call.data.arguments ?? {})) });\n } catch (err) {\n const code = err instanceof CliError ? `[${err.code}] ` : '';\n\n const message = err instanceof z.ZodError\n ? z.prettifyError(err)\n : err instanceof Error\n ? err.message\n : String(err);\n\n return ok(id, { content: [{ type: 'text', text: `${code}${message}` }], isError: true });\n }\n }\n\n default:\n return { jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } };\n }\n };\n}\n\n/** Serve newline-delimited JSON-RPC on stdin/stdout. Logs must go to stderr. */\nexport function serveStdio(handle: ReturnType<typeof createMcpHandler>) {\n const lines = createInterface({ input: process.stdin });\n\n lines.on('line', (line) => {\n if (!line.trim()) return;\n let message: Json;\n\n try {\n message = parseJson(line);\n } catch {\n process.stdout.write(\n `${JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } })}\\n`,\n );\n\n return;\n }\n\n void handle(message).then((response) => {\n if (response) process.stdout.write(`${JSON.stringify(response)}\\n`);\n });\n });\n\n return new Promise<void>((resolve) => lines.on('close', resolve));\n}\n"],"mappings":"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","names":["z","z"]}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type Json } from 'state-scenarios';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
export type Content = {
|
|
4
|
+
type: 'text';
|
|
5
|
+
text: string;
|
|
6
|
+
} | {
|
|
7
|
+
type: 'image';
|
|
8
|
+
data: string;
|
|
9
|
+
mimeType: string;
|
|
10
|
+
};
|
|
11
|
+
export interface McpTool {
|
|
12
|
+
name: string;
|
|
13
|
+
description: string;
|
|
14
|
+
inputSchema: z.core.JSONSchema.BaseSchema;
|
|
15
|
+
call(args: Json): Promise<Content[]>;
|
|
16
|
+
}
|
|
17
|
+
/** Define a tool whose arguments are checked against `input` before `run` sees them. */
|
|
18
|
+
export declare function tool<S extends z.ZodObject>(def: {
|
|
19
|
+
name: string;
|
|
20
|
+
description: string;
|
|
21
|
+
input: S;
|
|
22
|
+
run(args: z.output<S>): Promise<Content[]>;
|
|
23
|
+
}): McpTool;
|
|
24
|
+
/**
|
|
25
|
+
* Minimal MCP over JSON-RPC: initialize, ping, tools/list and tools/call. Returns the
|
|
26
|
+
* response to send, or null for notifications.
|
|
27
|
+
*/
|
|
28
|
+
export declare function createMcpHandler(tools: McpTool[], info: {
|
|
29
|
+
name: string;
|
|
30
|
+
version: string;
|
|
31
|
+
}): (raw: Json) => Promise<{
|
|
32
|
+
jsonrpc: string;
|
|
33
|
+
id: string | number;
|
|
34
|
+
result: {};
|
|
35
|
+
} | {
|
|
36
|
+
jsonrpc: string;
|
|
37
|
+
id: null;
|
|
38
|
+
error: {
|
|
39
|
+
code: number;
|
|
40
|
+
message: string;
|
|
41
|
+
};
|
|
42
|
+
} | {
|
|
43
|
+
jsonrpc: string;
|
|
44
|
+
id: string | number;
|
|
45
|
+
error: {
|
|
46
|
+
code: number;
|
|
47
|
+
message: string;
|
|
48
|
+
};
|
|
49
|
+
} | null>;
|
|
50
|
+
/** Serve newline-delimited JSON-RPC on stdin/stdout. Logs must go to stderr. */
|
|
51
|
+
export declare function serveStdio(handle: ReturnType<typeof createMcpHandler>): Promise<void>;
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type Catalog } from 'state-scenarios';
|
|
2
|
+
import { type BrowserFlags } from './browser.js';
|
|
3
|
+
export interface McpOptions {
|
|
4
|
+
dir: string;
|
|
5
|
+
baseURL: string;
|
|
6
|
+
/** Reads the scenarios folder again, so files saved during the session show up. */
|
|
7
|
+
loadCatalog: () => Catalog;
|
|
8
|
+
flags: BrowserFlags;
|
|
9
|
+
version: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Tools that let a coding agent open the app in any scenario, look at it, drive it, and save
|
|
13
|
+
* what it shows as a new scenario. One browser page per server; each agent starts its own server.
|
|
14
|
+
*/
|
|
15
|
+
export declare function mcpTools({ dir, baseURL, loadCatalog, flags }: McpOptions): {
|
|
16
|
+
tools: import("./mcp-protocol.js").McpTool[];
|
|
17
|
+
close: () => Promise<void | undefined>;
|
|
18
|
+
};
|
|
19
|
+
export declare function serveMcp(options: McpOptions): Promise<void>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CliError
|
|
3
|
+
} from "./chunk-GCYOLAFU.js";
|
|
4
|
+
|
|
5
|
+
// src/record.ts
|
|
6
|
+
import { spawnSync } from "child_process";
|
|
7
|
+
import { copyFileSync } from "fs";
|
|
8
|
+
function visibleCrop(box, viewport) {
|
|
9
|
+
const x = Math.max(0, box.x);
|
|
10
|
+
const y = Math.max(0, box.y);
|
|
11
|
+
const width = Math.min(viewport.width, box.x + box.width) - x;
|
|
12
|
+
const height = Math.min(viewport.height, box.y + box.height) - y;
|
|
13
|
+
if (width < 2 || height < 2) return void 0;
|
|
14
|
+
return { crop: { x, y, width, height }, clipped: width < box.width || height < box.height };
|
|
15
|
+
}
|
|
16
|
+
var even = (n) => Math.max(2, Math.floor(n / 2) * 2);
|
|
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);
|
|
25
|
+
const codec = {
|
|
26
|
+
mp4: ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "23", "-movflags", "+faststart"],
|
|
27
|
+
webm: ["-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "32"],
|
|
28
|
+
gif: []
|
|
29
|
+
}[format];
|
|
30
|
+
const vf = format === "gif" ? `${filters.join(",")},split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer` : filters.join(",");
|
|
31
|
+
return [
|
|
32
|
+
"-y",
|
|
33
|
+
"-loglevel",
|
|
34
|
+
"error",
|
|
35
|
+
"-i",
|
|
36
|
+
input,
|
|
37
|
+
"-ss",
|
|
38
|
+
start.toFixed(2),
|
|
39
|
+
...vf ? ["-vf", vf] : [],
|
|
40
|
+
"-an",
|
|
41
|
+
...codec,
|
|
42
|
+
output
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
function encodeVideo(options) {
|
|
46
|
+
if (options.format === "webm" && !options.crop && !options.fps && !options.width) {
|
|
47
|
+
copyFileSync(options.input, options.output);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const ffmpeg = process.env.FFMPEG_PATH || "ffmpeg";
|
|
51
|
+
const result = spawnSync(ffmpeg, ffmpegArgs(options), { encoding: "utf8" });
|
|
52
|
+
if (result.error) {
|
|
53
|
+
throw new CliError(
|
|
54
|
+
"FFMPEG_MISSING",
|
|
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`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if (result.status !== 0) throw new Error(`ffmpeg failed: ${result.stderr.trim()}`);
|
|
59
|
+
}
|
|
60
|
+
export {
|
|
61
|
+
encodeVideo,
|
|
62
|
+
ffmpegArgs,
|
|
63
|
+
visibleCrop
|
|
64
|
+
};
|
|
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
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export type VideoFormat = 'mp4' | 'gif' | 'webm';
|
|
2
|
+
export interface Crop {
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The part of an element's box the video shows. Video holds only the viewport, so a crop must
|
|
10
|
+
* stay inside it; `clipped` says the element was larger than the visible area.
|
|
11
|
+
*/
|
|
12
|
+
export declare function visibleCrop(box: Crop, viewport: {
|
|
13
|
+
width: number;
|
|
14
|
+
height: number;
|
|
15
|
+
}): {
|
|
16
|
+
crop: {
|
|
17
|
+
x: number;
|
|
18
|
+
y: number;
|
|
19
|
+
width: number;
|
|
20
|
+
height: number;
|
|
21
|
+
};
|
|
22
|
+
clipped: boolean;
|
|
23
|
+
} | undefined;
|
|
24
|
+
/**
|
|
25
|
+
* ffmpeg arguments that trim the page load off the start, crop to an element, and encode.
|
|
26
|
+
* MP4 is H.264 in yuv420p so it plays everywhere, including Safari and phones; GIF uses a
|
|
27
|
+
* generated palette, which keeps it sharp at a fraction of the naive size.
|
|
28
|
+
*/
|
|
29
|
+
export declare function ffmpegArgs({ input, output, format, start, crop, fps, width }: {
|
|
30
|
+
input: string;
|
|
31
|
+
output: string;
|
|
32
|
+
format: VideoFormat;
|
|
33
|
+
/** Seconds to skip, e.g. the page loading before the steps begin. */
|
|
34
|
+
start: number;
|
|
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;
|
|
40
|
+
}): string[];
|
|
41
|
+
/**
|
|
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.
|
|
44
|
+
*/
|
|
45
|
+
export declare function encodeVideo(options: Parameters<typeof ffmpegArgs>[0]): void;
|