nodebench-mcp 2.22.0 → 2.25.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.
Files changed (38) hide show
  1. package/README.md +366 -280
  2. package/dist/__tests__/multiHopDogfood.test.d.ts +12 -0
  3. package/dist/__tests__/multiHopDogfood.test.js +303 -0
  4. package/dist/__tests__/multiHopDogfood.test.js.map +1 -0
  5. package/dist/__tests__/presetRealWorldBench.test.js +2 -0
  6. package/dist/__tests__/presetRealWorldBench.test.js.map +1 -1
  7. package/dist/__tests__/tools.test.js +158 -6
  8. package/dist/__tests__/tools.test.js.map +1 -1
  9. package/dist/__tests__/toolsetGatingEval.test.js +2 -0
  10. package/dist/__tests__/toolsetGatingEval.test.js.map +1 -1
  11. package/dist/dashboard/html.d.ts +18 -0
  12. package/dist/dashboard/html.js +1251 -0
  13. package/dist/dashboard/html.js.map +1 -0
  14. package/dist/dashboard/server.d.ts +17 -0
  15. package/dist/dashboard/server.js +278 -0
  16. package/dist/dashboard/server.js.map +1 -0
  17. package/dist/db.js +38 -0
  18. package/dist/db.js.map +1 -1
  19. package/dist/index.js +19 -9
  20. package/dist/index.js.map +1 -1
  21. package/dist/tools/prReportTools.d.ts +11 -0
  22. package/dist/tools/prReportTools.js +911 -0
  23. package/dist/tools/prReportTools.js.map +1 -0
  24. package/dist/tools/progressiveDiscoveryTools.js +111 -24
  25. package/dist/tools/progressiveDiscoveryTools.js.map +1 -1
  26. package/dist/tools/skillUpdateTools.d.ts +24 -0
  27. package/dist/tools/skillUpdateTools.js +469 -0
  28. package/dist/tools/skillUpdateTools.js.map +1 -0
  29. package/dist/tools/toolRegistry.d.ts +15 -1
  30. package/dist/tools/toolRegistry.js +315 -11
  31. package/dist/tools/toolRegistry.js.map +1 -1
  32. package/dist/tools/uiUxDiveAdvancedTools.js +61 -0
  33. package/dist/tools/uiUxDiveAdvancedTools.js.map +1 -1
  34. package/dist/tools/uiUxDiveTools.js +154 -1
  35. package/dist/tools/uiUxDiveTools.js.map +1 -1
  36. package/dist/toolsetRegistry.js +4 -0
  37. package/dist/toolsetRegistry.js.map +1 -1
  38. package/package.json +2 -2
@@ -0,0 +1,911 @@
1
+ /**
2
+ * PR Report tools — visual PR creation from UI Dive sessions.
3
+ *
4
+ * - generate_pr_report: Rich markdown PR body with screenshots, timeline, bug fixes, past sessions
5
+ * - export_pr_screenshots: Export before/after screenshot pairs to a directory for git commit
6
+ * - create_visual_pr: End-to-end PR creation via `gh pr create`
7
+ *
8
+ * Bridges the UI Dive visual QA system with GitHub PR workflows.
9
+ */
10
+ import { mkdirSync, writeFileSync, existsSync, copyFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { execSync } from "node:child_process";
13
+ import { getDb } from "../db.js";
14
+ import { getDashboardUrl } from "../dashboard/server.js";
15
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
16
+ function gitExecOptions(repoPath) {
17
+ return {
18
+ cwd: repoPath || process.cwd(),
19
+ encoding: "utf8",
20
+ timeout: 15000,
21
+ stdio: ["pipe", "pipe", "pipe"],
22
+ };
23
+ }
24
+ function runGit(command, repoPath) {
25
+ return execSync(command, gitExecOptions(repoPath)).toString().trim();
26
+ }
27
+ function runGh(command, repoPath) {
28
+ return execSync(command, { ...gitExecOptions(repoPath), timeout: 30000 })
29
+ .toString()
30
+ .trim();
31
+ }
32
+ function resolveSessionId(rawId) {
33
+ const db = getDb();
34
+ if (!rawId || rawId === "latest") {
35
+ const latest = db
36
+ .prepare("SELECT id FROM ui_dive_sessions ORDER BY created_at DESC LIMIT 1")
37
+ .get();
38
+ return latest?.id ?? null;
39
+ }
40
+ return rawId;
41
+ }
42
+ /** Export a single screenshot to disk. Prefers file_path (full image), falls back to base64. */
43
+ function exportScreenshot(screenshotId, outputPath, db) {
44
+ if (!screenshotId)
45
+ return { success: false, path: null, warning: "No screenshot ID" };
46
+ const row = db
47
+ .prepare("SELECT base64_thumbnail, file_path FROM ui_dive_screenshots WHERE id = ?")
48
+ .get(screenshotId);
49
+ if (!row)
50
+ return {
51
+ success: false,
52
+ path: null,
53
+ warning: `Screenshot ${screenshotId} not found in DB`,
54
+ };
55
+ // Prefer file_path (always full image)
56
+ if (row.file_path && existsSync(row.file_path)) {
57
+ copyFileSync(row.file_path, outputPath);
58
+ return { success: true, path: outputPath };
59
+ }
60
+ // Fall back to base64_thumbnail — skip if truncated (<1KB likely means 500-char truncation)
61
+ if (row.base64_thumbnail && row.base64_thumbnail.length > 1000) {
62
+ const buf = Buffer.from(row.base64_thumbnail, "base64");
63
+ writeFileSync(outputPath, buf);
64
+ return { success: true, path: outputPath };
65
+ }
66
+ return {
67
+ success: false,
68
+ path: null,
69
+ warning: `No usable image data for screenshot ${screenshotId}${row.base64_thumbnail ? " (base64 truncated)" : ""}`,
70
+ };
71
+ }
72
+ function severityBadge(severity) {
73
+ const badges = {
74
+ critical: "![critical](https://img.shields.io/badge/-CRITICAL-red?style=flat-square)",
75
+ high: "![high](https://img.shields.io/badge/-HIGH-orange?style=flat-square)",
76
+ medium: "![medium](https://img.shields.io/badge/-MEDIUM-yellow?style=flat-square)",
77
+ low: "![low](https://img.shields.io/badge/-LOW-blue?style=flat-square)",
78
+ };
79
+ return badges[severity] ?? `\`${severity}\``;
80
+ }
81
+ function changeTypeBadge(changeType) {
82
+ const badges = {
83
+ bugfix: "![bugfix](https://img.shields.io/badge/-BUGFIX-brightgreen?style=flat-square)",
84
+ design_fix: "![design](https://img.shields.io/badge/-DESIGN-blueviolet?style=flat-square)",
85
+ feature: "![feature](https://img.shields.io/badge/-FEATURE-blue?style=flat-square)",
86
+ refactor: "![refactor](https://img.shields.io/badge/-REFACTOR-lightgrey?style=flat-square)",
87
+ accessibility: "![a11y](https://img.shields.io/badge/-A11Y-purple?style=flat-square)",
88
+ performance: "![perf](https://img.shields.io/badge/-PERF-ff69b4?style=flat-square)",
89
+ content: "![content](https://img.shields.io/badge/-CONTENT-teal?style=flat-square)",
90
+ responsive: "![responsive](https://img.shields.io/badge/-RESPONSIVE-cyan?style=flat-square)",
91
+ };
92
+ return badges[changeType] ?? `\`${changeType}\``;
93
+ }
94
+ function timeEmoji(eventType) {
95
+ const emojis = {
96
+ bug: "Bug",
97
+ fix: "Fix Verified",
98
+ changelog: "Change",
99
+ design_issue: "Design Issue",
100
+ interaction_error: "Error",
101
+ test: "Test",
102
+ };
103
+ return emojis[eventType] ?? eventType;
104
+ }
105
+ function fmtTime(isoDate) {
106
+ try {
107
+ return new Date(isoDate).toLocaleTimeString("en-US", {
108
+ hour: "2-digit",
109
+ minute: "2-digit",
110
+ });
111
+ }
112
+ catch {
113
+ return isoDate;
114
+ }
115
+ }
116
+ function fmtDate(isoDate) {
117
+ try {
118
+ return new Date(isoDate).toLocaleDateString("en-US", {
119
+ year: "numeric",
120
+ month: "short",
121
+ day: "numeric",
122
+ });
123
+ }
124
+ catch {
125
+ return isoDate;
126
+ }
127
+ }
128
+ /** Normalize Windows backslashes to forward slashes for markdown image paths */
129
+ function mdPath(p) {
130
+ return p.replace(/\\/g, "/");
131
+ }
132
+ function buildTimeline(sessionId, db) {
133
+ const events = [];
134
+ // Bugs
135
+ const bugs = db
136
+ .prepare("SELECT severity, title, created_at FROM ui_dive_bugs WHERE session_id = ? ORDER BY created_at")
137
+ .all(sessionId);
138
+ for (const b of bugs) {
139
+ events.push({
140
+ time: b.created_at,
141
+ type: "bug",
142
+ summary: b.title,
143
+ severity: b.severity,
144
+ });
145
+ }
146
+ // Fix verifications
147
+ const fixes = db
148
+ .prepare(`SELECT f.fix_description, f.verified, f.created_at, b.title as bug_title
149
+ FROM ui_dive_fix_verifications f
150
+ LEFT JOIN ui_dive_bugs b ON f.bug_id = b.id
151
+ WHERE f.session_id = ? ORDER BY f.created_at`)
152
+ .all(sessionId);
153
+ for (const f of fixes) {
154
+ events.push({
155
+ time: f.created_at,
156
+ type: "fix",
157
+ summary: `${f.bug_title ?? "Bug"}: ${f.fix_description}${f.verified ? " (verified)" : ""}`,
158
+ });
159
+ }
160
+ // Changelogs
161
+ const changelogs = db
162
+ .prepare("SELECT change_type, description, created_at FROM ui_dive_changelogs WHERE session_id = ? ORDER BY created_at")
163
+ .all(sessionId);
164
+ for (const c of changelogs) {
165
+ events.push({
166
+ time: c.created_at,
167
+ type: "changelog",
168
+ summary: `[${c.change_type}] ${c.description}`,
169
+ });
170
+ }
171
+ // Design issues
172
+ const designIssues = db
173
+ .prepare("SELECT severity, title, created_at FROM ui_dive_design_issues WHERE session_id = ? ORDER BY created_at")
174
+ .all(sessionId);
175
+ for (const d of designIssues) {
176
+ events.push({
177
+ time: d.created_at,
178
+ type: "design_issue",
179
+ summary: d.title,
180
+ severity: d.severity,
181
+ });
182
+ }
183
+ // Interaction errors only (to keep timeline focused)
184
+ const errors = db
185
+ .prepare(`SELECT i.action, i.target, i.observation, i.created_at
186
+ FROM ui_dive_interactions i
187
+ WHERE i.session_id = ? AND i.result != 'success'
188
+ ORDER BY i.created_at`)
189
+ .all(sessionId);
190
+ for (const e of errors) {
191
+ events.push({
192
+ time: e.created_at,
193
+ type: "interaction_error",
194
+ summary: `${e.action} on ${e.target ?? "unknown"}: ${e.observation ?? "error"}`,
195
+ });
196
+ }
197
+ // Tests
198
+ const tests = db
199
+ .prepare("SELECT test_name, status, created_at FROM ui_dive_interaction_tests WHERE session_id = ? ORDER BY created_at")
200
+ .all(sessionId);
201
+ for (const t of tests) {
202
+ events.push({
203
+ time: t.created_at,
204
+ type: "test",
205
+ summary: `${t.test_name}: ${t.status}`,
206
+ });
207
+ }
208
+ // Sort by time
209
+ events.sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime());
210
+ return events;
211
+ }
212
+ // ─── Core markdown generation ─────────────────────────────────────────────────
213
+ function generateMarkdown(sessionId, includeScreenshots, assetDir, exportedPairs) {
214
+ const db = getDb();
215
+ const dashUrl = getDashboardUrl();
216
+ // Session info
217
+ const session = db
218
+ .prepare("SELECT * FROM ui_dive_sessions WHERE id = ?")
219
+ .get(sessionId);
220
+ // Stats
221
+ const count = (table, where = "session_id = ?") => db
222
+ .prepare(`SELECT COUNT(*) as c FROM ${table} WHERE ${where}`)
223
+ .get(sessionId)?.c ?? 0;
224
+ const componentCount = count("ui_dive_components");
225
+ const bugCount = count("ui_dive_bugs");
226
+ const bugsResolved = count("ui_dive_bugs", "session_id = ? AND status = 'resolved'");
227
+ const screenshotCount = count("ui_dive_screenshots");
228
+ const testCount = count("ui_dive_interaction_tests");
229
+ const testsPassed = count("ui_dive_interaction_tests", "session_id = ? AND status = 'passed'");
230
+ const testsFailed = count("ui_dive_interaction_tests", "session_id = ? AND status = 'failed'");
231
+ const designIssueCount = count("ui_dive_design_issues");
232
+ const fixCount = count("ui_dive_fix_verifications");
233
+ const fixesVerified = count("ui_dive_fix_verifications", "session_id = ? AND verified = 1");
234
+ const changelogCount = count("ui_dive_changelogs");
235
+ // Latest code review
236
+ const review = db
237
+ .prepare("SELECT score, summary, severity_counts FROM ui_dive_code_reviews WHERE session_id = ? ORDER BY created_at DESC LIMIT 1")
238
+ .get(sessionId);
239
+ // Aggregate files changed from changelogs
240
+ const allFilesChanged = new Set();
241
+ const changelogs = db
242
+ .prepare("SELECT files_changed FROM ui_dive_changelogs WHERE session_id = ?")
243
+ .all(sessionId);
244
+ for (const cl of changelogs) {
245
+ if (cl.files_changed) {
246
+ try {
247
+ const files = JSON.parse(cl.files_changed);
248
+ if (Array.isArray(files))
249
+ files.forEach((f) => allFilesChanged.add(f));
250
+ }
251
+ catch { /* ignore parse errors */ }
252
+ }
253
+ }
254
+ const fixFiles = db
255
+ .prepare("SELECT files_changed FROM ui_dive_fix_verifications WHERE session_id = ?")
256
+ .all(sessionId);
257
+ for (const fv of fixFiles) {
258
+ if (fv.files_changed) {
259
+ try {
260
+ const files = JSON.parse(fv.files_changed);
261
+ if (Array.isArray(files))
262
+ files.forEach((f) => allFilesChanged.add(f));
263
+ }
264
+ catch { /* ignore */ }
265
+ }
266
+ }
267
+ // Bug details
268
+ const bugs = db
269
+ .prepare(`SELECT b.severity, b.title, b.status, b.category, c.name as component_name
270
+ FROM ui_dive_bugs b
271
+ LEFT JOIN ui_dive_components c ON b.component_id = c.id
272
+ WHERE b.session_id = ?
273
+ ORDER BY CASE b.severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END`)
274
+ .all(sessionId);
275
+ // Fix verifications with bug context
276
+ const fixDetails = db
277
+ .prepare(`SELECT f.fix_description, f.verified, f.git_commit, b.title as bug_title, b.severity as bug_severity
278
+ FROM ui_dive_fix_verifications f
279
+ LEFT JOIN ui_dive_bugs b ON f.bug_id = b.id
280
+ WHERE f.session_id = ?
281
+ ORDER BY f.created_at`)
282
+ .all(sessionId);
283
+ // Design issues
284
+ const designIssues = db
285
+ .prepare(`SELECT d.issue_type, d.severity, d.title, d.expected_value, d.actual_value, c.name as component_name
286
+ FROM ui_dive_design_issues d
287
+ LEFT JOIN ui_dive_components c ON d.component_id = c.id
288
+ WHERE d.session_id = ?
289
+ ORDER BY CASE d.severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END`)
290
+ .all(sessionId);
291
+ // Test results
292
+ const testResults = db
293
+ .prepare(`SELECT t.test_name, t.status, t.steps_total, t.steps_passed, t.steps_failed, c.name as component_name
294
+ FROM ui_dive_interaction_tests t
295
+ LEFT JOIN ui_dive_components c ON t.component_id = c.id
296
+ WHERE t.session_id = ?
297
+ ORDER BY t.created_at`)
298
+ .all(sessionId);
299
+ // Past sessions (same app_url)
300
+ const pastSessions = session?.app_url
301
+ ? db
302
+ .prepare(`SELECT id, app_name, status, created_at,
303
+ (SELECT COUNT(*) FROM ui_dive_bugs WHERE session_id = s.id) as bug_count,
304
+ (SELECT COUNT(*) FROM ui_dive_bugs WHERE session_id = s.id AND status = 'resolved') as bugs_resolved
305
+ FROM ui_dive_sessions s
306
+ WHERE app_url = ? AND id != ?
307
+ ORDER BY created_at DESC LIMIT 5`)
308
+ .all(session.app_url, sessionId)
309
+ : [];
310
+ // Timeline
311
+ const timeline = buildTimeline(sessionId, db);
312
+ // Auto-generate title
313
+ const changeTypes = [
314
+ ...new Set(changelogs.map((cl) => {
315
+ try {
316
+ return JSON.parse(JSON.stringify(cl)).change_type;
317
+ }
318
+ catch {
319
+ return null;
320
+ }
321
+ })),
322
+ ].filter(Boolean);
323
+ // Re-query changelogs for change_type
324
+ const clTypes = db
325
+ .prepare("SELECT DISTINCT change_type FROM ui_dive_changelogs WHERE session_id = ?")
326
+ .all(sessionId);
327
+ const types = clTypes.map((c) => c.change_type);
328
+ const primaryType = types.includes("bugfix") || types.includes("design_fix")
329
+ ? "fix"
330
+ : types.includes("feature")
331
+ ? "feat"
332
+ : types.includes("refactor")
333
+ ? "refactor"
334
+ : "fix";
335
+ const scope = session?.app_name ?? "ui";
336
+ const titleSummary = bugCount > 0
337
+ ? `visual QA: ${bugsResolved}/${bugCount} bugs fixed`
338
+ : `visual QA session for ${session?.app_name ?? "app"}`;
339
+ const suggestedTitle = `${primaryType}(${scope}): ${titleSummary}`;
340
+ // Health score (from code review or calculated)
341
+ let healthScore = review?.score ?? null;
342
+ let healthGrade = "";
343
+ if (healthScore !== null) {
344
+ healthGrade =
345
+ healthScore >= 90
346
+ ? "A"
347
+ : healthScore >= 80
348
+ ? "B"
349
+ : healthScore >= 70
350
+ ? "C"
351
+ : healthScore >= 60
352
+ ? "D"
353
+ : "F";
354
+ }
355
+ // ── Build markdown ────────────────────────────────────────────────
356
+ const md = [];
357
+ md.push("## UI Dive QA Report\n");
358
+ md.push(`**App:** ${session?.app_name ?? "Unknown"} (${session?.app_url ?? "N/A"})`);
359
+ if (dashUrl) {
360
+ md.push(`**Dashboard:** [${dashUrl}](${dashUrl})`);
361
+ }
362
+ if (healthScore !== null) {
363
+ md.push(`**Health Score:** ${healthScore}/100 (${healthGrade})`);
364
+ }
365
+ md.push("");
366
+ // Summary table
367
+ md.push("### Summary\n");
368
+ md.push("| Metric | Count |");
369
+ md.push("|--------|-------|");
370
+ md.push(`| Components Tested | ${componentCount} |`);
371
+ md.push(`| Bugs Found | ${bugCount}${bugs.length > 0 ? ` (${bugs.filter((b) => b.severity === "critical").length} critical, ${bugs.filter((b) => b.severity === "high").length} high)` : ""} |`);
372
+ md.push(`| Bugs Fixed | ${bugsResolved} |`);
373
+ md.push(`| Fixes Verified | ${fixesVerified}/${fixCount} |`);
374
+ md.push(`| Design Issues | ${designIssueCount} |`);
375
+ md.push(`| Tests | ${testCount} (${testsPassed} passed, ${testsFailed} failed) |`);
376
+ md.push(`| Screenshots | ${screenshotCount} |`);
377
+ md.push(`| Changelogs | ${changelogCount} |`);
378
+ md.push("");
379
+ // Visual Changes
380
+ if (includeScreenshots && exportedPairs.length > 0) {
381
+ md.push("### Visual Changes\n");
382
+ for (let i = 0; i < exportedPairs.length; i++) {
383
+ const pair = exportedPairs[i];
384
+ md.push(`#### ${i + 1}. ${changeTypeBadge(pair.changeType)} ${pair.description}\n`);
385
+ if (pair.beforePath || pair.afterPath) {
386
+ md.push("| Before | After |");
387
+ md.push("|--------|-------|");
388
+ const beforeImg = pair.beforePath
389
+ ? `![Before](${mdPath(pair.beforePath)})`
390
+ : "_No before screenshot_";
391
+ const afterImg = pair.afterPath
392
+ ? `![After](${mdPath(pair.afterPath)})`
393
+ : "_No after screenshot_";
394
+ md.push(`| ${beforeImg} | ${afterImg} |`);
395
+ md.push("");
396
+ }
397
+ }
398
+ }
399
+ else if (includeScreenshots && changelogCount > 0) {
400
+ // No exported pairs, but changelogs exist — show text-only
401
+ md.push("### Changes\n");
402
+ const cls = db
403
+ .prepare("SELECT change_type, description, files_changed, git_commit, created_at FROM ui_dive_changelogs WHERE session_id = ? ORDER BY created_at")
404
+ .all(sessionId);
405
+ for (const cl of cls) {
406
+ md.push(`- ${changeTypeBadge(cl.change_type)} ${cl.description}`);
407
+ if (cl.git_commit)
408
+ md.push(` Commit: \`${cl.git_commit}\``);
409
+ if (cl.files_changed) {
410
+ try {
411
+ const files = JSON.parse(cl.files_changed);
412
+ if (Array.isArray(files) && files.length > 0) {
413
+ md.push(` Files: ${files.map((f) => `\`${f}\``).join(", ")}`);
414
+ }
415
+ }
416
+ catch { /* skip */ }
417
+ }
418
+ }
419
+ md.push("");
420
+ }
421
+ // Bug Fixes
422
+ if (fixDetails.length > 0) {
423
+ md.push("### Bug Fixes\n");
424
+ for (const f of fixDetails) {
425
+ const verified = f.verified ? " **Verified**" : " _Pending_";
426
+ md.push(`- ${severityBadge(f.bug_severity ?? "medium")} **${f.bug_title ?? "Bug"}** — ${f.fix_description}${verified}`);
427
+ }
428
+ md.push("");
429
+ }
430
+ // Timeline (collapsible)
431
+ if (timeline.length > 0) {
432
+ md.push(`<details>\n<summary>Timeline (${timeline.length} events)</summary>\n`);
433
+ md.push("| Time | Type | Details |");
434
+ md.push("|------|------|---------|");
435
+ for (const ev of timeline) {
436
+ const sev = ev.severity ? ` ${severityBadge(ev.severity)}` : "";
437
+ md.push(`| ${fmtTime(ev.time)} | ${timeEmoji(ev.type)} | ${ev.summary}${sev} |`);
438
+ }
439
+ md.push("\n</details>\n");
440
+ }
441
+ // Design Improvements (collapsible)
442
+ if (designIssues.length > 0) {
443
+ md.push(`<details>\n<summary>Design Improvements (${designIssues.length})</summary>\n`);
444
+ for (const d of designIssues) {
445
+ const vals = d.expected_value || d.actual_value
446
+ ? `: expected \`${d.expected_value ?? "?"}\`, got \`${d.actual_value ?? "?"}\``
447
+ : "";
448
+ md.push(`- ${severityBadge(d.severity)} **${d.title}** (${d.issue_type}${d.component_name ? `, ${d.component_name}` : ""})${vals}`);
449
+ }
450
+ md.push("\n</details>\n");
451
+ }
452
+ // Test Results (collapsible)
453
+ if (testResults.length > 0) {
454
+ md.push(`<details>\n<summary>Test Results (${testsPassed}/${testCount} passed)</summary>\n`);
455
+ md.push("| Test | Component | Status | Steps Passed | Steps Failed |");
456
+ md.push("|------|-----------|--------|--------------|--------------|");
457
+ for (const t of testResults) {
458
+ const statusIcon = t.status === "passed" ? "Pass" : t.status === "failed" ? "Fail" : t.status;
459
+ md.push(`| ${t.test_name} | ${t.component_name ?? "—"} | ${statusIcon} | ${t.steps_passed ?? 0} | ${t.steps_failed ?? 0} |`);
460
+ }
461
+ md.push("\n</details>\n");
462
+ }
463
+ // Code Review Score
464
+ if (review) {
465
+ md.push("### Code Review Score\n");
466
+ md.push(`**Score:** ${review.score}/100 (${healthGrade})`);
467
+ if (review.summary)
468
+ md.push(`\n${review.summary}`);
469
+ md.push("");
470
+ }
471
+ // Past Sessions
472
+ if (pastSessions.length > 0) {
473
+ md.push("### Past Sessions\n");
474
+ md.push("| Session | Date | Status | Bugs Found/Fixed |");
475
+ md.push("|---------|------|--------|-------------------|");
476
+ for (const ps of pastSessions) {
477
+ const sessionLink = dashUrl
478
+ ? `[${ps.id.slice(0, 8)}](${dashUrl}?session=${ps.id})`
479
+ : `\`${ps.id.slice(0, 8)}\``;
480
+ md.push(`| ${sessionLink} | ${fmtDate(ps.created_at)} | ${ps.status} | ${ps.bugs_resolved}/${ps.bug_count} |`);
481
+ }
482
+ md.push("");
483
+ }
484
+ // Files changed
485
+ if (allFilesChanged.size > 0) {
486
+ md.push("### Files Changed\n");
487
+ for (const f of allFilesChanged) {
488
+ md.push(`- \`${f}\``);
489
+ }
490
+ md.push("");
491
+ }
492
+ // Footer
493
+ md.push("---");
494
+ if (dashUrl) {
495
+ md.push(`> Interactive dashboard: [${dashUrl}](${dashUrl})`);
496
+ }
497
+ md.push("> Generated by NodeBench MCP `generate_pr_report`");
498
+ let markdown = md.join("\n");
499
+ // Safety: GitHub PR body limit ~65,535 chars
500
+ if (markdown.length > 60000) {
501
+ markdown =
502
+ markdown.slice(0, 59900) +
503
+ "\n\n---\n_Report truncated (exceeded 60,000 character limit). View full report on the dashboard._\n";
504
+ }
505
+ return {
506
+ markdown,
507
+ title: suggestedTitle,
508
+ filesChanged: [...allFilesChanged],
509
+ };
510
+ }
511
+ // ─── Tools ────────────────────────────────────────────────────────────────────
512
+ export const prReportTools = [
513
+ // ─── Tool 1: generate_pr_report ──────────────────────────────────────────
514
+ {
515
+ name: "generate_pr_report",
516
+ description: "Generate a rich markdown PR body from a UI Dive session. Compiles visual changes (before/after screenshot comparisons), a unified timeline of all change events, bug fixes with severity badges, design improvements, interaction test results, code review score, links to past sessions, and a dashboard URL. Does NOT create the PR — returns the markdown and metadata for use with `gh pr create` or create_visual_pr. If asset_dir is provided, also exports screenshot PNGs for committing.",
517
+ inputSchema: {
518
+ type: "object",
519
+ properties: {
520
+ session_id: {
521
+ type: "string",
522
+ description: "UI Dive session ID. Omit or pass 'latest' to use the most recent session.",
523
+ },
524
+ include_screenshots: {
525
+ type: "boolean",
526
+ description: "Include before/after screenshot image references in the markdown (default: true). Set false for text-only.",
527
+ },
528
+ asset_dir: {
529
+ type: "string",
530
+ description: "Directory to export screenshot PNGs for git commit (e.g. '.nodebench/pr-assets/'). Paths in the markdown will be relative to this. If omitted, no files are exported.",
531
+ },
532
+ },
533
+ },
534
+ handler: async (args) => {
535
+ const sessionId = resolveSessionId(args.session_id);
536
+ if (!sessionId) {
537
+ return {
538
+ error: true,
539
+ message: "No UI Dive sessions found. Start a dive first with start_ui_dive.",
540
+ };
541
+ }
542
+ const db = getDb();
543
+ const session = db
544
+ .prepare("SELECT * FROM ui_dive_sessions WHERE id = ?")
545
+ .get(sessionId);
546
+ if (!session) {
547
+ return { error: true, message: `Session not found: ${sessionId}` };
548
+ }
549
+ const includeScreenshots = args.include_screenshots !== false;
550
+ const assetDir = args.asset_dir ?? null;
551
+ let exportedPairs = [];
552
+ // Export screenshots if asset_dir provided
553
+ if (assetDir && includeScreenshots) {
554
+ mkdirSync(assetDir, { recursive: true });
555
+ const changelogsWithSS = db
556
+ .prepare(`SELECT id, change_type, description, before_screenshot_id, after_screenshot_id
557
+ FROM ui_dive_changelogs WHERE session_id = ? ORDER BY created_at`)
558
+ .all(sessionId);
559
+ const fixesWithSS = db
560
+ .prepare(`SELECT f.id, f.before_screenshot_id, f.after_screenshot_id, f.fix_description, b.title as bug_title
561
+ FROM ui_dive_fix_verifications f
562
+ LEFT JOIN ui_dive_bugs b ON f.bug_id = b.id
563
+ WHERE f.session_id = ? ORDER BY f.created_at`)
564
+ .all(sessionId);
565
+ let idx = 1;
566
+ for (const cl of changelogsWithSS) {
567
+ if (!cl.before_screenshot_id && !cl.after_screenshot_id)
568
+ continue;
569
+ const beforeResult = exportScreenshot(cl.before_screenshot_id, join(assetDir, `${idx}-${cl.change_type}-before.png`), db);
570
+ const afterResult = exportScreenshot(cl.after_screenshot_id, join(assetDir, `${idx}-${cl.change_type}-after.png`), db);
571
+ if (beforeResult.success || afterResult.success) {
572
+ exportedPairs.push({
573
+ beforePath: beforeResult.path,
574
+ afterPath: afterResult.path,
575
+ description: cl.description,
576
+ changeType: cl.change_type,
577
+ });
578
+ idx++;
579
+ }
580
+ }
581
+ for (const fx of fixesWithSS) {
582
+ if (!fx.before_screenshot_id && !fx.after_screenshot_id)
583
+ continue;
584
+ const beforeResult = exportScreenshot(fx.before_screenshot_id, join(assetDir, `${idx}-fix-before.png`), db);
585
+ const afterResult = exportScreenshot(fx.after_screenshot_id, join(assetDir, `${idx}-fix-after.png`), db);
586
+ if (beforeResult.success || afterResult.success) {
587
+ exportedPairs.push({
588
+ beforePath: beforeResult.path,
589
+ afterPath: afterResult.path,
590
+ description: fx.fix_description ?? fx.bug_title ?? "Fix",
591
+ changeType: "fix",
592
+ });
593
+ idx++;
594
+ }
595
+ }
596
+ }
597
+ const { markdown, title, filesChanged } = generateMarkdown(sessionId, includeScreenshots, assetDir, exportedPairs);
598
+ return {
599
+ markdown,
600
+ title,
601
+ filesChanged,
602
+ screenshotPaths: exportedPairs.flatMap((p) => [p.beforePath, p.afterPath].filter(Boolean)),
603
+ sessionId,
604
+ dashboardUrl: getDashboardUrl(),
605
+ };
606
+ },
607
+ },
608
+ // ─── Tool 2: export_pr_screenshots ───────────────────────────────────────
609
+ {
610
+ name: "export_pr_screenshots",
611
+ description: "Export before/after screenshot pairs from changelogs and fix verifications to a local directory. Screenshots are written as PNG files with naming convention `{index}-{type}-before.png` / `{index}-{type}-after.png` so they can be committed to the repo and referenced in PR markdown. Prefers file_path (full image) over base64_thumbnail (may be truncated).",
612
+ inputSchema: {
613
+ type: "object",
614
+ properties: {
615
+ session_id: {
616
+ type: "string",
617
+ description: "UI Dive session ID. Use 'latest' for the most recent session.",
618
+ },
619
+ output_dir: {
620
+ type: "string",
621
+ description: "Output directory for PNG files (default: '.nodebench/pr-assets/'). Created if it does not exist.",
622
+ },
623
+ },
624
+ required: ["session_id"],
625
+ },
626
+ handler: async (args) => {
627
+ const sessionId = resolveSessionId(args.session_id);
628
+ if (!sessionId) {
629
+ return {
630
+ error: true,
631
+ message: "No UI Dive sessions found. Start a dive first with start_ui_dive.",
632
+ };
633
+ }
634
+ const db = getDb();
635
+ const session = db
636
+ .prepare("SELECT * FROM ui_dive_sessions WHERE id = ?")
637
+ .get(sessionId);
638
+ if (!session) {
639
+ return { error: true, message: `Session not found: ${sessionId}` };
640
+ }
641
+ const outDir = args.output_dir || ".nodebench/pr-assets";
642
+ mkdirSync(outDir, { recursive: true });
643
+ const warnings = [];
644
+ const exported = [];
645
+ // Changelogs with screenshots
646
+ const changelogs = db
647
+ .prepare(`SELECT id, change_type, description, before_screenshot_id, after_screenshot_id
648
+ FROM ui_dive_changelogs WHERE session_id = ? ORDER BY created_at`)
649
+ .all(sessionId);
650
+ let idx = 1;
651
+ for (const cl of changelogs) {
652
+ if (!cl.before_screenshot_id && !cl.after_screenshot_id)
653
+ continue;
654
+ const beforeResult = exportScreenshot(cl.before_screenshot_id, join(outDir, `${idx}-${cl.change_type}-before.png`), db);
655
+ const afterResult = exportScreenshot(cl.after_screenshot_id, join(outDir, `${idx}-${cl.change_type}-after.png`), db);
656
+ if (beforeResult.warning)
657
+ warnings.push(beforeResult.warning);
658
+ if (afterResult.warning)
659
+ warnings.push(afterResult.warning);
660
+ if (beforeResult.success || afterResult.success) {
661
+ exported.push({
662
+ beforePath: beforeResult.path,
663
+ afterPath: afterResult.path,
664
+ description: cl.description,
665
+ changeType: cl.change_type,
666
+ sourceTable: "changelog",
667
+ });
668
+ idx++;
669
+ }
670
+ }
671
+ // Fix verifications with screenshots
672
+ const fixes = db
673
+ .prepare(`SELECT f.id, f.before_screenshot_id, f.after_screenshot_id, f.fix_description, b.title as bug_title
674
+ FROM ui_dive_fix_verifications f
675
+ LEFT JOIN ui_dive_bugs b ON f.bug_id = b.id
676
+ WHERE f.session_id = ? ORDER BY f.created_at`)
677
+ .all(sessionId);
678
+ for (const fx of fixes) {
679
+ if (!fx.before_screenshot_id && !fx.after_screenshot_id)
680
+ continue;
681
+ const beforeResult = exportScreenshot(fx.before_screenshot_id, join(outDir, `${idx}-fix-before.png`), db);
682
+ const afterResult = exportScreenshot(fx.after_screenshot_id, join(outDir, `${idx}-fix-after.png`), db);
683
+ if (beforeResult.warning)
684
+ warnings.push(beforeResult.warning);
685
+ if (afterResult.warning)
686
+ warnings.push(afterResult.warning);
687
+ if (beforeResult.success || afterResult.success) {
688
+ exported.push({
689
+ beforePath: beforeResult.path,
690
+ afterPath: afterResult.path,
691
+ description: fx.fix_description ?? fx.bug_title ?? "Fix",
692
+ changeType: "fix",
693
+ sourceTable: "fix_verification",
694
+ });
695
+ idx++;
696
+ }
697
+ }
698
+ const totalFiles = exported.reduce((sum, e) => sum + (e.beforePath ? 1 : 0) + (e.afterPath ? 1 : 0), 0);
699
+ return {
700
+ sessionId,
701
+ outputDir: outDir,
702
+ exported,
703
+ totalFiles,
704
+ warnings: warnings.length > 0 ? warnings : undefined,
705
+ _hint: "Screenshots exported. Stage and commit them with your branch, then use generate_pr_report to reference them in the PR body.",
706
+ };
707
+ },
708
+ },
709
+ // ─── Tool 3: create_visual_pr ────────────────────────────────────────────
710
+ {
711
+ name: "create_visual_pr",
712
+ description: "End-to-end PR creation: exports screenshots, generates a rich markdown PR body with visual evidence (before/after comparisons, timeline, bug fixes, past sessions), checks git state, pushes, and creates a GitHub PR via `gh pr create`. Requires the GitHub CLI (gh) installed and authenticated. Returns the PR URL.",
713
+ inputSchema: {
714
+ type: "object",
715
+ properties: {
716
+ session_id: {
717
+ type: "string",
718
+ description: "UI Dive session ID. Omit or pass 'latest' for the most recent session.",
719
+ },
720
+ pr_title: {
721
+ type: "string",
722
+ description: "PR title. If omitted, auto-generates from session data in conventional commit format.",
723
+ },
724
+ base_branch: {
725
+ type: "string",
726
+ description: "Base branch to merge into (default: 'main').",
727
+ },
728
+ asset_dir: {
729
+ type: "string",
730
+ description: "Directory for exported screenshot PNGs (default: '.nodebench/pr-assets/').",
731
+ },
732
+ repo_path: {
733
+ type: "string",
734
+ description: "Repository root path (default: current working directory).",
735
+ },
736
+ push: {
737
+ type: "boolean",
738
+ description: "Push the current branch to remote before creating PR (default: true).",
739
+ },
740
+ draft: {
741
+ type: "boolean",
742
+ description: "Create as a draft PR (default: false).",
743
+ },
744
+ },
745
+ },
746
+ handler: async (args) => {
747
+ const cwd = args.repo_path || process.cwd();
748
+ const baseBranch = args.base_branch || "main";
749
+ const assetDir = args.asset_dir || ".nodebench/pr-assets";
750
+ const shouldPush = args.push !== false;
751
+ const isDraft = args.draft === true;
752
+ // Check gh availability
753
+ try {
754
+ runGh("gh --version", cwd);
755
+ }
756
+ catch {
757
+ return {
758
+ error: true,
759
+ message: "GitHub CLI (gh) not found. Install from https://cli.github.com/ and authenticate with `gh auth login`.",
760
+ };
761
+ }
762
+ // Check gh auth
763
+ try {
764
+ runGh("gh auth status", cwd);
765
+ }
766
+ catch (err) {
767
+ return {
768
+ error: true,
769
+ message: `GitHub CLI not authenticated: ${err.message ?? "Run `gh auth login` first."}`,
770
+ };
771
+ }
772
+ // Resolve session
773
+ const sessionId = resolveSessionId(args.session_id);
774
+ if (!sessionId) {
775
+ return {
776
+ error: true,
777
+ message: "No UI Dive sessions found. Start a dive first with start_ui_dive.",
778
+ };
779
+ }
780
+ const db = getDb();
781
+ const session = db
782
+ .prepare("SELECT * FROM ui_dive_sessions WHERE id = ?")
783
+ .get(sessionId);
784
+ if (!session) {
785
+ return { error: true, message: `Session not found: ${sessionId}` };
786
+ }
787
+ // Get current branch
788
+ let currentBranch;
789
+ try {
790
+ currentBranch = runGit("git branch --show-current", cwd);
791
+ }
792
+ catch (err) {
793
+ return {
794
+ error: true,
795
+ message: `Git error: ${err.message ?? "not a git repository"}`,
796
+ };
797
+ }
798
+ if (currentBranch === baseBranch) {
799
+ return {
800
+ error: true,
801
+ message: `Cannot create PR from '${baseBranch}' to '${baseBranch}'. Switch to a feature branch first.`,
802
+ };
803
+ }
804
+ // Export screenshots
805
+ mkdirSync(assetDir, { recursive: true });
806
+ const exportedPairs = [];
807
+ const changelogsWithSS = db
808
+ .prepare(`SELECT id, change_type, description, before_screenshot_id, after_screenshot_id
809
+ FROM ui_dive_changelogs WHERE session_id = ? ORDER BY created_at`)
810
+ .all(sessionId);
811
+ const fixesWithSS = db
812
+ .prepare(`SELECT f.id, f.before_screenshot_id, f.after_screenshot_id, f.fix_description, b.title as bug_title
813
+ FROM ui_dive_fix_verifications f
814
+ LEFT JOIN ui_dive_bugs b ON f.bug_id = b.id
815
+ WHERE f.session_id = ? ORDER BY f.created_at`)
816
+ .all(sessionId);
817
+ let idx = 1;
818
+ for (const cl of changelogsWithSS) {
819
+ if (!cl.before_screenshot_id && !cl.after_screenshot_id)
820
+ continue;
821
+ const beforeResult = exportScreenshot(cl.before_screenshot_id, join(assetDir, `${idx}-${cl.change_type}-before.png`), db);
822
+ const afterResult = exportScreenshot(cl.after_screenshot_id, join(assetDir, `${idx}-${cl.change_type}-after.png`), db);
823
+ if (beforeResult.success || afterResult.success) {
824
+ exportedPairs.push({
825
+ beforePath: beforeResult.path,
826
+ afterPath: afterResult.path,
827
+ description: cl.description,
828
+ changeType: cl.change_type,
829
+ });
830
+ idx++;
831
+ }
832
+ }
833
+ for (const fx of fixesWithSS) {
834
+ if (!fx.before_screenshot_id && !fx.after_screenshot_id)
835
+ continue;
836
+ const beforeResult = exportScreenshot(fx.before_screenshot_id, join(assetDir, `${idx}-fix-before.png`), db);
837
+ const afterResult = exportScreenshot(fx.after_screenshot_id, join(assetDir, `${idx}-fix-after.png`), db);
838
+ if (beforeResult.success || afterResult.success) {
839
+ exportedPairs.push({
840
+ beforePath: beforeResult.path,
841
+ afterPath: afterResult.path,
842
+ description: fx.fix_description ?? fx.bug_title ?? "Fix",
843
+ changeType: "fix",
844
+ });
845
+ idx++;
846
+ }
847
+ }
848
+ // Generate markdown
849
+ const { markdown, title, filesChanged } = generateMarkdown(sessionId, true, assetDir, exportedPairs);
850
+ const prTitle = args.pr_title || title;
851
+ // Stage screenshot assets if any were exported
852
+ const screenshotCount = exportedPairs.reduce((sum, e) => sum + (e.beforePath ? 1 : 0) + (e.afterPath ? 1 : 0), 0);
853
+ if (screenshotCount > 0) {
854
+ try {
855
+ runGit(`git add "${assetDir}"`, cwd);
856
+ runGit(`git commit -m "chore: add PR screenshot assets from UI Dive"`, cwd);
857
+ }
858
+ catch {
859
+ // Might already be committed or nothing to add — proceed anyway
860
+ }
861
+ }
862
+ // Push if requested
863
+ if (shouldPush) {
864
+ try {
865
+ runGit(`git push -u origin ${currentBranch}`, cwd);
866
+ }
867
+ catch (err) {
868
+ return {
869
+ error: true,
870
+ message: `Push failed: ${err.message ?? "unknown error"}. Push manually and retry with push:false.`,
871
+ };
872
+ }
873
+ }
874
+ // Create PR
875
+ try {
876
+ const draftFlag = isDraft ? " --draft" : "";
877
+ // Write body to a temp file to avoid shell escaping issues
878
+ const tempBody = join(assetDir, `_pr_body_${Date.now()}.md`);
879
+ writeFileSync(tempBody, markdown, "utf8");
880
+ const result = runGh(`gh pr create --title "${prTitle.replace(/"/g, '\\"')}" --base "${baseBranch}" --body-file "${tempBody}"${draftFlag}`, cwd);
881
+ // Clean up temp body file
882
+ try {
883
+ require("node:fs").unlinkSync(tempBody);
884
+ }
885
+ catch { /* best effort cleanup */ }
886
+ // Parse PR URL from gh output
887
+ const prUrl = result.trim();
888
+ const prNumberMatch = prUrl.match(/\/pull\/(\d+)/);
889
+ const prNumber = prNumberMatch ? parseInt(prNumberMatch[1], 10) : 0;
890
+ return {
891
+ prUrl,
892
+ prNumber,
893
+ title: prTitle,
894
+ markdown,
895
+ screenshotCount,
896
+ branch: currentBranch,
897
+ baseBranch,
898
+ };
899
+ }
900
+ catch (err) {
901
+ return {
902
+ error: true,
903
+ message: `Failed to create PR: ${err.message ?? "unknown error"}. You can use the markdown manually with \`gh pr create --body-file\`.`,
904
+ markdown,
905
+ title: prTitle,
906
+ };
907
+ }
908
+ },
909
+ },
910
+ ];
911
+ //# sourceMappingURL=prReportTools.js.map