@qwertybit/pr-preview 0.1.2 → 0.1.4
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/CHANGELOG.md +71 -0
- package/README.md +84 -27
- package/dist/cli/index.js +1029 -131
- package/dist/cli/index.js.map +1 -1
- package/dist/inpage/recorder.global.js +79 -14
- package/package.json +3 -3
package/dist/cli/index.js
CHANGED
|
@@ -6,12 +6,13 @@ import { Command } from "commander";
|
|
|
6
6
|
|
|
7
7
|
// src/cli/commands/init.ts
|
|
8
8
|
import { existsSync } from "fs";
|
|
9
|
-
import { readFile, writeFile, appendFile } from "fs/promises";
|
|
9
|
+
import { readFile, writeFile, appendFile, mkdir } from "fs/promises";
|
|
10
10
|
import path from "path";
|
|
11
11
|
|
|
12
12
|
// src/cli/ui/logger.ts
|
|
13
13
|
import pc from "picocolors";
|
|
14
14
|
import ora from "ora";
|
|
15
|
+
import { createInterface } from "readline/promises";
|
|
15
16
|
var log = {
|
|
16
17
|
info(msg) {
|
|
17
18
|
console.log(`${pc.cyan("\u25CF")} ${msg}`);
|
|
@@ -30,6 +31,20 @@ var log = {
|
|
|
30
31
|
},
|
|
31
32
|
spinner(text) {
|
|
32
33
|
return ora({ text, color: "cyan" }).start();
|
|
34
|
+
},
|
|
35
|
+
/**
|
|
36
|
+
* Ask a yes/no question. Defaults to "no". In a non-interactive shell
|
|
37
|
+
* (no TTY, e.g. CI) it resolves to `false` without blocking.
|
|
38
|
+
*/
|
|
39
|
+
async confirm(question) {
|
|
40
|
+
if (!process.stdin.isTTY) return false;
|
|
41
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
42
|
+
try {
|
|
43
|
+
const answer = await rl.question(`${pc.yellow("?")} ${question} ${pc.dim("(y/N)")} `);
|
|
44
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
45
|
+
} finally {
|
|
46
|
+
rl.close();
|
|
47
|
+
}
|
|
33
48
|
}
|
|
34
49
|
};
|
|
35
50
|
|
|
@@ -77,10 +92,105 @@ var GITIGNORE_BLOCK = `
|
|
|
77
92
|
# pr-preview (worktrees, recordings, output)
|
|
78
93
|
.pr-preview/
|
|
79
94
|
`;
|
|
95
|
+
async function writeMcpConfig(root) {
|
|
96
|
+
const mcpPath = path.join(root, ".mcp.json");
|
|
97
|
+
const entry = { command: "npx", args: ["pr-preview", "mcp"] };
|
|
98
|
+
let doc = {};
|
|
99
|
+
if (existsSync(mcpPath)) {
|
|
100
|
+
try {
|
|
101
|
+
doc = JSON.parse(await readFile(mcpPath, "utf8"));
|
|
102
|
+
} catch {
|
|
103
|
+
log.warn(".mcp.json exists but isn't valid JSON \u2014 leaving it untouched.");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
doc.mcpServers ??= {};
|
|
108
|
+
if (doc.mcpServers["pr-preview"]) {
|
|
109
|
+
const overwrite = await log.confirm(
|
|
110
|
+
".mcp.json already has a pr-preview server. Overwrite it?"
|
|
111
|
+
);
|
|
112
|
+
if (!overwrite) {
|
|
113
|
+
log.warn("Left the existing pr-preview MCP server untouched.");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
doc.mcpServers["pr-preview"] = entry;
|
|
118
|
+
await writeFile(mcpPath, JSON.stringify(doc, null, 2) + "\n");
|
|
119
|
+
log.success(`${existsSync(mcpPath) ? "Updated" : "Created"} .mcp.json (pr-preview MCP server for Claude Code)`);
|
|
120
|
+
}
|
|
121
|
+
var RECORD_SKILL = `---
|
|
122
|
+
name: record
|
|
123
|
+
description: Record a video of a user journey through a web app, automatically, using PR Preview (@qwertybit/pr-preview). Claude drives the app itself and produces an MP4 \u2014 no human clicking. Usage: /record [url] [journey in plain English]. Use when the user wants to record, film, or capture a walkthrough or before/after video of an app for a pull request or demo.
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
# /record \u2014 record an app journey automatically with PR Preview
|
|
127
|
+
|
|
128
|
+
You record a real video walkthrough of a web app by **driving it yourself** through the
|
|
129
|
+
PR Preview MCP server \u2014 not by asking the user to click. PR Preview opens its harness (a
|
|
130
|
+
visible Chrome window with the app in an iframe and a step sidebar); you perform the
|
|
131
|
+
journey through its tools and it captures a clean, PR-ready MP4.
|
|
132
|
+
|
|
133
|
+
## Arguments
|
|
134
|
+
\`/record [url] [journey]\`
|
|
135
|
+
- **url** (optional): the app to record, e.g. \`http://localhost:3000\` or \`https://staging.example.com\`. The first URL-looking token is the URL; the rest is the journey.
|
|
136
|
+
- **journey** (optional): what to do, in plain English (e.g. "add 3 books to the cart, then go to checkout").
|
|
137
|
+
|
|
138
|
+
## Preconditions
|
|
139
|
+
The PR Preview MCP tools must be connected: \`start_recording\`, \`snapshot\`, \`act\`,
|
|
140
|
+
\`next_pass\`, \`finish_recording\`, \`open_pr\`, \`detect_localhost\`.
|
|
141
|
+
- If they are NOT available, tell the user to install and connect it:
|
|
142
|
+
\`npm i -D @qwertybit/pr-preview\` \u2192 \`npx pr-preview init\` (writes \`.mcp.json\`) \u2192 reload Claude Code.
|
|
143
|
+
Stop until it's connected.
|
|
144
|
+
|
|
145
|
+
## Steps
|
|
146
|
+
1. **Resolve the URL.** If a URL was given, use it. Otherwise call \`detect_localhost\`; if apps
|
|
147
|
+
are running, ask the user which to record; if none are, ask for a local, staging, or
|
|
148
|
+
production URL. **Never guess.**
|
|
149
|
+
2. **Start.** Call \`start_recording\` with \`{ url }\`. A Chrome window opens with the harness \u2014
|
|
150
|
+
the app runs in the iframe and the sidebar records each step. Do NOT click "Start recording";
|
|
151
|
+
\`start_recording\` already began it. (For a base-vs-branch PR comparison, use
|
|
152
|
+
\`{ mode: "before-after" }\` with a managed dev server instead.)
|
|
153
|
+
3. **Drive the journey.** Read the returned accessibility snapshot. For each step in the journey,
|
|
154
|
+
call \`act\` (\`click\` / \`fill\` / \`press\` / \`hover\` / \`navigate\` / \`scroll\`), targeting elements
|
|
155
|
+
by their \`[ref=\u2026]\` handle. Take a fresh snapshot after any step that changes the page. Perform
|
|
156
|
+
the journey faithfully \u2014 **you drive it; never ask the user to click.**
|
|
157
|
+
4. **Finish.** Call \`finish_recording\` and report the output MP4 path.
|
|
158
|
+
5. **PR (optional).** If the user asked, call \`open_pr\` with the produced file(s).
|
|
159
|
+
|
|
160
|
+
## Important
|
|
161
|
+
- This is agent-driven but a REAL capture of the real app \u2014 nothing is synthesized.
|
|
162
|
+
- Do NOT use the \`pr-preview run\` CLI for this \u2014 that is the manual, human-in-the-loop flow that
|
|
163
|
+
waits for a person to click. Always drive via the MCP tools above so recording is automatic.
|
|
164
|
+
`;
|
|
165
|
+
async function writeRecordSkill(root) {
|
|
166
|
+
const skillDir = path.join(root, ".claude", "skills", "record");
|
|
167
|
+
const skillPath = path.join(skillDir, "SKILL.md");
|
|
168
|
+
if (existsSync(skillPath)) {
|
|
169
|
+
const overwrite = await log.confirm(
|
|
170
|
+
".claude/skills/record/SKILL.md already exists. Overwrite it?"
|
|
171
|
+
);
|
|
172
|
+
if (!overwrite) {
|
|
173
|
+
log.warn("Left the existing /record skill untouched.");
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
await mkdir(skillDir, { recursive: true });
|
|
178
|
+
await writeFile(skillPath, RECORD_SKILL);
|
|
179
|
+
log.success("Installed the /record slash command (.claude/skills/record/SKILL.md)");
|
|
180
|
+
}
|
|
80
181
|
async function initCommand(root) {
|
|
81
182
|
const configPath = path.join(root, "pr-preview.config.js");
|
|
82
|
-
|
|
83
|
-
|
|
183
|
+
const existingConfig = [".js", ".ts", ".json"].map((ext) => path.join(root, `pr-preview.config${ext}`)).find(existsSync);
|
|
184
|
+
if (existingConfig) {
|
|
185
|
+
const overwrite = await log.confirm(
|
|
186
|
+
`A pr-preview config already exists (${path.basename(existingConfig)}). Overwrite it?`
|
|
187
|
+
);
|
|
188
|
+
if (overwrite) {
|
|
189
|
+
await writeFile(existingConfig, CONFIG_TEMPLATE);
|
|
190
|
+
log.success(`Overwrote ${path.basename(existingConfig)}`);
|
|
191
|
+
} else {
|
|
192
|
+
log.warn("Left the existing pr-preview config untouched.");
|
|
193
|
+
}
|
|
84
194
|
} else {
|
|
85
195
|
await writeFile(configPath, CONFIG_TEMPLATE);
|
|
86
196
|
log.success(`Created ${path.basename(configPath)}`);
|
|
@@ -91,12 +201,22 @@ async function initCommand(root) {
|
|
|
91
201
|
await appendFile(gitignorePath, GITIGNORE_BLOCK);
|
|
92
202
|
log.success("Added .pr-preview/ to .gitignore");
|
|
93
203
|
}
|
|
204
|
+
await writeMcpConfig(root);
|
|
205
|
+
await writeRecordSkill(root);
|
|
94
206
|
log.info("Edit the config to match your dev command, then run `pr-preview run` on a PR branch.");
|
|
207
|
+
log.info("Or ask Claude Code to record a flow for you (e.g. \u201Crecord my add-to-cart flow\u201D),");
|
|
208
|
+
log.info("or run the /record slash command: `/record localhost:3000 add a book, then checkout`.");
|
|
95
209
|
}
|
|
96
210
|
|
|
97
|
-
// src/
|
|
98
|
-
import
|
|
99
|
-
import
|
|
211
|
+
// src/mcp/server.ts
|
|
212
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
213
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
214
|
+
import { z as z2 } from "zod";
|
|
215
|
+
import path14 from "path";
|
|
216
|
+
|
|
217
|
+
// src/mcp/recorder.ts
|
|
218
|
+
import { existsSync as existsSync8 } from "fs";
|
|
219
|
+
import path12 from "path";
|
|
100
220
|
|
|
101
221
|
// src/config/load.ts
|
|
102
222
|
import { existsSync as existsSync2 } from "fs";
|
|
@@ -147,7 +267,22 @@ var configSchema = z.object({
|
|
|
147
267
|
// 24fps keeps the synthetic cursor's motion fluid in the clip.
|
|
148
268
|
fps: z.number().positive().max(60).default(24),
|
|
149
269
|
quality: z.enum(["high", "max"]).default("high"),
|
|
150
|
-
maxColors: z.number().int().min(2).max(256).default(256)
|
|
270
|
+
maxColors: z.number().int().min(2).max(256).default(256),
|
|
271
|
+
/**
|
|
272
|
+
* Motion smoothing for the MP4. The screencast delivers a low, variable
|
|
273
|
+
* frame rate, so the raw capture can look choppy. Interpolation synthesises
|
|
274
|
+
* intermediate frames up to `smoothFps` from the real captured frames:
|
|
275
|
+
* - "blend" (default): cross-dissolves frames — natural motion-blur, never
|
|
276
|
+
* warps text/geometry. Safe for any site.
|
|
277
|
+
* - "mci": motion-compensated — sharper moving cursor, but can warp fast-
|
|
278
|
+
* scrolling dense text on some pages.
|
|
279
|
+
* - "off": no interpolation (raw capture rate).
|
|
280
|
+
*/
|
|
281
|
+
interpolate: z.enum(["blend", "mci", "off"]).default("blend"),
|
|
282
|
+
/** Target fps for interpolation (only used when interpolate ≠ "off").
|
|
283
|
+
* 60 = display-refresh smoothness; interpolation fills every gap between
|
|
284
|
+
* the real captured frames up to this rate. */
|
|
285
|
+
smoothFps: z.number().int().positive().max(120).default(60)
|
|
151
286
|
}).default({}),
|
|
152
287
|
/**
|
|
153
288
|
* Start-of-pass reset choice default: true (default) offers "reset" (clear
|
|
@@ -425,8 +560,8 @@ function harnessDistDir() {
|
|
|
425
560
|
async function startHarnessServer(opts) {
|
|
426
561
|
const dist = harnessDistDir();
|
|
427
562
|
let appUrl = opts.appUrl;
|
|
428
|
-
const server = http.createServer(async (
|
|
429
|
-
const urlPath = (
|
|
563
|
+
const server = http.createServer(async (req2, res) => {
|
|
564
|
+
const urlPath = (req2.url ?? "/").split("?")[0];
|
|
430
565
|
if (urlPath === "/runtime.json") {
|
|
431
566
|
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
|
432
567
|
res.end(JSON.stringify({ appUrl, mode: opts.mode, viewport: opts.viewport }));
|
|
@@ -544,10 +679,10 @@ async function playwrightDisabledFeatures() {
|
|
|
544
679
|
if (cachedBaseDisabledFeatures !== null) return cachedBaseDisabledFeatures;
|
|
545
680
|
cachedBaseDisabledFeatures = "";
|
|
546
681
|
try {
|
|
547
|
-
const
|
|
548
|
-
const arg =
|
|
682
|
+
const probe2 = await chromium.launchServer({ headless: true });
|
|
683
|
+
const arg = probe2.process().spawnargs.find((a) => a.startsWith("--disable-features="));
|
|
549
684
|
cachedBaseDisabledFeatures = arg ? arg.slice("--disable-features=".length) : "";
|
|
550
|
-
await
|
|
685
|
+
await probe2.close();
|
|
551
686
|
} catch {
|
|
552
687
|
}
|
|
553
688
|
return cachedBaseDisabledFeatures;
|
|
@@ -852,7 +987,9 @@ async function startScreencast(page, opts = {}) {
|
|
|
852
987
|
await cdp.send("Page.startScreencast", {
|
|
853
988
|
format: "jpeg",
|
|
854
989
|
quality: opts.quality ?? 90,
|
|
855
|
-
everyNthFrame: opts.everyNthFrame ?? 1
|
|
990
|
+
everyNthFrame: opts.everyNthFrame ?? 1,
|
|
991
|
+
...opts.maxWidth ? { maxWidth: opts.maxWidth } : {},
|
|
992
|
+
...opts.maxHeight ? { maxHeight: opts.maxHeight } : {}
|
|
856
993
|
});
|
|
857
994
|
return {
|
|
858
995
|
frameCount() {
|
|
@@ -918,14 +1055,14 @@ function resampleToFps(frames, fps) {
|
|
|
918
1055
|
}
|
|
919
1056
|
|
|
920
1057
|
// src/encode/gif.ts
|
|
921
|
-
import { mkdir as
|
|
1058
|
+
import { mkdir as mkdir3, writeFile as writeFile3 } from "fs/promises";
|
|
922
1059
|
import path8 from "path";
|
|
923
1060
|
import sharp2 from "sharp";
|
|
924
1061
|
import * as gifencNs from "gifenc";
|
|
925
1062
|
|
|
926
1063
|
// src/encode/ffmpeg.ts
|
|
927
1064
|
import { execFile, spawn as spawn2 } from "child_process";
|
|
928
|
-
import { mkdtemp, rm, writeFile as writeFile2, mkdir } from "fs/promises";
|
|
1065
|
+
import { mkdtemp, rm, writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
|
|
929
1066
|
import { tmpdir } from "os";
|
|
930
1067
|
import path6 from "path";
|
|
931
1068
|
var available = null;
|
|
@@ -953,14 +1090,14 @@ async function withFrameDir(pngFrames, fn) {
|
|
|
953
1090
|
}
|
|
954
1091
|
async function encodeWithFfmpeg(pngFrames, fps, outFile) {
|
|
955
1092
|
await withFrameDir(pngFrames, async (pattern) => {
|
|
956
|
-
await
|
|
1093
|
+
await mkdir2(path6.dirname(outFile), { recursive: true });
|
|
957
1094
|
const filter = `[0:v]fps=${fps},split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle`;
|
|
958
1095
|
await run("ffmpeg", ["-y", "-framerate", String(fps), "-i", pattern, "-filter_complex", filter, outFile]);
|
|
959
1096
|
});
|
|
960
1097
|
}
|
|
961
1098
|
async function encodeMp4(pngFrames, fps, outFile) {
|
|
962
1099
|
await withFrameDir(pngFrames, async (pattern) => {
|
|
963
|
-
await
|
|
1100
|
+
await mkdir2(path6.dirname(outFile), { recursive: true });
|
|
964
1101
|
await run("ffmpeg", [
|
|
965
1102
|
"-y",
|
|
966
1103
|
"-framerate",
|
|
@@ -983,6 +1120,39 @@ async function encodeMp4(pngFrames, fps, outFile) {
|
|
|
983
1120
|
]);
|
|
984
1121
|
});
|
|
985
1122
|
}
|
|
1123
|
+
async function encodeMp4Interpolated(pngFrames, durationsSec, outFps, mode, outFile) {
|
|
1124
|
+
const dir = await mkdtemp(path6.join(tmpdir(), "pr-preview-frames-"));
|
|
1125
|
+
try {
|
|
1126
|
+
await Promise.all(
|
|
1127
|
+
pngFrames.map(
|
|
1128
|
+
(buf, i) => writeFile2(path6.join(dir, `frame${String(i).padStart(5, "0")}.png`), buf)
|
|
1129
|
+
)
|
|
1130
|
+
);
|
|
1131
|
+
const lines = [];
|
|
1132
|
+
pngFrames.forEach((_, i) => {
|
|
1133
|
+
lines.push(`file 'frame${String(i).padStart(5, "0")}.png'`);
|
|
1134
|
+
lines.push(`duration ${Math.max(1e-3, durationsSec[i] ?? 1 / outFps).toFixed(4)}`);
|
|
1135
|
+
});
|
|
1136
|
+
lines.push(`file 'frame${String(pngFrames.length - 1).padStart(5, "0")}.png'`);
|
|
1137
|
+
await writeFile2(path6.join(dir, "list.txt"), lines.join("\n") + "\n");
|
|
1138
|
+
await mkdir2(path6.dirname(outFile), { recursive: true });
|
|
1139
|
+
const interp = mode === "mci" ? `minterpolate=fps=${outFps}:mi_mode=mci:me_mode=bidir:mc_mode=aobmc:vsbmc=1:scd=fdiff` : `minterpolate=fps=${outFps}:mi_mode=blend:scd=fdiff`;
|
|
1140
|
+
const common = ["-c:v", "libx264", "-preset", "slow", "-crf", "19", "-pix_fmt", "yuv420p", "-movflags", "+faststart", outFile];
|
|
1141
|
+
const input = ["-f", "concat", "-safe", "0", "-i", path6.join(dir, "list.txt")];
|
|
1142
|
+
const scale = "scale=trunc(iw/2)*2:trunc(ih/2)*2";
|
|
1143
|
+
try {
|
|
1144
|
+
await run("ffmpeg", ["-y", ...input, "-vf", `${interp},${scale}`, "-r", String(outFps), ...common]);
|
|
1145
|
+
} catch (err) {
|
|
1146
|
+
console.warn(
|
|
1147
|
+
`[pr-preview] motion smoothing (${mode}) failed; encoding without interpolation \u2014 the clip will look choppier.
|
|
1148
|
+
${err.message?.split("\n")[0] ?? err}`
|
|
1149
|
+
);
|
|
1150
|
+
await run("ffmpeg", ["-y", ...input, "-vf", scale, "-r", String(outFps), ...common]);
|
|
1151
|
+
}
|
|
1152
|
+
} finally {
|
|
1153
|
+
await rm(dir, { recursive: true, force: true });
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
986
1156
|
function run(cmd, args) {
|
|
987
1157
|
return new Promise((resolve, reject) => {
|
|
988
1158
|
const child = spawn2(cmd, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
@@ -1207,7 +1377,7 @@ async function encodeGif(rawFrames, opts) {
|
|
|
1207
1377
|
opts.onProgress?.("Encoding GIF", ++done, frames.length);
|
|
1208
1378
|
}
|
|
1209
1379
|
gif.finish();
|
|
1210
|
-
await
|
|
1380
|
+
await mkdir3(path8.dirname(opts.outFile), { recursive: true });
|
|
1211
1381
|
await writeFile3(opts.outFile, gif.bytes());
|
|
1212
1382
|
return { path: opts.outFile, frameCount: frames.length };
|
|
1213
1383
|
}
|
|
@@ -1222,7 +1392,9 @@ async function encodeMedia(rawFrames, opts) {
|
|
|
1222
1392
|
const paths = [];
|
|
1223
1393
|
let frameCount = 0;
|
|
1224
1394
|
if (doMp4) {
|
|
1225
|
-
const
|
|
1395
|
+
const interpolate = opts.interpolate ?? "off";
|
|
1396
|
+
const smooth = interpolate !== "off";
|
|
1397
|
+
const frames = smooth ? rawFrames : resampleToFps(rawFrames, opts.fps);
|
|
1226
1398
|
frameCount = frames.length;
|
|
1227
1399
|
const meta = await sharp3(frames[0].data).metadata();
|
|
1228
1400
|
const crop = cssRectToFramePixels(
|
|
@@ -1248,7 +1420,16 @@ async function encodeMedia(rawFrames, opts) {
|
|
|
1248
1420
|
}
|
|
1249
1421
|
const file = `${opts.outBase}.mp4`;
|
|
1250
1422
|
opts.onProgress?.("Encoding MP4", frames.length, frames.length);
|
|
1251
|
-
|
|
1423
|
+
if (smooth) {
|
|
1424
|
+
const MAX_HOLD = 0.5;
|
|
1425
|
+
const durations = frames.map(
|
|
1426
|
+
(f, i) => i < frames.length - 1 ? Math.min(MAX_HOLD, Math.max(1e-3, (frames[i + 1].t - f.t) / 1e3)) : 1 / opts.fps
|
|
1427
|
+
);
|
|
1428
|
+
const outFps = Math.max(opts.smoothFps ?? 30, opts.fps);
|
|
1429
|
+
await encodeMp4Interpolated(pngs, durations, outFps, interpolate, file);
|
|
1430
|
+
} else {
|
|
1431
|
+
await encodeMp4(pngs, opts.fps, file);
|
|
1432
|
+
}
|
|
1252
1433
|
paths.push(file);
|
|
1253
1434
|
}
|
|
1254
1435
|
if (doGif) {
|
|
@@ -1513,8 +1694,12 @@ var Session = class {
|
|
|
1513
1694
|
}
|
|
1514
1695
|
const page = this.page;
|
|
1515
1696
|
if (!page) return;
|
|
1697
|
+
const { maxWidth, maxHeight } = await page.evaluate(() => ({
|
|
1698
|
+
maxWidth: Math.min(window.innerWidth, 1920),
|
|
1699
|
+
maxHeight: Math.min(window.innerHeight, 1200)
|
|
1700
|
+
})).catch(() => ({ maxWidth: 1920, maxHeight: 1200 }));
|
|
1516
1701
|
try {
|
|
1517
|
-
this.recordingFeed = await startScreencast(page, { quality: 80 });
|
|
1702
|
+
this.recordingFeed = await startScreencast(page, { quality: 80, maxWidth, maxHeight });
|
|
1518
1703
|
} catch {
|
|
1519
1704
|
this.recordingFeed = null;
|
|
1520
1705
|
}
|
|
@@ -1586,6 +1771,8 @@ var Session = class {
|
|
|
1586
1771
|
fps: this.config.gif.fps,
|
|
1587
1772
|
quality: this.config.gif.quality,
|
|
1588
1773
|
maxColors: this.config.gif.maxColors,
|
|
1774
|
+
interpolate: this.config.gif.interpolate,
|
|
1775
|
+
smoothFps: this.config.gif.smoothFps,
|
|
1589
1776
|
format: this.config.format,
|
|
1590
1777
|
outBase,
|
|
1591
1778
|
label: label ? { pass: this.singleClip ? "single" : which, ...label } : void 0,
|
|
@@ -1732,82 +1919,59 @@ async function bootstrap(repoRoot, config, mode, opts = {}) {
|
|
|
1732
1919
|
};
|
|
1733
1920
|
}
|
|
1734
1921
|
|
|
1735
|
-
// src/
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
timestamp: z2.number(),
|
|
1753
|
-
thumbnail: z2.string().optional()
|
|
1754
|
-
});
|
|
1755
|
-
var journeySchema = z2.object({
|
|
1756
|
-
version: z2.literal(1),
|
|
1757
|
-
createdAt: z2.string(),
|
|
1758
|
-
baseRef: z2.string().optional(),
|
|
1759
|
-
viewport: z2.object({
|
|
1760
|
-
width: z2.number(),
|
|
1761
|
-
height: z2.number(),
|
|
1762
|
-
deviceScaleFactor: z2.number()
|
|
1763
|
-
}),
|
|
1764
|
-
startUrl: z2.string(),
|
|
1765
|
-
steps: z2.array(stepSchema)
|
|
1766
|
-
});
|
|
1767
|
-
async function saveJourney(file, journey) {
|
|
1768
|
-
const clean = {
|
|
1769
|
-
...journey,
|
|
1770
|
-
steps: journey.steps.map((s) => {
|
|
1771
|
-
const { thumbnail: _thumbnail, ...rest } = s;
|
|
1772
|
-
return rest;
|
|
1773
|
-
})
|
|
1774
|
-
};
|
|
1775
|
-
await mkdir3(path10.dirname(file), { recursive: true });
|
|
1776
|
-
await writeFile4(file, JSON.stringify(clean, null, 2) + "\n");
|
|
1922
|
+
// src/mcp/motion.ts
|
|
1923
|
+
var MOTION = {
|
|
1924
|
+
glideMs: 750,
|
|
1925
|
+
// ceiling; the cursor scales the actual glide by distance
|
|
1926
|
+
scrollMs: 600,
|
|
1927
|
+
typeDelayMs: 35,
|
|
1928
|
+
settleMs: 140,
|
|
1929
|
+
holdMs: 80
|
|
1930
|
+
// brief pause after a glide lands, before the click, so the
|
|
1931
|
+
// arrival + click-pulse frames are captured
|
|
1932
|
+
};
|
|
1933
|
+
async function glideCursor(frame, x, y, ms = MOTION.glideMs) {
|
|
1934
|
+
await frame.evaluate(
|
|
1935
|
+
({ x: x2, y: y2, ms: ms2 }) => window.__prPreviewCursor?.moveTo(x2, y2, ms2) ?? Promise.resolve(),
|
|
1936
|
+
{ x, y, ms }
|
|
1937
|
+
).catch(() => {
|
|
1938
|
+
});
|
|
1777
1939
|
}
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1940
|
+
async function showCursor(frame, x, y) {
|
|
1941
|
+
await frame.evaluate(
|
|
1942
|
+
({ x: x2, y: y2 }) => {
|
|
1943
|
+
const c = window.__prPreviewCursor;
|
|
1944
|
+
if (!c) return;
|
|
1945
|
+
c.show(x2 ?? (window.innerWidth || 1280) / 2, y2 ?? (window.innerHeight || 800) / 2);
|
|
1946
|
+
},
|
|
1947
|
+
{ x: x ?? null, y: y ?? null }
|
|
1948
|
+
).catch(() => {
|
|
1949
|
+
});
|
|
1950
|
+
}
|
|
1951
|
+
async function pulseCursor(frame) {
|
|
1952
|
+
await frame.evaluate(
|
|
1953
|
+
() => window.__prPreviewCursor?.clickPulse() ?? Promise.resolve()
|
|
1954
|
+
).catch(() => {
|
|
1955
|
+
});
|
|
1956
|
+
}
|
|
1957
|
+
async function smoothScrollBy(frame, deltaY, ms = MOTION.scrollMs) {
|
|
1958
|
+
await frame.evaluate(
|
|
1959
|
+
({ deltaY: deltaY2, ms: ms2 }) => new Promise((resolve) => {
|
|
1960
|
+
const startY = window.scrollY;
|
|
1961
|
+
const t0 = performance.now();
|
|
1962
|
+
const ease = (t) => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
|
|
1963
|
+
const tick = (now) => {
|
|
1964
|
+
const t = Math.min(1, (now - t0) / ms2);
|
|
1965
|
+
window.scrollTo(0, startY + deltaY2 * ease(t));
|
|
1966
|
+
if (t < 1) requestAnimationFrame(tick);
|
|
1967
|
+
else resolve();
|
|
1968
|
+
};
|
|
1969
|
+
requestAnimationFrame(tick);
|
|
1970
|
+
}),
|
|
1971
|
+
{ deltaY, ms }
|
|
1972
|
+
).catch(() => {
|
|
1973
|
+
});
|
|
1805
1974
|
}
|
|
1806
|
-
|
|
1807
|
-
// src/cli/commands/run.ts
|
|
1808
|
-
import { existsSync as existsSync8 } from "fs";
|
|
1809
|
-
import path14 from "path";
|
|
1810
|
-
import pc3 from "picocolors";
|
|
1811
1975
|
|
|
1812
1976
|
// src/git/exec.ts
|
|
1813
1977
|
import { execFile as execFile2 } from "child_process";
|
|
@@ -1886,7 +2050,7 @@ async function detectBase(repoRoot, override) {
|
|
|
1886
2050
|
// src/git/worktree.ts
|
|
1887
2051
|
import { existsSync as existsSync6 } from "fs";
|
|
1888
2052
|
import { rm as rm2 } from "fs/promises";
|
|
1889
|
-
import
|
|
2053
|
+
import path10 from "path";
|
|
1890
2054
|
var WORKTREE_DIR = ".pr-preview/worktrees";
|
|
1891
2055
|
function explainGitCrash(err, repoRoot) {
|
|
1892
2056
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -1903,7 +2067,7 @@ Original error: ${msg}`
|
|
|
1903
2067
|
throw err;
|
|
1904
2068
|
}
|
|
1905
2069
|
async function createBaseWorktree(repoRoot, sha) {
|
|
1906
|
-
const dir =
|
|
2070
|
+
const dir = path10.join(repoRoot, WORKTREE_DIR, `base-${sha.slice(0, 12)}`);
|
|
1907
2071
|
if (existsSync6(dir) && await tryGit(dir, ["rev-parse", "HEAD"]) === sha) {
|
|
1908
2072
|
return { dir, remove: () => removeWorktree(repoRoot, dir) };
|
|
1909
2073
|
}
|
|
@@ -1929,12 +2093,12 @@ async function pruneStaleWorktrees(repoRoot) {
|
|
|
1929
2093
|
|
|
1930
2094
|
// src/server/pkgManager.ts
|
|
1931
2095
|
import { existsSync as existsSync7 } from "fs";
|
|
1932
|
-
import
|
|
2096
|
+
import path11 from "path";
|
|
1933
2097
|
import { spawn as spawn3 } from "child_process";
|
|
1934
2098
|
function detectPackageManager(dir) {
|
|
1935
|
-
if (existsSync7(
|
|
1936
|
-
if (existsSync7(
|
|
1937
|
-
if (existsSync7(
|
|
2099
|
+
if (existsSync7(path11.join(dir, "pnpm-lock.yaml"))) return "pnpm";
|
|
2100
|
+
if (existsSync7(path11.join(dir, "yarn.lock"))) return "yarn";
|
|
2101
|
+
if (existsSync7(path11.join(dir, "bun.lockb")) || existsSync7(path11.join(dir, "bun.lock"))) {
|
|
1938
2102
|
return "bun";
|
|
1939
2103
|
}
|
|
1940
2104
|
return "npm";
|
|
@@ -1961,6 +2125,737 @@ ${tail}`));
|
|
|
1961
2125
|
});
|
|
1962
2126
|
}
|
|
1963
2127
|
|
|
2128
|
+
// src/mcp/recorder.ts
|
|
2129
|
+
function stamp() {
|
|
2130
|
+
const d = /* @__PURE__ */ new Date();
|
|
2131
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
2132
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
2133
|
+
}
|
|
2134
|
+
function fileStamp() {
|
|
2135
|
+
const d = /* @__PURE__ */ new Date();
|
|
2136
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
2137
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`;
|
|
2138
|
+
}
|
|
2139
|
+
var shortRef = (ref) => ref.replace(/^origin\//, "");
|
|
2140
|
+
var slug = (s) => shortRef(s).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "clip";
|
|
2141
|
+
var PreviewRecorder = class {
|
|
2142
|
+
constructor(repoRoot) {
|
|
2143
|
+
this.repoRoot = repoRoot;
|
|
2144
|
+
}
|
|
2145
|
+
repoRoot;
|
|
2146
|
+
boot = null;
|
|
2147
|
+
page = null;
|
|
2148
|
+
config = null;
|
|
2149
|
+
outDir = ".pr-preview/output";
|
|
2150
|
+
mode = "single";
|
|
2151
|
+
pass = "before";
|
|
2152
|
+
started = false;
|
|
2153
|
+
// before/after bookkeeping
|
|
2154
|
+
beforeServer = null;
|
|
2155
|
+
worktree = null;
|
|
2156
|
+
baseLabel = "";
|
|
2157
|
+
headLabel = "";
|
|
2158
|
+
fstamp = "";
|
|
2159
|
+
timestamp = "";
|
|
2160
|
+
beforePaths = [];
|
|
2161
|
+
fellBackToGif = false;
|
|
2162
|
+
get isActive() {
|
|
2163
|
+
return this.started;
|
|
2164
|
+
}
|
|
2165
|
+
/** Launch the browser + app and begin recording. Returns the first snapshot. */
|
|
2166
|
+
async start(opts = {}) {
|
|
2167
|
+
if (this.started) throw new Error("A recording is already in progress \u2014 call finish_recording first.");
|
|
2168
|
+
this.mode = opts.mode ?? "single";
|
|
2169
|
+
if (this.mode === "before-after") return this.startBeforeAfter();
|
|
2170
|
+
return this.startSingle(opts.url);
|
|
2171
|
+
}
|
|
2172
|
+
async startSingle(url) {
|
|
2173
|
+
let config;
|
|
2174
|
+
try {
|
|
2175
|
+
config = (await loadConfig(this.repoRoot)).config;
|
|
2176
|
+
} catch (err) {
|
|
2177
|
+
if (url && err instanceof ConfigError) config = configSchema.parse({ devCommand: "external", url });
|
|
2178
|
+
else throw err;
|
|
2179
|
+
}
|
|
2180
|
+
this.config = config;
|
|
2181
|
+
this.outDir = path12.resolve(this.repoRoot, config.output);
|
|
2182
|
+
this.headLabel = await this.currentBranch();
|
|
2183
|
+
this.timestamp = stamp();
|
|
2184
|
+
this.fstamp = fileStamp();
|
|
2185
|
+
const boot = url ? await bootstrap(this.repoRoot, config, "run", { fixedUrl: url }) : await bootstrap(this.repoRoot, config, "run");
|
|
2186
|
+
this.boot = boot;
|
|
2187
|
+
boot.session.setPasses(1);
|
|
2188
|
+
if (!url) await boot.startApp("before", this.repoRoot);
|
|
2189
|
+
const browser = await boot.openBrowser("before");
|
|
2190
|
+
this.page = browser.page;
|
|
2191
|
+
await this.beginRecording();
|
|
2192
|
+
this.started = true;
|
|
2193
|
+
return { snapshot: await this.snapshot(), startUrl: boot.session.startUrl, mode: this.mode };
|
|
2194
|
+
}
|
|
2195
|
+
async startBeforeAfter() {
|
|
2196
|
+
const config = (await loadConfig(this.repoRoot)).config;
|
|
2197
|
+
this.config = config;
|
|
2198
|
+
this.outDir = path12.resolve(this.repoRoot, config.output);
|
|
2199
|
+
const currentBranch = await tryGit(this.repoRoot, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
2200
|
+
if (!currentBranch) {
|
|
2201
|
+
throw new GitError(
|
|
2202
|
+
"before/after recording needs you to be on a branch (detached HEAD).\nCheck out your PR branch first, or use single mode."
|
|
2203
|
+
);
|
|
2204
|
+
}
|
|
2205
|
+
const base = await detectBase(this.repoRoot, config.baseBranch);
|
|
2206
|
+
this.baseLabel = shortRef(base.ref);
|
|
2207
|
+
this.headLabel = shortRef(currentBranch);
|
|
2208
|
+
this.timestamp = stamp();
|
|
2209
|
+
this.fstamp = fileStamp();
|
|
2210
|
+
const worktree = await createBaseWorktree(this.repoRoot, base.sha);
|
|
2211
|
+
this.worktree = worktree;
|
|
2212
|
+
if (!config.keepWorktree) registerCleanup(() => worktree.remove());
|
|
2213
|
+
if (config.install !== "never") {
|
|
2214
|
+
const appDir = path12.join(worktree.dir, config.cwd);
|
|
2215
|
+
const hasModules = existsSync8(path12.join(appDir, "node_modules"));
|
|
2216
|
+
if (config.install === "always" || !hasModules) {
|
|
2217
|
+
await installDependencies(appDir, detectPackageManager(appDir));
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
const boot = await bootstrap(this.repoRoot, config, "run");
|
|
2221
|
+
this.boot = boot;
|
|
2222
|
+
boot.session.setPasses(2);
|
|
2223
|
+
boot.session.setBranches(base.ref, currentBranch);
|
|
2224
|
+
this.beforeServer = await boot.startApp("before", worktree.dir);
|
|
2225
|
+
const browser = await boot.openBrowser("before");
|
|
2226
|
+
this.page = browser.page;
|
|
2227
|
+
this.pass = "before";
|
|
2228
|
+
await this.beginRecording();
|
|
2229
|
+
this.started = true;
|
|
2230
|
+
return { snapshot: await this.snapshot(), startUrl: boot.session.startUrl, mode: this.mode };
|
|
2231
|
+
}
|
|
2232
|
+
/** before/after only: finish the BEFORE clip, switch to the branch app, start AFTER. */
|
|
2233
|
+
async nextPass() {
|
|
2234
|
+
if (!this.started) throw new Error("No active recording \u2014 call start_recording first.");
|
|
2235
|
+
if (this.mode !== "before-after") throw new Error("next_pass is only valid in before-after mode.");
|
|
2236
|
+
if (this.pass !== "before") throw new Error("Already recording the AFTER pass.");
|
|
2237
|
+
const boot = this.boot;
|
|
2238
|
+
boot.session.setPhase("idle");
|
|
2239
|
+
const before = await boot.session.encodeClip("before", this.outPath("before", this.baseLabel), {
|
|
2240
|
+
branch: this.baseLabel,
|
|
2241
|
+
baseBranch: this.baseLabel,
|
|
2242
|
+
timestamp: this.timestamp
|
|
2243
|
+
});
|
|
2244
|
+
this.beforePaths = before.paths;
|
|
2245
|
+
this.fellBackToGif ||= before.fellBackToGif;
|
|
2246
|
+
await this.beforeServer?.stop();
|
|
2247
|
+
await boot.startApp("after", this.repoRoot);
|
|
2248
|
+
boot.session.switchPass("after");
|
|
2249
|
+
await boot.switchApp("after");
|
|
2250
|
+
this.pass = "after";
|
|
2251
|
+
await this.beginRecording();
|
|
2252
|
+
return { snapshot: await this.snapshot() };
|
|
2253
|
+
}
|
|
2254
|
+
/** Accessibility tree of the app (with [ref=eN] handles) for Claude to target. */
|
|
2255
|
+
async snapshot() {
|
|
2256
|
+
const frame = await this.frame();
|
|
2257
|
+
await frame.waitForLoadState("domcontentloaded").catch(() => {
|
|
2258
|
+
});
|
|
2259
|
+
return frame.locator("body").ariaSnapshot({ mode: "ai" });
|
|
2260
|
+
}
|
|
2261
|
+
/** Perform one action in the app, then return the fresh snapshot. Motions are
|
|
2262
|
+
* animated in-page (synthetic-cursor glide, rAF scroll, per-character typing)
|
|
2263
|
+
* so the live screencast captures continuous video, not a jumpy slideshow. */
|
|
2264
|
+
async act(a) {
|
|
2265
|
+
if (!this.started) throw new Error("No active recording \u2014 call start_recording first.");
|
|
2266
|
+
const frame = await this.frame();
|
|
2267
|
+
switch (a.action) {
|
|
2268
|
+
case "click": {
|
|
2269
|
+
const loc = frame.locator(`aria-ref=${req(a.ref, "ref")}`);
|
|
2270
|
+
await this.glideToLocator(loc);
|
|
2271
|
+
await pulseCursor(frame);
|
|
2272
|
+
await loc.click({ timeout: 15e3 });
|
|
2273
|
+
break;
|
|
2274
|
+
}
|
|
2275
|
+
case "fill": {
|
|
2276
|
+
const loc = frame.locator(`aria-ref=${req(a.ref, "ref")}`);
|
|
2277
|
+
await this.glideToLocator(loc);
|
|
2278
|
+
await loc.click({ timeout: 15e3 });
|
|
2279
|
+
await loc.fill("", { timeout: 15e3 });
|
|
2280
|
+
await loc.pressSequentially(req(a.text, "text"), { delay: MOTION.typeDelayMs, timeout: 15e3 });
|
|
2281
|
+
break;
|
|
2282
|
+
}
|
|
2283
|
+
case "press": {
|
|
2284
|
+
const loc = frame.locator(`aria-ref=${req(a.ref, "ref")}`);
|
|
2285
|
+
const key = req(a.key, "key");
|
|
2286
|
+
await this.glideToLocator(loc);
|
|
2287
|
+
if (key === "Enter" || key === " " || key === "Spacebar") await pulseCursor(frame);
|
|
2288
|
+
await loc.press(key, { timeout: 15e3 });
|
|
2289
|
+
break;
|
|
2290
|
+
}
|
|
2291
|
+
case "hover": {
|
|
2292
|
+
const loc = frame.locator(`aria-ref=${req(a.ref, "ref")}`);
|
|
2293
|
+
await this.glideToLocator(loc);
|
|
2294
|
+
await loc.hover({ timeout: 15e3 });
|
|
2295
|
+
break;
|
|
2296
|
+
}
|
|
2297
|
+
case "scroll":
|
|
2298
|
+
if (a.ref) await this.smoothScrollToLocator(frame.locator(`aria-ref=${a.ref}`));
|
|
2299
|
+
else {
|
|
2300
|
+
const delta = await frame.evaluate(() => window.innerHeight * 0.8);
|
|
2301
|
+
await smoothScrollBy(frame, delta);
|
|
2302
|
+
}
|
|
2303
|
+
break;
|
|
2304
|
+
case "navigate": {
|
|
2305
|
+
const target = new URL(req(a.url, "url"), this.boot.session.targetOrigin + "/").toString();
|
|
2306
|
+
await frame.goto(target, { waitUntil: "domcontentloaded" });
|
|
2307
|
+
await showCursor(await this.frame());
|
|
2308
|
+
break;
|
|
2309
|
+
}
|
|
2310
|
+
case "wait":
|
|
2311
|
+
await this.page.waitForTimeout(Math.min(a.ms ?? 1e3, 15e3));
|
|
2312
|
+
break;
|
|
2313
|
+
default:
|
|
2314
|
+
throw new Error(`Unknown action: ${String(a.action)}`);
|
|
2315
|
+
}
|
|
2316
|
+
await this.page.waitForTimeout(MOTION.settleMs);
|
|
2317
|
+
return { snapshot: await this.snapshot() };
|
|
2318
|
+
}
|
|
2319
|
+
/** Bring a target on-screen, then glide the synthetic cursor to its centre.
|
|
2320
|
+
* Coordinates are frame-local (what the in-page cursor uses). */
|
|
2321
|
+
async glideToLocator(loc) {
|
|
2322
|
+
await loc.scrollIntoViewIfNeeded({ timeout: 15e3 }).catch(() => {
|
|
2323
|
+
});
|
|
2324
|
+
const c = await loc.evaluate((el) => {
|
|
2325
|
+
const r = el.getBoundingClientRect();
|
|
2326
|
+
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
|
2327
|
+
}).catch(() => null);
|
|
2328
|
+
if (c) {
|
|
2329
|
+
await glideCursor(await this.frame(), c.x, c.y);
|
|
2330
|
+
await this.page.waitForTimeout(MOTION.holdMs);
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
/** Smoothly scroll a target to the vertical centre of the viewport. The delta
|
|
2334
|
+
* is measured inside the frame (frame-local coords). */
|
|
2335
|
+
async smoothScrollToLocator(loc) {
|
|
2336
|
+
const delta = await loc.evaluate((el) => {
|
|
2337
|
+
const r = el.getBoundingClientRect();
|
|
2338
|
+
return r.top + r.height / 2 - window.innerHeight / 2;
|
|
2339
|
+
}).catch(() => null);
|
|
2340
|
+
if (delta === null) {
|
|
2341
|
+
await loc.scrollIntoViewIfNeeded({ timeout: 15e3 }).catch(() => {
|
|
2342
|
+
});
|
|
2343
|
+
return;
|
|
2344
|
+
}
|
|
2345
|
+
if (Math.abs(delta) < 8) return;
|
|
2346
|
+
await smoothScrollBy(await this.frame(), delta);
|
|
2347
|
+
}
|
|
2348
|
+
/** Stop recording, encode the final clip(s), and return the output file paths. */
|
|
2349
|
+
async finish(opts = {}) {
|
|
2350
|
+
if (!this.started) throw new Error("No active recording \u2014 call start_recording first.");
|
|
2351
|
+
if (this.mode === "before-after" && this.pass !== "after") {
|
|
2352
|
+
throw new Error("Record the AFTER pass first: call next_pass, redo the journey, then finish_recording.");
|
|
2353
|
+
}
|
|
2354
|
+
const boot = this.boot;
|
|
2355
|
+
boot.session.setPhase("idle");
|
|
2356
|
+
let files;
|
|
2357
|
+
if (this.mode === "before-after") {
|
|
2358
|
+
const after = await boot.session.encodeClip("after", this.outPath("after", this.headLabel), {
|
|
2359
|
+
branch: this.headLabel,
|
|
2360
|
+
baseBranch: this.baseLabel,
|
|
2361
|
+
timestamp: this.timestamp
|
|
2362
|
+
});
|
|
2363
|
+
this.fellBackToGif ||= after.fellBackToGif;
|
|
2364
|
+
files = [...this.beforePaths, ...after.paths];
|
|
2365
|
+
} else {
|
|
2366
|
+
const clip = await boot.session.encodeClip("before", this.outPath("single", opts.name ?? this.headLabel), {
|
|
2367
|
+
branch: this.headLabel,
|
|
2368
|
+
baseBranch: this.headLabel,
|
|
2369
|
+
timestamp: this.timestamp
|
|
2370
|
+
});
|
|
2371
|
+
this.fellBackToGif ||= clip.fellBackToGif;
|
|
2372
|
+
files = clip.paths;
|
|
2373
|
+
}
|
|
2374
|
+
const fellBack = this.fellBackToGif;
|
|
2375
|
+
await this.dispose();
|
|
2376
|
+
return { files, fellBackToGif: fellBack };
|
|
2377
|
+
}
|
|
2378
|
+
/** Tear everything down (browser, harness, dev servers, worktree) without encoding. */
|
|
2379
|
+
async dispose() {
|
|
2380
|
+
this.started = false;
|
|
2381
|
+
this.boot = null;
|
|
2382
|
+
this.page = null;
|
|
2383
|
+
this.beforeServer = null;
|
|
2384
|
+
this.worktree = null;
|
|
2385
|
+
await runCleanups();
|
|
2386
|
+
}
|
|
2387
|
+
// ── helpers ────────────────────────────────────────────────────────────────
|
|
2388
|
+
outPath(which, label) {
|
|
2389
|
+
return path12.join(this.outDir, `${which === "single" ? slug(label) : `${which}-${slug(label)}`}-${this.fstamp}`);
|
|
2390
|
+
}
|
|
2391
|
+
/** Capture the start path, then begin the live screencast recording. */
|
|
2392
|
+
async beginRecording() {
|
|
2393
|
+
const boot = this.boot;
|
|
2394
|
+
const frame = await getAppFrame(this.page, boot.session.targetOrigin);
|
|
2395
|
+
await frame.waitForLoadState("domcontentloaded").catch(() => {
|
|
2396
|
+
});
|
|
2397
|
+
boot.session.startUrl = await frame.evaluate(() => location.pathname + location.search).catch(() => "/");
|
|
2398
|
+
boot.session.setPhase("recording");
|
|
2399
|
+
await showCursor(frame);
|
|
2400
|
+
}
|
|
2401
|
+
async frame() {
|
|
2402
|
+
if (!this.page || !this.boot) throw new Error("No active recording.");
|
|
2403
|
+
return getAppFrame(this.page, this.boot.session.targetOrigin);
|
|
2404
|
+
}
|
|
2405
|
+
async currentBranch() {
|
|
2406
|
+
const ref = await tryGit(this.repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
2407
|
+
return ref ? shortRef(ref) : "app";
|
|
2408
|
+
}
|
|
2409
|
+
};
|
|
2410
|
+
function req(v, name) {
|
|
2411
|
+
if (v === void 0 || v === null || v === "") throw new Error(`Missing required "${name}" for this action.`);
|
|
2412
|
+
return v;
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
// src/mcp/publish.ts
|
|
2416
|
+
import { execFile as execFile3 } from "child_process";
|
|
2417
|
+
import { promisify } from "util";
|
|
2418
|
+
import { copyFile, mkdir as mkdir4 } from "fs/promises";
|
|
2419
|
+
import { existsSync as existsSync9 } from "fs";
|
|
2420
|
+
import path13 from "path";
|
|
2421
|
+
var pexec = promisify(execFile3);
|
|
2422
|
+
async function run2(cmd, args, cwd) {
|
|
2423
|
+
try {
|
|
2424
|
+
const { stdout } = await pexec(cmd, args, { cwd, maxBuffer: 16 * 1024 * 1024 });
|
|
2425
|
+
return stdout.trim();
|
|
2426
|
+
} catch (e) {
|
|
2427
|
+
const err = e;
|
|
2428
|
+
throw new Error(`\`${cmd} ${args.slice(0, 2).join(" ")}\` failed: ${(err.stderr || err.message || String(e)).trim()}`);
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
async function openPr(repoRoot, files, opts = {}) {
|
|
2432
|
+
if (files.length === 0) {
|
|
2433
|
+
throw new Error("No clip files provided \u2014 run finish_recording first and pass its output paths.");
|
|
2434
|
+
}
|
|
2435
|
+
try {
|
|
2436
|
+
await run2("gh", ["--version"], repoRoot);
|
|
2437
|
+
} catch {
|
|
2438
|
+
throw new Error("GitHub CLI (gh) not found. Install it from https://cli.github.com, then `gh auth login`.");
|
|
2439
|
+
}
|
|
2440
|
+
try {
|
|
2441
|
+
await run2("gh", ["auth", "status"], repoRoot);
|
|
2442
|
+
} catch {
|
|
2443
|
+
throw new Error("GitHub CLI isn't authenticated. Run `gh auth login`, then try again.");
|
|
2444
|
+
}
|
|
2445
|
+
const branch = await tryGit(repoRoot, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
2446
|
+
if (!branch) throw new Error("You're on a detached HEAD. Check out your PR branch first.");
|
|
2447
|
+
if (!await tryGit(repoRoot, ["remote", "get-url", "origin"])) {
|
|
2448
|
+
throw new Error("No `origin` remote found. Add one (and push the branch), then retry.");
|
|
2449
|
+
}
|
|
2450
|
+
const slug3 = await repoSlug(repoRoot);
|
|
2451
|
+
const abs = files.map((f) => path13.isAbsolute(f) ? f : path13.resolve(repoRoot, f));
|
|
2452
|
+
for (const f of abs) if (!existsSync9(f)) throw new Error(`Clip file not found: ${f}`);
|
|
2453
|
+
const destDir = path13.join(repoRoot, "pr-preview");
|
|
2454
|
+
await mkdir4(destDir, { recursive: true });
|
|
2455
|
+
const committed = [];
|
|
2456
|
+
let embedGifRel = null;
|
|
2457
|
+
const mp4Rel = [];
|
|
2458
|
+
for (const f of abs) {
|
|
2459
|
+
const base = path13.basename(f);
|
|
2460
|
+
await copyFile(f, path13.join(destDir, base));
|
|
2461
|
+
committed.push(`pr-preview/${base}`);
|
|
2462
|
+
if (base.endsWith(".gif") && !embedGifRel) embedGifRel = `pr-preview/${base}`;
|
|
2463
|
+
if (base.endsWith(".mp4")) mp4Rel.push(`pr-preview/${base}`);
|
|
2464
|
+
}
|
|
2465
|
+
if (!embedGifRel && mp4Rel.length && await ffmpegAvailable()) {
|
|
2466
|
+
const src = path13.join(repoRoot, mp4Rel[0]);
|
|
2467
|
+
const gifName = path13.basename(mp4Rel[0]).replace(/\.mp4$/, ".gif");
|
|
2468
|
+
await run2(
|
|
2469
|
+
"ffmpeg",
|
|
2470
|
+
["-y", "-i", src, "-vf", "fps=15,scale=900:-1:flags=lanczos,split[a][b];[a]palettegen[p];[b][p]paletteuse", path13.join(destDir, gifName)],
|
|
2471
|
+
repoRoot
|
|
2472
|
+
);
|
|
2473
|
+
committed.push(`pr-preview/${gifName}`);
|
|
2474
|
+
embedGifRel = `pr-preview/${gifName}`;
|
|
2475
|
+
}
|
|
2476
|
+
await git(repoRoot, ["add", "--", ...committed]);
|
|
2477
|
+
try {
|
|
2478
|
+
await git(repoRoot, ["commit", "-m", "Add PR preview clip"]);
|
|
2479
|
+
} catch (e) {
|
|
2480
|
+
if (!/nothing to commit/i.test(String(e))) throw e;
|
|
2481
|
+
}
|
|
2482
|
+
await run2("git", ["push", "-u", "origin", branch], repoRoot);
|
|
2483
|
+
const rawBase = `https://github.com/${slug3}/raw/${branch}`;
|
|
2484
|
+
const body = buildBody(embedGifRel, mp4Rel, rawBase);
|
|
2485
|
+
const args = ["pr", "create", "--title", opts.title ?? `Preview: ${branch}`, "--body", body, "--head", branch];
|
|
2486
|
+
if (opts.base) args.push("--base", opts.base);
|
|
2487
|
+
const prUrl = await run2("gh", args, repoRoot);
|
|
2488
|
+
return { prUrl, committed, embedded: embedGifRel ? "gif" : "mp4-link" };
|
|
2489
|
+
}
|
|
2490
|
+
function buildBody(gifRel, mp4Rel, rawBase) {
|
|
2491
|
+
const lines = ["## PR Preview", ""];
|
|
2492
|
+
if (gifRel) lines.push(``, "");
|
|
2493
|
+
if (mp4Rel.length) {
|
|
2494
|
+
lines.push("Full-quality MP4:");
|
|
2495
|
+
for (const m of mp4Rel) lines.push(`- [${path13.basename(m)}](${rawBase}/${m})`);
|
|
2496
|
+
lines.push("");
|
|
2497
|
+
}
|
|
2498
|
+
lines.push("<sub>Recorded with [PR Preview](https://pr-preview.com) via Claude Code.</sub>");
|
|
2499
|
+
return lines.join("\n");
|
|
2500
|
+
}
|
|
2501
|
+
async function repoSlug(repoRoot) {
|
|
2502
|
+
try {
|
|
2503
|
+
return await run2("gh", ["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"], repoRoot);
|
|
2504
|
+
} catch {
|
|
2505
|
+
}
|
|
2506
|
+
const url = await tryGit(repoRoot, ["remote", "get-url", "origin"]) ?? "";
|
|
2507
|
+
const m = url.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
|
|
2508
|
+
if (!m) throw new Error("Couldn't determine the GitHub owner/repo from the origin remote.");
|
|
2509
|
+
return m[1];
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
// src/mcp/detect.ts
|
|
2513
|
+
var CANDIDATE_PORTS = [
|
|
2514
|
+
3e3,
|
|
2515
|
+
3001,
|
|
2516
|
+
3002,
|
|
2517
|
+
3003,
|
|
2518
|
+
// Next.js / CRA / Node
|
|
2519
|
+
5173,
|
|
2520
|
+
5174,
|
|
2521
|
+
4321,
|
|
2522
|
+
// Vite / Astro
|
|
2523
|
+
4200,
|
|
2524
|
+
// Angular
|
|
2525
|
+
8080,
|
|
2526
|
+
8e3,
|
|
2527
|
+
5e3,
|
|
2528
|
+
4e3,
|
|
2529
|
+
3333,
|
|
2530
|
+
// misc dev servers
|
|
2531
|
+
1420,
|
|
2532
|
+
// Tauri
|
|
2533
|
+
6006
|
|
2534
|
+
// Storybook
|
|
2535
|
+
];
|
|
2536
|
+
async function probe(url, timeoutMs = 1200) {
|
|
2537
|
+
const controller = new AbortController();
|
|
2538
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
2539
|
+
try {
|
|
2540
|
+
const res = await fetch(url, { signal: controller.signal, redirect: "manual" });
|
|
2541
|
+
if (res.status >= 500) return null;
|
|
2542
|
+
let title;
|
|
2543
|
+
try {
|
|
2544
|
+
const html = await res.text();
|
|
2545
|
+
title = html.match(/<title[^>]*>([^<]*)<\/title>/i)?.[1]?.trim() || void 0;
|
|
2546
|
+
} catch {
|
|
2547
|
+
}
|
|
2548
|
+
return { url, title };
|
|
2549
|
+
} catch {
|
|
2550
|
+
return null;
|
|
2551
|
+
} finally {
|
|
2552
|
+
clearTimeout(timer);
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
async function detectLocalApps(preferred) {
|
|
2556
|
+
const urls = /* @__PURE__ */ new Set();
|
|
2557
|
+
if (preferred && /^https?:\/\//.test(preferred)) urls.add(preferred);
|
|
2558
|
+
for (const port of CANDIDATE_PORTS) urls.add(`http://localhost:${port}`);
|
|
2559
|
+
const results = await Promise.all([...urls].map((u) => probe(u)));
|
|
2560
|
+
return results.filter((r) => r !== null);
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
// src/mcp/server.ts
|
|
2564
|
+
function urlAsk(found, hasConfig) {
|
|
2565
|
+
const lines = ["No URL was specified \u2014 I won't guess which app to record."];
|
|
2566
|
+
if (found.length) {
|
|
2567
|
+
lines.push("", "Apps currently running on localhost:");
|
|
2568
|
+
for (const f of found) lines.push(`- ${f.url}${f.title ? ` \u2014 ${f.title}` : ""}`);
|
|
2569
|
+
lines.push("", "Ask the user which of these to record (confirm the exact URL), then call start_recording again with that `url`.");
|
|
2570
|
+
} else {
|
|
2571
|
+
lines.push(
|
|
2572
|
+
"",
|
|
2573
|
+
"Nothing is responding on common localhost ports \u2014 the project may not be running locally.",
|
|
2574
|
+
"Ask the user for the URL to record: their local dev server URL, or a staging / production URL. Then call start_recording with that `url`."
|
|
2575
|
+
);
|
|
2576
|
+
}
|
|
2577
|
+
if (hasConfig) {
|
|
2578
|
+
lines.push(
|
|
2579
|
+
"",
|
|
2580
|
+
"Alternatively, if the user wants PR Preview to start the project's own dev server (from pr-preview.config.js), call start_recording with `useDevServer: true`."
|
|
2581
|
+
);
|
|
2582
|
+
}
|
|
2583
|
+
return lines.join("\n");
|
|
2584
|
+
}
|
|
2585
|
+
async function startMcpServer(repoRoot) {
|
|
2586
|
+
const recorder = new PreviewRecorder(repoRoot);
|
|
2587
|
+
const server = new McpServer({
|
|
2588
|
+
name: "pr-preview",
|
|
2589
|
+
version: "0.1.0"
|
|
2590
|
+
});
|
|
2591
|
+
const text = (s) => ({ content: [{ type: "text", text: s }] });
|
|
2592
|
+
const fail = (e) => ({
|
|
2593
|
+
content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }],
|
|
2594
|
+
isError: true
|
|
2595
|
+
});
|
|
2596
|
+
const SNAPSHOT_HINT = "\n\nEach line is an element with a [ref=eN] handle. To act on one, call `act` with that ref. Always take a fresh snapshot after any action that changes the page (refs are only valid for the most recent snapshot).";
|
|
2597
|
+
server.tool(
|
|
2598
|
+
"detect_localhost",
|
|
2599
|
+
"Probe common localhost ports and list the apps currently running. Use this when the user hasn't given a URL, to offer them the running app(s) to record.",
|
|
2600
|
+
{},
|
|
2601
|
+
async () => {
|
|
2602
|
+
try {
|
|
2603
|
+
const found = await detectLocalApps();
|
|
2604
|
+
if (!found.length) {
|
|
2605
|
+
return text(
|
|
2606
|
+
"No app is responding on common localhost ports. Ask the user for a URL to record \u2014 their local dev server, or a staging / production URL."
|
|
2607
|
+
);
|
|
2608
|
+
}
|
|
2609
|
+
return text(
|
|
2610
|
+
"Apps running on localhost:\n" + found.map((f) => `- ${f.url}${f.title ? ` \u2014 ${f.title}` : ""}`).join("\n") + "\n\nAsk the user which one to record, then call start_recording with that url."
|
|
2611
|
+
);
|
|
2612
|
+
} catch (e) {
|
|
2613
|
+
return fail(e);
|
|
2614
|
+
}
|
|
2615
|
+
}
|
|
2616
|
+
);
|
|
2617
|
+
server.tool(
|
|
2618
|
+
"start_recording",
|
|
2619
|
+
"Open the user's app in the PR Preview harness \u2014 a real, VISIBLE Chrome window where the app runs inside the harness iframe and the sidebar records each step \u2014 and begin recording. Returns the app's accessibility snapshot so you can pick the first action.\n\nURL handling \u2014 do NOT guess: if the user gave a URL, pass it as `url`. If they did NOT specify one, call this with no `url`; it detects apps on localhost and returns them so you can ASK the user which URL to record. If nothing is running locally, ask the user for a staging or production URL. Only pass `useDevServer: true` when the user explicitly wants PR Preview to start the project's own dev server (from pr-preview.config.js).\n\nmode 'single' (default) records one clip. mode 'before-after' records the SAME journey twice \u2014 first on the PR's base branch, then on your branch \u2014 for a true before/after (uses a managed dev server and requires being on a git branch; `url` is ignored). In before-after mode: drive the journey with `act`, call `next_pass`, redo the SAME journey, then finish.",
|
|
2620
|
+
{
|
|
2621
|
+
url: z2.string().optional().describe("URL of an already-running app to record (ask the user if unsure)"),
|
|
2622
|
+
mode: z2.enum(["single", "before-after"]).optional(),
|
|
2623
|
+
useDevServer: z2.boolean().optional().describe("Start the project's own dev server from pr-preview.config.js instead of using a URL")
|
|
2624
|
+
},
|
|
2625
|
+
async ({ url, mode, useDevServer }) => {
|
|
2626
|
+
try {
|
|
2627
|
+
const m = mode ?? "single";
|
|
2628
|
+
if (m === "single" && !url && !useDevServer) {
|
|
2629
|
+
const found = await detectLocalApps();
|
|
2630
|
+
let hasConfig = false;
|
|
2631
|
+
try {
|
|
2632
|
+
await loadConfig(repoRoot);
|
|
2633
|
+
hasConfig = true;
|
|
2634
|
+
} catch {
|
|
2635
|
+
}
|
|
2636
|
+
return text(urlAsk(found, hasConfig));
|
|
2637
|
+
}
|
|
2638
|
+
const r = await recorder.start({ url, mode });
|
|
2639
|
+
const win = "A Chrome window has opened with the PR Preview harness \u2014 the app is running in the iframe and the sidebar records each step.";
|
|
2640
|
+
const hint = r.mode === "before-after" ? "\n\nThis is the BEFORE pass (base branch). Perform the journey, then call `next_pass`." : "";
|
|
2641
|
+
return text(`${win}
|
|
2642
|
+
Recording started at ${r.startUrl}. Current page:
|
|
2643
|
+
|
|
2644
|
+
${r.snapshot}${SNAPSHOT_HINT}${hint}`);
|
|
2645
|
+
} catch (e) {
|
|
2646
|
+
return fail(e);
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
);
|
|
2650
|
+
server.tool(
|
|
2651
|
+
"next_pass",
|
|
2652
|
+
"before-after mode only: finish the BEFORE clip, switch the app to your branch, and begin the AFTER recording. After calling this, redo the SAME journey with `act`, then call `finish_recording`.",
|
|
2653
|
+
{},
|
|
2654
|
+
async () => {
|
|
2655
|
+
try {
|
|
2656
|
+
const { snapshot } = await recorder.nextPass();
|
|
2657
|
+
return text(
|
|
2658
|
+
`BEFORE clip captured. Now recording the AFTER pass (your branch) \u2014 redo the same journey.
|
|
2659
|
+
|
|
2660
|
+
${snapshot}${SNAPSHOT_HINT}`
|
|
2661
|
+
);
|
|
2662
|
+
} catch (e) {
|
|
2663
|
+
return fail(e);
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
);
|
|
2667
|
+
server.tool(
|
|
2668
|
+
"snapshot",
|
|
2669
|
+
"Return the current accessibility snapshot of the app (elements with [ref=eN] handles). Use this to see the page before choosing the next action.",
|
|
2670
|
+
{},
|
|
2671
|
+
async () => {
|
|
2672
|
+
try {
|
|
2673
|
+
return text(await recorder.snapshot() + SNAPSHOT_HINT);
|
|
2674
|
+
} catch (e) {
|
|
2675
|
+
return fail(e);
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
);
|
|
2679
|
+
server.tool(
|
|
2680
|
+
"act",
|
|
2681
|
+
"Perform one action in the app, then return the fresh snapshot. Actions: `click` (ref), `fill` (ref + text), `press` (ref + key, e.g. Enter), `hover` (ref), `navigate` (url or path), `scroll` (optional ref to scroll into view), `wait` (ms). The action is recorded into the live clip.",
|
|
2682
|
+
{
|
|
2683
|
+
action: z2.enum(["click", "fill", "press", "hover", "navigate", "scroll", "wait"]),
|
|
2684
|
+
ref: z2.string().optional().describe("Element ref from the latest snapshot, e.g. e14"),
|
|
2685
|
+
text: z2.string().optional().describe("Text to type (for fill)"),
|
|
2686
|
+
key: z2.string().optional().describe("Key to press, e.g. Enter (for press)"),
|
|
2687
|
+
url: z2.string().optional().describe("URL or path (for navigate)"),
|
|
2688
|
+
ms: z2.number().optional().describe("Milliseconds to pause (for wait)")
|
|
2689
|
+
},
|
|
2690
|
+
async (args) => {
|
|
2691
|
+
try {
|
|
2692
|
+
const { snapshot } = await recorder.act(args);
|
|
2693
|
+
return text(`Done: ${args.action}. Current page:
|
|
2694
|
+
|
|
2695
|
+
${snapshot}${SNAPSHOT_HINT}`);
|
|
2696
|
+
} catch (e) {
|
|
2697
|
+
return fail(e);
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
);
|
|
2701
|
+
server.tool(
|
|
2702
|
+
"finish_recording",
|
|
2703
|
+
"Stop recording, encode the clip, and return the output file path(s). Call this when the journey is complete. `name` optionally sets the output filename.",
|
|
2704
|
+
{ name: z2.string().optional() },
|
|
2705
|
+
async ({ name }) => {
|
|
2706
|
+
try {
|
|
2707
|
+
const { files, fellBackToGif } = await recorder.finish({ name });
|
|
2708
|
+
const rel = files.map((f) => path14.relative(repoRoot, f));
|
|
2709
|
+
const note = fellBackToGif ? "\n(ffmpeg was not found, so a GIF was produced instead of MP4 \u2014 `brew install ffmpeg` for MP4.)" : "";
|
|
2710
|
+
return text(
|
|
2711
|
+
`Recording complete. Output:
|
|
2712
|
+
${rel.map((r) => ` - ${r}`).join("\n")}${note}
|
|
2713
|
+
|
|
2714
|
+
Drag the file into your PR description to embed it, or commit it and open the PR.`
|
|
2715
|
+
);
|
|
2716
|
+
} catch (e) {
|
|
2717
|
+
return fail(e);
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
);
|
|
2721
|
+
server.tool(
|
|
2722
|
+
"open_pr",
|
|
2723
|
+
"Commit the recorded clip into the repo, push the current branch, and open a pull request with the preview embedded in the body. Pass the file path(s) returned by finish_recording. Requires the GitHub CLI (gh) authenticated and an `origin` remote. Note: GitHub only plays inline video from its own CDN, so this embeds an animated GIF (rendered inline) and links the full MP4. Side effects: it creates a commit on your branch and pushes it.",
|
|
2724
|
+
{
|
|
2725
|
+
files: z2.array(z2.string()).describe("Clip file path(s) from finish_recording"),
|
|
2726
|
+
title: z2.string().optional().describe("PR title (defaults to 'Preview: <branch>')"),
|
|
2727
|
+
base: z2.string().optional().describe("Base branch for the PR (e.g. main)")
|
|
2728
|
+
},
|
|
2729
|
+
async ({ files, title, base }) => {
|
|
2730
|
+
try {
|
|
2731
|
+
const { prUrl, committed, embedded } = await openPr(repoRoot, files, { title, base });
|
|
2732
|
+
const how = embedded === "gif" ? "an inline GIF preview" : "a link to the MP4";
|
|
2733
|
+
return text(
|
|
2734
|
+
`Pull request opened: ${prUrl}
|
|
2735
|
+
Committed ${committed.length} file(s) under pr-preview/ and embedded ${how}.`
|
|
2736
|
+
);
|
|
2737
|
+
} catch (e) {
|
|
2738
|
+
return fail(e);
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
);
|
|
2742
|
+
server.tool(
|
|
2743
|
+
"cancel_recording",
|
|
2744
|
+
"Abort the current recording and close the browser without producing a clip.",
|
|
2745
|
+
{},
|
|
2746
|
+
async () => {
|
|
2747
|
+
try {
|
|
2748
|
+
await recorder.dispose();
|
|
2749
|
+
return text("Recording cancelled \u2014 browser closed, no clip produced.");
|
|
2750
|
+
} catch (e) {
|
|
2751
|
+
return fail(e);
|
|
2752
|
+
}
|
|
2753
|
+
}
|
|
2754
|
+
);
|
|
2755
|
+
const transport = new StdioServerTransport();
|
|
2756
|
+
await server.connect(transport);
|
|
2757
|
+
await new Promise((resolve) => {
|
|
2758
|
+
transport.onclose = () => {
|
|
2759
|
+
void recorder.dispose().finally(resolve);
|
|
2760
|
+
};
|
|
2761
|
+
});
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
// src/cli/commands/mcp.ts
|
|
2765
|
+
async function mcpCommand(repoRoot) {
|
|
2766
|
+
const toStderr = (...args) => {
|
|
2767
|
+
process.stderr.write(
|
|
2768
|
+
args.map((a) => typeof a === "string" ? a : String(a)).join(" ") + "\n"
|
|
2769
|
+
);
|
|
2770
|
+
};
|
|
2771
|
+
console.log = toStderr;
|
|
2772
|
+
console.info = toStderr;
|
|
2773
|
+
console.warn = toStderr;
|
|
2774
|
+
console.debug = toStderr;
|
|
2775
|
+
await startMcpServer(repoRoot);
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
// src/cli/commands/record.ts
|
|
2779
|
+
import path16 from "path";
|
|
2780
|
+
import pc2 from "picocolors";
|
|
2781
|
+
|
|
2782
|
+
// src/recorder/journey.ts
|
|
2783
|
+
import { readFile as readFile5, writeFile as writeFile4, mkdir as mkdir5 } from "fs/promises";
|
|
2784
|
+
import path15 from "path";
|
|
2785
|
+
import { z as z3 } from "zod";
|
|
2786
|
+
var stepSchema = z3.object({
|
|
2787
|
+
id: z3.string(),
|
|
2788
|
+
type: z3.enum(["click", "fill", "select", "press", "scroll", "navigate", "wait"]),
|
|
2789
|
+
selectors: z3.array(z3.string()).optional(),
|
|
2790
|
+
coordinates: z3.object({ xNorm: z3.number(), yNorm: z3.number() }).optional(),
|
|
2791
|
+
frameUrl: z3.string().optional(),
|
|
2792
|
+
value: z3.string().optional(),
|
|
2793
|
+
key: z3.string().optional(),
|
|
2794
|
+
scrollTarget: z3.string().optional(),
|
|
2795
|
+
scroll: z3.object({ xNorm: z3.number(), yNorm: z3.number() }).optional(),
|
|
2796
|
+
url: z3.string().optional(),
|
|
2797
|
+
causesNavigation: z3.boolean().optional(),
|
|
2798
|
+
label: z3.string().optional(),
|
|
2799
|
+
timestamp: z3.number(),
|
|
2800
|
+
thumbnail: z3.string().optional()
|
|
2801
|
+
});
|
|
2802
|
+
var journeySchema = z3.object({
|
|
2803
|
+
version: z3.literal(1),
|
|
2804
|
+
createdAt: z3.string(),
|
|
2805
|
+
baseRef: z3.string().optional(),
|
|
2806
|
+
viewport: z3.object({
|
|
2807
|
+
width: z3.number(),
|
|
2808
|
+
height: z3.number(),
|
|
2809
|
+
deviceScaleFactor: z3.number()
|
|
2810
|
+
}),
|
|
2811
|
+
startUrl: z3.string(),
|
|
2812
|
+
steps: z3.array(stepSchema)
|
|
2813
|
+
});
|
|
2814
|
+
async function saveJourney(file, journey) {
|
|
2815
|
+
const clean = {
|
|
2816
|
+
...journey,
|
|
2817
|
+
steps: journey.steps.map((s) => {
|
|
2818
|
+
const { thumbnail: _thumbnail, ...rest } = s;
|
|
2819
|
+
return rest;
|
|
2820
|
+
})
|
|
2821
|
+
};
|
|
2822
|
+
await mkdir5(path15.dirname(file), { recursive: true });
|
|
2823
|
+
await writeFile4(file, JSON.stringify(clean, null, 2) + "\n");
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
// src/cli/commands/record.ts
|
|
2827
|
+
async function recordCommand(repoRoot, opts) {
|
|
2828
|
+
const { config } = await loadConfig(repoRoot);
|
|
2829
|
+
const journeyFile = path16.resolve(repoRoot, opts.out ?? ".pr-preview/journey.json");
|
|
2830
|
+
try {
|
|
2831
|
+
const boot = await bootstrap(repoRoot, config, "record");
|
|
2832
|
+
await boot.startApp("after", repoRoot);
|
|
2833
|
+
await boot.openBrowser("after");
|
|
2834
|
+
log.info(`Harness open at ${pc2.bold(boot.harness.url)}`);
|
|
2835
|
+
log.step("Click Record in the sidebar, perform the journey, then Confirm.");
|
|
2836
|
+
const steps = await boot.session.recordUntilConfirmed();
|
|
2837
|
+
if (steps.length === 0) {
|
|
2838
|
+
log.warn("No steps recorded \u2014 nothing saved.");
|
|
2839
|
+
return;
|
|
2840
|
+
}
|
|
2841
|
+
await saveJourney(journeyFile, {
|
|
2842
|
+
version: 1,
|
|
2843
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2844
|
+
viewport: { ...config.viewport, deviceScaleFactor: 2 },
|
|
2845
|
+
startUrl: boot.session.startUrl,
|
|
2846
|
+
steps
|
|
2847
|
+
});
|
|
2848
|
+
log.success(`Saved ${steps.length} steps to ${path16.relative(repoRoot, journeyFile)}`);
|
|
2849
|
+
} finally {
|
|
2850
|
+
await runCleanups();
|
|
2851
|
+
}
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2854
|
+
// src/cli/commands/run.ts
|
|
2855
|
+
import { existsSync as existsSync10 } from "fs";
|
|
2856
|
+
import path17 from "path";
|
|
2857
|
+
import pc3 from "picocolors";
|
|
2858
|
+
|
|
1964
2859
|
// src/cli/util/openPath.ts
|
|
1965
2860
|
import { spawn as spawn4 } from "child_process";
|
|
1966
2861
|
function openPath(target) {
|
|
@@ -1992,25 +2887,25 @@ function revealFiles(files, dir) {
|
|
|
1992
2887
|
function captureClip(session, which, outBase, label) {
|
|
1993
2888
|
return session.encodeClip(which, outBase, label);
|
|
1994
2889
|
}
|
|
1995
|
-
function
|
|
2890
|
+
function stamp2() {
|
|
1996
2891
|
const d = /* @__PURE__ */ new Date();
|
|
1997
2892
|
const p = (n) => String(n).padStart(2, "0");
|
|
1998
2893
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
1999
2894
|
}
|
|
2000
|
-
function
|
|
2895
|
+
function shortRef2(ref) {
|
|
2001
2896
|
return ref.replace(/^origin\//, "");
|
|
2002
2897
|
}
|
|
2003
|
-
function
|
|
2004
|
-
return
|
|
2898
|
+
function slug2(ref) {
|
|
2899
|
+
return shortRef2(ref).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
2005
2900
|
}
|
|
2006
|
-
function
|
|
2901
|
+
function fileStamp2() {
|
|
2007
2902
|
const d = /* @__PURE__ */ new Date();
|
|
2008
2903
|
const p = (n) => String(n).padStart(2, "0");
|
|
2009
2904
|
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`;
|
|
2010
2905
|
}
|
|
2011
2906
|
async function branchName(repoRoot, fallback) {
|
|
2012
2907
|
const ref = await tryGit(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
2013
|
-
return ref ?
|
|
2908
|
+
return ref ? shortRef2(ref) : fallback;
|
|
2014
2909
|
}
|
|
2015
2910
|
async function runCommand(repoRoot, opts) {
|
|
2016
2911
|
let config;
|
|
@@ -2030,7 +2925,7 @@ async function runCommand(repoRoot, opts) {
|
|
|
2030
2925
|
keepWorktree: opts.keepWorktree ?? config.keepWorktree,
|
|
2031
2926
|
single: opts.single
|
|
2032
2927
|
};
|
|
2033
|
-
const outDir =
|
|
2928
|
+
const outDir = path17.resolve(repoRoot, config.output);
|
|
2034
2929
|
const passes = opts.single ? 1 : config.passes;
|
|
2035
2930
|
if (passes === 1) return runSingleClip(repoRoot, config, outDir, opts);
|
|
2036
2931
|
if (opts.url) return runWithExternalApp(repoRoot, config, outDir, opts);
|
|
@@ -2048,11 +2943,11 @@ async function runCommand(repoRoot, opts) {
|
|
|
2048
2943
|
}
|
|
2049
2944
|
const worktree = await createBaseWorktree(repoRoot, base.sha);
|
|
2050
2945
|
if (!opts.keepWorktree) registerCleanup(() => worktree.remove());
|
|
2051
|
-
log.success(`Base worktree at ${
|
|
2946
|
+
log.success(`Base worktree at ${path17.relative(repoRoot, worktree.dir)}`);
|
|
2052
2947
|
if (config.install !== "never") {
|
|
2053
|
-
const appDir =
|
|
2948
|
+
const appDir = path17.join(worktree.dir, config.cwd);
|
|
2054
2949
|
const pm = detectPackageManager(appDir);
|
|
2055
|
-
const hasModules =
|
|
2950
|
+
const hasModules = existsSync10(path17.join(appDir, "node_modules"));
|
|
2056
2951
|
if (config.install === "always" || !hasModules) {
|
|
2057
2952
|
const spinner = log.spinner(`Installing dependencies in worktree (${pm}) \u2026`);
|
|
2058
2953
|
try {
|
|
@@ -2075,21 +2970,21 @@ async function runCommand(repoRoot, opts) {
|
|
|
2075
2970
|
const beforeSteps = await boot.session.recordUntilConfirmed();
|
|
2076
2971
|
if (beforeSteps.length === 0) throw new Error("No steps recorded for BEFORE \u2014 aborting.");
|
|
2077
2972
|
await saveJourneyFile(repoRoot, "journey-before.json", config, boot.session.startUrl, beforeSteps, base.sha);
|
|
2078
|
-
const timestamp =
|
|
2079
|
-
const fstamp =
|
|
2080
|
-
const baseLabel =
|
|
2081
|
-
const headLabel =
|
|
2973
|
+
const timestamp = stamp2();
|
|
2974
|
+
const fstamp = fileStamp2();
|
|
2975
|
+
const baseLabel = shortRef2(base.ref);
|
|
2976
|
+
const headLabel = shortRef2(currentBranch);
|
|
2082
2977
|
log.info("Capturing BEFORE \u2026");
|
|
2083
2978
|
const before = await captureClip(
|
|
2084
2979
|
boot.session,
|
|
2085
2980
|
"before",
|
|
2086
|
-
|
|
2981
|
+
path17.join(outDir, `before-${slug2(base.ref)}-${fstamp}`),
|
|
2087
2982
|
{ branch: baseLabel, baseBranch: baseLabel, timestamp }
|
|
2088
2983
|
);
|
|
2089
2984
|
if (before.fellBackToGif) {
|
|
2090
2985
|
log.warn("ffmpeg not found \u2014 produced a GIF instead of MP4 (brew install ffmpeg).");
|
|
2091
2986
|
}
|
|
2092
|
-
log.success(`${before.paths.map((p) =>
|
|
2987
|
+
log.success(`${before.paths.map((p) => path17.basename(p)).join(" + ")} (${before.frameCount} frames)`);
|
|
2093
2988
|
await beforeServer.stop();
|
|
2094
2989
|
await boot.startApp("after", repoRoot);
|
|
2095
2990
|
boot.session.switchPass("after");
|
|
@@ -2106,10 +3001,10 @@ async function runCommand(repoRoot, opts) {
|
|
|
2106
3001
|
const after = await captureClip(
|
|
2107
3002
|
boot.session,
|
|
2108
3003
|
"after",
|
|
2109
|
-
|
|
3004
|
+
path17.join(outDir, `after-${slug2(currentBranch)}-${fstamp}`),
|
|
2110
3005
|
{ branch: headLabel, baseBranch: baseLabel, timestamp }
|
|
2111
3006
|
);
|
|
2112
|
-
log.success(`${after.paths.map((p) =>
|
|
3007
|
+
log.success(`${after.paths.map((p) => path17.basename(p)).join(" + ")} (${after.frameCount} frames)`);
|
|
2113
3008
|
boot.session.setPhase("done");
|
|
2114
3009
|
boot.harness.bus.send({
|
|
2115
3010
|
type: "DONE",
|
|
@@ -2118,7 +3013,7 @@ async function runCommand(repoRoot, opts) {
|
|
|
2118
3013
|
console.log();
|
|
2119
3014
|
log.success(pc3.bold("Done \u2014 drag these into your PR description:"));
|
|
2120
3015
|
for (const p of [...before.paths, ...after.paths]) {
|
|
2121
|
-
log.step(
|
|
3016
|
+
log.step(path17.relative(repoRoot, p));
|
|
2122
3017
|
}
|
|
2123
3018
|
revealFiles([...before.paths, ...after.paths], outDir);
|
|
2124
3019
|
} finally {
|
|
@@ -2143,12 +3038,12 @@ async function runSingleClip(repoRoot, config, outDir, opts) {
|
|
|
2143
3038
|
const steps = await boot.session.recordUntilConfirmed();
|
|
2144
3039
|
if (steps.length === 0) throw new Error("No steps recorded \u2014 aborting.");
|
|
2145
3040
|
await saveJourneyFile(repoRoot, "journey.json", config, boot.session.startUrl, steps);
|
|
2146
|
-
const timestamp =
|
|
3041
|
+
const timestamp = stamp2();
|
|
2147
3042
|
log.info("Capturing the clip \u2026");
|
|
2148
3043
|
const clip = await captureClip(
|
|
2149
3044
|
boot.session,
|
|
2150
3045
|
"before",
|
|
2151
|
-
|
|
3046
|
+
path17.join(outDir, `${slug2(branch)}-${fileStamp2()}`),
|
|
2152
3047
|
{ branch, baseBranch: branch, timestamp }
|
|
2153
3048
|
);
|
|
2154
3049
|
if (clip.fellBackToGif) {
|
|
@@ -2159,7 +3054,7 @@ async function runSingleClip(repoRoot, config, outDir, opts) {
|
|
|
2159
3054
|
boot.harness.bus.send({ type: "DONE", outputs: { before: clip.paths[0] } });
|
|
2160
3055
|
console.log();
|
|
2161
3056
|
log.success(pc3.bold("Done \u2014 your clip:"));
|
|
2162
|
-
for (const p of clip.paths) log.step(
|
|
3057
|
+
for (const p of clip.paths) log.step(path17.relative(repoRoot, p));
|
|
2163
3058
|
revealFiles(clip.paths, outDir);
|
|
2164
3059
|
} finally {
|
|
2165
3060
|
await runCleanups();
|
|
@@ -2178,17 +3073,17 @@ async function runWithExternalApp(repoRoot, config, outDir, opts) {
|
|
|
2178
3073
|
await boot.session.promptResetChoice();
|
|
2179
3074
|
const beforeSteps = await boot.session.recordUntilConfirmed();
|
|
2180
3075
|
if (beforeSteps.length === 0) throw new Error("No steps recorded for BEFORE \u2014 aborting.");
|
|
2181
|
-
const timestamp =
|
|
2182
|
-
const fstamp =
|
|
3076
|
+
const timestamp = stamp2();
|
|
3077
|
+
const fstamp = fileStamp2();
|
|
2183
3078
|
await saveJourneyFile(repoRoot, "journey-before.json", config, boot.session.startUrl, beforeSteps);
|
|
2184
3079
|
log.info("Capturing BEFORE \u2026");
|
|
2185
3080
|
const before = await captureClip(
|
|
2186
3081
|
boot.session,
|
|
2187
3082
|
"before",
|
|
2188
|
-
|
|
3083
|
+
path17.join(outDir, `before-${slug2(beforeBranch)}-${fstamp}`),
|
|
2189
3084
|
{ branch: beforeBranch, baseBranch: beforeBranch, timestamp }
|
|
2190
3085
|
);
|
|
2191
|
-
log.success(`${before.paths.map((p) =>
|
|
3086
|
+
log.success(`${before.paths.map((p) => path17.basename(p)).join(" + ")} (${before.frameCount} frames)`);
|
|
2192
3087
|
log.info(pc3.bold("\u2192 Switch your app to the PR branch and restart it on the same URL, then Continue in the harness."));
|
|
2193
3088
|
boot.harness.bus.send({
|
|
2194
3089
|
type: "MANUAL_PAUSE",
|
|
@@ -2211,22 +3106,22 @@ async function runWithExternalApp(repoRoot, config, outDir, opts) {
|
|
|
2211
3106
|
const after = await captureClip(
|
|
2212
3107
|
boot.session,
|
|
2213
3108
|
"after",
|
|
2214
|
-
|
|
3109
|
+
path17.join(outDir, `after-${slug2(afterBranch)}-${fstamp}`),
|
|
2215
3110
|
{ branch: afterBranch, baseBranch: beforeBranch, timestamp }
|
|
2216
3111
|
);
|
|
2217
|
-
log.success(`${after.paths.map((p) =>
|
|
3112
|
+
log.success(`${after.paths.map((p) => path17.basename(p)).join(" + ")} (${after.frameCount} frames)`);
|
|
2218
3113
|
boot.session.setPhase("done");
|
|
2219
3114
|
boot.harness.bus.send({ type: "DONE", outputs: { before: before.paths[0], after: after.paths[0] } });
|
|
2220
3115
|
console.log();
|
|
2221
3116
|
log.success(pc3.bold("Done \u2014 drag these into your PR description:"));
|
|
2222
|
-
for (const p of [...before.paths, ...after.paths]) log.step(
|
|
3117
|
+
for (const p of [...before.paths, ...after.paths]) log.step(path17.relative(repoRoot, p));
|
|
2223
3118
|
revealFiles([...before.paths, ...after.paths], outDir);
|
|
2224
3119
|
} finally {
|
|
2225
3120
|
await runCleanups();
|
|
2226
3121
|
}
|
|
2227
3122
|
}
|
|
2228
3123
|
async function saveJourneyFile(repoRoot, name, config, startUrl, steps, baseRef) {
|
|
2229
|
-
await saveJourney(
|
|
3124
|
+
await saveJourney(path17.join(repoRoot, ".pr-preview", name), {
|
|
2230
3125
|
version: 1,
|
|
2231
3126
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2232
3127
|
baseRef,
|
|
@@ -2273,6 +3168,9 @@ Notes:
|
|
|
2273
3168
|
\u2022 Without --url, a before/after run needs a git repo and to be on a branch with commits
|
|
2274
3169
|
beyond its base (it records the base in a throwaway worktree, your branch in place).`
|
|
2275
3170
|
).action((opts) => wrap(() => runCommand(process.cwd(), opts)));
|
|
3171
|
+
program.command("mcp").description(
|
|
3172
|
+
"Run the Model Context Protocol server (stdio) so Claude Code can drive a recording from a prompt"
|
|
3173
|
+
).action(() => wrap(() => mcpCommand(process.cwd())));
|
|
2276
3174
|
async function wrap(fn) {
|
|
2277
3175
|
try {
|
|
2278
3176
|
await fn();
|