demo-this-pr 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.
@@ -0,0 +1,846 @@
1
+ import { cp, mkdir, readdir, writeFile } from "node:fs/promises";
2
+ import { createRequire } from "node:module";
3
+ import { join, relative } from "node:path";
4
+ import { chromium } from "@playwright/test";
5
+ import { runProcess } from "./shell.js";
6
+ const require = createRequire(import.meta.url);
7
+ const ffmpegPath = require("ffmpeg-static");
8
+ const INTRO_HOLD_MS = 3600;
9
+ const CAPTION_HOLD_MS = 900;
10
+ const POINTER_HOLD_MS = 1300;
11
+ const AFTER_ACTION_HOLD_MS = 1100;
12
+ export async function writePlaywrightArtifacts(outputDir, plan) {
13
+ const assetsDir = join(outputDir, "assets");
14
+ await mkdir(join(assetsDir, "screenshots"), { recursive: true });
15
+ await mkdir(join(assetsDir, "mcp-screenshots"), { recursive: true });
16
+ await mkdir(join(assetsDir, "test-results"), { recursive: true });
17
+ const specPath = join(outputDir, "pr-demo.spec.ts");
18
+ const configPath = join(outputDir, "playwright.config.ts");
19
+ const planPath = join(outputDir, "demo-plan.json");
20
+ const mcpGuidePath = join(outputDir, "MCP_PLAYWRIGHT.md");
21
+ const mcpScriptPath = join(outputDir, "mcp-playwright-demo.js");
22
+ await writeFile(planPath, `${JSON.stringify(plan, null, 2)}\n`, "utf8");
23
+ await writeFile(specPath, renderSpec(plan, assetsDir), "utf8");
24
+ await writeFile(configPath, renderConfig(outputDir), "utf8");
25
+ await writeFile(mcpScriptPath, renderMcpScript(plan, assetsDir), "utf8");
26
+ await writeFile(mcpGuidePath, renderMcpGuide(plan, mcpScriptPath, outputDir), "utf8");
27
+ return { specPath, configPath, planPath, mcpGuidePath, mcpScriptPath };
28
+ }
29
+ export async function runPlaywrightDemo(outputDir, plan, options) {
30
+ const assetsDir = join(outputDir, "assets");
31
+ const videoDir = join(assetsDir, "test-results", "programmatic-video");
32
+ await mkdir(videoDir, { recursive: true });
33
+ let browser;
34
+ let webmPath;
35
+ try {
36
+ browser = await chromium.launch({ headless: !options.headed, slowMo: options.headed ? 260 : 120 });
37
+ const context = await browser.newContext({
38
+ viewport: { width: 1440, height: 900 },
39
+ recordVideo: {
40
+ dir: videoDir,
41
+ size: { width: 1440, height: 900 }
42
+ }
43
+ });
44
+ const page = await context.newPage();
45
+ await runDemoPlan(page, plan, assetsDir);
46
+ const video = page.video();
47
+ await context.close();
48
+ await browser.close();
49
+ browser = undefined;
50
+ const rawVideoPath = await video?.path();
51
+ if (rawVideoPath) {
52
+ webmPath = await copyVideo(rawVideoPath, outputDir);
53
+ }
54
+ const conversion = webmPath ? await convertToMp4(outputDir, webmPath) : { mp4Path: undefined, error: undefined };
55
+ return {
56
+ attempted: true,
57
+ passed: true,
58
+ command: "playwright chromium programmatic demo",
59
+ output: `Recorded ${plan.steps.length} step(s).`,
60
+ videoPath: conversion.mp4Path ?? webmPath,
61
+ webmPath,
62
+ mp4Path: conversion.mp4Path,
63
+ conversionError: conversion.error
64
+ };
65
+ }
66
+ catch (error) {
67
+ await browser?.close();
68
+ const message = error instanceof Error ? error.message : String(error);
69
+ return {
70
+ attempted: true,
71
+ passed: false,
72
+ command: "playwright chromium programmatic demo",
73
+ output: message,
74
+ webmPath,
75
+ videoPath: webmPath,
76
+ error: message
77
+ };
78
+ }
79
+ }
80
+ function renderConfig(outputDir) {
81
+ return `import { defineConfig, devices } from "@playwright/test";
82
+
83
+ export default defineConfig({
84
+ testDir: ".",
85
+ outputDir: ${JSON.stringify(join(outputDir, "assets", "test-results"))},
86
+ timeout: 60_000,
87
+ reporter: [["list"], ["html", { outputFolder: ${JSON.stringify(join(outputDir, "assets", "playwright-report"))}, open: "never" }]],
88
+ use: {
89
+ ...devices["Desktop Chrome"],
90
+ video: "on",
91
+ screenshot: "only-on-failure",
92
+ trace: "retain-on-failure",
93
+ viewport: { width: 1440, height: 900 },
94
+ launchOptions: { slowMo: 120 }
95
+ },
96
+ projects: [{ name: "chromium" }]
97
+ });
98
+ `;
99
+ }
100
+ function renderSpec(plan, assetsDir) {
101
+ return `import { expect, test, type Page } from "@playwright/test";
102
+
103
+ const plan = ${JSON.stringify(plan, null, 2)} as const;
104
+ const assetsDir = ${JSON.stringify(assetsDir)};
105
+
106
+ test(plan.title, async ({ page }) => {
107
+ await showIntro(page, plan);
108
+
109
+ for (const [index, step] of plan.steps.entries()) {
110
+ await showCaption(page, step.caption ?? actionLabel(step), index + 1, plan.steps.length);
111
+
112
+ if ("goto" in step && step.goto) {
113
+ if (!plan.baseUrl) {
114
+ throw new Error("Demo step uses goto but baseUrl is missing.");
115
+ }
116
+ await page.goto(resolveUrl(plan.baseUrl, step.goto));
117
+ }
118
+
119
+ if ("click" in step && step.click) {
120
+ const target = page.locator(step.click).first();
121
+ await pointAtLocator(page, target, \`Click \${step.click}\`);
122
+ await target.click();
123
+ }
124
+
125
+ if ("fill" in step && step.fill) {
126
+ const target = page.locator(step.fill.selector).first();
127
+ await pointAtLocator(page, target, \`Fill \${step.fill.selector}\`);
128
+ await target.fill(step.fill.value);
129
+ }
130
+
131
+ if ("press" in step && step.press) {
132
+ const target = page.locator(step.press.selector).first();
133
+ await pointAtLocator(page, target, \`Press \${step.press.key}\`);
134
+ await target.press(step.press.key);
135
+ }
136
+
137
+ if ("expectText" in step && step.expectText) {
138
+ const target = page.getByText(step.expectText, { exact: false }).first();
139
+ await expect(target).toBeVisible();
140
+ await pointAtLocator(page, target, \`Verify \${step.expectText}\`);
141
+ }
142
+
143
+ if ("waitMs" in step && step.waitMs !== undefined) {
144
+ await page.waitForTimeout(step.waitMs);
145
+ }
146
+
147
+ if ("screenshot" in step && step.screenshot) {
148
+ await page.screenshot({ path: \`\${assetsDir}/screenshots/\${String(index + 1).padStart(2, "0")}-\${slug(step.screenshot)}.png\`, fullPage: true });
149
+ }
150
+
151
+ await page.waitForTimeout(250);
152
+ }
153
+ });
154
+
155
+ async function showCaption(page: Page, caption: string, index: number, total: number) {
156
+ await page.evaluate(
157
+ ({ caption, index, total }) => {
158
+ const id = "__demo_this_pr_caption";
159
+ let element = document.getElementById(id);
160
+ if (!element) {
161
+ element = document.createElement("div");
162
+ element.id = id;
163
+ document.body.appendChild(element);
164
+ }
165
+ element.textContent = \`\${index}/\${total} · \${caption}\`;
166
+ Object.assign(element.style, {
167
+ position: "fixed",
168
+ left: "24px",
169
+ bottom: "24px",
170
+ zIndex: "2147483647",
171
+ maxWidth: "760px",
172
+ padding: "12px 14px",
173
+ borderRadius: "8px",
174
+ background: "rgba(12, 18, 28, 0.92)",
175
+ color: "#fff",
176
+ font: "500 15px/1.4 system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
177
+ boxShadow: "0 12px 32px rgba(0,0,0,.24)"
178
+ });
179
+ },
180
+ { caption, index, total }
181
+ );
182
+ }
183
+
184
+ async function showIntro(page: Page, demoPlan: typeof plan) {
185
+ await page.setContent(renderIntroHtml(demoPlan));
186
+ await page.waitForTimeout(2200);
187
+ }
188
+
189
+ async function pointAtLocator(page: Page, locator: import("@playwright/test").Locator, label: string) {
190
+ await locator.scrollIntoViewIfNeeded();
191
+ const box = await locator.boundingBox();
192
+ if (!box) {
193
+ return;
194
+ }
195
+
196
+ const x = box.x + box.width / 2;
197
+ const y = box.y + box.height / 2;
198
+ await page.mouse.move(x, y, { steps: 18 });
199
+ await page.evaluate(
200
+ ({ x, y, label }) => {
201
+ let cursor = document.getElementById("__demo_this_pr_cursor");
202
+ if (!cursor) {
203
+ cursor = document.createElement("div");
204
+ cursor.id = "__demo_this_pr_cursor";
205
+ cursor.innerHTML = \`<div class="dot"></div><div class="label"></div>\`;
206
+ document.body.appendChild(cursor);
207
+ }
208
+
209
+ Object.assign(cursor.style, {
210
+ position: "fixed",
211
+ left: \`\${x}px\`,
212
+ top: \`\${y}px\`,
213
+ transform: "translate(-8px, -8px)",
214
+ zIndex: "2147483647",
215
+ pointerEvents: "none",
216
+ transition: "left 260ms ease, top 260ms ease"
217
+ });
218
+
219
+ const dot = cursor.querySelector(".dot") as HTMLElement;
220
+ Object.assign(dot.style, {
221
+ width: "18px",
222
+ height: "18px",
223
+ borderRadius: "999px",
224
+ background: "#38bdf8",
225
+ border: "3px solid #ffffff",
226
+ boxShadow: "0 0 0 8px rgba(56, 189, 248, .22), 0 8px 20px rgba(0,0,0,.28)"
227
+ });
228
+
229
+ const labelEl = cursor.querySelector(".label") as HTMLElement;
230
+ labelEl.textContent = label;
231
+ Object.assign(labelEl.style, {
232
+ margin: "8px 0 0 12px",
233
+ padding: "7px 10px",
234
+ borderRadius: "8px",
235
+ background: "rgba(15, 23, 42, .92)",
236
+ color: "#fff",
237
+ font: "600 13px/1.3 system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
238
+ whiteSpace: "nowrap",
239
+ boxShadow: "0 8px 24px rgba(0,0,0,.22)"
240
+ });
241
+ },
242
+ { x, y, label }
243
+ );
244
+ await page.waitForTimeout(550);
245
+ }
246
+
247
+ function section(title: string, items: readonly string[]) {
248
+ if (!items.length) return "";
249
+ return \`
250
+ <div style="margin-top:18px">
251
+ <h2 style="font-size:13px;color:#93c5fd;text-transform:uppercase;letter-spacing:.08em;margin:0 0 8px">\${escapeHtml(title)}</h2>
252
+ <ul style="margin:0;padding-left:20px;color:#e2e8f0">\${items.map((item) => \`<li style="margin:4px 0">\${escapeHtml(item)}</li>\`).join("")}</ul>
253
+ </div>
254
+ \`;
255
+ }
256
+
257
+ function renderIntroHtml(demoPlan: typeof plan) {
258
+ const tests = demoPlan.context?.tests ?? [];
259
+ const changes = demoPlan.context?.changes ?? [];
260
+ const technologies = demoPlan.context?.technologies ?? [];
261
+ const files = demoPlan.context?.changedFiles ?? [];
262
+
263
+ return \`<!doctype html>
264
+ <html>
265
+ <head>
266
+ <meta charset="utf-8">
267
+ <title>\${escapeHtml(demoPlan.title)}</title>
268
+ </head>
269
+ <body style="margin:0;background:#0f172a;color:#e5edf7;font:15px/1.45 system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif">
270
+ <section style="max-width:980px;margin:72px auto;padding:32px;border:1px solid rgba(148,163,184,.35);border-radius:14px;background:rgba(15,23,42,.92);box-shadow:0 24px 80px rgba(0,0,0,.35)">
271
+ <div style="color:#93c5fd;text-transform:uppercase;font-weight:700;font-size:12px;letter-spacing:.08em">Demo this PR</div>
272
+ <h1 style="font-size:34px;line-height:1.1;margin:10px 0 12px">\${escapeHtml(demoPlan.title)}</h1>
273
+ <p style="font-size:18px;color:#cbd5e1;margin:0 0 24px">\${escapeHtml(demoPlan.why ?? "Review the changed behavior.")}</p>
274
+ \${section("What changed", changes)}
275
+ \${section("Technology used", technologies)}
276
+ \${section("Tests", tests.map((test) => \`\${test.status.toUpperCase()} · \${test.command} · \${formatMs(test.durationMs)}\`))}
277
+ \${section("Changed files", files.slice(0, 6))}
278
+ <p style="color:#94a3b8;margin:24px 0 0">Video is local-only evidence for review. It is not meant to be committed or uploaded automatically.</p>
279
+ </section>
280
+ </body>
281
+ </html>\`;
282
+ }
283
+
284
+ function escapeHtml(value: string) {
285
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
286
+ }
287
+
288
+ function formatMs(ms: number) {
289
+ return ms < 1000 ? \`\${ms}ms\` : \`\${(ms / 1000).toFixed(1)}s\`;
290
+ }
291
+
292
+ function resolveUrl(baseUrl: string, target: string) {
293
+ return new URL(target, baseUrl).toString();
294
+ }
295
+
296
+ function actionLabel(step: Record<string, unknown>) {
297
+ if (step.goto) return \`Open \${step.goto}\`;
298
+ if (step.click) return \`Click \${step.click}\`;
299
+ if (step.fill && typeof step.fill === "object") return "Fill field";
300
+ if (step.press && typeof step.press === "object") return "Press key";
301
+ if (step.expectText) return \`Verify \${step.expectText}\`;
302
+ if (step.screenshot) return \`Capture \${step.screenshot}\`;
303
+ if (step.waitMs) return \`Wait \${step.waitMs}ms\`;
304
+ return "Demo step";
305
+ }
306
+
307
+ function slug(value: string) {
308
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 48) || "step";
309
+ }
310
+ `;
311
+ }
312
+ async function runDemoPlan(page, plan, assetsDir) {
313
+ await showRuntimeIntro(page, plan);
314
+ for (const [index, step] of plan.steps.entries()) {
315
+ await showRuntimeCaption(page, step.caption ?? describeStep(step), index + 1, plan.steps.length);
316
+ await page.waitForTimeout(CAPTION_HOLD_MS);
317
+ if (step.goto) {
318
+ if (!plan.baseUrl) {
319
+ throw new Error("Demo step uses goto but baseUrl is missing.");
320
+ }
321
+ await page.goto(resolveRuntimeUrl(plan.baseUrl, step.goto));
322
+ await page.waitForTimeout(AFTER_ACTION_HOLD_MS);
323
+ }
324
+ if (step.click) {
325
+ const target = page.locator(step.click).first();
326
+ await pointAtRuntimeLocator(page, target, `Click ${step.click}`);
327
+ await target.click();
328
+ await page.waitForTimeout(AFTER_ACTION_HOLD_MS);
329
+ }
330
+ if (step.fill) {
331
+ const target = page.locator(step.fill.selector).first();
332
+ await pointAtRuntimeLocator(page, target, `Fill ${step.fill.selector}`);
333
+ await target.fill(step.fill.value);
334
+ await page.waitForTimeout(AFTER_ACTION_HOLD_MS);
335
+ }
336
+ if (step.press) {
337
+ const target = page.locator(step.press.selector).first();
338
+ await pointAtRuntimeLocator(page, target, `Press ${step.press.key}`);
339
+ await target.press(step.press.key);
340
+ await page.waitForTimeout(AFTER_ACTION_HOLD_MS);
341
+ }
342
+ if (step.expectText) {
343
+ const target = page.getByText(step.expectText, { exact: false }).first();
344
+ await target.waitFor({ state: "visible", timeout: 15_000 });
345
+ await pointAtRuntimeLocator(page, target, `Verify ${step.expectText}`);
346
+ await page.waitForTimeout(AFTER_ACTION_HOLD_MS);
347
+ }
348
+ if (step.waitMs !== undefined) {
349
+ await page.waitForTimeout(step.waitMs);
350
+ }
351
+ if (step.screenshot) {
352
+ await page.screenshot({ path: `${assetsDir}/screenshots/${String(index + 1).padStart(2, "0")}-${slugRuntime(step.screenshot)}.png`, fullPage: true });
353
+ }
354
+ await page.waitForTimeout(700);
355
+ }
356
+ }
357
+ async function showRuntimeIntro(page, plan) {
358
+ await page.setContent(renderRuntimeIntroHtml(plan));
359
+ await page.waitForTimeout(INTRO_HOLD_MS);
360
+ }
361
+ async function showRuntimeCaption(page, caption, index, total) {
362
+ await page.evaluate(({ caption, index, total }) => {
363
+ const id = "__demo_this_pr_caption";
364
+ let element = document.getElementById(id);
365
+ if (!element) {
366
+ element = document.createElement("div");
367
+ element.id = id;
368
+ document.body.appendChild(element);
369
+ }
370
+ element.textContent = `${index}/${total} · ${caption}`;
371
+ Object.assign(element.style, {
372
+ position: "fixed",
373
+ left: "24px",
374
+ bottom: "24px",
375
+ zIndex: "2147483647",
376
+ maxWidth: "760px",
377
+ padding: "12px 14px",
378
+ borderRadius: "8px",
379
+ background: "rgba(12, 18, 28, 0.92)",
380
+ color: "#fff",
381
+ font: "500 15px/1.4 system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
382
+ boxShadow: "0 12px 32px rgba(0,0,0,.24)"
383
+ });
384
+ }, { caption, index, total });
385
+ }
386
+ async function pointAtRuntimeLocator(page, locator, label) {
387
+ await locator.scrollIntoViewIfNeeded();
388
+ const box = await locator.boundingBox();
389
+ if (!box) {
390
+ return;
391
+ }
392
+ const x = box.x + box.width / 2;
393
+ const y = box.y + box.height / 2;
394
+ await page.mouse.move(x, y, { steps: 42 });
395
+ await page.evaluate(({ x, y, label, box }) => {
396
+ let highlight = document.getElementById("__demo_this_pr_highlight");
397
+ if (!highlight) {
398
+ highlight = document.createElement("div");
399
+ highlight.id = "__demo_this_pr_highlight";
400
+ document.body.appendChild(highlight);
401
+ }
402
+ Object.assign(highlight.style, {
403
+ position: "fixed",
404
+ left: `${Math.max(box.x - 8, 0)}px`,
405
+ top: `${Math.max(box.y - 8, 0)}px`,
406
+ width: `${box.width + 16}px`,
407
+ height: `${box.height + 16}px`,
408
+ zIndex: "2147483646",
409
+ pointerEvents: "none",
410
+ border: "4px solid #38bdf8",
411
+ borderRadius: "10px",
412
+ boxShadow: "0 0 0 8px rgba(56,189,248,.22), 0 0 34px rgba(14,165,233,.45)",
413
+ transition: "left 320ms ease, top 320ms ease, width 320ms ease, height 320ms ease"
414
+ });
415
+ let cursor = document.getElementById("__demo_this_pr_cursor");
416
+ if (!cursor) {
417
+ cursor = document.createElement("div");
418
+ cursor.id = "__demo_this_pr_cursor";
419
+ cursor.innerHTML = `<div class="dot"></div><div class="label"></div>`;
420
+ document.body.appendChild(cursor);
421
+ }
422
+ Object.assign(cursor.style, {
423
+ position: "fixed",
424
+ left: `${x}px`,
425
+ top: `${y}px`,
426
+ transform: "translate(-8px, -8px)",
427
+ zIndex: "2147483647",
428
+ pointerEvents: "none",
429
+ transition: "left 260ms ease, top 260ms ease"
430
+ });
431
+ const dot = cursor.querySelector(".dot");
432
+ Object.assign(dot.style, {
433
+ width: "18px",
434
+ height: "18px",
435
+ borderRadius: "999px",
436
+ background: "#38bdf8",
437
+ border: "3px solid #ffffff",
438
+ boxShadow: "0 0 0 8px rgba(56, 189, 248, .22), 0 8px 20px rgba(0,0,0,.28)"
439
+ });
440
+ const labelEl = cursor.querySelector(".label");
441
+ labelEl.textContent = label;
442
+ Object.assign(labelEl.style, {
443
+ margin: "8px 0 0 12px",
444
+ padding: "7px 10px",
445
+ borderRadius: "8px",
446
+ background: "rgba(15, 23, 42, .92)",
447
+ color: "#fff",
448
+ font: "600 13px/1.3 system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
449
+ whiteSpace: "nowrap",
450
+ boxShadow: "0 8px 24px rgba(0,0,0,.22)"
451
+ });
452
+ }, { x, y, label, box });
453
+ await page.waitForTimeout(POINTER_HOLD_MS);
454
+ }
455
+ function renderRuntimeIntroHtml(plan) {
456
+ const tests = plan.context?.tests ?? [];
457
+ const changes = plan.context?.changes ?? [];
458
+ const technologies = plan.context?.technologies ?? [];
459
+ const files = plan.context?.changedFiles ?? [];
460
+ return `<!doctype html>
461
+ <html>
462
+ <head>
463
+ <meta charset="utf-8">
464
+ <title>${escapeRuntimeHtml(plan.title)}</title>
465
+ </head>
466
+ <body style="margin:0;background:#0f172a;color:#e5edf7;font:15px/1.45 system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif">
467
+ <section style="max-width:980px;margin:72px auto;padding:32px;border:1px solid rgba(148,163,184,.35);border-radius:14px;background:rgba(15,23,42,.92);box-shadow:0 24px 80px rgba(0,0,0,.35)">
468
+ <div style="color:#93c5fd;text-transform:uppercase;font-weight:700;font-size:12px;letter-spacing:.08em">Demo this PR</div>
469
+ <h1 style="font-size:34px;line-height:1.1;margin:10px 0 12px">${escapeRuntimeHtml(plan.title)}</h1>
470
+ <p style="font-size:18px;color:#cbd5e1;margin:0 0 24px">${escapeRuntimeHtml(plan.why ?? "Review the changed behavior.")}</p>
471
+ ${runtimeSection("What changed", changes)}
472
+ ${runtimeSection("Technology used", technologies)}
473
+ ${runtimeSection("Tests", tests.map((test) => `${test.status.toUpperCase()} · ${test.command} · ${formatRuntimeMs(test.durationMs)}`))}
474
+ ${runtimeSection("Changed files", files.slice(0, 6))}
475
+ <p style="color:#94a3b8;margin:24px 0 0">Video is local-only evidence for review. It is not meant to be committed or uploaded automatically.</p>
476
+ </section>
477
+ </body>
478
+ </html>`;
479
+ }
480
+ function runtimeSection(title, items) {
481
+ if (!items.length)
482
+ return "";
483
+ return `
484
+ <div style="margin-top:18px">
485
+ <h2 style="font-size:13px;color:#93c5fd;text-transform:uppercase;letter-spacing:.08em;margin:0 0 8px">${escapeRuntimeHtml(title)}</h2>
486
+ <ul style="margin:0;padding-left:20px;color:#e2e8f0">${items.map((item) => `<li style="margin:4px 0">${escapeRuntimeHtml(item)}</li>`).join("")}</ul>
487
+ </div>
488
+ `;
489
+ }
490
+ function escapeRuntimeHtml(value) {
491
+ return String(value).replace(/&/gu, "&amp;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;").replace(/"/gu, "&quot;");
492
+ }
493
+ function formatRuntimeMs(ms) {
494
+ return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
495
+ }
496
+ function resolveRuntimeUrl(baseUrl, target) {
497
+ if (/^https?:\/\//iu.test(target))
498
+ return target;
499
+ return `${String(baseUrl).replace(/\/+$/u, "")}/${String(target).replace(/^\/+/u, "")}`;
500
+ }
501
+ function slugRuntime(value) {
502
+ return value.toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-|-$/gu, "").slice(0, 48) || "step";
503
+ }
504
+ function renderMcpScript(plan, assetsDir) {
505
+ return `(async (page) => {
506
+ const plan = ${JSON.stringify(plan, null, 2)};
507
+ const assetsDir = ${JSON.stringify(assetsDir)};
508
+
509
+ await showIntro(page, plan);
510
+
511
+ for (const [index, step] of plan.steps.entries()) {
512
+ await showCaption(page, step.caption ?? actionLabel(step), index + 1, plan.steps.length);
513
+
514
+ if (step.goto) {
515
+ if (!plan.baseUrl) {
516
+ throw new Error("Demo step uses goto but baseUrl is missing.");
517
+ }
518
+ await page.goto(resolveUrl(plan.baseUrl, step.goto));
519
+ }
520
+
521
+ if (step.click) {
522
+ const target = page.locator(step.click).first();
523
+ await pointAtLocator(page, target, \`Click \${step.click}\`);
524
+ await target.click();
525
+ }
526
+
527
+ if (step.fill) {
528
+ const target = page.locator(step.fill.selector).first();
529
+ await pointAtLocator(page, target, \`Fill \${step.fill.selector}\`);
530
+ await target.fill(step.fill.value);
531
+ }
532
+
533
+ if (step.press) {
534
+ const target = page.locator(step.press.selector).first();
535
+ await pointAtLocator(page, target, \`Press \${step.press.key}\`);
536
+ await target.press(step.press.key);
537
+ }
538
+
539
+ if (step.expectText) {
540
+ const target = page.getByText(step.expectText, { exact: false }).first();
541
+ await target.waitFor({ state: "visible" });
542
+ await pointAtLocator(page, target, \`Verify \${step.expectText}\`);
543
+ }
544
+
545
+ if (step.waitMs !== undefined) {
546
+ await page.waitForTimeout(step.waitMs);
547
+ }
548
+
549
+ if (step.screenshot) {
550
+ await page.screenshot({ path: \`\${assetsDir}/mcp-screenshots/\${String(index + 1).padStart(2, "0")}-\${slug(step.screenshot)}.png\`, fullPage: true });
551
+ }
552
+
553
+ await page.waitForTimeout(250);
554
+ }
555
+
556
+ return {
557
+ title: plan.title,
558
+ steps: plan.steps.length,
559
+ url: page.url(),
560
+ screenshotsDir: \`\${assetsDir}/mcp-screenshots\`
561
+ };
562
+
563
+ async function showCaption(page, caption, index, total) {
564
+ await page.evaluate(
565
+ ({ caption, index, total }) => {
566
+ const id = "__demo_this_pr_caption";
567
+ let element = document.getElementById(id);
568
+ if (!element) {
569
+ element = document.createElement("div");
570
+ element.id = id;
571
+ document.body.appendChild(element);
572
+ }
573
+ element.textContent = \`\${index}/\${total} · \${caption}\`;
574
+ Object.assign(element.style, {
575
+ position: "fixed",
576
+ left: "24px",
577
+ bottom: "24px",
578
+ zIndex: "2147483647",
579
+ maxWidth: "760px",
580
+ padding: "12px 14px",
581
+ borderRadius: "8px",
582
+ background: "rgba(12, 18, 28, 0.92)",
583
+ color: "#fff",
584
+ font: "500 15px/1.4 system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
585
+ boxShadow: "0 12px 32px rgba(0,0,0,.24)"
586
+ });
587
+ },
588
+ { caption, index, total }
589
+ );
590
+ }
591
+
592
+ async function showIntro(page, demoPlan) {
593
+ await page.setContent(renderIntroHtml(demoPlan));
594
+ await page.waitForTimeout(2200);
595
+ }
596
+
597
+ async function pointAtLocator(page, locator, label) {
598
+ await locator.scrollIntoViewIfNeeded();
599
+ const box = await locator.boundingBox();
600
+ if (!box) return;
601
+
602
+ const x = box.x + box.width / 2;
603
+ const y = box.y + box.height / 2;
604
+ await page.mouse.move(x, y, { steps: 18 });
605
+ await page.evaluate(
606
+ ({ x, y, label }) => {
607
+ let cursor = document.getElementById("__demo_this_pr_cursor");
608
+ if (!cursor) {
609
+ cursor = document.createElement("div");
610
+ cursor.id = "__demo_this_pr_cursor";
611
+ cursor.innerHTML = \`<div class="dot"></div><div class="label"></div>\`;
612
+ document.body.appendChild(cursor);
613
+ }
614
+
615
+ Object.assign(cursor.style, {
616
+ position: "fixed",
617
+ left: \`\${x}px\`,
618
+ top: \`\${y}px\`,
619
+ transform: "translate(-8px, -8px)",
620
+ zIndex: "2147483647",
621
+ pointerEvents: "none",
622
+ transition: "left 260ms ease, top 260ms ease"
623
+ });
624
+
625
+ const dot = cursor.querySelector(".dot");
626
+ Object.assign(dot.style, {
627
+ width: "18px",
628
+ height: "18px",
629
+ borderRadius: "999px",
630
+ background: "#38bdf8",
631
+ border: "3px solid #ffffff",
632
+ boxShadow: "0 0 0 8px rgba(56, 189, 248, .22), 0 8px 20px rgba(0,0,0,.28)"
633
+ });
634
+
635
+ const labelEl = cursor.querySelector(".label");
636
+ labelEl.textContent = label;
637
+ Object.assign(labelEl.style, {
638
+ margin: "8px 0 0 12px",
639
+ padding: "7px 10px",
640
+ borderRadius: "8px",
641
+ background: "rgba(15, 23, 42, .92)",
642
+ color: "#fff",
643
+ font: "600 13px/1.3 system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
644
+ whiteSpace: "nowrap",
645
+ boxShadow: "0 8px 24px rgba(0,0,0,.22)"
646
+ });
647
+ },
648
+ { x, y, label }
649
+ );
650
+ await page.waitForTimeout(550);
651
+ }
652
+
653
+ function renderIntroHtml(demoPlan) {
654
+ const tests = demoPlan.context?.tests ?? [];
655
+ const changes = demoPlan.context?.changes ?? [];
656
+ const technologies = demoPlan.context?.technologies ?? [];
657
+ const files = demoPlan.context?.changedFiles ?? [];
658
+
659
+ return \`<!doctype html>
660
+ <html>
661
+ <head>
662
+ <meta charset="utf-8">
663
+ <title>\${escapeHtml(demoPlan.title)}</title>
664
+ </head>
665
+ <body style="margin:0;background:#0f172a;color:#e5edf7;font:15px/1.45 system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif">
666
+ <section style="max-width:980px;margin:72px auto;padding:32px;border:1px solid rgba(148,163,184,.35);border-radius:14px;background:rgba(15,23,42,.92);box-shadow:0 24px 80px rgba(0,0,0,.35)">
667
+ <div style="color:#93c5fd;text-transform:uppercase;font-weight:700;font-size:12px;letter-spacing:.08em">Demo this PR</div>
668
+ <h1 style="font-size:34px;line-height:1.1;margin:10px 0 12px">\${escapeHtml(demoPlan.title)}</h1>
669
+ <p style="font-size:18px;color:#cbd5e1;margin:0 0 24px">\${escapeHtml(demoPlan.why ?? "Review the changed behavior.")}</p>
670
+ \${section("What changed", changes)}
671
+ \${section("Technology used", technologies)}
672
+ \${section("Tests", tests.map((test) => \`\${test.status.toUpperCase()} · \${test.command} · \${formatMs(test.durationMs)}\`))}
673
+ \${section("Changed files", files.slice(0, 6))}
674
+ <p style="color:#94a3b8;margin:24px 0 0">Video is local-only evidence for review. It is not meant to be committed or uploaded automatically.</p>
675
+ </section>
676
+ </body>
677
+ </html>\`;
678
+ }
679
+
680
+ function section(title, items) {
681
+ if (!items.length) return "";
682
+ return \`
683
+ <div style="margin-top:18px">
684
+ <h2 style="font-size:13px;color:#93c5fd;text-transform:uppercase;letter-spacing:.08em;margin:0 0 8px">\${escapeHtml(title)}</h2>
685
+ <ul style="margin:0;padding-left:20px;color:#e2e8f0">\${items.map((item) => \`<li style="margin:4px 0">\${escapeHtml(item)}</li>\`).join("")}</ul>
686
+ </div>
687
+ \`;
688
+ }
689
+
690
+ function escapeHtml(value) {
691
+ return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
692
+ }
693
+
694
+ function formatMs(ms) {
695
+ return ms < 1000 ? \`\${ms}ms\` : \`\${(ms / 1000).toFixed(1)}s\`;
696
+ }
697
+
698
+ function actionLabel(step) {
699
+ if (step.goto) return \`Open \${step.goto}\`;
700
+ if (step.click) return \`Click \${step.click}\`;
701
+ if (step.fill) return \`Fill \${step.fill.selector}\`;
702
+ if (step.press) return \`Press \${step.press.key}\`;
703
+ if (step.expectText) return \`Verify \${step.expectText}\`;
704
+ if (step.screenshot) return \`Capture \${step.screenshot}\`;
705
+ if (step.waitMs) return \`Wait \${step.waitMs}ms\`;
706
+ return "Demo step";
707
+ }
708
+
709
+ function resolveUrl(baseUrl, target) {
710
+ if (/^https?:\\/\\//i.test(target)) return target;
711
+ return \`\${String(baseUrl).replace(/\\/+$/, "")}/\${String(target).replace(/^\\/+/, "")}\`;
712
+ }
713
+
714
+ function slug(value) {
715
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 48) || "step";
716
+ }
717
+ })
718
+ `;
719
+ }
720
+ function renderMcpGuide(plan, mcpScriptPath, outputDir) {
721
+ return `# MCP Playwright verification
722
+
723
+ This repo prefers Playwright MCP for browser verification. Use an isolated MCP Chromium context, not the user's normal Chrome profile.
724
+
725
+ ## Feature
726
+
727
+ ${plan.title}
728
+
729
+ ## Why
730
+
731
+ ${plan.why ?? "No reason provided."}
732
+
733
+ ## Run with MCP
734
+
735
+ 1. Start the app under review.
736
+ 2. Open or select an isolated Playwright MCP Chromium tab.
737
+ 3. Resize the viewport to 1440x900.
738
+ 4. Navigate to \`${plan.baseUrl ?? "the local app URL"}\`.
739
+ 5. Run the generated script through \`mcp__playwright__.browser_run_code_unsafe\`:
740
+
741
+ \`\`\`json
742
+ {
743
+ "filename": ${JSON.stringify(mcpScriptPath)}
744
+ }
745
+ \`\`\`
746
+
747
+ 6. Capture a final full-page screenshot through MCP if useful.
748
+ 7. Close the MCP tab/window after verification.
749
+
750
+ ## Demo steps
751
+
752
+ ${plan.steps.map((step, index) => `${index + 1}. ${step.caption ?? describeStep(step)}`).join("\n")}
753
+
754
+ ## Artifacts
755
+
756
+ - MCP script: \`${relative(outputDir, mcpScriptPath)}\`
757
+ - Screenshots: \`assets/mcp-screenshots/\`
758
+ - Optional video runner: \`pr-demo.spec.ts\`
759
+ `;
760
+ }
761
+ function describeStep(step) {
762
+ if (step.caption) {
763
+ return step.caption;
764
+ }
765
+ if (step.goto) {
766
+ return `Open ${step.goto}`;
767
+ }
768
+ if (step.click) {
769
+ return `Click ${step.click}`;
770
+ }
771
+ if (step.fill) {
772
+ return `Fill ${step.fill.selector}`;
773
+ }
774
+ if (step.press) {
775
+ return `Press ${step.press.key}`;
776
+ }
777
+ if (step.expectText) {
778
+ return `Verify ${step.expectText}`;
779
+ }
780
+ if (step.screenshot) {
781
+ return `Capture ${step.screenshot}`;
782
+ }
783
+ if (step.waitMs !== undefined) {
784
+ return `Wait ${step.waitMs}ms`;
785
+ }
786
+ return "Demo step";
787
+ }
788
+ async function copyFirstVideo(outputDir) {
789
+ const testResultsDir = join(outputDir, "assets", "test-results");
790
+ const video = await findFile(testResultsDir, ".webm");
791
+ if (!video) {
792
+ return undefined;
793
+ }
794
+ const target = join(outputDir, "assets", "demo-this-pr.webm");
795
+ await cp(video, target);
796
+ return relative(outputDir, target);
797
+ }
798
+ async function copyVideo(source, outputDir) {
799
+ const target = join(outputDir, "assets", "demo-this-pr.webm");
800
+ await cp(source, target);
801
+ return relative(outputDir, target);
802
+ }
803
+ async function convertToMp4(outputDir, webmPath) {
804
+ if (!ffmpegPath) {
805
+ return { error: "ffmpeg-static did not provide a binary for this platform." };
806
+ }
807
+ const source = join(outputDir, webmPath);
808
+ const target = join(outputDir, "assets", "demo-this-pr.mp4");
809
+ const result = await runProcess(ffmpegPath, ["-y", "-i", source, "-movflags", "faststart", "-pix_fmt", "yuv420p", target], outputDir);
810
+ if (result.exitCode !== 0) {
811
+ return { error: trimOutput(`${result.stdout}\n${result.stderr}`) || "ffmpeg failed to convert WebM to MP4." };
812
+ }
813
+ return { mp4Path: relative(outputDir, target) };
814
+ }
815
+ async function findFile(dir, suffix) {
816
+ let entries;
817
+ try {
818
+ entries = await readdir(dir, { withFileTypes: true });
819
+ }
820
+ catch {
821
+ return undefined;
822
+ }
823
+ for (const entry of entries) {
824
+ const path = join(dir, entry.name);
825
+ if (entry.isFile() && entry.name.endsWith(suffix)) {
826
+ return path;
827
+ }
828
+ if (entry.isDirectory()) {
829
+ const nested = await findFile(path, suffix);
830
+ if (nested) {
831
+ return nested;
832
+ }
833
+ }
834
+ }
835
+ return undefined;
836
+ }
837
+ function summarizeFailure(result) {
838
+ const output = trimOutput(`${result.stderr}\n${result.stdout}`);
839
+ if (/Executable doesn't exist|playwright install|browser.*not found/iu.test(output)) {
840
+ return "Playwright browser is not installed. Run: npx playwright install chromium";
841
+ }
842
+ return output.split("\n").slice(-8).join("\n");
843
+ }
844
+ function trimOutput(output) {
845
+ return output.trim().split("\n").slice(-80).join("\n");
846
+ }