@qwertybit/pr-preview 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/LICENSE +30 -0
- package/README.md +242 -0
- package/assets/fonts/Inter.ttf +0 -0
- package/dist/cli/index.js +2251 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/harness/assets/index-BvUCb8Lb.css +1 -0
- package/dist/harness/assets/index-ZmTksMZ5.js +1 -0
- package/dist/harness/index.html +14 -0
- package/dist/harness/logo-wordmark.svg +22 -0
- package/dist/harness/logo.svg +23 -0
- package/dist/inpage/recorder.global.js +344 -0
- package/package.json +83 -0
|
@@ -0,0 +1,2251 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/cli/commands/init.ts
|
|
7
|
+
import { existsSync } from "fs";
|
|
8
|
+
import { readFile, writeFile, appendFile } from "fs/promises";
|
|
9
|
+
import path from "path";
|
|
10
|
+
|
|
11
|
+
// src/cli/ui/logger.ts
|
|
12
|
+
import pc from "picocolors";
|
|
13
|
+
import ora from "ora";
|
|
14
|
+
var log = {
|
|
15
|
+
info(msg) {
|
|
16
|
+
console.log(`${pc.cyan("\u25CF")} ${msg}`);
|
|
17
|
+
},
|
|
18
|
+
success(msg) {
|
|
19
|
+
console.log(`${pc.green("\u2714")} ${msg}`);
|
|
20
|
+
},
|
|
21
|
+
warn(msg) {
|
|
22
|
+
console.log(`${pc.yellow("\u25B2")} ${msg}`);
|
|
23
|
+
},
|
|
24
|
+
error(msg) {
|
|
25
|
+
console.error(`${pc.red("\u2716")} ${msg}`);
|
|
26
|
+
},
|
|
27
|
+
step(msg) {
|
|
28
|
+
console.log(pc.dim(` ${msg}`));
|
|
29
|
+
},
|
|
30
|
+
spinner(text) {
|
|
31
|
+
return ora({ text, color: "cyan" }).start();
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// src/cli/commands/init.ts
|
|
36
|
+
var CONFIG_TEMPLATE = `/** @type {import('pr-preview').Config} */
|
|
37
|
+
export default {
|
|
38
|
+
// \u2500\u2500 App under preview \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
39
|
+
// Command that starts your dev server. $PORT is set for you.
|
|
40
|
+
devCommand: "npm run dev",
|
|
41
|
+
// Where the app answers once ready. {port} is replaced with the allocated port.
|
|
42
|
+
url: "http://localhost:{port}",
|
|
43
|
+
// Frontend directory relative to the repo root (monorepos).
|
|
44
|
+
cwd: ".",
|
|
45
|
+
// How long to wait for the dev server (ms).
|
|
46
|
+
readyTimeout: 60000,
|
|
47
|
+
|
|
48
|
+
// \u2500\u2500 Run options (set here once instead of passing CLI flags every time) \u2500
|
|
49
|
+
// Use an app YOU already run instead of a managed dev server. Set this for
|
|
50
|
+
// apps that need real env/backends.
|
|
51
|
+
// externalUrl: "http://localhost:3000",
|
|
52
|
+
// Override PR-base ("before") detection, e.g. "origin/main".
|
|
53
|
+
// baseBranch: undefined,
|
|
54
|
+
// Reuse the base worktree across runs (skips reinstall).
|
|
55
|
+
// keepWorktree: false,
|
|
56
|
+
// 2 (default) = before/after comparison; 1 = a single standalone clip.
|
|
57
|
+
// (run --single forces 1.)
|
|
58
|
+
passes: 2,
|
|
59
|
+
|
|
60
|
+
// \u2500\u2500 Output \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
61
|
+
output: ".pr-preview/output",
|
|
62
|
+
format: "mp4", // "mp4" | "gif" | "both"
|
|
63
|
+
// Start-of-pass reset choice default: true offers "reset to a clean app",
|
|
64
|
+
// false offers "keep my session". The nudge only shows when there's state.
|
|
65
|
+
resetStorage: true,
|
|
66
|
+
viewport: { width: 1920, height: 1080 },
|
|
67
|
+
|
|
68
|
+
// \u2500\u2500 Permissions \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
69
|
+
// Grant browser permissions so apps that ask for them work (others stay
|
|
70
|
+
// denied silently \u2014 no native prompt blocks the run).
|
|
71
|
+
// permissions: ["geolocation", "clipboard-read", "clipboard-write"],
|
|
72
|
+
// geolocation: { latitude: 51.5074, longitude: -0.1278 }, // fixed = deterministic
|
|
73
|
+
};
|
|
74
|
+
`;
|
|
75
|
+
var GITIGNORE_BLOCK = `
|
|
76
|
+
# pr-preview (worktrees, recordings, output)
|
|
77
|
+
.pr-preview/
|
|
78
|
+
`;
|
|
79
|
+
async function initCommand(root) {
|
|
80
|
+
const configPath = path.join(root, "pr-preview.config.js");
|
|
81
|
+
if (existsSync(configPath) || existsSync(path.join(root, "pr-preview.config.ts")) || existsSync(path.join(root, "pr-preview.config.json"))) {
|
|
82
|
+
log.warn("A pr-preview config already exists \u2014 leaving it untouched.");
|
|
83
|
+
} else {
|
|
84
|
+
await writeFile(configPath, CONFIG_TEMPLATE);
|
|
85
|
+
log.success(`Created ${path.basename(configPath)}`);
|
|
86
|
+
}
|
|
87
|
+
const gitignorePath = path.join(root, ".gitignore");
|
|
88
|
+
const current = existsSync(gitignorePath) ? await readFile(gitignorePath, "utf8") : "";
|
|
89
|
+
if (!current.includes(".pr-preview/")) {
|
|
90
|
+
await appendFile(gitignorePath, GITIGNORE_BLOCK);
|
|
91
|
+
log.success("Added .pr-preview/ to .gitignore");
|
|
92
|
+
}
|
|
93
|
+
log.info("Edit the config to match your dev command, then run `pr-preview run` on a PR branch.");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// src/cli/commands/record.ts
|
|
97
|
+
import path11 from "path";
|
|
98
|
+
import pc2 from "picocolors";
|
|
99
|
+
|
|
100
|
+
// src/config/load.ts
|
|
101
|
+
import { existsSync as existsSync2 } from "fs";
|
|
102
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
103
|
+
import path2 from "path";
|
|
104
|
+
import { createJiti } from "jiti";
|
|
105
|
+
|
|
106
|
+
// src/config/schema.ts
|
|
107
|
+
import { z } from "zod";
|
|
108
|
+
var configSchema = z.object({
|
|
109
|
+
/** Command that starts the project's dev server, e.g. "npm run dev". */
|
|
110
|
+
devCommand: z.string().min(1),
|
|
111
|
+
/** URL the app is reachable on once ready. Supports {port} templating. */
|
|
112
|
+
url: z.string().min(1),
|
|
113
|
+
/** Working directory of the frontend relative to the repo root (monorepos). */
|
|
114
|
+
cwd: z.string().default("."),
|
|
115
|
+
/** ms to wait for the dev server to answer. */
|
|
116
|
+
readyTimeout: z.number().int().positive().default(6e4),
|
|
117
|
+
/** Override PR base detection (the "before" ref). e.g. "origin/main". */
|
|
118
|
+
baseBranch: z.string().optional(),
|
|
119
|
+
/**
|
|
120
|
+
* Run against an app YOU already have running, instead of letting pr-preview
|
|
121
|
+
* start a dev server in a worktree. Set this (e.g. "http://localhost:3000")
|
|
122
|
+
* for apps that need real env/backends. Equivalent to `run --url`; the CLI
|
|
123
|
+
* flag overrides it. When set, devCommand/url are ignored for `run`.
|
|
124
|
+
*/
|
|
125
|
+
externalUrl: z.string().optional(),
|
|
126
|
+
/** Reuse the base worktree across runs (skips reinstall). `--keep-worktree`. */
|
|
127
|
+
keepWorktree: z.boolean().default(false),
|
|
128
|
+
/** Dependency install behavior in the base worktree. */
|
|
129
|
+
install: z.enum(["auto", "always", "never"]).default("auto"),
|
|
130
|
+
/** Output directory for the clips, relative to repo root. */
|
|
131
|
+
output: z.string().default(".pr-preview/output"),
|
|
132
|
+
/**
|
|
133
|
+
* Output format. "mp4" (default) is small + HQ and uploads straight into a
|
|
134
|
+
* GitHub PR description; needs ffmpeg on PATH (falls back to gif if
|
|
135
|
+
* missing). "both" produces the two side by side.
|
|
136
|
+
*/
|
|
137
|
+
format: z.enum(["mp4", "gif", "both"]).default("mp4"),
|
|
138
|
+
/**
|
|
139
|
+
* How many recordings the session does: 2 (default) = before/after on the
|
|
140
|
+
* base branch and your branch; 1 = a single standalone clip of the current
|
|
141
|
+
* app (no comparison, no base worktree). `run --single` forces 1.
|
|
142
|
+
*/
|
|
143
|
+
passes: z.union([z.literal(1), z.literal(2)]).default(2),
|
|
144
|
+
gif: z.object({
|
|
145
|
+
width: z.number().int().positive().default(900),
|
|
146
|
+
// 24fps keeps the synthetic cursor's motion fluid in the clip.
|
|
147
|
+
fps: z.number().positive().max(60).default(24),
|
|
148
|
+
quality: z.enum(["high", "max"]).default("high"),
|
|
149
|
+
maxColors: z.number().int().min(2).max(256).default(256)
|
|
150
|
+
}).default({}),
|
|
151
|
+
/**
|
|
152
|
+
* Start-of-pass reset choice default: true (default) offers "reset" (clear
|
|
153
|
+
* cookies + localStorage + sessionStorage and reload) before recording;
|
|
154
|
+
* false offers "keep my session". The nudge only appears when there's state
|
|
155
|
+
* to reset.
|
|
156
|
+
*/
|
|
157
|
+
resetStorage: z.boolean().default(true),
|
|
158
|
+
/** Logical app resolution — Full HD by default. The harness scales the
|
|
159
|
+
* iframe down to fit the window, keeping this resolution and ratio. */
|
|
160
|
+
viewport: z.object({
|
|
161
|
+
width: z.number().int().positive().default(1920),
|
|
162
|
+
height: z.number().int().positive().default(1080)
|
|
163
|
+
}).default({}),
|
|
164
|
+
/** Strip X-Frame-Options / frame-ancestors so any app loads in the iframe. */
|
|
165
|
+
headerStrip: z.boolean().default(true),
|
|
166
|
+
/**
|
|
167
|
+
* Browser permissions to GRANT up front (Playwright names) so a native
|
|
168
|
+
* prompt never blocks the run. Defaults to allow-all (the broadly-supported
|
|
169
|
+
* set); unsupported names for your Chrome are skipped gracefully. Set your
|
|
170
|
+
* own narrower list to override, or [] to deny everything.
|
|
171
|
+
*/
|
|
172
|
+
permissions: z.array(z.string()).default([
|
|
173
|
+
"geolocation",
|
|
174
|
+
"notifications",
|
|
175
|
+
"camera",
|
|
176
|
+
"microphone",
|
|
177
|
+
"clipboard-read",
|
|
178
|
+
"clipboard-write",
|
|
179
|
+
"midi",
|
|
180
|
+
"background-sync",
|
|
181
|
+
"accelerometer",
|
|
182
|
+
"gyroscope",
|
|
183
|
+
"magnetometer",
|
|
184
|
+
"payment-handler",
|
|
185
|
+
"storage-access"
|
|
186
|
+
]),
|
|
187
|
+
/**
|
|
188
|
+
* Fixed geolocation for the session (implies the "geolocation" permission),
|
|
189
|
+
* so location-based apps render real, deterministic results in both clips.
|
|
190
|
+
*/
|
|
191
|
+
geolocation: z.object({
|
|
192
|
+
latitude: z.number(),
|
|
193
|
+
longitude: z.number(),
|
|
194
|
+
accuracy: z.number().nonnegative().optional()
|
|
195
|
+
}).optional()
|
|
196
|
+
});
|
|
197
|
+
function resolveUrl(config, port) {
|
|
198
|
+
return config.url.replace("{port}", String(port));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// src/config/load.ts
|
|
202
|
+
var CONFIG_FILES = [
|
|
203
|
+
"pr-preview.config.ts",
|
|
204
|
+
"pr-preview.config.mts",
|
|
205
|
+
"pr-preview.config.js",
|
|
206
|
+
"pr-preview.config.mjs",
|
|
207
|
+
"pr-preview.config.json"
|
|
208
|
+
];
|
|
209
|
+
var ConfigError = class extends Error {
|
|
210
|
+
};
|
|
211
|
+
async function loadConfig(root) {
|
|
212
|
+
const file = CONFIG_FILES.map((f) => path2.join(root, f)).find(existsSync2);
|
|
213
|
+
if (!file) {
|
|
214
|
+
throw new ConfigError(
|
|
215
|
+
`No pr-preview config found in ${root}. Run \`pr-preview init\` to create one.`
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
let raw;
|
|
219
|
+
if (file.endsWith(".json")) {
|
|
220
|
+
raw = JSON.parse(await readFile2(file, "utf8"));
|
|
221
|
+
} else {
|
|
222
|
+
const jiti = createJiti(import.meta.url, { interopDefault: true });
|
|
223
|
+
raw = await jiti.import(file, { default: true });
|
|
224
|
+
}
|
|
225
|
+
const parsed = configSchema.safeParse(raw);
|
|
226
|
+
if (!parsed.success) {
|
|
227
|
+
const issues = parsed.error.issues.map((i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
|
|
228
|
+
throw new ConfigError(`Invalid config in ${path2.basename(file)}:
|
|
229
|
+
${issues}`);
|
|
230
|
+
}
|
|
231
|
+
return { config: parsed.data, file };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// src/session/bootstrap.ts
|
|
235
|
+
import path9 from "path";
|
|
236
|
+
|
|
237
|
+
// src/server/ports.ts
|
|
238
|
+
import getPort, { portNumbers } from "get-port";
|
|
239
|
+
async function allocatePorts() {
|
|
240
|
+
const pinned = process.env.PR_PREVIEW_PORTS?.split(",").map(Number);
|
|
241
|
+
if (pinned?.length === 3 && pinned.every((p) => Number.isInteger(p) && p > 0)) {
|
|
242
|
+
return { harness: pinned[0], beforeApp: pinned[1], afterApp: pinned[2] };
|
|
243
|
+
}
|
|
244
|
+
const harness = await getPort({ port: portNumbers(4310, 4400) });
|
|
245
|
+
const beforeApp = await getPort({ port: portNumbers(4401, 4500), exclude: [harness] });
|
|
246
|
+
const afterApp = await getPort({
|
|
247
|
+
port: portNumbers(4501, 4600),
|
|
248
|
+
exclude: [harness, beforeApp]
|
|
249
|
+
});
|
|
250
|
+
return { harness, beforeApp, afterApp };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// src/server/devServer.ts
|
|
254
|
+
import { spawn } from "child_process";
|
|
255
|
+
import { appendFileSync, mkdirSync } from "fs";
|
|
256
|
+
import path3 from "path";
|
|
257
|
+
import treeKill from "tree-kill";
|
|
258
|
+
var DevServerError = class extends Error {
|
|
259
|
+
};
|
|
260
|
+
async function startDevServer(opts) {
|
|
261
|
+
if (opts.logFile) mkdirSync(path3.dirname(opts.logFile), { recursive: true });
|
|
262
|
+
const child = spawn(opts.command, {
|
|
263
|
+
cwd: opts.cwd,
|
|
264
|
+
shell: true,
|
|
265
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
266
|
+
env: { ...process.env, PORT: String(opts.port), BROWSER: "none", FORCE_COLOR: "0" }
|
|
267
|
+
});
|
|
268
|
+
let logTail = "";
|
|
269
|
+
const onChunk = (chunk) => {
|
|
270
|
+
logTail = (logTail + chunk.toString()).slice(-4e3);
|
|
271
|
+
if (opts.logFile) appendFileSync(opts.logFile, chunk);
|
|
272
|
+
};
|
|
273
|
+
child.stdout.on("data", onChunk);
|
|
274
|
+
child.stderr.on("data", onChunk);
|
|
275
|
+
let exited = false;
|
|
276
|
+
let exitCode = null;
|
|
277
|
+
child.on("exit", (code) => {
|
|
278
|
+
exited = true;
|
|
279
|
+
exitCode = code;
|
|
280
|
+
});
|
|
281
|
+
const deadline = Date.now() + opts.readyTimeout;
|
|
282
|
+
while (Date.now() < deadline) {
|
|
283
|
+
if (exited) {
|
|
284
|
+
throw new DevServerError(
|
|
285
|
+
`Dev server exited early (code ${exitCode}). Last output:
|
|
286
|
+
${logTail}`
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
try {
|
|
290
|
+
const res = await fetch(opts.url, { redirect: "manual" });
|
|
291
|
+
if (res.status < 500) {
|
|
292
|
+
return { url: opts.url, stop: () => killTree(child) };
|
|
293
|
+
}
|
|
294
|
+
} catch {
|
|
295
|
+
}
|
|
296
|
+
await sleep(400);
|
|
297
|
+
}
|
|
298
|
+
await killTree(child);
|
|
299
|
+
throw new DevServerError(
|
|
300
|
+
`Dev server did not answer at ${opts.url} within ${opts.readyTimeout}ms. Last output:
|
|
301
|
+
${logTail}`
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
function killTree(child) {
|
|
305
|
+
return new Promise((resolve) => {
|
|
306
|
+
if (child.pid == null || child.exitCode !== null) return resolve();
|
|
307
|
+
treeKill(child.pid, "SIGTERM", () => {
|
|
308
|
+
setTimeout(() => {
|
|
309
|
+
if (child.exitCode === null && child.pid != null) {
|
|
310
|
+
treeKill(child.pid, "SIGKILL", () => resolve());
|
|
311
|
+
} else {
|
|
312
|
+
resolve();
|
|
313
|
+
}
|
|
314
|
+
}, 1500);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
function sleep(ms) {
|
|
319
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// src/server/harnessServer.ts
|
|
323
|
+
import http from "http";
|
|
324
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
325
|
+
import { existsSync as existsSync3 } from "fs";
|
|
326
|
+
import path4 from "path";
|
|
327
|
+
import { fileURLToPath } from "url";
|
|
328
|
+
import { WebSocketServer } from "ws";
|
|
329
|
+
|
|
330
|
+
// src/ipc/protocol.ts
|
|
331
|
+
function serialize(msg) {
|
|
332
|
+
return JSON.stringify(msg);
|
|
333
|
+
}
|
|
334
|
+
function parseClientMessage(raw) {
|
|
335
|
+
try {
|
|
336
|
+
const msg = JSON.parse(raw);
|
|
337
|
+
if (typeof msg === "object" && msg !== null && typeof msg.type === "string") {
|
|
338
|
+
return msg;
|
|
339
|
+
}
|
|
340
|
+
} catch {
|
|
341
|
+
}
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// src/ipc/bus.ts
|
|
346
|
+
var Bus = class {
|
|
347
|
+
sockets = /* @__PURE__ */ new Set();
|
|
348
|
+
handlers = /* @__PURE__ */ new Set();
|
|
349
|
+
/** Replayed to late-joining sidebars so they can render current state. */
|
|
350
|
+
helloFactory = null;
|
|
351
|
+
attach(wss) {
|
|
352
|
+
wss.on("connection", (ws) => {
|
|
353
|
+
this.sockets.add(ws);
|
|
354
|
+
if (this.helloFactory) ws.send(serialize(this.helloFactory()));
|
|
355
|
+
ws.on("message", (data) => {
|
|
356
|
+
const msg = parseClientMessage(data.toString());
|
|
357
|
+
if (msg) for (const h of this.handlers) h(msg);
|
|
358
|
+
});
|
|
359
|
+
ws.on("close", () => this.sockets.delete(ws));
|
|
360
|
+
ws.on("error", () => this.sockets.delete(ws));
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
onHello(factory) {
|
|
364
|
+
this.helloFactory = factory;
|
|
365
|
+
}
|
|
366
|
+
send(msg) {
|
|
367
|
+
const data = serialize(msg);
|
|
368
|
+
for (const ws of this.sockets) {
|
|
369
|
+
if (ws.readyState === ws.OPEN) ws.send(data);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
onMessage(handler) {
|
|
373
|
+
this.handlers.add(handler);
|
|
374
|
+
return () => this.handlers.delete(handler);
|
|
375
|
+
}
|
|
376
|
+
/** Resolve once a message of the given type arrives. */
|
|
377
|
+
waitFor(type) {
|
|
378
|
+
return new Promise((resolve) => {
|
|
379
|
+
const off = this.onMessage((msg) => {
|
|
380
|
+
if (msg.type === type) {
|
|
381
|
+
off();
|
|
382
|
+
resolve(msg);
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
/** Resolve with whichever of the given message types arrives first. */
|
|
388
|
+
waitForAny(...types) {
|
|
389
|
+
return new Promise((resolve) => {
|
|
390
|
+
const off = this.onMessage((msg) => {
|
|
391
|
+
if (types.includes(msg.type)) {
|
|
392
|
+
off();
|
|
393
|
+
resolve(msg);
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
// src/server/harnessServer.ts
|
|
401
|
+
var MIME = {
|
|
402
|
+
".html": "text/html; charset=utf-8",
|
|
403
|
+
".js": "text/javascript; charset=utf-8",
|
|
404
|
+
".css": "text/css; charset=utf-8",
|
|
405
|
+
".svg": "image/svg+xml",
|
|
406
|
+
".png": "image/png",
|
|
407
|
+
".json": "application/json",
|
|
408
|
+
".map": "application/json"
|
|
409
|
+
};
|
|
410
|
+
function harnessDistDir() {
|
|
411
|
+
const here = path4.dirname(fileURLToPath(import.meta.url));
|
|
412
|
+
const candidates = [
|
|
413
|
+
path4.resolve(here, "../harness"),
|
|
414
|
+
path4.resolve(here, "../../dist/harness")
|
|
415
|
+
];
|
|
416
|
+
const found = candidates.find((c) => existsSync3(path4.join(c, "index.html")));
|
|
417
|
+
if (!found) {
|
|
418
|
+
throw new Error(
|
|
419
|
+
`Harness UI build not found (looked in: ${candidates.join(", ")}). Run \`npm run build\` first.`
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
return found;
|
|
423
|
+
}
|
|
424
|
+
async function startHarnessServer(opts) {
|
|
425
|
+
const dist = harnessDistDir();
|
|
426
|
+
let appUrl = opts.appUrl;
|
|
427
|
+
const server = http.createServer(async (req, res) => {
|
|
428
|
+
const urlPath = (req.url ?? "/").split("?")[0];
|
|
429
|
+
if (urlPath === "/runtime.json") {
|
|
430
|
+
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
|
431
|
+
res.end(JSON.stringify({ appUrl, mode: opts.mode, viewport: opts.viewport }));
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const rel = urlPath === "/" ? "index.html" : urlPath.slice(1);
|
|
435
|
+
const file = path4.join(dist, path4.normalize(rel));
|
|
436
|
+
if (!file.startsWith(dist) || !existsSync3(file)) {
|
|
437
|
+
res.writeHead(200, { "content-type": MIME[".html"] });
|
|
438
|
+
res.end(await readFile3(path4.join(dist, "index.html")));
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
res.writeHead(200, {
|
|
442
|
+
"content-type": MIME[path4.extname(file)] ?? "application/octet-stream",
|
|
443
|
+
"cache-control": "no-store"
|
|
444
|
+
});
|
|
445
|
+
res.end(await readFile3(file));
|
|
446
|
+
});
|
|
447
|
+
const wss = new WebSocketServer({ server, path: "/ws" });
|
|
448
|
+
const bus = new Bus();
|
|
449
|
+
bus.attach(wss);
|
|
450
|
+
await new Promise((resolve, reject) => {
|
|
451
|
+
server.once("error", reject);
|
|
452
|
+
server.listen(opts.port, "127.0.0.1", resolve);
|
|
453
|
+
});
|
|
454
|
+
return {
|
|
455
|
+
url: `http://127.0.0.1:${opts.port}`,
|
|
456
|
+
bus,
|
|
457
|
+
setAppUrl(url) {
|
|
458
|
+
appUrl = url;
|
|
459
|
+
},
|
|
460
|
+
close: () => new Promise((resolve) => {
|
|
461
|
+
wss.close();
|
|
462
|
+
for (const ws of wss.clients) ws.terminate();
|
|
463
|
+
server.close(() => resolve());
|
|
464
|
+
})
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// src/browser/launch.ts
|
|
469
|
+
import { chromium } from "playwright";
|
|
470
|
+
|
|
471
|
+
// src/browser/headerStrip.ts
|
|
472
|
+
async function installHeaderStrip(context, targetOrigins) {
|
|
473
|
+
await context.route(
|
|
474
|
+
(url) => targetOrigins.includes(url.origin),
|
|
475
|
+
async (route) => {
|
|
476
|
+
const type = route.request().resourceType();
|
|
477
|
+
if (type !== "document") return route.continue();
|
|
478
|
+
const response = await route.fetch();
|
|
479
|
+
const headers = { ...response.headers() };
|
|
480
|
+
delete headers["x-frame-options"];
|
|
481
|
+
const csp = headers["content-security-policy"];
|
|
482
|
+
if (csp) {
|
|
483
|
+
const stripped = csp.split(";").filter((d) => !/^\s*frame-ancestors/i.test(d)).join(";").trim();
|
|
484
|
+
if (stripped) headers["content-security-policy"] = stripped;
|
|
485
|
+
else delete headers["content-security-policy"];
|
|
486
|
+
}
|
|
487
|
+
await route.fulfill({ response, headers });
|
|
488
|
+
}
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// src/browser/recorderInject.ts
|
|
493
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
494
|
+
import { existsSync as existsSync4 } from "fs";
|
|
495
|
+
import path5 from "path";
|
|
496
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
497
|
+
var recorderSource = null;
|
|
498
|
+
async function loadRecorderSource() {
|
|
499
|
+
if (recorderSource) return recorderSource;
|
|
500
|
+
const here = path5.dirname(fileURLToPath2(import.meta.url));
|
|
501
|
+
const candidates = [
|
|
502
|
+
path5.resolve(here, "../inpage/recorder.global.js"),
|
|
503
|
+
// dist/cli → dist/inpage
|
|
504
|
+
path5.resolve(here, "../../dist/inpage/recorder.global.js"),
|
|
505
|
+
// running from src (tests)
|
|
506
|
+
path5.resolve(here, "../../../dist/inpage/recorder.global.js")
|
|
507
|
+
];
|
|
508
|
+
const found = candidates.find(existsSync4);
|
|
509
|
+
if (!found) {
|
|
510
|
+
throw new Error(
|
|
511
|
+
`In-page recorder bundle not found (looked in: ${candidates.join(", ")}). Run \`npm run build\` first.`
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
recorderSource = await readFile4(found, "utf8");
|
|
515
|
+
return recorderSource;
|
|
516
|
+
}
|
|
517
|
+
async function installRecorderBinding(page, targetOrigins, onEvent) {
|
|
518
|
+
await page.exposeBinding("__prPreviewEmit", (source, json) => {
|
|
519
|
+
try {
|
|
520
|
+
if (!targetOrigins.includes(new URL(source.frame.url()).origin)) return;
|
|
521
|
+
} catch {
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
try {
|
|
525
|
+
onEvent(JSON.parse(json));
|
|
526
|
+
} catch {
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
await page.addInitScript({ content: await loadRecorderSource() });
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// src/browser/launch.ts
|
|
533
|
+
var SIDEBAR_WIDTH = 320;
|
|
534
|
+
var BANNER_HEIGHT = 44 + 36;
|
|
535
|
+
var LNA_DISABLE = [
|
|
536
|
+
"LocalNetworkAccessChecks",
|
|
537
|
+
"BlockInsecurePrivateNetworkRequests",
|
|
538
|
+
"PrivateNetworkAccessSendPreflights",
|
|
539
|
+
"PrivateNetworkAccessForNavigations"
|
|
540
|
+
];
|
|
541
|
+
var cachedBaseDisabledFeatures = null;
|
|
542
|
+
async function playwrightDisabledFeatures() {
|
|
543
|
+
if (cachedBaseDisabledFeatures !== null) return cachedBaseDisabledFeatures;
|
|
544
|
+
cachedBaseDisabledFeatures = "";
|
|
545
|
+
try {
|
|
546
|
+
const probe = await chromium.launchServer({ headless: true });
|
|
547
|
+
const arg = probe.process().spawnargs.find((a) => a.startsWith("--disable-features="));
|
|
548
|
+
cachedBaseDisabledFeatures = arg ? arg.slice("--disable-features=".length) : "";
|
|
549
|
+
await probe.close();
|
|
550
|
+
} catch {
|
|
551
|
+
}
|
|
552
|
+
return cachedBaseDisabledFeatures;
|
|
553
|
+
}
|
|
554
|
+
async function grantPermissionsResilient(context, perms) {
|
|
555
|
+
if (perms.length === 0) return;
|
|
556
|
+
try {
|
|
557
|
+
await context.grantPermissions(perms);
|
|
558
|
+
} catch {
|
|
559
|
+
const good = [];
|
|
560
|
+
for (const p of perms) {
|
|
561
|
+
try {
|
|
562
|
+
await context.grantPermissions([...good, p]);
|
|
563
|
+
good.push(p);
|
|
564
|
+
} catch {
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
async function launchSession(opts) {
|
|
570
|
+
const headless = process.env.PR_PREVIEW_HEADLESS === "1";
|
|
571
|
+
const windowChrome = headless ? 0 : 88;
|
|
572
|
+
const baseDisabled = await playwrightDisabledFeatures();
|
|
573
|
+
const args = [
|
|
574
|
+
"--hide-crash-restore-bubble",
|
|
575
|
+
// Auto-accept camera/mic prompts with a fake device so getUserMedia
|
|
576
|
+
// never blocks the run.
|
|
577
|
+
"--use-fake-ui-for-media-stream",
|
|
578
|
+
"--use-fake-device-for-media-stream",
|
|
579
|
+
// Reduce the automation fingerprint (hides navigator.webdriver) so OAuth
|
|
580
|
+
// providers like Google are less likely to refuse sign-in with "this
|
|
581
|
+
// browser may not be secure". Not a guarantee — they're aggressive.
|
|
582
|
+
"--disable-blink-features=AutomationControlled",
|
|
583
|
+
`--window-size=${opts.appViewport.width + SIDEBAR_WIDTH},${opts.appViewport.height + BANNER_HEIGHT + windowChrome}`
|
|
584
|
+
];
|
|
585
|
+
if (baseDisabled) args.push(`--disable-features=${[baseDisabled, ...LNA_DISABLE].join(",")}`);
|
|
586
|
+
const browser = await chromium.launch({
|
|
587
|
+
headless,
|
|
588
|
+
args,
|
|
589
|
+
// Drop the "controlled by automation" flag — another OAuth-detection signal.
|
|
590
|
+
ignoreDefaultArgs: ["--enable-automation"]
|
|
591
|
+
});
|
|
592
|
+
const context = await browser.newContext({
|
|
593
|
+
viewport: null
|
|
594
|
+
// follow the real window — user can resize freely
|
|
595
|
+
});
|
|
596
|
+
const permissions = new Set(opts.permissions ?? []);
|
|
597
|
+
if (opts.geolocation) permissions.add("geolocation");
|
|
598
|
+
await grantPermissionsResilient(context, [...permissions]);
|
|
599
|
+
const geolocation = opts.geolocation ?? (permissions.has("geolocation") ? { latitude: 0, longitude: 0 } : void 0);
|
|
600
|
+
if (geolocation) await context.setGeolocation(geolocation).catch(() => {
|
|
601
|
+
});
|
|
602
|
+
if (opts.headerStrip) await installHeaderStrip(context, opts.targetOrigins);
|
|
603
|
+
const page = await context.newPage();
|
|
604
|
+
context.on("dialog", (dialog) => {
|
|
605
|
+
void dialog.accept(dialog.type() === "prompt" ? dialog.defaultValue() : void 0).catch(() => {
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
await installRecorderBinding(page, opts.targetOrigins, opts.onRawEvent);
|
|
609
|
+
await page.goto(opts.harnessUrl, { waitUntil: "domcontentloaded" });
|
|
610
|
+
return {
|
|
611
|
+
browser,
|
|
612
|
+
context,
|
|
613
|
+
page,
|
|
614
|
+
close: async () => {
|
|
615
|
+
await context.close().catch(() => {
|
|
616
|
+
});
|
|
617
|
+
await browser.close().catch(() => {
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// src/session/session.ts
|
|
624
|
+
import sharp4 from "sharp";
|
|
625
|
+
|
|
626
|
+
// src/recorder/steps.ts
|
|
627
|
+
var StepBuilder = class {
|
|
628
|
+
steps = [];
|
|
629
|
+
counter = 0;
|
|
630
|
+
pendingFill = null;
|
|
631
|
+
listeners = /* @__PURE__ */ new Set();
|
|
632
|
+
onChange(listener) {
|
|
633
|
+
this.listeners.add(listener);
|
|
634
|
+
return () => this.listeners.delete(listener);
|
|
635
|
+
}
|
|
636
|
+
notify(change) {
|
|
637
|
+
for (const l of this.listeners) l(change);
|
|
638
|
+
}
|
|
639
|
+
nextId() {
|
|
640
|
+
return `stp_${String(++this.counter).padStart(2, "0")}`;
|
|
641
|
+
}
|
|
642
|
+
getSteps() {
|
|
643
|
+
this.flushFill();
|
|
644
|
+
return [...this.steps];
|
|
645
|
+
}
|
|
646
|
+
/** Replace all steps (journey reuse / re-record truncation). */
|
|
647
|
+
setSteps(steps) {
|
|
648
|
+
this.pendingFill = null;
|
|
649
|
+
this.steps = [...steps];
|
|
650
|
+
this.counter = steps.length;
|
|
651
|
+
this.notify({ kind: "reset" });
|
|
652
|
+
}
|
|
653
|
+
removeStep(id) {
|
|
654
|
+
const i = this.steps.findIndex((s) => s.id === id);
|
|
655
|
+
if (i >= 0) {
|
|
656
|
+
this.steps.splice(i, 1);
|
|
657
|
+
this.notify({ kind: "removed", stepId: id });
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
/** Drop the given step and everything after it (re-record from there). */
|
|
661
|
+
truncateFrom(id) {
|
|
662
|
+
this.flushFill();
|
|
663
|
+
const i = this.steps.findIndex((s) => s.id === id);
|
|
664
|
+
if (i >= 0) {
|
|
665
|
+
this.steps = this.steps.slice(0, i);
|
|
666
|
+
this.notify({ kind: "reset" });
|
|
667
|
+
}
|
|
668
|
+
return [...this.steps];
|
|
669
|
+
}
|
|
670
|
+
setThumbnail(id, thumbnail) {
|
|
671
|
+
const step = this.steps.find((s) => s.id === id);
|
|
672
|
+
if (step) {
|
|
673
|
+
step.thumbnail = thumbnail;
|
|
674
|
+
this.notify({ kind: "updated", step });
|
|
675
|
+
}
|
|
676
|
+
return step;
|
|
677
|
+
}
|
|
678
|
+
handle(event) {
|
|
679
|
+
switch (event.kind) {
|
|
680
|
+
case "input": {
|
|
681
|
+
const sameField = this.pendingFill && selectorsOverlap(this.pendingFill.selectors ?? [], event.selectors);
|
|
682
|
+
if (sameField && this.pendingFill) {
|
|
683
|
+
this.pendingFill.value = event.value;
|
|
684
|
+
this.pendingFill.timestamp = event.ts;
|
|
685
|
+
this.notify({ kind: "updated", step: this.pendingFill });
|
|
686
|
+
} else {
|
|
687
|
+
this.flushFill();
|
|
688
|
+
const prev = this.steps[this.steps.length - 1];
|
|
689
|
+
if (prev?.type === "click" && selectorsOverlap(prev.selectors ?? [], event.selectors) && event.ts - prev.timestamp < 3e3) {
|
|
690
|
+
this.steps.pop();
|
|
691
|
+
this.notify({ kind: "removed", stepId: prev.id });
|
|
692
|
+
}
|
|
693
|
+
this.pendingFill = {
|
|
694
|
+
id: this.nextId(),
|
|
695
|
+
type: "fill",
|
|
696
|
+
selectors: event.selectors,
|
|
697
|
+
value: event.value,
|
|
698
|
+
coordinates: { xNorm: event.xNorm, yNorm: event.yNorm },
|
|
699
|
+
frameUrl: event.frameUrl,
|
|
700
|
+
timestamp: event.ts
|
|
701
|
+
};
|
|
702
|
+
this.steps.push(this.pendingFill);
|
|
703
|
+
this.notify({ kind: "added", step: this.pendingFill });
|
|
704
|
+
}
|
|
705
|
+
break;
|
|
706
|
+
}
|
|
707
|
+
case "click": {
|
|
708
|
+
this.flushFill();
|
|
709
|
+
const step = {
|
|
710
|
+
id: this.nextId(),
|
|
711
|
+
type: "click",
|
|
712
|
+
selectors: event.selectors,
|
|
713
|
+
coordinates: { xNorm: event.xNorm, yNorm: event.yNorm },
|
|
714
|
+
frameUrl: event.frameUrl,
|
|
715
|
+
label: event.text,
|
|
716
|
+
timestamp: event.ts
|
|
717
|
+
};
|
|
718
|
+
this.steps.push(step);
|
|
719
|
+
this.notify({ kind: "added", step });
|
|
720
|
+
break;
|
|
721
|
+
}
|
|
722
|
+
case "select": {
|
|
723
|
+
this.flushFill();
|
|
724
|
+
const step = {
|
|
725
|
+
id: this.nextId(),
|
|
726
|
+
type: "select",
|
|
727
|
+
selectors: event.selectors,
|
|
728
|
+
value: event.value,
|
|
729
|
+
coordinates: { xNorm: event.xNorm, yNorm: event.yNorm },
|
|
730
|
+
frameUrl: event.frameUrl,
|
|
731
|
+
timestamp: event.ts
|
|
732
|
+
};
|
|
733
|
+
this.steps.push(step);
|
|
734
|
+
this.notify({ kind: "added", step });
|
|
735
|
+
break;
|
|
736
|
+
}
|
|
737
|
+
case "key": {
|
|
738
|
+
this.flushFill();
|
|
739
|
+
const step = {
|
|
740
|
+
id: this.nextId(),
|
|
741
|
+
type: "press",
|
|
742
|
+
key: event.key,
|
|
743
|
+
selectors: event.selectors,
|
|
744
|
+
frameUrl: event.frameUrl,
|
|
745
|
+
timestamp: event.ts
|
|
746
|
+
};
|
|
747
|
+
this.steps.push(step);
|
|
748
|
+
this.notify({ kind: "added", step });
|
|
749
|
+
break;
|
|
750
|
+
}
|
|
751
|
+
case "scroll": {
|
|
752
|
+
this.flushFill();
|
|
753
|
+
const prev = this.steps[this.steps.length - 1];
|
|
754
|
+
if (prev?.type === "scroll" && prev.scrollTarget === event.target) {
|
|
755
|
+
prev.scroll = { xNorm: event.xNorm, yNorm: event.yNorm };
|
|
756
|
+
prev.timestamp = event.ts;
|
|
757
|
+
this.notify({ kind: "updated", step: prev });
|
|
758
|
+
} else {
|
|
759
|
+
const step = {
|
|
760
|
+
id: this.nextId(),
|
|
761
|
+
type: "scroll",
|
|
762
|
+
scrollTarget: event.target,
|
|
763
|
+
scroll: { xNorm: event.xNorm, yNorm: event.yNorm },
|
|
764
|
+
frameUrl: event.frameUrl,
|
|
765
|
+
timestamp: event.ts
|
|
766
|
+
};
|
|
767
|
+
this.steps.push(step);
|
|
768
|
+
this.notify({ kind: "added", step });
|
|
769
|
+
}
|
|
770
|
+
break;
|
|
771
|
+
}
|
|
772
|
+
case "navigate": {
|
|
773
|
+
this.flushFill();
|
|
774
|
+
const prev = this.steps[this.steps.length - 1];
|
|
775
|
+
if (prev && (prev.type === "click" || prev.type === "press") && event.ts - prev.timestamp < 800) {
|
|
776
|
+
prev.causesNavigation = true;
|
|
777
|
+
this.notify({ kind: "updated", step: prev });
|
|
778
|
+
} else if (prev?.type === "navigate") {
|
|
779
|
+
prev.url = event.url;
|
|
780
|
+
prev.timestamp = event.ts;
|
|
781
|
+
this.notify({ kind: "updated", step: prev });
|
|
782
|
+
} else {
|
|
783
|
+
const step = {
|
|
784
|
+
id: this.nextId(),
|
|
785
|
+
type: "navigate",
|
|
786
|
+
url: event.url,
|
|
787
|
+
frameUrl: event.frameUrl,
|
|
788
|
+
timestamp: event.ts
|
|
789
|
+
};
|
|
790
|
+
this.steps.push(step);
|
|
791
|
+
this.notify({ kind: "added", step });
|
|
792
|
+
}
|
|
793
|
+
break;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
flushFill() {
|
|
798
|
+
this.pendingFill = null;
|
|
799
|
+
}
|
|
800
|
+
};
|
|
801
|
+
function selectorsOverlap(a, b) {
|
|
802
|
+
return a.some((s) => b.includes(s));
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// src/recorder/types.ts
|
|
806
|
+
function describeStep(step) {
|
|
807
|
+
switch (step.type) {
|
|
808
|
+
case "click":
|
|
809
|
+
return `Click ${step.label ?? firstSelector(step)}`;
|
|
810
|
+
case "fill":
|
|
811
|
+
return `Type "${truncate(step.value ?? "", 24)}" into ${firstSelector(step)}`;
|
|
812
|
+
case "select":
|
|
813
|
+
return `Select "${truncate(step.value ?? "", 24)}" in ${firstSelector(step)}`;
|
|
814
|
+
case "press":
|
|
815
|
+
return `Press ${step.key}`;
|
|
816
|
+
case "scroll":
|
|
817
|
+
return `Scroll ${step.scrollTarget === "window" ? "page" : step.scrollTarget ?? "page"}`;
|
|
818
|
+
case "navigate":
|
|
819
|
+
return `Go to ${step.url}`;
|
|
820
|
+
case "wait":
|
|
821
|
+
return "Wait";
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
function firstSelector(step) {
|
|
825
|
+
return step.selectors?.[0] ?? "element";
|
|
826
|
+
}
|
|
827
|
+
function truncate(s, n) {
|
|
828
|
+
return s.length > n ? s.slice(0, n - 1) + "\u2026" : s;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// src/capture/screencast.ts
|
|
832
|
+
async function startScreencast(page, opts = {}) {
|
|
833
|
+
const cdp = await page.context().newCDPSession(page);
|
|
834
|
+
const frames = [];
|
|
835
|
+
let t0 = null;
|
|
836
|
+
let paused = false;
|
|
837
|
+
let pauseBeganAbs = null;
|
|
838
|
+
let removed = 0;
|
|
839
|
+
let lastAbs = 0;
|
|
840
|
+
const onFrame = async (event) => {
|
|
841
|
+
const abs = (event.metadata.timestamp ?? Date.now() / 1e3) * 1e3;
|
|
842
|
+
lastAbs = abs;
|
|
843
|
+
if (t0 === null) t0 = abs;
|
|
844
|
+
if (!paused) {
|
|
845
|
+
frames.push({ data: Buffer.from(event.data, "base64"), t: abs - t0 - removed });
|
|
846
|
+
}
|
|
847
|
+
await cdp.send("Page.screencastFrameAck", { sessionId: event.sessionId }).catch(() => {
|
|
848
|
+
});
|
|
849
|
+
};
|
|
850
|
+
cdp.on("Page.screencastFrame", onFrame);
|
|
851
|
+
await cdp.send("Page.startScreencast", {
|
|
852
|
+
format: "jpeg",
|
|
853
|
+
quality: opts.quality ?? 90,
|
|
854
|
+
everyNthFrame: opts.everyNthFrame ?? 1
|
|
855
|
+
});
|
|
856
|
+
return {
|
|
857
|
+
frameCount() {
|
|
858
|
+
return frames.length;
|
|
859
|
+
},
|
|
860
|
+
truncate(n) {
|
|
861
|
+
if (n >= 0 && n < frames.length) frames.length = n;
|
|
862
|
+
},
|
|
863
|
+
latest() {
|
|
864
|
+
return frames.length ? frames[frames.length - 1].data : null;
|
|
865
|
+
},
|
|
866
|
+
pause() {
|
|
867
|
+
if (paused) return;
|
|
868
|
+
paused = true;
|
|
869
|
+
pauseBeganAbs = lastAbs;
|
|
870
|
+
},
|
|
871
|
+
resume() {
|
|
872
|
+
if (!paused) return;
|
|
873
|
+
paused = false;
|
|
874
|
+
if (pauseBeganAbs !== null) removed += Math.max(0, lastAbs - pauseBeganAbs);
|
|
875
|
+
pauseBeganAbs = null;
|
|
876
|
+
},
|
|
877
|
+
async stop() {
|
|
878
|
+
await cdp.send("Page.stopScreencast").catch(() => {
|
|
879
|
+
});
|
|
880
|
+
cdp.off("Page.screencastFrame", onFrame);
|
|
881
|
+
await cdp.detach().catch(() => {
|
|
882
|
+
});
|
|
883
|
+
return frames;
|
|
884
|
+
}
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// src/capture/region.ts
|
|
889
|
+
function cssRectToFramePixels(rect, frameSize, pageCssSize) {
|
|
890
|
+
const scaleX = frameSize.width / pageCssSize.width;
|
|
891
|
+
const scaleY = frameSize.height / pageCssSize.height;
|
|
892
|
+
const left = Math.max(0, Math.round(rect.x * scaleX));
|
|
893
|
+
const top = Math.max(0, Math.round(rect.y * scaleY));
|
|
894
|
+
return {
|
|
895
|
+
left,
|
|
896
|
+
top,
|
|
897
|
+
width: Math.min(Math.round(rect.width * scaleX), frameSize.width - left),
|
|
898
|
+
height: Math.min(Math.round(rect.height * scaleY), frameSize.height - top)
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// src/encode/media.ts
|
|
903
|
+
import sharp3 from "sharp";
|
|
904
|
+
|
|
905
|
+
// src/capture/frames.ts
|
|
906
|
+
function resampleToFps(frames, fps) {
|
|
907
|
+
if (frames.length === 0) return [];
|
|
908
|
+
const interval = 1e3 / fps;
|
|
909
|
+
const duration = frames[frames.length - 1].t;
|
|
910
|
+
const out = [];
|
|
911
|
+
let src = 0;
|
|
912
|
+
for (let t = 0; t <= duration; t += interval) {
|
|
913
|
+
while (src + 1 < frames.length && frames[src + 1].t <= t) src++;
|
|
914
|
+
out.push({ data: frames[src].data, t });
|
|
915
|
+
}
|
|
916
|
+
return out;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// src/encode/gif.ts
|
|
920
|
+
import { mkdir as mkdir2, writeFile as writeFile3 } from "fs/promises";
|
|
921
|
+
import path8 from "path";
|
|
922
|
+
import sharp2 from "sharp";
|
|
923
|
+
import * as gifencNs from "gifenc";
|
|
924
|
+
|
|
925
|
+
// src/encode/ffmpeg.ts
|
|
926
|
+
import { execFile, spawn as spawn2 } from "child_process";
|
|
927
|
+
import { mkdtemp, rm, writeFile as writeFile2, mkdir } from "fs/promises";
|
|
928
|
+
import { tmpdir } from "os";
|
|
929
|
+
import path6 from "path";
|
|
930
|
+
var available = null;
|
|
931
|
+
function ffmpegAvailable() {
|
|
932
|
+
if (available !== null) return Promise.resolve(available);
|
|
933
|
+
return new Promise((resolve) => {
|
|
934
|
+
execFile("ffmpeg", ["-version"], (err) => {
|
|
935
|
+
available = !err;
|
|
936
|
+
resolve(available);
|
|
937
|
+
});
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
async function withFrameDir(pngFrames, fn) {
|
|
941
|
+
const dir = await mkdtemp(path6.join(tmpdir(), "pr-preview-frames-"));
|
|
942
|
+
try {
|
|
943
|
+
await Promise.all(
|
|
944
|
+
pngFrames.map(
|
|
945
|
+
(buf, i) => writeFile2(path6.join(dir, `frame${String(i).padStart(5, "0")}.png`), buf)
|
|
946
|
+
)
|
|
947
|
+
);
|
|
948
|
+
return await fn(path6.join(dir, "frame%05d.png"));
|
|
949
|
+
} finally {
|
|
950
|
+
await rm(dir, { recursive: true, force: true });
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
async function encodeWithFfmpeg(pngFrames, fps, outFile) {
|
|
954
|
+
await withFrameDir(pngFrames, async (pattern) => {
|
|
955
|
+
await mkdir(path6.dirname(outFile), { recursive: true });
|
|
956
|
+
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`;
|
|
957
|
+
await run("ffmpeg", ["-y", "-framerate", String(fps), "-i", pattern, "-filter_complex", filter, outFile]);
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
async function encodeMp4(pngFrames, fps, outFile) {
|
|
961
|
+
await withFrameDir(pngFrames, async (pattern) => {
|
|
962
|
+
await mkdir(path6.dirname(outFile), { recursive: true });
|
|
963
|
+
await run("ffmpeg", [
|
|
964
|
+
"-y",
|
|
965
|
+
"-framerate",
|
|
966
|
+
String(fps),
|
|
967
|
+
"-i",
|
|
968
|
+
pattern,
|
|
969
|
+
"-c:v",
|
|
970
|
+
"libx264",
|
|
971
|
+
"-preset",
|
|
972
|
+
"slow",
|
|
973
|
+
"-crf",
|
|
974
|
+
"19",
|
|
975
|
+
"-pix_fmt",
|
|
976
|
+
"yuv420p",
|
|
977
|
+
"-vf",
|
|
978
|
+
"scale=trunc(iw/2)*2:trunc(ih/2)*2",
|
|
979
|
+
"-movflags",
|
|
980
|
+
"+faststart",
|
|
981
|
+
outFile
|
|
982
|
+
]);
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
function run(cmd, args) {
|
|
986
|
+
return new Promise((resolve, reject) => {
|
|
987
|
+
const child = spawn2(cmd, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
988
|
+
let err = "";
|
|
989
|
+
child.stderr.on("data", (c) => err = (err + c.toString()).slice(-2e3));
|
|
990
|
+
child.on("error", reject);
|
|
991
|
+
child.on(
|
|
992
|
+
"exit",
|
|
993
|
+
(code) => code === 0 ? resolve() : reject(new Error(`${cmd} failed (${code}):
|
|
994
|
+
${err}`))
|
|
995
|
+
);
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// src/encode/label.ts
|
|
1000
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync2, writeFileSync } from "fs";
|
|
1001
|
+
import path7 from "path";
|
|
1002
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
1003
|
+
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
1004
|
+
import sharp from "sharp";
|
|
1005
|
+
var BLURPLE = "#635BFF";
|
|
1006
|
+
var GREEN = "#2DA44E";
|
|
1007
|
+
var INK = "#1F2328";
|
|
1008
|
+
var MUTED = "#57606A";
|
|
1009
|
+
function fontFile() {
|
|
1010
|
+
const here = path7.dirname(fileURLToPath3(import.meta.url));
|
|
1011
|
+
const candidates = [
|
|
1012
|
+
path7.resolve(here, "../../assets/fonts/Inter.ttf"),
|
|
1013
|
+
// dist/encode → dist/../assets? n/a
|
|
1014
|
+
path7.resolve(here, "../assets/fonts/Inter.ttf"),
|
|
1015
|
+
// dist/encode → dist/assets
|
|
1016
|
+
path7.resolve(here, "../../../assets/fonts/Inter.ttf")
|
|
1017
|
+
// src/encode → repo/assets
|
|
1018
|
+
];
|
|
1019
|
+
return candidates.find(existsSync5) ?? candidates[candidates.length - 1];
|
|
1020
|
+
}
|
|
1021
|
+
var fontconfigReady = false;
|
|
1022
|
+
function ensureFontconfig() {
|
|
1023
|
+
if (fontconfigReady || process.env.FONTCONFIG_FILE) {
|
|
1024
|
+
fontconfigReady = true;
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
try {
|
|
1028
|
+
const dir = path7.join(tmpdir2(), "pr-preview-fontconfig");
|
|
1029
|
+
mkdirSync2(dir, { recursive: true });
|
|
1030
|
+
const conf = path7.join(dir, "fonts.conf");
|
|
1031
|
+
writeFileSync(
|
|
1032
|
+
conf,
|
|
1033
|
+
`<?xml version="1.0"?>
|
|
1034
|
+
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
|
|
1035
|
+
<fontconfig>
|
|
1036
|
+
<dir>${path7.dirname(fontFile())}</dir>
|
|
1037
|
+
<cachedir>${path7.join(dir, "cache")}</cachedir>
|
|
1038
|
+
</fontconfig>
|
|
1039
|
+
`
|
|
1040
|
+
);
|
|
1041
|
+
process.env.FONTCONFIG_FILE = conf;
|
|
1042
|
+
} catch {
|
|
1043
|
+
}
|
|
1044
|
+
fontconfigReady = true;
|
|
1045
|
+
}
|
|
1046
|
+
function escapeMarkup(s) {
|
|
1047
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1048
|
+
}
|
|
1049
|
+
async function renderLabel(spec) {
|
|
1050
|
+
ensureFontconfig();
|
|
1051
|
+
const accent = spec.pass === "after" ? GREEN : BLURPLE;
|
|
1052
|
+
const branch = escapeMarkup(spec.branch);
|
|
1053
|
+
const base = escapeMarkup(spec.baseBranch);
|
|
1054
|
+
const ts = escapeMarkup(spec.timestamp);
|
|
1055
|
+
const tagSpan = spec.pass === "single" ? `<span foreground="${accent}" weight="700">\u25CF</span>` : `<span foreground="${accent}" weight="700">\u25CF ${spec.pass === "before" ? "BEFORE" : "AFTER"}</span>`;
|
|
1056
|
+
const context = spec.pass === "single" ? `${ts}` : spec.pass === "before" ? `base branch \xB7 ${ts}` : `vs base ${base} \xB7 ${ts}`;
|
|
1057
|
+
const markup = `${tagSpan} <span foreground="${INK}" weight="700">${branch}</span> <span foreground="${MUTED}">${context}</span>`;
|
|
1058
|
+
const pt = Math.round(Math.min(28, Math.max(15, spec.videoWidth * 0.0145)));
|
|
1059
|
+
const text = await sharp({
|
|
1060
|
+
text: { text: markup, fontfile: fontFile(), font: `sans ${pt}`, rgba: true, dpi: 72 }
|
|
1061
|
+
}).png().toBuffer();
|
|
1062
|
+
const tm = await sharp(text).metadata();
|
|
1063
|
+
const tw = tm.width ?? 200;
|
|
1064
|
+
const th = tm.height ?? pt;
|
|
1065
|
+
const padX = Math.round(pt * 0.85);
|
|
1066
|
+
const padY = Math.round(pt * 0.6);
|
|
1067
|
+
const w = tw + padX * 2;
|
|
1068
|
+
const h = th + padY * 2;
|
|
1069
|
+
const r = Math.round(h / 2);
|
|
1070
|
+
const bg = Buffer.from(
|
|
1071
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}">
|
|
1072
|
+
<rect x="0.5" y="0.5" width="${w - 1}" height="${h - 1}" rx="${r}" ry="${r}"
|
|
1073
|
+
fill="#FFFFFF" fill-opacity="0.94" stroke="#D0D7DE" stroke-opacity="0.9"/>
|
|
1074
|
+
</svg>`
|
|
1075
|
+
);
|
|
1076
|
+
return sharp(bg).composite([{ input: text, top: padY, left: padX }]).png().toBuffer();
|
|
1077
|
+
}
|
|
1078
|
+
var LOGO_SVG = `<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
|
1079
|
+
<defs><linearGradient id="g" x1="6" y1="4" x2="58" y2="60" gradientUnits="userSpaceOnUse">
|
|
1080
|
+
<stop stop-color="#635BFF"/><stop offset="1" stop-color="#4B45C6"/></linearGradient></defs>
|
|
1081
|
+
<rect x="2" y="2" width="60" height="60" rx="15" fill="url(#g)"/>
|
|
1082
|
+
<g transform="rotate(-9 32 19)"><rect x="13" y="13.5" width="38" height="9.5" rx="2.4" fill="#fff"/>
|
|
1083
|
+
<path d="M19 13.5 24 23M27 13.5 32 23M35 13.5 40 23M43 13.5 48 23" stroke="#4B45C6" stroke-width="2.3" stroke-linecap="round"/></g>
|
|
1084
|
+
<rect x="13" y="26" width="38" height="24" rx="4.5" fill="#fff"/>
|
|
1085
|
+
<g stroke="#4B45C6" stroke-width="2.7" stroke-linecap="round" stroke-linejoin="round" fill="none">
|
|
1086
|
+
<path d="M23 32 17.5 38 23 44"/><path d="M41 32 46.5 38 41 44"/><path d="M35.5 30.5 28.5 45.5" stroke="#635BFF"/></g>
|
|
1087
|
+
</svg>`;
|
|
1088
|
+
async function renderWatermark(videoWidth) {
|
|
1089
|
+
ensureFontconfig();
|
|
1090
|
+
const pt = Math.round(Math.min(22, Math.max(12, videoWidth * 0.0115)));
|
|
1091
|
+
const logoPx = Math.round(pt * 1.7);
|
|
1092
|
+
const logo = await sharp(Buffer.from(LOGO_SVG)).resize(logoPx, logoPx).png().toBuffer();
|
|
1093
|
+
const text = await sharp({
|
|
1094
|
+
text: {
|
|
1095
|
+
text: `<span foreground="${INK}" weight="600">pr-preview</span><span foreground="${MUTED}">.com</span>`,
|
|
1096
|
+
fontfile: fontFile(),
|
|
1097
|
+
font: `sans ${pt}`,
|
|
1098
|
+
rgba: true,
|
|
1099
|
+
dpi: 72
|
|
1100
|
+
}
|
|
1101
|
+
}).png().toBuffer();
|
|
1102
|
+
const tm = await sharp(text).metadata();
|
|
1103
|
+
const tw = tm.width ?? 120;
|
|
1104
|
+
const th = tm.height ?? pt;
|
|
1105
|
+
const gap = Math.round(pt * 0.5);
|
|
1106
|
+
const padX = Math.round(pt * 0.75);
|
|
1107
|
+
const padY = Math.round(pt * 0.5);
|
|
1108
|
+
const h = Math.max(logoPx, th) + padY * 2;
|
|
1109
|
+
const w = padX * 2 + logoPx + gap + tw;
|
|
1110
|
+
const r = Math.round(h / 2);
|
|
1111
|
+
const bg = Buffer.from(
|
|
1112
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}">
|
|
1113
|
+
<rect x="0.5" y="0.5" width="${w - 1}" height="${h - 1}" rx="${r}" ry="${r}"
|
|
1114
|
+
fill="#FFFFFF" fill-opacity="0.94" stroke="#D0D7DE" stroke-opacity="0.9"/>
|
|
1115
|
+
</svg>`
|
|
1116
|
+
);
|
|
1117
|
+
return sharp(bg).composite([
|
|
1118
|
+
{ input: logo, top: Math.round((h - logoPx) / 2), left: padX },
|
|
1119
|
+
{ input: text, top: Math.round((h - th) / 2), left: padX + logoPx + gap }
|
|
1120
|
+
]).png().toBuffer();
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// src/encode/gif.ts
|
|
1124
|
+
var gifencLib = typeof gifencNs.quantize === "function" ? gifencNs : gifencNs.default;
|
|
1125
|
+
var { GIFEncoder, quantize: quantize2, applyPalette } = gifencLib;
|
|
1126
|
+
async function buildOverlays(label, outWidth, outHeight) {
|
|
1127
|
+
const inset = Math.round(outWidth * 0.018);
|
|
1128
|
+
const overlays = [];
|
|
1129
|
+
if (label) {
|
|
1130
|
+
const png = await renderLabel({ ...label, videoWidth: outWidth });
|
|
1131
|
+
const m = await sharp2(png).metadata();
|
|
1132
|
+
overlays.push({ input: png, top: outHeight - (m.height ?? 0) - inset, left: inset });
|
|
1133
|
+
}
|
|
1134
|
+
const wm = await renderWatermark(outWidth);
|
|
1135
|
+
const wmMeta = await sharp2(wm).metadata();
|
|
1136
|
+
overlays.push({
|
|
1137
|
+
input: wm,
|
|
1138
|
+
top: outHeight - (wmMeta.height ?? 0) - inset,
|
|
1139
|
+
left: outWidth - (wmMeta.width ?? 0) - inset
|
|
1140
|
+
});
|
|
1141
|
+
return overlays;
|
|
1142
|
+
}
|
|
1143
|
+
async function encodeGif(rawFrames, opts) {
|
|
1144
|
+
if (rawFrames.length === 0) throw new Error("No frames captured \u2014 nothing to encode");
|
|
1145
|
+
const frames = resampleToFps(rawFrames, opts.fps);
|
|
1146
|
+
const meta = await sharp2(frames[0].data).metadata();
|
|
1147
|
+
const crop = cssRectToFramePixels(
|
|
1148
|
+
opts.cropCss,
|
|
1149
|
+
{ width: meta.width, height: meta.height },
|
|
1150
|
+
opts.pageCssSize
|
|
1151
|
+
);
|
|
1152
|
+
const outWidth = Math.min(opts.width, crop.width);
|
|
1153
|
+
const outHeight = Math.round(crop.height / crop.width * outWidth);
|
|
1154
|
+
const overlays = await buildOverlays(opts.label, outWidth, outHeight);
|
|
1155
|
+
const rgbaCache = /* @__PURE__ */ new Map();
|
|
1156
|
+
const decode = async (jpeg) => {
|
|
1157
|
+
const hit = rgbaCache.get(jpeg);
|
|
1158
|
+
if (hit) return hit;
|
|
1159
|
+
let pipe = sharp2(jpeg).extract(crop).resize(outWidth, outHeight);
|
|
1160
|
+
if (overlays.length) pipe = pipe.composite(overlays);
|
|
1161
|
+
const { data } = await pipe.ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
1162
|
+
const rgba = new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength);
|
|
1163
|
+
rgbaCache.set(jpeg, rgba);
|
|
1164
|
+
return rgba;
|
|
1165
|
+
};
|
|
1166
|
+
if (opts.quality === "max" && await ffmpegAvailable()) {
|
|
1167
|
+
const pngs = [];
|
|
1168
|
+
for (const f of frames) {
|
|
1169
|
+
let pipe = sharp2(f.data).extract(crop).resize(outWidth, outHeight);
|
|
1170
|
+
if (overlays.length) pipe = pipe.composite(overlays);
|
|
1171
|
+
pngs.push(await pipe.png().toBuffer());
|
|
1172
|
+
opts.onProgress?.("Rendering frames", pngs.length, frames.length);
|
|
1173
|
+
}
|
|
1174
|
+
opts.onProgress?.("Encoding GIF", frames.length, frames.length);
|
|
1175
|
+
await encodeWithFfmpeg(pngs, opts.fps, opts.outFile);
|
|
1176
|
+
return { path: opts.outFile, frameCount: frames.length };
|
|
1177
|
+
}
|
|
1178
|
+
const sampleCount = Math.min(10, frames.length);
|
|
1179
|
+
const sampleStride = Math.max(1, Math.floor(frames.length / sampleCount));
|
|
1180
|
+
const samples = [];
|
|
1181
|
+
for (let i = 0; i < frames.length; i += sampleStride) {
|
|
1182
|
+
samples.push(await decode(frames[i].data));
|
|
1183
|
+
}
|
|
1184
|
+
const combined = new Uint8ClampedArray(samples.reduce((n, s) => n + s.length, 0));
|
|
1185
|
+
let offset = 0;
|
|
1186
|
+
for (const s of samples) {
|
|
1187
|
+
combined.set(s, offset);
|
|
1188
|
+
offset += s.length;
|
|
1189
|
+
}
|
|
1190
|
+
const palette = quantize2(combined, opts.maxColors);
|
|
1191
|
+
const gif = GIFEncoder();
|
|
1192
|
+
const delay = 1e3 / opts.fps;
|
|
1193
|
+
let first = true;
|
|
1194
|
+
let done = 0;
|
|
1195
|
+
for (const frame of frames) {
|
|
1196
|
+
const rgba = await decode(frame.data);
|
|
1197
|
+
const indexed = applyPalette(rgba, palette);
|
|
1198
|
+
gif.writeFrame(indexed, outWidth, outHeight, {
|
|
1199
|
+
palette: first ? palette : void 0,
|
|
1200
|
+
first,
|
|
1201
|
+
delay,
|
|
1202
|
+
repeat: first ? 0 : void 0
|
|
1203
|
+
// loop forever
|
|
1204
|
+
});
|
|
1205
|
+
first = false;
|
|
1206
|
+
opts.onProgress?.("Encoding GIF", ++done, frames.length);
|
|
1207
|
+
}
|
|
1208
|
+
gif.finish();
|
|
1209
|
+
await mkdir2(path8.dirname(opts.outFile), { recursive: true });
|
|
1210
|
+
await writeFile3(opts.outFile, gif.bytes());
|
|
1211
|
+
return { path: opts.outFile, frameCount: frames.length };
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// src/encode/media.ts
|
|
1215
|
+
async function encodeMedia(rawFrames, opts) {
|
|
1216
|
+
if (rawFrames.length === 0) throw new Error("No frames captured \u2014 nothing to encode");
|
|
1217
|
+
const wantsMp4 = opts.format === "mp4" || opts.format === "both";
|
|
1218
|
+
const haveFfmpeg = await ffmpegAvailable();
|
|
1219
|
+
const doMp4 = wantsMp4 && haveFfmpeg;
|
|
1220
|
+
const doGif = opts.format === "gif" || opts.format === "both" || wantsMp4 && !haveFfmpeg;
|
|
1221
|
+
const paths = [];
|
|
1222
|
+
let frameCount = 0;
|
|
1223
|
+
if (doMp4) {
|
|
1224
|
+
const frames = resampleToFps(rawFrames, opts.fps);
|
|
1225
|
+
frameCount = frames.length;
|
|
1226
|
+
const meta = await sharp3(frames[0].data).metadata();
|
|
1227
|
+
const crop = cssRectToFramePixels(
|
|
1228
|
+
opts.cropCss,
|
|
1229
|
+
{ width: meta.width, height: meta.height },
|
|
1230
|
+
opts.pageCssSize
|
|
1231
|
+
);
|
|
1232
|
+
const outWidth = Math.min(opts.width * 2, crop.width);
|
|
1233
|
+
const outHeight = Math.round(crop.height / crop.width * outWidth);
|
|
1234
|
+
const overlays = await buildOverlays(opts.label, outWidth, outHeight);
|
|
1235
|
+
const pngs = [];
|
|
1236
|
+
const cache = /* @__PURE__ */ new Map();
|
|
1237
|
+
for (const f of frames) {
|
|
1238
|
+
let png = cache.get(f.data);
|
|
1239
|
+
if (!png) {
|
|
1240
|
+
let pipe = sharp3(f.data).extract(crop).resize(outWidth, outHeight);
|
|
1241
|
+
if (overlays.length) pipe = pipe.composite(overlays);
|
|
1242
|
+
png = await pipe.png().toBuffer();
|
|
1243
|
+
cache.set(f.data, png);
|
|
1244
|
+
}
|
|
1245
|
+
pngs.push(png);
|
|
1246
|
+
opts.onProgress?.("Rendering frames", pngs.length, frames.length);
|
|
1247
|
+
}
|
|
1248
|
+
const file = `${opts.outBase}.mp4`;
|
|
1249
|
+
opts.onProgress?.("Encoding MP4", frames.length, frames.length);
|
|
1250
|
+
await encodeMp4(pngs, opts.fps, file);
|
|
1251
|
+
paths.push(file);
|
|
1252
|
+
}
|
|
1253
|
+
if (doGif) {
|
|
1254
|
+
const result = await encodeGif(rawFrames, { ...opts, outFile: `${opts.outBase}.gif` });
|
|
1255
|
+
paths.push(result.path);
|
|
1256
|
+
frameCount = result.frameCount;
|
|
1257
|
+
}
|
|
1258
|
+
return { paths, frameCount, fellBackToGif: wantsMp4 && !haveFfmpeg };
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// src/browser/frame.ts
|
|
1262
|
+
async function getAppFrame(page, targetOrigin, timeoutMs = 15e3) {
|
|
1263
|
+
const deadline = Date.now() + timeoutMs;
|
|
1264
|
+
while (Date.now() < deadline) {
|
|
1265
|
+
const frame = page.frames().find((f) => {
|
|
1266
|
+
try {
|
|
1267
|
+
return new URL(f.url()).origin === targetOrigin;
|
|
1268
|
+
} catch {
|
|
1269
|
+
return false;
|
|
1270
|
+
}
|
|
1271
|
+
});
|
|
1272
|
+
if (frame && !frame.isDetached()) return frame;
|
|
1273
|
+
await page.waitForTimeout(200);
|
|
1274
|
+
}
|
|
1275
|
+
throw new Error(`App iframe (${targetOrigin}) did not appear within ${timeoutMs}ms`);
|
|
1276
|
+
}
|
|
1277
|
+
async function getAppFrameRect(page) {
|
|
1278
|
+
const box = await page.locator("#app-frame").boundingBox();
|
|
1279
|
+
if (!box) throw new Error("App iframe element (#app-frame) not found in harness page");
|
|
1280
|
+
return box;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// src/session/session.ts
|
|
1284
|
+
var Session = class {
|
|
1285
|
+
constructor(bus, config, mode, appUrl) {
|
|
1286
|
+
this.bus = bus;
|
|
1287
|
+
this.config = config;
|
|
1288
|
+
this.mode = mode;
|
|
1289
|
+
this.appUrl = appUrl;
|
|
1290
|
+
this.passInfo.resetStorage = config.resetStorage;
|
|
1291
|
+
bus.onHello(() => ({
|
|
1292
|
+
type: "HELLO",
|
|
1293
|
+
phase: this.phase,
|
|
1294
|
+
steps: this.builder.getSteps().map(toSummary),
|
|
1295
|
+
appUrl: this.appUrl,
|
|
1296
|
+
mode: this.mode,
|
|
1297
|
+
passInfo: this.passInfo
|
|
1298
|
+
}));
|
|
1299
|
+
bus.onMessage((msg) => {
|
|
1300
|
+
if (msg.type === "RELOAD_IFRAME") void this.reloadAppFrame();
|
|
1301
|
+
});
|
|
1302
|
+
this.builder.onChange((change) => {
|
|
1303
|
+
switch (change.kind) {
|
|
1304
|
+
case "added":
|
|
1305
|
+
if (this.recordingFeed) this.frameMark.set(change.step.id, this.recordingFeed.frameCount());
|
|
1306
|
+
bus.send({ type: "STEP_ADDED", step: toSummary(change.step) });
|
|
1307
|
+
this.scheduleThumbnail(change.step);
|
|
1308
|
+
break;
|
|
1309
|
+
case "updated":
|
|
1310
|
+
if (this.recordingFeed) this.frameMark.set(change.step.id, this.recordingFeed.frameCount());
|
|
1311
|
+
bus.send({ type: "STEP_UPDATED", step: toSummary(change.step) });
|
|
1312
|
+
break;
|
|
1313
|
+
case "removed":
|
|
1314
|
+
bus.send({ type: "STEP_REMOVED", stepId: change.stepId });
|
|
1315
|
+
break;
|
|
1316
|
+
case "reset":
|
|
1317
|
+
bus.send({ type: "STEPS_RESET", steps: this.builder.getSteps().map(toSummary) });
|
|
1318
|
+
break;
|
|
1319
|
+
}
|
|
1320
|
+
});
|
|
1321
|
+
}
|
|
1322
|
+
bus;
|
|
1323
|
+
config;
|
|
1324
|
+
mode;
|
|
1325
|
+
appUrl;
|
|
1326
|
+
builder = new StepBuilder();
|
|
1327
|
+
/** Path the journey starts on — captured when recording begins. */
|
|
1328
|
+
startUrl = "/";
|
|
1329
|
+
phase = "idle";
|
|
1330
|
+
page = null;
|
|
1331
|
+
thumbnailQueue = Promise.resolve();
|
|
1332
|
+
/** Full screencast kept alive during recording — its frames ARE the clip,
|
|
1333
|
+
* and its latest frame drives the sidebar thumbnails. */
|
|
1334
|
+
recordingFeed = null;
|
|
1335
|
+
/** frames.length at the moment each step was recorded — lets a re-record
|
|
1336
|
+
* truncate the footage at that step. */
|
|
1337
|
+
frameMark = /* @__PURE__ */ new Map();
|
|
1338
|
+
passInfo = {
|
|
1339
|
+
pass: "before",
|
|
1340
|
+
done: { before: false, after: false },
|
|
1341
|
+
resetStorage: true
|
|
1342
|
+
};
|
|
1343
|
+
/** Single-recording session — the clip's caption drops the BEFORE/AFTER pill. */
|
|
1344
|
+
singleClip = false;
|
|
1345
|
+
/** The confirmed BEFORE journey, offered as a starting point for AFTER. */
|
|
1346
|
+
beforeJourney = null;
|
|
1347
|
+
attachPage(page, appUrl) {
|
|
1348
|
+
this.page = page;
|
|
1349
|
+
this.appUrl = appUrl;
|
|
1350
|
+
}
|
|
1351
|
+
get targetOrigin() {
|
|
1352
|
+
return new URL(this.appUrl).origin;
|
|
1353
|
+
}
|
|
1354
|
+
setPhase(phase, detail) {
|
|
1355
|
+
this.phase = phase;
|
|
1356
|
+
this.bus.send({ type: "PHASE_CHANGED", phase, detail });
|
|
1357
|
+
if (phase === "recording") void this.onEnterRecording();
|
|
1358
|
+
else void this.onLeaveRecording();
|
|
1359
|
+
}
|
|
1360
|
+
/** Set branch labels for the tab strip (run mode). */
|
|
1361
|
+
setBranches(before, after) {
|
|
1362
|
+
this.passInfo = { ...this.passInfo, branches: { before, after } };
|
|
1363
|
+
this.bus.send({ type: "PASS_CHANGED", passInfo: this.passInfo });
|
|
1364
|
+
}
|
|
1365
|
+
/** Declare the session plan (1 = single clip, 2 = before/after) so the
|
|
1366
|
+
* harness wizard/tabs render the right shape. */
|
|
1367
|
+
setPasses(passes) {
|
|
1368
|
+
this.singleClip = passes === 1;
|
|
1369
|
+
this.passInfo = { ...this.passInfo, passes };
|
|
1370
|
+
this.bus.send({ type: "PASS_CHANGED", passInfo: this.passInfo });
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* Blocking nudge at the start of a pass, BEFORE recording: clear the app
|
|
1374
|
+
* (network/cookies/storage) and reload, or keep the current session. Shown
|
|
1375
|
+
* only when there's actually state to reset.
|
|
1376
|
+
*/
|
|
1377
|
+
async promptResetChoice() {
|
|
1378
|
+
if (!await this.hasResettableState()) return;
|
|
1379
|
+
this.bus.send({
|
|
1380
|
+
type: "RESET_PROMPT",
|
|
1381
|
+
pass: this.passInfo.pass,
|
|
1382
|
+
defaultReset: this.config.resetStorage
|
|
1383
|
+
});
|
|
1384
|
+
const { reset } = await this.bus.waitFor("RESET_CHOICE");
|
|
1385
|
+
this.passInfo = { ...this.passInfo, resetStorage: reset };
|
|
1386
|
+
this.bus.send({ type: "PASS_CHANGED", passInfo: this.passInfo });
|
|
1387
|
+
if (reset) await this.resetIframe();
|
|
1388
|
+
}
|
|
1389
|
+
/** Is there any cookie / localStorage / sessionStorage worth resetting? */
|
|
1390
|
+
async hasResettableState() {
|
|
1391
|
+
const page = this.page;
|
|
1392
|
+
if (!page) return false;
|
|
1393
|
+
try {
|
|
1394
|
+
const cookies = await page.context().cookies();
|
|
1395
|
+
if (cookies.length > 0) return true;
|
|
1396
|
+
const frame = await getAppFrame(page, this.targetOrigin, 5e3);
|
|
1397
|
+
return await frame.evaluate(() => localStorage.length > 0 || sessionStorage.length > 0);
|
|
1398
|
+
} catch {
|
|
1399
|
+
return false;
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
/** Clear cookies + localStorage + sessionStorage and reload the app. */
|
|
1403
|
+
async resetIframe() {
|
|
1404
|
+
const page = this.page;
|
|
1405
|
+
if (!page) return;
|
|
1406
|
+
await page.context().clearCookies().catch(() => {
|
|
1407
|
+
});
|
|
1408
|
+
try {
|
|
1409
|
+
const frame = await getAppFrame(page, this.targetOrigin, 5e3);
|
|
1410
|
+
await frame.evaluate(() => {
|
|
1411
|
+
localStorage.clear();
|
|
1412
|
+
sessionStorage.clear();
|
|
1413
|
+
});
|
|
1414
|
+
} catch {
|
|
1415
|
+
}
|
|
1416
|
+
await page.reload({ waitUntil: "domcontentloaded" });
|
|
1417
|
+
await getAppFrame(page, this.targetOrigin).then((f) => f.waitForLoadState("domcontentloaded").catch(() => {
|
|
1418
|
+
})).catch(() => {
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
/** Switch the session to the other pass; AFTER starts with a clean slate. */
|
|
1422
|
+
switchPass(pass) {
|
|
1423
|
+
this.passInfo = { ...this.passInfo, pass };
|
|
1424
|
+
if (pass === "after") {
|
|
1425
|
+
this.beforeJourney = { steps: this.builder.getSteps(), startUrl: this.startUrl };
|
|
1426
|
+
this.builder.setSteps([]);
|
|
1427
|
+
this.frameMark.clear();
|
|
1428
|
+
this.startUrl = "/";
|
|
1429
|
+
this.passInfo = { ...this.passInfo, resetStorage: this.config.resetStorage };
|
|
1430
|
+
}
|
|
1431
|
+
this.bus.send({ type: "PASS_CHANGED", passInfo: this.passInfo });
|
|
1432
|
+
}
|
|
1433
|
+
markGifDone(which) {
|
|
1434
|
+
this.passInfo = { ...this.passInfo, done: { ...this.passInfo.done, [which]: true } };
|
|
1435
|
+
this.bus.send({ type: "PASS_CHANGED", passInfo: this.passInfo });
|
|
1436
|
+
}
|
|
1437
|
+
/** Raw events from the in-page recorder land here (via exposeBinding). */
|
|
1438
|
+
handleRawEvent = (event) => {
|
|
1439
|
+
if (this.phase === "recording") this.builder.handle(event);
|
|
1440
|
+
};
|
|
1441
|
+
/** Reload the current page inside the app iframe (cross-origin safe via CDP). */
|
|
1442
|
+
async reloadAppFrame() {
|
|
1443
|
+
const page = this.page;
|
|
1444
|
+
if (!page) return;
|
|
1445
|
+
try {
|
|
1446
|
+
const frame = await getAppFrame(page, this.targetOrigin, 3e3);
|
|
1447
|
+
await frame.evaluate(() => location.reload());
|
|
1448
|
+
} catch {
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
/**
|
|
1452
|
+
* Drive the interactive record/edit loop until the user confirms.
|
|
1453
|
+
* Resolves with the confirmed steps.
|
|
1454
|
+
*/
|
|
1455
|
+
async recordUntilConfirmed() {
|
|
1456
|
+
return new Promise((resolve, reject) => {
|
|
1457
|
+
const off = this.bus.onMessage((msg) => {
|
|
1458
|
+
switch (msg.type) {
|
|
1459
|
+
case "START_RECORD":
|
|
1460
|
+
if (this.builder.getSteps().length === 0) {
|
|
1461
|
+
void this.currentAppPath().then((p) => this.startUrl = p);
|
|
1462
|
+
}
|
|
1463
|
+
this.setPhase("recording");
|
|
1464
|
+
break;
|
|
1465
|
+
case "STOP_RECORD":
|
|
1466
|
+
this.setPhase("idle");
|
|
1467
|
+
break;
|
|
1468
|
+
case "DELETE_STEP":
|
|
1469
|
+
this.builder.removeStep(msg.stepId);
|
|
1470
|
+
break;
|
|
1471
|
+
case "RERECORD_FROM":
|
|
1472
|
+
this.truncateLiveFrames(msg.stepId);
|
|
1473
|
+
this.builder.truncateFrom(msg.stepId);
|
|
1474
|
+
this.setPhase("recording", "Re-recording \u2014 perform the journey from this point");
|
|
1475
|
+
break;
|
|
1476
|
+
case "LOAD_BEFORE_STEPS":
|
|
1477
|
+
if (this.beforeJourney) {
|
|
1478
|
+
this.builder.setSteps(this.beforeJourney.steps);
|
|
1479
|
+
this.startUrl = this.beforeJourney.startUrl;
|
|
1480
|
+
}
|
|
1481
|
+
break;
|
|
1482
|
+
case "CONFIRM":
|
|
1483
|
+
this.setPhase("idle");
|
|
1484
|
+
off();
|
|
1485
|
+
resolve(this.builder.getSteps());
|
|
1486
|
+
break;
|
|
1487
|
+
case "ABORT":
|
|
1488
|
+
off();
|
|
1489
|
+
reject(new Error("Aborted from the sidebar"));
|
|
1490
|
+
break;
|
|
1491
|
+
}
|
|
1492
|
+
});
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
async currentAppPath() {
|
|
1496
|
+
const page = this.page;
|
|
1497
|
+
if (!page) return "/";
|
|
1498
|
+
try {
|
|
1499
|
+
const frame = await getAppFrame(page, this.targetOrigin, 5e3);
|
|
1500
|
+
return await frame.evaluate(() => location.pathname + location.search);
|
|
1501
|
+
} catch {
|
|
1502
|
+
return "/";
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
/** Enter recording: start the full screencast (or resume it) + tell the
|
|
1506
|
+
* in-page cursor to follow the real mouse so it lands in the footage. */
|
|
1507
|
+
async onEnterRecording() {
|
|
1508
|
+
await this.setIframeRecording(true);
|
|
1509
|
+
if (this.recordingFeed) {
|
|
1510
|
+
this.recordingFeed.resume();
|
|
1511
|
+
return;
|
|
1512
|
+
}
|
|
1513
|
+
const page = this.page;
|
|
1514
|
+
if (!page) return;
|
|
1515
|
+
try {
|
|
1516
|
+
this.recordingFeed = await startScreencast(page, { quality: 80 });
|
|
1517
|
+
} catch {
|
|
1518
|
+
this.recordingFeed = null;
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
/** Leave recording: pause the feed (keep its frames). */
|
|
1522
|
+
async onLeaveRecording() {
|
|
1523
|
+
this.recordingFeed?.pause();
|
|
1524
|
+
await this.setIframeRecording(false);
|
|
1525
|
+
}
|
|
1526
|
+
/** Fully stop the recording feed and return its frames (the clip). */
|
|
1527
|
+
async takeRecordingFrames() {
|
|
1528
|
+
const feed = this.recordingFeed;
|
|
1529
|
+
this.recordingFeed = null;
|
|
1530
|
+
if (!feed) return [];
|
|
1531
|
+
return feed.stop();
|
|
1532
|
+
}
|
|
1533
|
+
/** Re-record: drop the footage recorded from `stepId` onwards. */
|
|
1534
|
+
truncateLiveFrames(stepId) {
|
|
1535
|
+
const mark = this.frameMark.get(stepId);
|
|
1536
|
+
if (mark != null) this.recordingFeed?.truncate(mark);
|
|
1537
|
+
for (const [id, n] of this.frameMark) if (mark != null && n >= mark) this.frameMark.delete(id);
|
|
1538
|
+
}
|
|
1539
|
+
/** Tell the in-page recorder to make the synthetic cursor follow the mouse. */
|
|
1540
|
+
async setIframeRecording(on) {
|
|
1541
|
+
const page = this.page;
|
|
1542
|
+
if (!page) return;
|
|
1543
|
+
try {
|
|
1544
|
+
const frame = await getAppFrame(page, this.targetOrigin, 2e3);
|
|
1545
|
+
await frame.evaluate((v) => {
|
|
1546
|
+
window.__prPreviewRecording = v;
|
|
1547
|
+
}, on);
|
|
1548
|
+
} catch {
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
/**
|
|
1552
|
+
* Trim dead time at the ends of the footage: keep a small lead-in before the
|
|
1553
|
+
* first interaction and a short tail after the last, so "getting ready" and
|
|
1554
|
+
* "reaching for Confirm" time isn't baked into the clip.
|
|
1555
|
+
*/
|
|
1556
|
+
trimToActivity(frames) {
|
|
1557
|
+
const marks = [...this.frameMark.values()].filter((n) => n >= 0 && n < frames.length).sort((a, b) => a - b);
|
|
1558
|
+
if (marks.length === 0) return frames;
|
|
1559
|
+
const HEAD = 12;
|
|
1560
|
+
const TAIL = 40;
|
|
1561
|
+
const head = Math.max(0, marks[0] - HEAD);
|
|
1562
|
+
const tail = Math.min(frames.length, marks[marks.length - 1] + TAIL);
|
|
1563
|
+
return frames.slice(head, tail);
|
|
1564
|
+
}
|
|
1565
|
+
/**
|
|
1566
|
+
* Encode the recorded footage into the clip — cropped to the iframe, with the
|
|
1567
|
+
* caption/watermark overlay. No replay, no state re-creation.
|
|
1568
|
+
*/
|
|
1569
|
+
async encodeClip(which, outBase, label) {
|
|
1570
|
+
const page = this.page;
|
|
1571
|
+
if (!page) throw new Error("No browser page attached");
|
|
1572
|
+
await this.setIframeRecording(false);
|
|
1573
|
+
const frames = this.trimToActivity(await this.takeRecordingFrames());
|
|
1574
|
+
if (frames.length === 0) throw new Error("No footage captured for the clip");
|
|
1575
|
+
const cropCss = await getAppFrameRect(page);
|
|
1576
|
+
const pageCssSize = await page.evaluate(() => ({
|
|
1577
|
+
width: window.innerWidth,
|
|
1578
|
+
height: window.innerHeight
|
|
1579
|
+
}));
|
|
1580
|
+
this.setPhase("encoding", `Encoding ${which} clip`);
|
|
1581
|
+
const result = await encodeMedia(frames, {
|
|
1582
|
+
cropCss,
|
|
1583
|
+
pageCssSize,
|
|
1584
|
+
width: this.config.gif.width,
|
|
1585
|
+
fps: this.config.gif.fps,
|
|
1586
|
+
quality: this.config.gif.quality,
|
|
1587
|
+
maxColors: this.config.gif.maxColors,
|
|
1588
|
+
format: this.config.format,
|
|
1589
|
+
outBase,
|
|
1590
|
+
label: label ? { pass: this.singleClip ? "single" : which, ...label } : void 0,
|
|
1591
|
+
onProgress: (stage, done, total) => this.bus.send({ type: "ENCODE_PROGRESS", which, stage, done, total })
|
|
1592
|
+
});
|
|
1593
|
+
this.markGifDone(which);
|
|
1594
|
+
this.bus.send({ type: "GIF_READY", which, path: result.paths[0] });
|
|
1595
|
+
return result;
|
|
1596
|
+
}
|
|
1597
|
+
/**
|
|
1598
|
+
* Crop the recording feed's latest frame to the iframe for the sidebar step
|
|
1599
|
+
* row. No discrete screenshot → no flicker on recorded actions.
|
|
1600
|
+
*/
|
|
1601
|
+
scheduleThumbnail(step) {
|
|
1602
|
+
const page = this.page;
|
|
1603
|
+
if (!page) return;
|
|
1604
|
+
this.thumbnailQueue = this.thumbnailQueue.then(async () => {
|
|
1605
|
+
try {
|
|
1606
|
+
const frame = this.recordingFeed?.latest();
|
|
1607
|
+
if (!frame) return;
|
|
1608
|
+
const meta = await sharp4(frame).metadata();
|
|
1609
|
+
if (!meta.width || !meta.height) return;
|
|
1610
|
+
const rect = await getAppFrameRect(page);
|
|
1611
|
+
const pageCss = await page.evaluate(() => ({
|
|
1612
|
+
width: window.innerWidth,
|
|
1613
|
+
height: window.innerHeight
|
|
1614
|
+
}));
|
|
1615
|
+
const crop = cssRectToFramePixels(rect, { width: meta.width, height: meta.height }, pageCss);
|
|
1616
|
+
if (crop.width < 2 || crop.height < 2) return;
|
|
1617
|
+
const small = await sharp4(frame).extract({ left: crop.left, top: crop.top, width: crop.width, height: crop.height }).resize(160).png().toBuffer();
|
|
1618
|
+
this.builder.setThumbnail(step.id, `data:image/png;base64,${small.toString("base64")}`);
|
|
1619
|
+
} catch {
|
|
1620
|
+
}
|
|
1621
|
+
});
|
|
1622
|
+
}
|
|
1623
|
+
};
|
|
1624
|
+
function toSummary(step) {
|
|
1625
|
+
return {
|
|
1626
|
+
id: step.id,
|
|
1627
|
+
type: step.type,
|
|
1628
|
+
label: describeStep(step),
|
|
1629
|
+
thumbnail: step.thumbnail
|
|
1630
|
+
};
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
// src/cli/cleanup.ts
|
|
1634
|
+
var cleanups = [];
|
|
1635
|
+
var installed = false;
|
|
1636
|
+
var running = false;
|
|
1637
|
+
function registerCleanup(fn) {
|
|
1638
|
+
install();
|
|
1639
|
+
cleanups.push(fn);
|
|
1640
|
+
return () => {
|
|
1641
|
+
const i = cleanups.indexOf(fn);
|
|
1642
|
+
if (i >= 0) cleanups.splice(i, 1);
|
|
1643
|
+
};
|
|
1644
|
+
}
|
|
1645
|
+
async function runCleanups() {
|
|
1646
|
+
if (running) return;
|
|
1647
|
+
running = true;
|
|
1648
|
+
for (const fn of [...cleanups].reverse()) {
|
|
1649
|
+
try {
|
|
1650
|
+
await fn();
|
|
1651
|
+
} catch {
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
cleanups.length = 0;
|
|
1655
|
+
running = false;
|
|
1656
|
+
}
|
|
1657
|
+
function install() {
|
|
1658
|
+
if (installed) return;
|
|
1659
|
+
installed = true;
|
|
1660
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
1661
|
+
process.on(signal, () => {
|
|
1662
|
+
void runCleanups().then(() => process.exit(130));
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1665
|
+
process.on("uncaughtException", (err) => {
|
|
1666
|
+
console.error(err);
|
|
1667
|
+
void runCleanups().then(() => process.exit(1));
|
|
1668
|
+
});
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
// src/session/bootstrap.ts
|
|
1672
|
+
async function bootstrap(repoRoot, config, mode, opts = {}) {
|
|
1673
|
+
const ports = await allocatePorts();
|
|
1674
|
+
const appUrl = (which) => opts.fixedUrl ?? resolveUrl(config, which === "before" ? ports.beforeApp : ports.afterApp);
|
|
1675
|
+
const initial = mode === "run" ? "before" : "after";
|
|
1676
|
+
const harness = await startHarnessServer({
|
|
1677
|
+
port: ports.harness,
|
|
1678
|
+
appUrl: appUrl(initial),
|
|
1679
|
+
mode,
|
|
1680
|
+
viewport: config.viewport
|
|
1681
|
+
});
|
|
1682
|
+
registerCleanup(() => harness.close());
|
|
1683
|
+
const session = new Session(harness.bus, config, mode, appUrl(initial));
|
|
1684
|
+
let browser = null;
|
|
1685
|
+
return {
|
|
1686
|
+
ports,
|
|
1687
|
+
harness,
|
|
1688
|
+
session,
|
|
1689
|
+
appUrl,
|
|
1690
|
+
async startApp(which, cwd) {
|
|
1691
|
+
const port = which === "before" ? ports.beforeApp : ports.afterApp;
|
|
1692
|
+
const url = appUrl(which);
|
|
1693
|
+
const spinner = log.spinner(`Starting ${which} dev server (${config.devCommand}) \u2026`);
|
|
1694
|
+
try {
|
|
1695
|
+
const handle = await startDevServer({
|
|
1696
|
+
command: config.devCommand,
|
|
1697
|
+
cwd: path9.join(cwd, config.cwd),
|
|
1698
|
+
port,
|
|
1699
|
+
url,
|
|
1700
|
+
readyTimeout: config.readyTimeout,
|
|
1701
|
+
logFile: path9.join(repoRoot, ".pr-preview", `dev-${which}.log`)
|
|
1702
|
+
});
|
|
1703
|
+
registerCleanup(() => handle.stop());
|
|
1704
|
+
spinner.succeed(`${which} app ready at ${url}`);
|
|
1705
|
+
return handle;
|
|
1706
|
+
} catch (err) {
|
|
1707
|
+
spinner.fail(`${which} dev server failed`);
|
|
1708
|
+
throw err;
|
|
1709
|
+
}
|
|
1710
|
+
},
|
|
1711
|
+
async openBrowser(which) {
|
|
1712
|
+
browser = await launchSession({
|
|
1713
|
+
harnessUrl: harness.url,
|
|
1714
|
+
targetOrigins: [new URL(appUrl("before")).origin, new URL(appUrl("after")).origin],
|
|
1715
|
+
appViewport: config.viewport,
|
|
1716
|
+
headerStrip: config.headerStrip,
|
|
1717
|
+
onRawEvent: session.handleRawEvent,
|
|
1718
|
+
permissions: config.permissions,
|
|
1719
|
+
geolocation: config.geolocation
|
|
1720
|
+
});
|
|
1721
|
+
registerCleanup(() => browser.close());
|
|
1722
|
+
session.attachPage(browser.page, appUrl(which));
|
|
1723
|
+
return browser;
|
|
1724
|
+
},
|
|
1725
|
+
async switchApp(which) {
|
|
1726
|
+
if (!browser) throw new Error("Browser not open");
|
|
1727
|
+
harness.setAppUrl(appUrl(which));
|
|
1728
|
+
session.attachPage(browser.page, appUrl(which));
|
|
1729
|
+
await browser.page.reload({ waitUntil: "domcontentloaded" });
|
|
1730
|
+
}
|
|
1731
|
+
};
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
// src/recorder/journey.ts
|
|
1735
|
+
import { readFile as readFile5, writeFile as writeFile4, mkdir as mkdir3 } from "fs/promises";
|
|
1736
|
+
import path10 from "path";
|
|
1737
|
+
import { z as z2 } from "zod";
|
|
1738
|
+
var stepSchema = z2.object({
|
|
1739
|
+
id: z2.string(),
|
|
1740
|
+
type: z2.enum(["click", "fill", "select", "press", "scroll", "navigate", "wait"]),
|
|
1741
|
+
selectors: z2.array(z2.string()).optional(),
|
|
1742
|
+
coordinates: z2.object({ xNorm: z2.number(), yNorm: z2.number() }).optional(),
|
|
1743
|
+
frameUrl: z2.string().optional(),
|
|
1744
|
+
value: z2.string().optional(),
|
|
1745
|
+
key: z2.string().optional(),
|
|
1746
|
+
scrollTarget: z2.string().optional(),
|
|
1747
|
+
scroll: z2.object({ xNorm: z2.number(), yNorm: z2.number() }).optional(),
|
|
1748
|
+
url: z2.string().optional(),
|
|
1749
|
+
causesNavigation: z2.boolean().optional(),
|
|
1750
|
+
label: z2.string().optional(),
|
|
1751
|
+
timestamp: z2.number(),
|
|
1752
|
+
thumbnail: z2.string().optional()
|
|
1753
|
+
});
|
|
1754
|
+
var journeySchema = z2.object({
|
|
1755
|
+
version: z2.literal(1),
|
|
1756
|
+
createdAt: z2.string(),
|
|
1757
|
+
baseRef: z2.string().optional(),
|
|
1758
|
+
viewport: z2.object({
|
|
1759
|
+
width: z2.number(),
|
|
1760
|
+
height: z2.number(),
|
|
1761
|
+
deviceScaleFactor: z2.number()
|
|
1762
|
+
}),
|
|
1763
|
+
startUrl: z2.string(),
|
|
1764
|
+
steps: z2.array(stepSchema)
|
|
1765
|
+
});
|
|
1766
|
+
async function saveJourney(file, journey) {
|
|
1767
|
+
const clean = {
|
|
1768
|
+
...journey,
|
|
1769
|
+
steps: journey.steps.map((s) => {
|
|
1770
|
+
const { thumbnail: _thumbnail, ...rest } = s;
|
|
1771
|
+
return rest;
|
|
1772
|
+
})
|
|
1773
|
+
};
|
|
1774
|
+
await mkdir3(path10.dirname(file), { recursive: true });
|
|
1775
|
+
await writeFile4(file, JSON.stringify(clean, null, 2) + "\n");
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
// src/cli/commands/record.ts
|
|
1779
|
+
async function recordCommand(repoRoot, opts) {
|
|
1780
|
+
const { config } = await loadConfig(repoRoot);
|
|
1781
|
+
const journeyFile = path11.resolve(repoRoot, opts.out ?? ".pr-preview/journey.json");
|
|
1782
|
+
try {
|
|
1783
|
+
const boot = await bootstrap(repoRoot, config, "record");
|
|
1784
|
+
await boot.startApp("after", repoRoot);
|
|
1785
|
+
await boot.openBrowser("after");
|
|
1786
|
+
log.info(`Harness open at ${pc2.bold(boot.harness.url)}`);
|
|
1787
|
+
log.step("Click Record in the sidebar, perform the journey, then Confirm.");
|
|
1788
|
+
const steps = await boot.session.recordUntilConfirmed();
|
|
1789
|
+
if (steps.length === 0) {
|
|
1790
|
+
log.warn("No steps recorded \u2014 nothing saved.");
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
await saveJourney(journeyFile, {
|
|
1794
|
+
version: 1,
|
|
1795
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1796
|
+
viewport: { ...config.viewport, deviceScaleFactor: 2 },
|
|
1797
|
+
startUrl: boot.session.startUrl,
|
|
1798
|
+
steps
|
|
1799
|
+
});
|
|
1800
|
+
log.success(`Saved ${steps.length} steps to ${path11.relative(repoRoot, journeyFile)}`);
|
|
1801
|
+
} finally {
|
|
1802
|
+
await runCleanups();
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
// src/cli/commands/run.ts
|
|
1807
|
+
import { existsSync as existsSync8 } from "fs";
|
|
1808
|
+
import path14 from "path";
|
|
1809
|
+
import pc3 from "picocolors";
|
|
1810
|
+
|
|
1811
|
+
// src/git/exec.ts
|
|
1812
|
+
import { execFile as execFile2 } from "child_process";
|
|
1813
|
+
var GitError = class extends Error {
|
|
1814
|
+
};
|
|
1815
|
+
function git(dir, args) {
|
|
1816
|
+
return new Promise((resolve, reject) => {
|
|
1817
|
+
execFile2("git", args, { cwd: dir, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
1818
|
+
if (err) reject(new GitError(`git ${args.join(" ")} failed: ${stderr || err.message}`));
|
|
1819
|
+
else resolve(stdout.trim());
|
|
1820
|
+
});
|
|
1821
|
+
});
|
|
1822
|
+
}
|
|
1823
|
+
async function tryGit(dir, args) {
|
|
1824
|
+
try {
|
|
1825
|
+
return await git(dir, args);
|
|
1826
|
+
} catch {
|
|
1827
|
+
return null;
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
function tryGh(dir, args) {
|
|
1831
|
+
return new Promise((resolve) => {
|
|
1832
|
+
execFile2("gh", args, { cwd: dir }, (err, stdout) => {
|
|
1833
|
+
resolve(err ? null : stdout.trim());
|
|
1834
|
+
});
|
|
1835
|
+
});
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
// src/git/base.ts
|
|
1839
|
+
async function detectBase(repoRoot, override) {
|
|
1840
|
+
if (!await tryGit(repoRoot, ["rev-parse", "--is-inside-work-tree"])) {
|
|
1841
|
+
throw new GitError(
|
|
1842
|
+
`Not inside a git repository (${repoRoot}). pr-preview run needs a git repo with a committed base branch \u2014 cd into your project and try again.`
|
|
1843
|
+
);
|
|
1844
|
+
}
|
|
1845
|
+
if (!await tryGit(repoRoot, ["rev-parse", "--verify", "HEAD"])) {
|
|
1846
|
+
throw new GitError(
|
|
1847
|
+
`This git repository has no commits yet, so there is no base to compare against. Commit your work and run pr-preview on a feature branch (or pass --base <ref>).`
|
|
1848
|
+
);
|
|
1849
|
+
}
|
|
1850
|
+
if (override) {
|
|
1851
|
+
const sha = await git(repoRoot, ["merge-base", "HEAD", override]);
|
|
1852
|
+
return { sha, source: "config", ref: override };
|
|
1853
|
+
}
|
|
1854
|
+
const ghBase = await tryGh(repoRoot, ["pr", "view", "--json", "baseRefName", "-q", ".baseRefName"]);
|
|
1855
|
+
if (ghBase) {
|
|
1856
|
+
const ref = await tryGit(repoRoot, ["rev-parse", "--verify", `origin/${ghBase}`]) ? `origin/${ghBase}` : ghBase;
|
|
1857
|
+
const sha = await tryGit(repoRoot, ["merge-base", "HEAD", ref]);
|
|
1858
|
+
if (sha) return { sha, source: "gh-pr", ref };
|
|
1859
|
+
}
|
|
1860
|
+
const candidates = [];
|
|
1861
|
+
const originHead = await tryGit(repoRoot, ["symbolic-ref", "refs/remotes/origin/HEAD"]);
|
|
1862
|
+
if (originHead) candidates.push(originHead.replace("refs/remotes/", ""));
|
|
1863
|
+
candidates.push("origin/main", "origin/master", "main", "master", "develop");
|
|
1864
|
+
const current = await git(repoRoot, ["rev-parse", "HEAD"]);
|
|
1865
|
+
for (const ref of candidates) {
|
|
1866
|
+
if (!await tryGit(repoRoot, ["rev-parse", "--verify", `${ref}^{commit}`])) continue;
|
|
1867
|
+
const sha = await tryGit(repoRoot, ["merge-base", "HEAD", ref]);
|
|
1868
|
+
if (sha && sha !== current) return { sha, source: "merge-base", ref };
|
|
1869
|
+
if (sha && sha === current) {
|
|
1870
|
+
throw new GitError(
|
|
1871
|
+
`Current branch has no commits beyond ${ref} \u2014 there is no "before" to compare. Switch to your PR branch first, or pass --base <ref>.`
|
|
1872
|
+
);
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
throw new GitError(
|
|
1876
|
+
"Could not detect the PR base. Pass --base <ref> or set baseBranch in pr-preview.config."
|
|
1877
|
+
);
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
// src/git/worktree.ts
|
|
1881
|
+
import { existsSync as existsSync6 } from "fs";
|
|
1882
|
+
import { rm as rm2 } from "fs/promises";
|
|
1883
|
+
import path12 from "path";
|
|
1884
|
+
var WORKTREE_DIR = ".pr-preview/worktrees";
|
|
1885
|
+
function explainGitCrash(err, repoRoot) {
|
|
1886
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1887
|
+
if (/died of signal|signal 1[01]\b|SIGBUS|SIGSEGV/i.test(msg)) {
|
|
1888
|
+
const inICloud = /\/(Documents|Desktop)\//.test(repoRoot);
|
|
1889
|
+
throw new GitError(
|
|
1890
|
+
`git crashed creating the base worktree (it died of a signal). This usually means the repository's git objects are iCloud-evicted "dataless" placeholders` + (inICloud ? " \u2014 and this repo is under ~/Documents, which iCloud syncs.\n\n" : ".\n\n") + `Fixes:
|
|
1891
|
+
\u2022 Move the repo out of iCloud (e.g. ~/dev), or disable Desktop & Documents sync, then retry.
|
|
1892
|
+
\u2022 Or force-download the objects now: find .git -type f -exec cat {} + >/dev/null
|
|
1893
|
+
|
|
1894
|
+
Original error: ${msg}`
|
|
1895
|
+
);
|
|
1896
|
+
}
|
|
1897
|
+
throw err;
|
|
1898
|
+
}
|
|
1899
|
+
async function createBaseWorktree(repoRoot, sha) {
|
|
1900
|
+
const dir = path12.join(repoRoot, WORKTREE_DIR, `base-${sha.slice(0, 12)}`);
|
|
1901
|
+
if (existsSync6(dir) && await tryGit(dir, ["rev-parse", "HEAD"]) === sha) {
|
|
1902
|
+
return { dir, remove: () => removeWorktree(repoRoot, dir) };
|
|
1903
|
+
}
|
|
1904
|
+
await pruneStaleWorktrees(repoRoot);
|
|
1905
|
+
if (existsSync6(dir)) await rm2(dir, { recursive: true, force: true });
|
|
1906
|
+
try {
|
|
1907
|
+
await git(repoRoot, ["worktree", "add", "--detach", dir, sha]);
|
|
1908
|
+
} catch (err) {
|
|
1909
|
+
await removeWorktree(repoRoot, dir).catch(() => {
|
|
1910
|
+
});
|
|
1911
|
+
explainGitCrash(err, repoRoot);
|
|
1912
|
+
}
|
|
1913
|
+
return { dir, remove: () => removeWorktree(repoRoot, dir) };
|
|
1914
|
+
}
|
|
1915
|
+
async function removeWorktree(repoRoot, dir) {
|
|
1916
|
+
await tryGit(repoRoot, ["worktree", "remove", "--force", dir]);
|
|
1917
|
+
if (existsSync6(dir)) await rm2(dir, { recursive: true, force: true });
|
|
1918
|
+
await tryGit(repoRoot, ["worktree", "prune"]);
|
|
1919
|
+
}
|
|
1920
|
+
async function pruneStaleWorktrees(repoRoot) {
|
|
1921
|
+
await tryGit(repoRoot, ["worktree", "prune"]);
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
// src/server/pkgManager.ts
|
|
1925
|
+
import { existsSync as existsSync7 } from "fs";
|
|
1926
|
+
import path13 from "path";
|
|
1927
|
+
import { spawn as spawn3 } from "child_process";
|
|
1928
|
+
function detectPackageManager(dir) {
|
|
1929
|
+
if (existsSync7(path13.join(dir, "pnpm-lock.yaml"))) return "pnpm";
|
|
1930
|
+
if (existsSync7(path13.join(dir, "yarn.lock"))) return "yarn";
|
|
1931
|
+
if (existsSync7(path13.join(dir, "bun.lockb")) || existsSync7(path13.join(dir, "bun.lock"))) {
|
|
1932
|
+
return "bun";
|
|
1933
|
+
}
|
|
1934
|
+
return "npm";
|
|
1935
|
+
}
|
|
1936
|
+
function installDependencies(dir, pm) {
|
|
1937
|
+
return new Promise((resolve, reject) => {
|
|
1938
|
+
const child = spawn3(pm, ["install"], {
|
|
1939
|
+
cwd: dir,
|
|
1940
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1941
|
+
env: process.env
|
|
1942
|
+
});
|
|
1943
|
+
let tail = "";
|
|
1944
|
+
const keepTail = (chunk) => {
|
|
1945
|
+
tail = (tail + chunk.toString()).slice(-2e3);
|
|
1946
|
+
};
|
|
1947
|
+
child.stdout.on("data", keepTail);
|
|
1948
|
+
child.stderr.on("data", keepTail);
|
|
1949
|
+
child.on("error", reject);
|
|
1950
|
+
child.on("exit", (code) => {
|
|
1951
|
+
if (code === 0) resolve();
|
|
1952
|
+
else reject(new Error(`${pm} install failed (exit ${code}):
|
|
1953
|
+
${tail}`));
|
|
1954
|
+
});
|
|
1955
|
+
});
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
// src/cli/util/openPath.ts
|
|
1959
|
+
import { spawn as spawn4 } from "child_process";
|
|
1960
|
+
function openPath(target) {
|
|
1961
|
+
if (process.env.PR_PREVIEW_HEADLESS === "1" || process.env.CI) return;
|
|
1962
|
+
const [cmd, args] = process.platform === "darwin" ? ["open", [target]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", target]] : ["xdg-open", [target]];
|
|
1963
|
+
try {
|
|
1964
|
+
const child = spawn4(cmd, [...args], { detached: true, stdio: "ignore" });
|
|
1965
|
+
child.on("error", () => {
|
|
1966
|
+
});
|
|
1967
|
+
child.unref();
|
|
1968
|
+
} catch {
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
function revealFiles(files, dir) {
|
|
1972
|
+
if (process.env.PR_PREVIEW_HEADLESS === "1" || process.env.CI) return;
|
|
1973
|
+
if (process.platform === "darwin" && files.length > 0) {
|
|
1974
|
+
try {
|
|
1975
|
+
const child = spawn4("open", ["-R", ...files], { detached: true, stdio: "ignore" });
|
|
1976
|
+
child.on("error", () => openPath(dir));
|
|
1977
|
+
child.unref();
|
|
1978
|
+
return;
|
|
1979
|
+
} catch {
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
openPath(dir);
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
// src/cli/commands/run.ts
|
|
1986
|
+
function captureClip(session, which, outBase, label) {
|
|
1987
|
+
return session.encodeClip(which, outBase, label);
|
|
1988
|
+
}
|
|
1989
|
+
function stamp() {
|
|
1990
|
+
const d = /* @__PURE__ */ new Date();
|
|
1991
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
1992
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
1993
|
+
}
|
|
1994
|
+
function shortRef(ref) {
|
|
1995
|
+
return ref.replace(/^origin\//, "");
|
|
1996
|
+
}
|
|
1997
|
+
function slug(ref) {
|
|
1998
|
+
return shortRef(ref).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
1999
|
+
}
|
|
2000
|
+
function fileStamp() {
|
|
2001
|
+
const d = /* @__PURE__ */ new Date();
|
|
2002
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
2003
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`;
|
|
2004
|
+
}
|
|
2005
|
+
async function branchName(repoRoot, fallback) {
|
|
2006
|
+
const ref = await tryGit(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
2007
|
+
return ref ? shortRef(ref) : fallback;
|
|
2008
|
+
}
|
|
2009
|
+
async function runCommand(repoRoot, opts) {
|
|
2010
|
+
let config;
|
|
2011
|
+
const urlFlag = opts.url;
|
|
2012
|
+
try {
|
|
2013
|
+
config = (await loadConfig(repoRoot)).config;
|
|
2014
|
+
} catch (err) {
|
|
2015
|
+
if (urlFlag && err instanceof ConfigError) {
|
|
2016
|
+
config = configSchema.parse({ devCommand: "external", url: urlFlag });
|
|
2017
|
+
} else {
|
|
2018
|
+
throw err;
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
opts = {
|
|
2022
|
+
url: opts.url ?? config.externalUrl,
|
|
2023
|
+
base: opts.base ?? config.baseBranch,
|
|
2024
|
+
keepWorktree: opts.keepWorktree ?? config.keepWorktree,
|
|
2025
|
+
single: opts.single
|
|
2026
|
+
};
|
|
2027
|
+
const outDir = path14.resolve(repoRoot, config.output);
|
|
2028
|
+
const passes = opts.single ? 1 : config.passes;
|
|
2029
|
+
if (passes === 1) return runSingleClip(repoRoot, config, outDir, opts);
|
|
2030
|
+
if (opts.url) return runWithExternalApp(repoRoot, config, outDir, opts);
|
|
2031
|
+
try {
|
|
2032
|
+
const base = await detectBase(repoRoot, opts.base ?? config.baseBranch);
|
|
2033
|
+
const currentBranch = await tryGit(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"]) ?? "HEAD";
|
|
2034
|
+
log.info(`PR base: ${pc3.bold(base.ref)} (${base.sha.slice(0, 10)}, via ${base.source})`);
|
|
2035
|
+
const worktree = await createBaseWorktree(repoRoot, base.sha);
|
|
2036
|
+
if (!opts.keepWorktree) registerCleanup(() => worktree.remove());
|
|
2037
|
+
log.success(`Base worktree at ${path14.relative(repoRoot, worktree.dir)}`);
|
|
2038
|
+
if (config.install !== "never") {
|
|
2039
|
+
const appDir = path14.join(worktree.dir, config.cwd);
|
|
2040
|
+
const pm = detectPackageManager(appDir);
|
|
2041
|
+
const hasModules = existsSync8(path14.join(appDir, "node_modules"));
|
|
2042
|
+
if (config.install === "always" || !hasModules) {
|
|
2043
|
+
const spinner = log.spinner(`Installing dependencies in worktree (${pm}) \u2026`);
|
|
2044
|
+
try {
|
|
2045
|
+
await installDependencies(appDir, pm);
|
|
2046
|
+
spinner.succeed("Worktree dependencies installed");
|
|
2047
|
+
} catch (err) {
|
|
2048
|
+
spinner.fail("Install failed");
|
|
2049
|
+
throw err;
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
const boot = await bootstrap(repoRoot, config, "run");
|
|
2054
|
+
boot.session.setPasses(2);
|
|
2055
|
+
boot.session.setBranches(base.ref, currentBranch);
|
|
2056
|
+
const beforeServer = await boot.startApp("before", worktree.dir);
|
|
2057
|
+
await boot.openBrowser("before");
|
|
2058
|
+
log.info(`Harness open at ${pc3.bold(boot.harness.url)}`);
|
|
2059
|
+
log.step(`Record the journey on BEFORE (${base.ref}), then Confirm in the sidebar.`);
|
|
2060
|
+
await boot.session.promptResetChoice();
|
|
2061
|
+
const beforeSteps = await boot.session.recordUntilConfirmed();
|
|
2062
|
+
if (beforeSteps.length === 0) throw new Error("No steps recorded for BEFORE \u2014 aborting.");
|
|
2063
|
+
await saveJourneyFile(repoRoot, "journey-before.json", config, boot.session.startUrl, beforeSteps, base.sha);
|
|
2064
|
+
const timestamp = stamp();
|
|
2065
|
+
const fstamp = fileStamp();
|
|
2066
|
+
const baseLabel = shortRef(base.ref);
|
|
2067
|
+
const headLabel = shortRef(currentBranch);
|
|
2068
|
+
log.info("Capturing BEFORE \u2026");
|
|
2069
|
+
const before = await captureClip(
|
|
2070
|
+
boot.session,
|
|
2071
|
+
"before",
|
|
2072
|
+
path14.join(outDir, `before-${slug(base.ref)}-${fstamp}`),
|
|
2073
|
+
{ branch: baseLabel, baseBranch: baseLabel, timestamp }
|
|
2074
|
+
);
|
|
2075
|
+
if (before.fellBackToGif) {
|
|
2076
|
+
log.warn("ffmpeg not found \u2014 produced a GIF instead of MP4 (brew install ffmpeg).");
|
|
2077
|
+
}
|
|
2078
|
+
log.success(`${before.paths.map((p) => path14.basename(p)).join(" + ")} (${before.frameCount} frames)`);
|
|
2079
|
+
await beforeServer.stop();
|
|
2080
|
+
await boot.startApp("after", repoRoot);
|
|
2081
|
+
boot.session.switchPass("after");
|
|
2082
|
+
await boot.switchApp("after");
|
|
2083
|
+
boot.session.setPhase("idle");
|
|
2084
|
+
log.step(
|
|
2085
|
+
`Now record the journey on AFTER (${currentBranch}) \u2014 the UI may differ. Record fresh or load the BEFORE steps, then Save.`
|
|
2086
|
+
);
|
|
2087
|
+
await boot.session.promptResetChoice();
|
|
2088
|
+
const afterSteps = await boot.session.recordUntilConfirmed();
|
|
2089
|
+
if (afterSteps.length === 0) throw new Error("No steps recorded for AFTER \u2014 aborting.");
|
|
2090
|
+
await saveJourneyFile(repoRoot, "journey-after.json", config, boot.session.startUrl, afterSteps);
|
|
2091
|
+
log.info("Capturing AFTER \u2026");
|
|
2092
|
+
const after = await captureClip(
|
|
2093
|
+
boot.session,
|
|
2094
|
+
"after",
|
|
2095
|
+
path14.join(outDir, `after-${slug(currentBranch)}-${fstamp}`),
|
|
2096
|
+
{ branch: headLabel, baseBranch: baseLabel, timestamp }
|
|
2097
|
+
);
|
|
2098
|
+
log.success(`${after.paths.map((p) => path14.basename(p)).join(" + ")} (${after.frameCount} frames)`);
|
|
2099
|
+
boot.session.setPhase("done");
|
|
2100
|
+
boot.harness.bus.send({
|
|
2101
|
+
type: "DONE",
|
|
2102
|
+
outputs: { before: before.paths[0], after: after.paths[0] }
|
|
2103
|
+
});
|
|
2104
|
+
console.log();
|
|
2105
|
+
log.success(pc3.bold("Done \u2014 drag these into your PR description:"));
|
|
2106
|
+
for (const p of [...before.paths, ...after.paths]) {
|
|
2107
|
+
log.step(path14.relative(repoRoot, p));
|
|
2108
|
+
}
|
|
2109
|
+
revealFiles([...before.paths, ...after.paths], outDir);
|
|
2110
|
+
} finally {
|
|
2111
|
+
await runCleanups();
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
async function runSingleClip(repoRoot, config, outDir, opts) {
|
|
2115
|
+
try {
|
|
2116
|
+
const boot = opts.url ? await bootstrap(repoRoot, config, "run", { fixedUrl: opts.url }) : await bootstrap(repoRoot, config, "run");
|
|
2117
|
+
boot.session.setPasses(1);
|
|
2118
|
+
const branch = await branchName(repoRoot, "app");
|
|
2119
|
+
let server;
|
|
2120
|
+
if (opts.url) {
|
|
2121
|
+
log.info(`Using your running app at ${pc3.bold(opts.url)} (single clip).`);
|
|
2122
|
+
} else {
|
|
2123
|
+
server = await boot.startApp("before", repoRoot);
|
|
2124
|
+
}
|
|
2125
|
+
await boot.openBrowser("before");
|
|
2126
|
+
log.info(`Harness open at ${pc3.bold(boot.harness.url)}`);
|
|
2127
|
+
log.step(`Record the journey, then Confirm to save the clip.`);
|
|
2128
|
+
await boot.session.promptResetChoice();
|
|
2129
|
+
const steps = await boot.session.recordUntilConfirmed();
|
|
2130
|
+
if (steps.length === 0) throw new Error("No steps recorded \u2014 aborting.");
|
|
2131
|
+
await saveJourneyFile(repoRoot, "journey.json", config, boot.session.startUrl, steps);
|
|
2132
|
+
const timestamp = stamp();
|
|
2133
|
+
log.info("Capturing the clip \u2026");
|
|
2134
|
+
const clip = await captureClip(
|
|
2135
|
+
boot.session,
|
|
2136
|
+
"before",
|
|
2137
|
+
path14.join(outDir, `${slug(branch)}-${fileStamp()}`),
|
|
2138
|
+
{ branch, baseBranch: branch, timestamp }
|
|
2139
|
+
);
|
|
2140
|
+
if (clip.fellBackToGif) {
|
|
2141
|
+
log.warn("ffmpeg not found \u2014 produced a GIF instead of MP4 (brew install ffmpeg).");
|
|
2142
|
+
}
|
|
2143
|
+
await server?.stop();
|
|
2144
|
+
boot.session.setPhase("done");
|
|
2145
|
+
boot.harness.bus.send({ type: "DONE", outputs: { before: clip.paths[0] } });
|
|
2146
|
+
console.log();
|
|
2147
|
+
log.success(pc3.bold("Done \u2014 your clip:"));
|
|
2148
|
+
for (const p of clip.paths) log.step(path14.relative(repoRoot, p));
|
|
2149
|
+
revealFiles(clip.paths, outDir);
|
|
2150
|
+
} finally {
|
|
2151
|
+
await runCleanups();
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
async function runWithExternalApp(repoRoot, config, outDir, opts) {
|
|
2155
|
+
log.info(`Using your running app at ${pc3.bold(opts.url)} (no dev server managed).`);
|
|
2156
|
+
try {
|
|
2157
|
+
const boot = await bootstrap(repoRoot, config, "run", { fixedUrl: opts.url });
|
|
2158
|
+
boot.session.setPasses(2);
|
|
2159
|
+
const beforeBranch = await branchName(repoRoot, "before");
|
|
2160
|
+
boot.session.setBranches(beforeBranch, "your PR branch");
|
|
2161
|
+
await boot.openBrowser("before");
|
|
2162
|
+
log.info(`Harness open at ${pc3.bold(boot.harness.url)}`);
|
|
2163
|
+
log.step(`Record the journey on your app (currently ${beforeBranch}), then Confirm.`);
|
|
2164
|
+
await boot.session.promptResetChoice();
|
|
2165
|
+
const beforeSteps = await boot.session.recordUntilConfirmed();
|
|
2166
|
+
if (beforeSteps.length === 0) throw new Error("No steps recorded for BEFORE \u2014 aborting.");
|
|
2167
|
+
const timestamp = stamp();
|
|
2168
|
+
const fstamp = fileStamp();
|
|
2169
|
+
await saveJourneyFile(repoRoot, "journey-before.json", config, boot.session.startUrl, beforeSteps);
|
|
2170
|
+
log.info("Capturing BEFORE \u2026");
|
|
2171
|
+
const before = await captureClip(
|
|
2172
|
+
boot.session,
|
|
2173
|
+
"before",
|
|
2174
|
+
path14.join(outDir, `before-${slug(beforeBranch)}-${fstamp}`),
|
|
2175
|
+
{ branch: beforeBranch, baseBranch: beforeBranch, timestamp }
|
|
2176
|
+
);
|
|
2177
|
+
log.success(`${before.paths.map((p) => path14.basename(p)).join(" + ")} (${before.frameCount} frames)`);
|
|
2178
|
+
log.info(pc3.bold("\u2192 Switch your app to the PR branch and restart it on the same URL, then Continue in the harness."));
|
|
2179
|
+
boot.harness.bus.send({
|
|
2180
|
+
type: "MANUAL_PAUSE",
|
|
2181
|
+
stepId: null,
|
|
2182
|
+
kind: "generic",
|
|
2183
|
+
label: `Switch your app to your PR branch and restart it on ${opts.url}, then click Continue.`
|
|
2184
|
+
});
|
|
2185
|
+
await boot.harness.bus.waitFor("CONTINUE");
|
|
2186
|
+
const afterBranch = await branchName(repoRoot, "after");
|
|
2187
|
+
boot.session.switchPass("after");
|
|
2188
|
+
boot.session.setBranches(beforeBranch, afterBranch);
|
|
2189
|
+
await boot.switchApp("after");
|
|
2190
|
+
boot.session.setPhase("idle");
|
|
2191
|
+
log.step(`Now record the journey on AFTER (${afterBranch}) \u2014 record fresh or load the BEFORE steps, then Save.`);
|
|
2192
|
+
await boot.session.promptResetChoice();
|
|
2193
|
+
const afterSteps = await boot.session.recordUntilConfirmed();
|
|
2194
|
+
if (afterSteps.length === 0) throw new Error("No steps recorded for AFTER \u2014 aborting.");
|
|
2195
|
+
await saveJourneyFile(repoRoot, "journey-after.json", config, boot.session.startUrl, afterSteps);
|
|
2196
|
+
log.info("Capturing AFTER \u2026");
|
|
2197
|
+
const after = await captureClip(
|
|
2198
|
+
boot.session,
|
|
2199
|
+
"after",
|
|
2200
|
+
path14.join(outDir, `after-${slug(afterBranch)}-${fstamp}`),
|
|
2201
|
+
{ branch: afterBranch, baseBranch: beforeBranch, timestamp }
|
|
2202
|
+
);
|
|
2203
|
+
log.success(`${after.paths.map((p) => path14.basename(p)).join(" + ")} (${after.frameCount} frames)`);
|
|
2204
|
+
boot.session.setPhase("done");
|
|
2205
|
+
boot.harness.bus.send({ type: "DONE", outputs: { before: before.paths[0], after: after.paths[0] } });
|
|
2206
|
+
console.log();
|
|
2207
|
+
log.success(pc3.bold("Done \u2014 drag these into your PR description:"));
|
|
2208
|
+
for (const p of [...before.paths, ...after.paths]) log.step(path14.relative(repoRoot, p));
|
|
2209
|
+
revealFiles([...before.paths, ...after.paths], outDir);
|
|
2210
|
+
} finally {
|
|
2211
|
+
await runCleanups();
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
async function saveJourneyFile(repoRoot, name, config, startUrl, steps, baseRef) {
|
|
2215
|
+
await saveJourney(path14.join(repoRoot, ".pr-preview", name), {
|
|
2216
|
+
version: 1,
|
|
2217
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2218
|
+
baseRef,
|
|
2219
|
+
viewport: { ...config.viewport, deviceScaleFactor: 2 },
|
|
2220
|
+
startUrl,
|
|
2221
|
+
steps
|
|
2222
|
+
});
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
// src/cli/index.ts
|
|
2226
|
+
var program = new Command();
|
|
2227
|
+
program.name("pr-preview").description("Record a user journey once, get before/after PR GIFs of your UI.").version("0.1.0");
|
|
2228
|
+
program.command("init").description("Scaffold pr-preview.config.js and .gitignore entries").action(() => wrap(() => initCommand(process.cwd())));
|
|
2229
|
+
program.command("record").description("Record a journey on the current branch (no GIFs)").option("-o, --out <file>", "journey output path", ".pr-preview/journey.json").action((opts) => wrap(() => recordCommand(process.cwd(), opts)));
|
|
2230
|
+
program.command("run").description("Record on the PR base, then produce before.gif and after.gif").option("-b, --base <ref>", "override PR base detection").option("--keep-worktree", "keep the base worktree around for faster re-runs").option(
|
|
2231
|
+
"-u, --url <url>",
|
|
2232
|
+
"use your own already-running app at <url> instead of starting a dev server (records BEFORE, you switch branches + restart, records AFTER)"
|
|
2233
|
+
).option(
|
|
2234
|
+
"-s, --single",
|
|
2235
|
+
"record a single standalone clip of the current app (no before/after comparison)"
|
|
2236
|
+
).action((opts) => wrap(() => runCommand(process.cwd(), opts)));
|
|
2237
|
+
async function wrap(fn) {
|
|
2238
|
+
try {
|
|
2239
|
+
await fn();
|
|
2240
|
+
process.exit(0);
|
|
2241
|
+
} catch (err) {
|
|
2242
|
+
if (err instanceof ConfigError || err instanceof GitError) {
|
|
2243
|
+
log.error(err.message);
|
|
2244
|
+
} else {
|
|
2245
|
+
log.error(err instanceof Error ? err.stack ?? err.message : String(err));
|
|
2246
|
+
}
|
|
2247
|
+
process.exit(1);
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
program.parse();
|
|
2251
|
+
//# sourceMappingURL=index.js.map
|