nodebench-mcp 2.20.2 → 2.21.1

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,883 @@
1
+ /**
2
+ * UI/UX Full Dive v2 — Advanced Tools
3
+ *
4
+ * Deep interaction testing, screenshot capture, design auditing,
5
+ * backend context linking, changelog tracking, and walkthrough generation.
6
+ *
7
+ * These tools complement the base dive tools (uiUxDiveTools.ts) and work
8
+ * with the MCP Bridge (playwright-mcp) for browser automation.
9
+ *
10
+ * Architecture:
11
+ * - Agent uses MCP Bridge to drive the browser (navigate, click, type, screenshot)
12
+ * - These tools provide structured storage and analysis on top of bridge actions
13
+ * - Screenshots are saved to disk + thumbnail stored in DB for fast retrieval
14
+ * - Interaction tests define preconditions → steps → expected/actual per step
15
+ * - Design audits compare computed styles across components for inconsistencies
16
+ * - Backend links connect UI components to API endpoints, Convex functions, DB tables
17
+ * - Changelogs track before/after with screenshots when fixes are applied
18
+ */
19
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync } from "node:fs";
20
+ import { join, basename } from "node:path";
21
+ import { homedir } from "node:os";
22
+ import { createConnection } from "node:net";
23
+ import { getDb } from "../db.js";
24
+ function genId(prefix) {
25
+ return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
26
+ }
27
+ function screenshotDir() {
28
+ const dir = join(homedir(), ".nodebench", "dive-screenshots");
29
+ mkdirSync(dir, { recursive: true });
30
+ return dir;
31
+ }
32
+ /** Try to connect to a TCP port. Resolves true if something is listening. */
33
+ function checkPort(port, host = "127.0.0.1", timeoutMs = 800) {
34
+ return new Promise((resolve) => {
35
+ const sock = createConnection({ port, host });
36
+ const timer = setTimeout(() => { sock.destroy(); resolve(false); }, timeoutMs);
37
+ sock.on("connect", () => { clearTimeout(timer); sock.destroy(); resolve(true); });
38
+ sock.on("error", () => { clearTimeout(timer); resolve(false); });
39
+ });
40
+ }
41
+ /** Recursively find files matching a test, up to maxDepth. */
42
+ function findFiles(dir, test, maxDepth = 4, depth = 0) {
43
+ if (depth > maxDepth || !existsSync(dir))
44
+ return [];
45
+ const results = [];
46
+ try {
47
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
48
+ if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === "dist" || entry.name === ".next")
49
+ continue;
50
+ const full = join(dir, entry.name);
51
+ if (entry.isFile() && test(entry.name))
52
+ results.push(full);
53
+ else if (entry.isDirectory())
54
+ results.push(...findFiles(full, test, maxDepth, depth + 1));
55
+ }
56
+ }
57
+ catch { /* permission errors etc */ }
58
+ return results;
59
+ }
60
+ /** Extract route paths from source code using common patterns. */
61
+ function extractRoutes(srcDir) {
62
+ const routes = [];
63
+ const seen = new Set();
64
+ // Find files that likely contain route definitions
65
+ const routeFiles = findFiles(srcDir, (name) => /\.(tsx?|jsx?)$/.test(name) && (/[Rr]out/.test(name) || /[Aa]pp/.test(name) || /[Ll]ayout/.test(name) ||
66
+ /[Nn]avigation/.test(name) || /[Ss]idebar/.test(name) || /pages/.test(name)));
67
+ for (const file of routeFiles.slice(0, 30)) {
68
+ try {
69
+ const content = readFileSync(file, "utf-8");
70
+ // Match React Router <Route path="..." patterns
71
+ const routeMatches = content.matchAll(/path\s*[:=]\s*["'`](\/[^"'`]*?)["'`]/g);
72
+ for (const m of routeMatches) {
73
+ const p = m[1];
74
+ if (!seen.has(p)) {
75
+ seen.add(p);
76
+ // Try to find component name nearby
77
+ const compMatch = content.slice(Math.max(0, m.index - 200), m.index + 200)
78
+ .match(/(?:element|component)\s*[:=]\s*[{<]?\s*(\w+)/);
79
+ routes.push({ path: p, file: file.replace(/\\/g, "/"), component: compMatch?.[1] });
80
+ }
81
+ }
82
+ }
83
+ catch { /* unreadable */ }
84
+ }
85
+ return routes.sort((a, b) => a.path.localeCompare(b.path));
86
+ }
87
+ export const uiUxDiveAdvancedTools = [
88
+ // ── 0. Project preflight — analyze project before diving ──────────────
89
+ {
90
+ name: "dive_preflight",
91
+ description: "Analyze a project BEFORE starting a UI dive. Scans the project directory to detect: framework (Vite, Next.js, CRA, etc.), dev scripts, required services (frontend, backend like Convex/Supabase/Firebase), port assignments, whether services are already running, route definitions from source code, and environment requirements. Returns a structured launch plan the agent should follow to get the app running before navigating. This is always Step 0 of a dive.",
92
+ inputSchema: {
93
+ type: "object",
94
+ properties: {
95
+ projectPath: { type: "string", description: "Absolute path to the project root directory" },
96
+ checkPorts: {
97
+ type: "boolean",
98
+ description: "Whether to probe common ports to see what is already running (default: true)",
99
+ },
100
+ scanRoutes: {
101
+ type: "boolean",
102
+ description: "Whether to scan source code for route definitions (default: true)",
103
+ },
104
+ },
105
+ required: ["projectPath"],
106
+ },
107
+ handler: async (args) => {
108
+ const { projectPath, checkPorts: doCheckPorts, scanRoutes: doScanRoutes } = args;
109
+ if (!existsSync(projectPath)) {
110
+ return { error: true, message: `Project path not found: ${projectPath}` };
111
+ }
112
+ // ── 1. Read package.json ──
113
+ const pkgPath = join(projectPath, "package.json");
114
+ let pkg = null;
115
+ if (existsSync(pkgPath)) {
116
+ try {
117
+ pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
118
+ }
119
+ catch { /* */ }
120
+ }
121
+ // ── 2. Detect framework ──
122
+ const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
123
+ const framework = { name: "unknown" };
124
+ const frameworkChecks = [
125
+ { name: "next", dep: "next", configs: ["next.config.js", "next.config.ts", "next.config.mjs"] },
126
+ { name: "vite", dep: "vite", configs: ["vite.config.ts", "vite.config.js", "vite.config.mjs"] },
127
+ { name: "create-react-app", dep: "react-scripts", configs: [] },
128
+ { name: "remix", dep: "@remix-run/react", configs: ["remix.config.js"] },
129
+ { name: "nuxt", dep: "nuxt", configs: ["nuxt.config.ts", "nuxt.config.js"] },
130
+ { name: "sveltekit", dep: "@sveltejs/kit", configs: ["svelte.config.js"] },
131
+ { name: "astro", dep: "astro", configs: ["astro.config.mjs", "astro.config.ts"] },
132
+ { name: "angular", dep: "@angular/core", configs: ["angular.json"] },
133
+ { name: "gatsby", dep: "gatsby", configs: ["gatsby-config.js", "gatsby-config.ts"] },
134
+ ];
135
+ for (const check of frameworkChecks) {
136
+ if (deps?.[check.dep]) {
137
+ framework.name = check.name;
138
+ framework.version = deps[check.dep];
139
+ for (const cfg of check.configs) {
140
+ if (existsSync(join(projectPath, cfg))) {
141
+ framework.configFile = cfg;
142
+ break;
143
+ }
144
+ }
145
+ break;
146
+ }
147
+ }
148
+ // ── 3. Detect dev scripts ──
149
+ const scripts = pkg?.scripts ?? {};
150
+ const devScripts = [];
151
+ const scriptPriority = ["dev", "dev:frontend", "start", "dev:web", "serve", "develop"];
152
+ for (const name of Object.keys(scripts)) {
153
+ let likely = "unknown";
154
+ const cmd = scripts[name];
155
+ if (/vite|next dev|react-scripts start|nuxt dev|astro dev/.test(cmd))
156
+ likely = "frontend";
157
+ else if (/convex dev|convex deploy/.test(cmd))
158
+ likely = "backend (convex)";
159
+ else if (/node.*server|express|fastify|hono/.test(cmd))
160
+ likely = "backend (api)";
161
+ else if (/tsc|typescript/.test(cmd))
162
+ likely = "build";
163
+ else if (/vitest|jest|playwright|cypress/.test(cmd))
164
+ likely = "test";
165
+ else if (/lint|eslint|prettier/.test(cmd))
166
+ likely = "lint";
167
+ if (likely === "frontend" || likely.startsWith("backend") || scriptPriority.includes(name)) {
168
+ devScripts.push({ name, command: cmd, likely });
169
+ }
170
+ }
171
+ // Sort by priority
172
+ devScripts.sort((a, b) => {
173
+ const ai = scriptPriority.indexOf(a.name);
174
+ const bi = scriptPriority.indexOf(b.name);
175
+ return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
176
+ });
177
+ // ── 4. Detect backend services ──
178
+ const services = [];
179
+ // Convex
180
+ if (existsSync(join(projectPath, "convex")) && (deps?.["convex"] || existsSync(join(projectPath, "convex.json")))) {
181
+ const convexScript = Object.entries(scripts).find(([, cmd]) => cmd.includes("convex dev"));
182
+ services.push({
183
+ name: "Convex",
184
+ type: "backend",
185
+ detected: "convex/ directory + convex dependency",
186
+ startCommand: convexScript ? `npm run ${convexScript[0]}` : "npx convex dev",
187
+ });
188
+ }
189
+ // Supabase
190
+ if (deps?.["@supabase/supabase-js"] || existsSync(join(projectPath, "supabase"))) {
191
+ services.push({ name: "Supabase", type: "backend", detected: "supabase dependency or supabase/ directory" });
192
+ }
193
+ // Firebase
194
+ if (deps?.["firebase"] || existsSync(join(projectPath, "firebase.json"))) {
195
+ services.push({ name: "Firebase", type: "backend", detected: "firebase dependency or firebase.json" });
196
+ }
197
+ // Prisma
198
+ if (deps?.["prisma"] || existsSync(join(projectPath, "prisma"))) {
199
+ services.push({ name: "Prisma", type: "orm", detected: "prisma dependency or prisma/ directory" });
200
+ }
201
+ // Docker
202
+ if (existsSync(join(projectPath, "docker-compose.yml")) || existsSync(join(projectPath, "docker-compose.yaml"))) {
203
+ services.push({ name: "Docker Compose", type: "infrastructure", detected: "docker-compose.yml found" });
204
+ }
205
+ // ── 5. Detect ports from config ──
206
+ let frontendPort = 3000; // default
207
+ if (framework.name === "vite")
208
+ frontendPort = 5173;
209
+ else if (framework.name === "next")
210
+ frontendPort = 3000;
211
+ else if (framework.name === "create-react-app")
212
+ frontendPort = 3000;
213
+ else if (framework.name === "nuxt")
214
+ frontendPort = 3000;
215
+ else if (framework.name === "astro")
216
+ frontendPort = 4321;
217
+ // Try to read port from vite config
218
+ if (framework.configFile && existsSync(join(projectPath, framework.configFile))) {
219
+ try {
220
+ const cfgContent = readFileSync(join(projectPath, framework.configFile), "utf-8");
221
+ const portMatch = cfgContent.match(/port\s*[:=]\s*(\d+)/);
222
+ if (portMatch)
223
+ frontendPort = parseInt(portMatch[1], 10);
224
+ }
225
+ catch { /* */ }
226
+ }
227
+ // ── 6. Check running ports ──
228
+ const portStatus = {};
229
+ if (doCheckPorts !== false) {
230
+ const portsToCheck = [frontendPort, 3000, 3001, 4321, 5173, 5174, 8080, 8788];
231
+ const uniquePorts = [...new Set(portsToCheck)];
232
+ await Promise.all(uniquePorts.map(async (p) => {
233
+ portStatus[p] = await checkPort(p);
234
+ }));
235
+ }
236
+ const frontendRunning = portStatus[frontendPort] === true;
237
+ // ── 7. Scan routes ──
238
+ let routes = [];
239
+ if (doScanRoutes !== false) {
240
+ const srcDir = existsSync(join(projectPath, "src")) ? join(projectPath, "src") :
241
+ existsSync(join(projectPath, "app")) ? join(projectPath, "app") : projectPath;
242
+ routes = extractRoutes(srcDir);
243
+ }
244
+ // ── 8. Check env files ──
245
+ const envFiles = [];
246
+ for (const name of [".env", ".env.local", ".env.development", ".env.development.local"]) {
247
+ if (existsSync(join(projectPath, name)))
248
+ envFiles.push(name);
249
+ }
250
+ // ── 9. Build launch plan ──
251
+ const launchSteps = [];
252
+ const runningServices = [];
253
+ if (!frontendRunning) {
254
+ const devCmd = devScripts.find(s => s.likely === "frontend");
255
+ launchSteps.push(devCmd
256
+ ? `Start frontend: npm run ${devCmd.name} (runs: ${devCmd.command})`
257
+ : `Start frontend: npm run dev (port ${frontendPort})`);
258
+ }
259
+ else {
260
+ runningServices.push(`Frontend already running on port ${frontendPort}`);
261
+ }
262
+ for (const svc of services) {
263
+ if (svc.type === "backend") {
264
+ launchSteps.push(svc.startCommand
265
+ ? `Start ${svc.name}: ${svc.startCommand}`
266
+ : `Start ${svc.name} (check project docs for startup command)`);
267
+ }
268
+ }
269
+ launchSteps.push(`Verify app is accessible at http://localhost:${frontendPort}`);
270
+ launchSteps.push("Then: start_ui_dive → navigate routes → discover components → test interactions");
271
+ return {
272
+ project: {
273
+ name: pkg?.name ?? basename(projectPath),
274
+ path: projectPath,
275
+ version: pkg?.version,
276
+ },
277
+ framework,
278
+ devScripts,
279
+ services,
280
+ ports: {
281
+ frontend: frontendPort,
282
+ frontendRunning,
283
+ status: portStatus,
284
+ },
285
+ routes: {
286
+ count: routes.length,
287
+ discovered: routes.slice(0, 50),
288
+ },
289
+ envFiles,
290
+ launchPlan: {
291
+ alreadyRunning: runningServices,
292
+ stepsNeeded: launchSteps,
293
+ appUrl: `http://localhost:${frontendPort}`,
294
+ },
295
+ _hint: frontendRunning
296
+ ? `App is running at http://localhost:${frontendPort}. Proceed with start_ui_dive({ appUrl: "http://localhost:${frontendPort}" }) then navigate routes with Playwright.`
297
+ : `App is NOT running. Execute the launch plan steps first, then start the dive.`,
298
+ };
299
+ },
300
+ },
301
+ // ── 1. Save a labeled screenshot ──────────────────────────────────────
302
+ {
303
+ name: "dive_save_screenshot",
304
+ description: "Save a screenshot during a dive session. Pass base64 image data (from bridge's browser_take_screenshot) or a file path. The screenshot is stored on disk and indexed in the DB with labels, route, component, and test references. Returns a screenshot_id you can link to bugs, test steps, design issues, and changelogs. This creates the visual evidence trail for the entire dive.",
305
+ inputSchema: {
306
+ type: "object",
307
+ properties: {
308
+ sessionId: { type: "string", description: "Dive session ID" },
309
+ label: { type: "string", description: "Human-readable label (e.g. 'Login form - initial state', 'After clicking submit')" },
310
+ base64Data: { type: "string", description: "Base64-encoded image data (from browser_take_screenshot)" },
311
+ filePath: { type: "string", description: "Alternative: path to an existing screenshot file" },
312
+ componentId: { type: "string", description: "Component this screenshot is for (optional)" },
313
+ route: { type: "string", description: "Current route/URL (optional)" },
314
+ testId: { type: "string", description: "Interaction test this belongs to (optional)" },
315
+ stepIndex: { type: "number", description: "Step index within a test (optional)" },
316
+ metadata: { type: "object", description: "Additional metadata (optional)" },
317
+ },
318
+ required: ["sessionId", "label"],
319
+ },
320
+ handler: async (args) => {
321
+ const { sessionId, label, base64Data, filePath, componentId, route, testId, stepIndex, metadata } = args;
322
+ const db = getDb();
323
+ const session = db.prepare("SELECT id FROM ui_dive_sessions WHERE id = ?").get(sessionId);
324
+ if (!session)
325
+ return { error: true, message: `Session not found: ${sessionId}` };
326
+ const id = genId("ss");
327
+ let savedPath = filePath ?? null;
328
+ // Save base64 data to disk
329
+ if (base64Data && !filePath) {
330
+ const dir = screenshotDir();
331
+ const filename = `${sessionId}_${id}.png`;
332
+ savedPath = join(dir, filename);
333
+ try {
334
+ const buffer = Buffer.from(base64Data, "base64");
335
+ writeFileSync(savedPath, buffer);
336
+ }
337
+ catch (e) {
338
+ return { error: true, message: `Failed to save screenshot: ${e.message}` };
339
+ }
340
+ }
341
+ // Store a small thumbnail (first 500 chars of base64 for quick preview)
342
+ const thumbnail = base64Data ? base64Data.slice(0, 500) : null;
343
+ db.prepare(`INSERT INTO ui_dive_screenshots (id, session_id, component_id, test_id, step_index, label, route, file_path, base64_thumbnail, metadata)
344
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, sessionId, componentId ?? null, testId ?? null, stepIndex ?? null, label, route ?? null, savedPath, thumbnail, metadata ? JSON.stringify(metadata) : null);
345
+ return {
346
+ screenshotId: id,
347
+ label,
348
+ filePath: savedPath,
349
+ componentId: componentId ?? null,
350
+ route: route ?? null,
351
+ _hint: `Screenshot saved. Reference it in bugs: tag_ui_bug({ screenshotRef: "${id}" }), test steps, design issues, or changelogs.`,
352
+ };
353
+ },
354
+ },
355
+ // ── 2. Run a structured interaction test ──────────────────────────────
356
+ {
357
+ name: "dive_interaction_test",
358
+ description: "Define and track a structured interaction test for a component. Provide preconditions and a sequence of test steps (action, target, expected outcome). The agent executes each step via the MCP Bridge (browser_click, browser_type, etc.), takes screenshots, and records actual results here. Each step gets pass/fail status. The test aggregates into an overall result. This creates the detailed walkthrough with preconditions, steps, expected vs actual, and visual evidence at each step.",
359
+ inputSchema: {
360
+ type: "object",
361
+ properties: {
362
+ sessionId: { type: "string", description: "Dive session ID" },
363
+ componentId: { type: "string", description: "Component being tested" },
364
+ testName: { type: "string", description: "Test name (e.g. 'Login form submission', 'Dark mode toggle')" },
365
+ description: { type: "string", description: "What this test validates" },
366
+ preconditions: {
367
+ type: "array",
368
+ description: "List of preconditions (e.g. ['User is logged out', 'Browser at /login', 'Dark mode is off'])",
369
+ items: { type: "string" },
370
+ },
371
+ steps: {
372
+ type: "array",
373
+ description: "Test steps to execute and track",
374
+ items: {
375
+ type: "object",
376
+ properties: {
377
+ action: { type: "string", description: "Action: click, type, navigate, hover, scroll, assert, wait, screenshot" },
378
+ target: { type: "string", description: "CSS selector, URL, or description" },
379
+ inputValue: { type: "string", description: "Value to type/enter (for type action)" },
380
+ expected: { type: "string", description: "Expected outcome (e.g. 'Form submits', 'Error message appears', 'Redirects to /dashboard')" },
381
+ screenshotLabel: { type: "string", description: "Label for the screenshot at this step (optional)" },
382
+ },
383
+ required: ["action", "expected"],
384
+ },
385
+ },
386
+ metadata: { type: "object", description: "Optional metadata" },
387
+ },
388
+ required: ["sessionId", "componentId", "testName", "steps"],
389
+ },
390
+ handler: async (args) => {
391
+ const { sessionId, componentId, testName, description, preconditions, steps, metadata } = args;
392
+ const db = getDb();
393
+ const session = db.prepare("SELECT id FROM ui_dive_sessions WHERE id = ?").get(sessionId);
394
+ if (!session)
395
+ return { error: true, message: `Session not found: ${sessionId}` };
396
+ const comp = db.prepare("SELECT id FROM ui_dive_components WHERE id = ?").get(componentId);
397
+ if (!comp)
398
+ return { error: true, message: `Component not found: ${componentId}` };
399
+ const testId = genId("test");
400
+ db.prepare(`INSERT INTO ui_dive_interaction_tests (id, session_id, component_id, test_name, description, preconditions, steps_total, metadata)
401
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(testId, sessionId, componentId, testName, description ?? null, preconditions ? JSON.stringify(preconditions) : null, steps.length, metadata ? JSON.stringify(metadata) : null);
402
+ // Create step rows
403
+ const stepIds = [];
404
+ for (let i = 0; i < steps.length; i++) {
405
+ const s = steps[i];
406
+ const stepId = genId("step");
407
+ db.prepare(`INSERT INTO ui_dive_test_steps (id, test_id, step_index, action, target, input_value, expected)
408
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(stepId, testId, i, s.action, s.target ?? null, s.inputValue ?? null, s.expected);
409
+ stepIds.push(stepId);
410
+ }
411
+ return {
412
+ testId,
413
+ testName,
414
+ componentId,
415
+ stepsTotal: steps.length,
416
+ stepIds,
417
+ status: "pending",
418
+ _workflow: [
419
+ "For each step, the agent should:",
420
+ "1. Execute the action via MCP Bridge (browser_click, browser_type, etc.)",
421
+ "2. Take a screenshot via bridge (browser_take_screenshot)",
422
+ "3. Save it: dive_save_screenshot({ testId, stepIndex, label, base64Data })",
423
+ "4. Record result: dive_record_test_step({ testId, stepIndex, actual, status, screenshotId })",
424
+ "5. After all steps: dive completes the test automatically",
425
+ ],
426
+ _hint: `Test created with ${steps.length} steps. Execute each step and record results with dive_record_test_step.`,
427
+ };
428
+ },
429
+ },
430
+ // ── 3. Record a test step result ──────────────────────────────────────
431
+ {
432
+ name: "dive_record_test_step",
433
+ description: "Record the actual result of a test step after executing it via the MCP Bridge. Compare expected vs actual, attach a screenshot, and mark pass/fail. When all steps are recorded, the test is automatically completed with an overall status.",
434
+ inputSchema: {
435
+ type: "object",
436
+ properties: {
437
+ testId: { type: "string", description: "Interaction test ID from dive_interaction_test" },
438
+ stepIndex: { type: "number", description: "0-based step index" },
439
+ actual: { type: "string", description: "What actually happened" },
440
+ status: {
441
+ type: "string",
442
+ description: "Step result: passed, failed, skipped, blocked",
443
+ enum: ["passed", "failed", "skipped", "blocked"],
444
+ },
445
+ screenshotId: { type: "string", description: "Screenshot ID from dive_save_screenshot (optional)" },
446
+ observation: { type: "string", description: "Additional notes about this step" },
447
+ durationMs: { type: "number", description: "How long the step took" },
448
+ },
449
+ required: ["testId", "stepIndex", "status", "actual"],
450
+ },
451
+ handler: async (args) => {
452
+ const { testId, stepIndex, actual, status, screenshotId, observation, durationMs } = args;
453
+ const db = getDb();
454
+ const step = db.prepare("SELECT id, expected FROM ui_dive_test_steps WHERE test_id = ? AND step_index = ?").get(testId, stepIndex);
455
+ if (!step)
456
+ return { error: true, message: `Step not found: test=${testId}, index=${stepIndex}` };
457
+ db.prepare("UPDATE ui_dive_test_steps SET actual = ?, status = ?, screenshot_id = ?, observation = ?, duration_ms = ? WHERE id = ?").run(actual, status, screenshotId ?? null, observation ?? null, durationMs ?? null, step.id);
458
+ // Check if all steps are done → auto-complete the test
459
+ const test = db.prepare("SELECT steps_total FROM ui_dive_interaction_tests WHERE id = ?").get(testId);
460
+ const completed = db.prepare("SELECT COUNT(*) as c FROM ui_dive_test_steps WHERE test_id = ? AND status != 'pending'").get(testId);
461
+ const passed = db.prepare("SELECT COUNT(*) as c FROM ui_dive_test_steps WHERE test_id = ? AND status = 'passed'").get(testId);
462
+ const failed = db.prepare("SELECT COUNT(*) as c FROM ui_dive_test_steps WHERE test_id = ? AND status = 'failed'").get(testId);
463
+ const allDone = completed.c >= test.steps_total;
464
+ if (allDone) {
465
+ const overallStatus = failed.c > 0 ? "failed" : "passed";
466
+ db.prepare("UPDATE ui_dive_interaction_tests SET status = ?, steps_passed = ?, steps_failed = ?, completed_at = datetime('now') WHERE id = ?").run(overallStatus, passed.c, failed.c, testId);
467
+ }
468
+ else {
469
+ db.prepare("UPDATE ui_dive_interaction_tests SET steps_passed = ?, steps_failed = ? WHERE id = ?").run(passed.c, failed.c, testId);
470
+ }
471
+ return {
472
+ stepId: step.id,
473
+ stepIndex,
474
+ expected: step.expected,
475
+ actual,
476
+ status,
477
+ match: status === "passed",
478
+ screenshotId: screenshotId ?? null,
479
+ testProgress: `${completed.c}/${test.steps_total}`,
480
+ testComplete: allDone,
481
+ ...(allDone ? { testStatus: failed.c > 0 ? "failed" : "passed" } : {}),
482
+ _hint: allDone
483
+ ? `Test complete: ${passed.c} passed, ${failed.c} failed.`
484
+ : `Step ${stepIndex} recorded. ${test.steps_total - completed.c} steps remaining.`,
485
+ };
486
+ },
487
+ },
488
+ // ── 4. Tag a design inconsistency ─────────────────────────────────────
489
+ {
490
+ name: "dive_design_issue",
491
+ description: "Tag a design inconsistency found during the dive. Covers visual problems like color mismatches, spacing deviations, font inconsistencies, alignment issues, contrast failures, responsive breakage, missing hover/focus states, and more. Link to a screenshot and the specific element. The agent uses bridge's browser_evaluate to extract computed styles and compare across components.",
492
+ inputSchema: {
493
+ type: "object",
494
+ properties: {
495
+ sessionId: { type: "string", description: "Dive session ID" },
496
+ componentId: { type: "string", description: "Component with the issue (optional)" },
497
+ issueType: {
498
+ type: "string",
499
+ description: "Type: color, spacing, font, alignment, contrast, responsive, hover_state, focus_state, animation, icon, border, shadow, z_index, overflow, consistency",
500
+ },
501
+ severity: {
502
+ type: "string",
503
+ description: "Severity: critical (broken UX), high (obvious visual bug), medium (noticeable deviation), low (minor polish)",
504
+ enum: ["critical", "high", "medium", "low"],
505
+ },
506
+ title: { type: "string", description: "Short description (e.g. 'Button color mismatch between header and sidebar')" },
507
+ description: { type: "string", description: "Detailed explanation" },
508
+ elementSelector: { type: "string", description: "CSS selector of the affected element" },
509
+ expectedValue: { type: "string", description: "What the design should be (e.g. '#3B82F6', '16px', 'Inter')" },
510
+ actualValue: { type: "string", description: "What was actually found (e.g. '#2563EB', '12px', 'system-ui')" },
511
+ screenshotId: { type: "string", description: "Screenshot showing the issue" },
512
+ route: { type: "string", description: "Route where the issue was found" },
513
+ metadata: { type: "object", description: "Additional context (e.g. { breakpoint: '768px', theme: 'dark' })" },
514
+ },
515
+ required: ["sessionId", "issueType", "title"],
516
+ },
517
+ handler: async (args) => {
518
+ const { sessionId, componentId, issueType, severity, title, description, elementSelector, expectedValue, actualValue, screenshotId, route, metadata } = args;
519
+ const db = getDb();
520
+ const session = db.prepare("SELECT id FROM ui_dive_sessions WHERE id = ?").get(sessionId);
521
+ if (!session)
522
+ return { error: true, message: `Session not found: ${sessionId}` };
523
+ const id = genId("design");
524
+ db.prepare(`INSERT INTO ui_dive_design_issues (id, session_id, component_id, issue_type, severity, title, description, element_selector, expected_value, actual_value, screenshot_id, route, metadata)
525
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, sessionId, componentId ?? null, issueType, severity ?? "medium", title, description ?? null, elementSelector ?? null, expectedValue ?? null, actualValue ?? null, screenshotId ?? null, route ?? null, metadata ? JSON.stringify(metadata) : null);
526
+ return {
527
+ designIssueId: id,
528
+ issueType,
529
+ severity: severity ?? "medium",
530
+ title,
531
+ expectedValue: expectedValue ?? null,
532
+ actualValue: actualValue ?? null,
533
+ _hint: `Design issue tagged. View all issues in the dive report. Fix it, then track with dive_changelog.`,
534
+ };
535
+ },
536
+ },
537
+ // ── 5. Link UI component to backend context ───────────────────────────
538
+ {
539
+ name: "dive_link_backend",
540
+ description: "Link a UI component to its backend dependencies. Connect components to API endpoints, Convex queries/mutations/actions, database tables, auth guards, WebSocket channels, or external services. This creates the full-stack traceability map — when a UI bug is found, you can immediately see which backend code is involved.",
541
+ inputSchema: {
542
+ type: "object",
543
+ properties: {
544
+ sessionId: { type: "string", description: "Dive session ID" },
545
+ componentId: { type: "string", description: "Component to link" },
546
+ links: {
547
+ type: "array",
548
+ description: "Backend references to link",
549
+ items: {
550
+ type: "object",
551
+ properties: {
552
+ linkType: {
553
+ type: "string",
554
+ description: "Type: convex_query, convex_mutation, convex_action, api_endpoint, db_table, auth_guard, websocket, external_service, env_var, cron_job",
555
+ },
556
+ path: { type: "string", description: "Path/identifier (e.g. 'api.domains.documents.documents.getSidebar', '/api/users', 'documents' table)" },
557
+ description: { type: "string", description: "What this backend dependency does for the component" },
558
+ method: { type: "string", description: "HTTP method for API endpoints (GET, POST, etc.)" },
559
+ },
560
+ required: ["linkType", "path"],
561
+ },
562
+ },
563
+ },
564
+ required: ["sessionId", "componentId", "links"],
565
+ },
566
+ handler: async (args) => {
567
+ const { sessionId, componentId, links } = args;
568
+ const db = getDb();
569
+ const session = db.prepare("SELECT id FROM ui_dive_sessions WHERE id = ?").get(sessionId);
570
+ if (!session)
571
+ return { error: true, message: `Session not found: ${sessionId}` };
572
+ const comp = db.prepare("SELECT id, name FROM ui_dive_components WHERE id = ?").get(componentId);
573
+ if (!comp)
574
+ return { error: true, message: `Component not found: ${componentId}` };
575
+ const ids = [];
576
+ for (const link of links) {
577
+ const id = genId("blink");
578
+ db.prepare(`INSERT INTO ui_dive_backend_links (id, session_id, component_id, link_type, path, description, method)
579
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(id, sessionId, componentId, link.linkType, link.path, link.description ?? null, link.method ?? null);
580
+ ids.push(id);
581
+ }
582
+ return {
583
+ componentId,
584
+ componentName: comp.name,
585
+ linksCreated: ids.length,
586
+ links: links.map((l, i) => ({ linkId: ids[i], ...l })),
587
+ _hint: `${ids.length} backend link(s) created for ${comp.name}. These will appear in the dive report and walkthrough.`,
588
+ };
589
+ },
590
+ },
591
+ // ── 6. Track a change (changelog entry) ───────────────────────────────
592
+ {
593
+ name: "dive_changelog",
594
+ description: "Record a change made to fix a bug, design issue, or improve a component. Links before/after screenshots to show what changed visually. Optionally references git commits and changed files. When the dive is re-run after fixes, the changelog provides a clear audit trail of what was wrong, what was changed, and how it looks now.",
595
+ inputSchema: {
596
+ type: "object",
597
+ properties: {
598
+ sessionId: { type: "string", description: "Dive session ID" },
599
+ componentId: { type: "string", description: "Component that was changed (optional)" },
600
+ changeType: {
601
+ type: "string",
602
+ description: "Type: bugfix, design_fix, feature, refactor, accessibility, performance, content, responsive",
603
+ },
604
+ description: { type: "string", description: "What was changed and why" },
605
+ beforeScreenshotId: { type: "string", description: "Screenshot before the change (from dive_save_screenshot)" },
606
+ afterScreenshotId: { type: "string", description: "Screenshot after the change" },
607
+ filesChanged: {
608
+ type: "array",
609
+ description: "List of files that were modified",
610
+ items: { type: "string" },
611
+ },
612
+ gitCommit: { type: "string", description: "Git commit hash (optional)" },
613
+ metadata: { type: "object", description: "Additional context (e.g. { bugId: '...', designIssueId: '...' })" },
614
+ },
615
+ required: ["sessionId", "changeType", "description"],
616
+ },
617
+ handler: async (args) => {
618
+ const { sessionId, componentId, changeType, description, beforeScreenshotId, afterScreenshotId, filesChanged, gitCommit, metadata } = args;
619
+ const db = getDb();
620
+ const session = db.prepare("SELECT id FROM ui_dive_sessions WHERE id = ?").get(sessionId);
621
+ if (!session)
622
+ return { error: true, message: `Session not found: ${sessionId}` };
623
+ const id = genId("chg");
624
+ db.prepare(`INSERT INTO ui_dive_changelogs (id, session_id, component_id, change_type, description, before_screenshot_id, after_screenshot_id, files_changed, git_commit, metadata)
625
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, sessionId, componentId ?? null, changeType, description, beforeScreenshotId ?? null, afterScreenshotId ?? null, filesChanged ? JSON.stringify(filesChanged) : null, gitCommit ?? null, metadata ? JSON.stringify(metadata) : null);
626
+ return {
627
+ changelogId: id,
628
+ changeType,
629
+ description,
630
+ beforeScreenshotId: beforeScreenshotId ?? null,
631
+ afterScreenshotId: afterScreenshotId ?? null,
632
+ filesChanged: filesChanged ?? [],
633
+ gitCommit: gitCommit ?? null,
634
+ _hint: "Changelog entry recorded. It will appear in the dive report and walkthrough.",
635
+ };
636
+ },
637
+ },
638
+ // ── 7. Generate a complete walkthrough ────────────────────────────────
639
+ {
640
+ name: "dive_walkthrough",
641
+ description: "Generate a comprehensive page-by-page, component-by-component walkthrough document for a dive session. Includes: route map with source files, component tree, interaction test results with pass/fail per step, screenshots referenced at each point, design issues found, backend dependencies, console errors, and changelog entries. This is the final deliverable — a complete QA document that an agent or human can review.",
642
+ inputSchema: {
643
+ type: "object",
644
+ properties: {
645
+ sessionId: { type: "string", description: "Dive session ID" },
646
+ format: {
647
+ type: "string",
648
+ description: "Output format: markdown (readable), json (structured), summary (condensed)",
649
+ enum: ["markdown", "json", "summary"],
650
+ },
651
+ includeScreenshotPaths: {
652
+ type: "boolean",
653
+ description: "Include file paths to screenshots (default: true)",
654
+ },
655
+ },
656
+ required: ["sessionId"],
657
+ },
658
+ handler: async (args) => {
659
+ const { sessionId, format, includeScreenshotPaths } = args;
660
+ const db = getDb();
661
+ const session = db.prepare("SELECT * FROM ui_dive_sessions WHERE id = ?").get(sessionId);
662
+ if (!session)
663
+ return { error: true, message: `Session not found: ${sessionId}` };
664
+ const components = db.prepare("SELECT * FROM ui_dive_components WHERE session_id = ? ORDER BY created_at").all(sessionId);
665
+ const bugs = db.prepare("SELECT * FROM ui_dive_bugs WHERE session_id = ? ORDER BY severity, created_at").all(sessionId);
666
+ const screenshots = db.prepare("SELECT * FROM ui_dive_screenshots WHERE session_id = ? ORDER BY created_at").all(sessionId);
667
+ const tests = db.prepare("SELECT * FROM ui_dive_interaction_tests WHERE session_id = ? ORDER BY created_at").all(sessionId);
668
+ const designIssues = db.prepare("SELECT * FROM ui_dive_design_issues WHERE session_id = ? ORDER BY severity, created_at").all(sessionId);
669
+ const backendLinks = db.prepare("SELECT * FROM ui_dive_backend_links WHERE session_id = ? ORDER BY component_id").all(sessionId);
670
+ const changelogs = db.prepare("SELECT * FROM ui_dive_changelogs WHERE session_id = ? ORDER BY created_at").all(sessionId);
671
+ // Load test steps for each test
672
+ const testSteps = {};
673
+ for (const test of tests) {
674
+ testSteps[test.id] = db.prepare("SELECT * FROM ui_dive_test_steps WHERE test_id = ? ORDER BY step_index").all(test.id);
675
+ }
676
+ // Group components by route (from metadata)
677
+ const routeGroups = new Map();
678
+ for (const comp of components) {
679
+ const meta = comp.metadata ? JSON.parse(comp.metadata) : {};
680
+ const route = meta.route ?? "(unrouted)";
681
+ if (!routeGroups.has(route))
682
+ routeGroups.set(route, []);
683
+ routeGroups.get(route).push({ ...comp, _meta: meta });
684
+ }
685
+ if (format === "json") {
686
+ return {
687
+ session: {
688
+ id: session.id,
689
+ appUrl: session.app_url,
690
+ appName: session.app_name,
691
+ status: session.status,
692
+ createdAt: session.created_at,
693
+ },
694
+ stats: {
695
+ routes: routeGroups.size,
696
+ components: components.length,
697
+ bugs: bugs.length,
698
+ screenshots: screenshots.length,
699
+ tests: tests.length,
700
+ testsPassed: tests.filter((t) => t.status === "passed").length,
701
+ testsFailed: tests.filter((t) => t.status === "failed").length,
702
+ designIssues: designIssues.length,
703
+ backendLinks: backendLinks.length,
704
+ changelogs: changelogs.length,
705
+ },
706
+ routes: Object.fromEntries([...routeGroups.entries()].map(([route, comps]) => [
707
+ route,
708
+ {
709
+ components: comps.map(c => ({
710
+ id: c.id,
711
+ name: c.name,
712
+ type: c.component_type,
713
+ status: c.status,
714
+ sourceFiles: c._meta.sourceFiles ?? [],
715
+ bugs: bugs.filter(b => b.component_id === c.id).map(b => ({ id: b.id, severity: b.severity, title: b.title })),
716
+ backendLinks: backendLinks.filter(l => l.component_id === c.id).map(l => ({ type: l.link_type, path: l.path })),
717
+ tests: tests.filter(t => t.component_id === c.id).map(t => ({
718
+ id: t.id,
719
+ name: t.test_name,
720
+ status: t.status,
721
+ passed: t.steps_passed,
722
+ failed: t.steps_failed,
723
+ total: t.steps_total,
724
+ steps: (testSteps[t.id] ?? []).map(s => ({
725
+ index: s.step_index,
726
+ action: s.action,
727
+ expected: s.expected,
728
+ actual: s.actual,
729
+ status: s.status,
730
+ screenshotId: s.screenshot_id,
731
+ })),
732
+ })),
733
+ })),
734
+ designIssues: designIssues.filter(d => comps.some(c => c.id === d.component_id)).map(d => ({
735
+ id: d.id,
736
+ type: d.issue_type,
737
+ severity: d.severity,
738
+ title: d.title,
739
+ expected: d.expected_value,
740
+ actual: d.actual_value,
741
+ })),
742
+ },
743
+ ])),
744
+ changelogs: changelogs.map(c => ({
745
+ id: c.id,
746
+ type: c.change_type,
747
+ description: c.description,
748
+ filesChanged: c.files_changed ? JSON.parse(c.files_changed) : [],
749
+ gitCommit: c.git_commit,
750
+ })),
751
+ screenshots: includeScreenshotPaths !== false
752
+ ? screenshots.map(s => ({ id: s.id, label: s.label, filePath: s.file_path, route: s.route }))
753
+ : undefined,
754
+ };
755
+ }
756
+ // Markdown format
757
+ const lines = [];
758
+ lines.push(`# UI/UX Dive Walkthrough: ${session.app_name ?? session.app_url}`);
759
+ lines.push(`**Session:** ${session.id} `);
760
+ lines.push(`**URL:** ${session.app_url} `);
761
+ lines.push(`**Date:** ${session.created_at} `);
762
+ lines.push(`**Status:** ${session.status}\n`);
763
+ // Stats
764
+ lines.push("## Summary\n");
765
+ lines.push(`| Metric | Value |`);
766
+ lines.push(`|--------|-------|`);
767
+ lines.push(`| Routes | ${routeGroups.size} |`);
768
+ lines.push(`| Components | ${components.length} |`);
769
+ lines.push(`| Interaction Tests | ${tests.length} (${tests.filter((t) => t.status === "passed").length} passed, ${tests.filter((t) => t.status === "failed").length} failed) |`);
770
+ lines.push(`| Bugs | ${bugs.length} |`);
771
+ lines.push(`| Design Issues | ${designIssues.length} |`);
772
+ lines.push(`| Screenshots | ${screenshots.length} |`);
773
+ lines.push(`| Backend Links | ${backendLinks.length} |`);
774
+ lines.push(`| Changelogs | ${changelogs.length} |`);
775
+ lines.push("");
776
+ // Route-by-route walkthrough
777
+ lines.push("## Route-by-Route Walkthrough\n");
778
+ for (const [route, comps] of routeGroups) {
779
+ const sourceFiles = comps[0]?._meta?.sourceFiles ?? [];
780
+ lines.push(`### ${route}\n`);
781
+ if (sourceFiles.length > 0)
782
+ lines.push(`**Source files:** ${sourceFiles.join(", ")} `);
783
+ lines.push(`**Components:** ${comps.length}\n`);
784
+ for (const comp of comps) {
785
+ lines.push(`#### ${comp.name} (${comp.component_type})`);
786
+ lines.push(`- **Status:** ${comp.status}`);
787
+ lines.push(`- **Interactions:** ${comp.interaction_count}`);
788
+ // Backend links
789
+ const compLinks = backendLinks.filter(l => l.component_id === comp.id);
790
+ if (compLinks.length > 0) {
791
+ lines.push(`- **Backend dependencies:**`);
792
+ for (const link of compLinks) {
793
+ lines.push(` - \`[${link.link_type}]\` ${link.path}${link.description ? ` -- ${link.description}` : ""}`);
794
+ }
795
+ }
796
+ // Tests for this component
797
+ const compTests = tests.filter(t => t.component_id === comp.id);
798
+ if (compTests.length > 0) {
799
+ lines.push(`\n**Interaction Tests:**\n`);
800
+ for (const test of compTests) {
801
+ const icon = test.status === "passed" ? "PASS" : test.status === "failed" ? "FAIL" : "PENDING";
802
+ lines.push(`##### [${icon}] ${test.test_name}`);
803
+ if (test.description)
804
+ lines.push(`${test.description}`);
805
+ if (test.preconditions) {
806
+ const preconds = JSON.parse(test.preconditions);
807
+ lines.push(`\n**Preconditions:**`);
808
+ for (const p of preconds)
809
+ lines.push(`- ${p}`);
810
+ }
811
+ lines.push(`\n| Step | Action | Expected | Actual | Status | Screenshot |`);
812
+ lines.push(`|------|--------|----------|--------|--------|------------|`);
813
+ for (const step of (testSteps[test.id] ?? [])) {
814
+ const stepIcon = step.status === "passed" ? "PASS" : step.status === "failed" ? "FAIL" : step.status;
815
+ const ssRef = step.screenshot_id ?? "-";
816
+ lines.push(`| ${step.step_index} | ${step.action} ${step.target ?? ""} | ${step.expected ?? ""} | ${step.actual ?? "-"} | ${stepIcon} | ${ssRef} |`);
817
+ }
818
+ lines.push("");
819
+ }
820
+ }
821
+ // Bugs
822
+ const compBugs = bugs.filter(b => b.component_id === comp.id);
823
+ if (compBugs.length > 0) {
824
+ lines.push(`\n**Bugs:**\n`);
825
+ for (const bug of compBugs) {
826
+ lines.push(`- **[${bug.severity.toUpperCase()}]** ${bug.title}`);
827
+ if (bug.description)
828
+ lines.push(` ${bug.description}`);
829
+ if (bug.screenshot_ref)
830
+ lines.push(` Screenshot: ${bug.screenshot_ref}`);
831
+ }
832
+ }
833
+ lines.push("");
834
+ }
835
+ // Design issues for this route
836
+ const routeDesignIssues = designIssues.filter(d => d.route === route);
837
+ if (routeDesignIssues.length > 0) {
838
+ lines.push(`**Design Issues on ${route}:**\n`);
839
+ for (const issue of routeDesignIssues) {
840
+ lines.push(`- **[${issue.severity.toUpperCase()}] ${issue.issue_type}:** ${issue.title}`);
841
+ if (issue.expected_value || issue.actual_value) {
842
+ lines.push(` Expected: ${issue.expected_value ?? "?"} | Actual: ${issue.actual_value ?? "?"}`);
843
+ }
844
+ }
845
+ lines.push("");
846
+ }
847
+ }
848
+ // Changelog
849
+ if (changelogs.length > 0) {
850
+ lines.push("## Changelog\n");
851
+ for (const chg of changelogs) {
852
+ lines.push(`### [${chg.change_type}] ${chg.description}`);
853
+ if (chg.files_changed) {
854
+ const files = JSON.parse(chg.files_changed);
855
+ lines.push(`**Files changed:** ${files.join(", ")}`);
856
+ }
857
+ if (chg.git_commit)
858
+ lines.push(`**Commit:** ${chg.git_commit}`);
859
+ if (chg.before_screenshot_id || chg.after_screenshot_id) {
860
+ lines.push(`**Before:** ${chg.before_screenshot_id ?? "-"} | **After:** ${chg.after_screenshot_id ?? "-"}`);
861
+ }
862
+ lines.push("");
863
+ }
864
+ }
865
+ const markdown = lines.join("\n");
866
+ return {
867
+ format: "markdown",
868
+ walkthrough: format === "summary" ? markdown.slice(0, 3000) : markdown,
869
+ stats: {
870
+ routes: routeGroups.size,
871
+ components: components.length,
872
+ tests: tests.length,
873
+ bugs: bugs.length,
874
+ designIssues: designIssues.length,
875
+ screenshots: screenshots.length,
876
+ backendLinks: backendLinks.length,
877
+ changelogs: changelogs.length,
878
+ },
879
+ };
880
+ },
881
+ },
882
+ ];
883
+ //# sourceMappingURL=uiUxDiveAdvancedTools.js.map