UNPKG

@debugg-ai/debugg-ai-mcp

Version:

Zero-Config, Fully AI-Managed End-to-End Testing for all code gen platforms.

341 lines (340 loc) 19.6 kB
/** * probePageHandler — lightweight no-LLM batch page probe. * * Mirrors triggerCrawlHandler's 4-step pattern (find template → execute → * poll → format response) but: (a) takes a list of targets and produces a * list of results, (b) does no agent steps (zero LLM in critical path), * (c) MCP-side aggregates per-target HAR slices into NetworkSummary[]. * * The backend "Page Probe" workflow template runs: * browser.setup → loop[targets](browser.navigate → browser.capture) → done * * Each browser.capture node emits per-iteration outputData with consoleSlice * + harSlice windowed to that URL's load span — that's what makes per-URL * networkSummary attribution accurate. */ import { config } from '../config/index.js'; import { Logger } from '../utils/logger.js'; import { handleExternalServiceError } from '../utils/errors.js'; import { DebuggAIServerClient } from '../services/index.js'; import { TunnelProvisionError } from '../services/tunnels.js'; import { tunnelManager } from '../services/ngrok/tunnelManager.js'; import { probeLocalPort, probeTunnelHealth } from '../utils/localReachability.js'; import { extractLocalhostPort } from '../utils/urlParser.js'; import { buildContext, findExistingTunnel, ensureTunnel, sanitizeResponseUrls, touchTunnelById, } from '../utils/tunnelContext.js'; import { getCachedTemplateUuid, invalidateTemplateCache } from '../utils/handlerCaches.js'; import { reaggregateByOriginPath, mapConsoleSlice } from '../utils/harSummarizer.js'; import { fetchImageAsBase64, imageContentBlock } from '../utils/imageUtils.js'; const logger = new Logger({ module: 'probePageHandler' }); const TEMPLATE_KEYWORD = 'page probe'; export async function probePageHandler(input, context, rawProgressCallback) { const startTime = Date.now(); logger.toolStart('probe_page', input); // Bead 0bq: progress circuit-breaker — see testPageChangesHandler for rationale. let progressDisabled = false; const progressCallback = rawProgressCallback ? async (update) => { if (progressDisabled) return; try { await rawProgressCallback(update); } catch (err) { progressDisabled = true; logger.warn('Progress emission failed; disabling further emissions for this request', { error: err instanceof Error ? err.message : String(err), }); } } : undefined; const client = new DebuggAIServerClient(config.api.key); await client.init(); const abortController = new AbortController(); const onStdinClose = () => { abortController.abort(); progressDisabled = true; }; process.stdin.once('close', onStdinClose); // Per-target tunnel contexts. Index aligns with input.targets[]. const targetContexts = []; // Tunnel keys we provisioned this call (for cleanup if creation fails after key acquired). const acquiredKeyIds = []; // Progress budget: 1 pre-flight + 1 template + 1 execute + N per-target captures + 1 done const TOTAL_STEPS = 3 + input.targets.length + 1; let progressStep = 0; try { if (progressCallback) { await progressCallback({ progress: ++progressStep, total: TOTAL_STEPS, message: `Pre-flight + tunnel setup (${input.targets.length} target${input.targets.length === 1 ? '' : 's'})...` }); } // ── Per-target pre-flight + tunnel resolution ────────────────────────── for (const target of input.targets) { const ctx = buildContext(target.url); if (ctx.isLocalhost) { // Pre-flight TCP probe: fail fast if dev server isn't listening. const port = extractLocalhostPort(ctx.originalUrl); if (typeof port === 'number') { const probe = await probeLocalPort(port); if (!probe.reachable) { const payload = { error: 'LocalServerUnreachable', message: `No server listening on 127.0.0.1:${port}. Start your dev server on that port before running probe_page. Probe result: ${probe.code} (${probe.detail ?? 'no detail'}).`, detail: { port, probeCode: probe.code, probeDetail: probe.detail, elapsedMs: probe.elapsedMs, }, }; logger.warn(`Pre-flight port probe failed for ${ctx.originalUrl}: ${probe.code} in ${probe.elapsedMs}ms`); return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true }; } } if (config.devMode) { // Dev mode: local backend can reach localhost directly — no tunnel needed. logger.info(`probe_page: dev mode — using localhost URL directly: ${ctx.originalUrl}`); targetContexts.push(ctx); } else { // Reuse existing tunnel for this port if any; otherwise provision. const reused = findExistingTunnel(ctx); if (reused) { targetContexts.push(reused); } else { let tunnel; try { tunnel = await client.tunnels.provisionWithRetry(); } catch (provisionError) { const msg = provisionError instanceof Error ? provisionError.message : String(provisionError); const diag = provisionError instanceof TunnelProvisionError ? ` ${provisionError.diagnosticSuffix()}` : ''; throw new Error(`Failed to provision tunnel for ${ctx.originalUrl}. ` + `(Detail: ${msg})${diag}`); } acquiredKeyIds.push(tunnel.keyId); let tunneled; try { tunneled = await ensureTunnel(ctx, tunnel.tunnelKey, tunnel.tunnelId, tunnel.keyId, () => client.revokeNgrokKey(tunnel.keyId)); } catch (tunnelError) { const msg = tunnelError instanceof Error ? tunnelError.message : String(tunnelError); throw new Error(`Tunnel creation failed for ${ctx.originalUrl}. (Detail: ${msg})`); } // Tunnel health probe: catch the IPv4/IPv6 bind / dead-server case // before committing to a full backend execution. if (tunneled.targetUrl) { const health = await probeTunnelHealth(tunneled.targetUrl); if (!health.healthy) { const payload = { error: 'TunnelTrafficBlocked', message: `Tunnel established but traffic isn't reaching the dev server. ${health.detail ?? ''}`, detail: { code: health.code, status: health.status, ngrokErrorCode: health.ngrokErrorCode, elapsedMs: health.elapsedMs, }, }; if (tunneled.tunnelId) { tunnelManager.stopTunnel(tunneled.tunnelId).catch((err) => logger.warn(`Failed to stop broken tunnel ${tunneled.tunnelId}: ${err}`)); } return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true }; } } targetContexts.push(tunneled); } } } else { // Public URL — no tunnel needed. targetContexts.push(ctx); } } // ── Locate workflow template ─────────────────────────────────────────── if (progressCallback) { await progressCallback({ progress: ++progressStep, total: TOTAL_STEPS, message: 'Locating page-probe workflow template...' }); } const templateUuid = await getCachedTemplateUuid(TEMPLATE_KEYWORD, async (name) => { return client.workflows.findTemplateByName(name); }); if (!templateUuid) { throw new Error(`Page Probe Workflow Template not found. ` + `Ensure the backend has a template matching "${TEMPLATE_KEYWORD}" seeded and accessible.`); } // ── Build contextData (camelCase; axiosTransport snake_cases on the wire) ── // Backend's browser.setup node (shared with App Evaluation + Raw Crawl // templates) requires `target_url` (singular). The Page Probe template // currently uses that node as-is — the per-target loop primitive is // pending. Send BOTH: // - targetUrl: first target's tunneled URL (satisfies browser.setup // today; will keep working when the loop wraps it later) // - targets[]: the full per-URL config for when the loop primitive // ships and iterates over them const firstTargetUrl = targetContexts[0]?.targetUrl ?? input.targets[0].url; const contextData = { targetUrl: firstTargetUrl, targets: input.targets.map((t, i) => ({ url: targetContexts[i].targetUrl ?? t.url, // Send null (not undefined) for optional fields so the field exists // in the target object even when the caller didn't pass one. Backend // placeholder resolver was fixed in commit 154e1e69 to type-preserve // null in single-placeholder substitutions, so null flows through. waitForSelector: t.waitForSelector ?? null, waitForLoadState: t.waitForLoadState, timeoutMs: t.timeoutMs, })), // Backend's browser.capture template binds {{include_dom}} and // {{include_screenshot}} from contextData (verified 2026-04-29). // The MCP-facing schema keeps `includeHtml` / `captureScreenshots` // for caller ergonomics; we just map them to what the template wants. includeDom: input.includeHtml, includeScreenshot: input.captureScreenshots, // Keep the original keys too for any downstream node that reads them // (cheap to send, future-proof against template field-name churn). includeHtml: input.includeHtml, captureScreenshots: input.captureScreenshots, }; // ── Execute ──────────────────────────────────────────────────────────── if (progressCallback) { await progressCallback({ progress: ++progressStep, total: TOTAL_STEPS, message: 'Queuing workflow execution...' }); } const executeResponse = await client.workflows.executeWorkflow(templateUuid, contextData); const executionUuid = executeResponse.executionUuid; logger.info(`Probe execution queued: ${executionUuid}`); // ── Poll ─────────────────────────────────────────────────────────────── let lastCompleted = -1; const finalExecution = await client.workflows.pollExecution(executionUuid, async (exec) => { // Keep all active tunnels alive during polling. for (const tc of targetContexts) { if (tc.tunnelId) touchTunnelById(tc.tunnelId); } if (!progressCallback) return; const completedNodes = (exec.nodeExecutions ?? []).filter(n => n.nodeType === 'browser.capture' && n.status === 'success').length; if (completedNodes !== lastCompleted) { lastCompleted = completedNodes; await progressCallback({ progress: Math.min(progressStep + completedNodes, TOTAL_STEPS - 1), total: TOTAL_STEPS, message: `Probed ${completedNodes}/${input.targets.length} target${input.targets.length === 1 ? '' : 's'}...`, }); } }, abortController.signal); // ── Format response ──────────────────────────────────────────────────── const duration = Date.now() - startTime; const captureNodes = (finalExecution.nodeExecutions ?? []) .filter(n => n.nodeType === 'browser.capture') .sort((a, b) => a.executionOrder - b.executionOrder); const results = []; for (let i = 0; i < input.targets.length; i++) { const target = input.targets[i]; const node = captureNodes[i]; const data = node?.outputData ?? {}; // Backend (post-154e1e69) emits browser.capture output_data with: // captured_url, status_code, title, load_time_ms, // console_slice (already per-capture, in {text, level, location, timestamp} shape), // network_summary (already pre-aggregated by FULL URL, // in {url, count, methods[], statuses{}, resource_types[]} shape), // surfer_page_uuid (reference to SurferPage row for screenshot/title/visible_text), // error // axiosTransport snake→camel'd at the wire, so JS-side these are // capturedUrl / consoleSlice / networkSummary / surferPageUuid / etc. // Re-aggregate networkSummary by origin+pathname so refetch loops // collapse (preserves the original client-feedback contract). const result = { url: target.url, // ORIGINAL caller URL — not the tunneled rewrite finalUrl: typeof data.capturedUrl === 'string' ? data.capturedUrl : typeof data.finalUrl === 'string' ? data.finalUrl : typeof data.url === 'string' ? data.url : target.url, statusCode: typeof data.statusCode === 'number' ? data.statusCode : 0, title: typeof data.title === 'string' ? data.title : null, loadTimeMs: typeof data.loadTimeMs === 'number' ? data.loadTimeMs : 0, consoleErrors: mapConsoleSlice(Array.isArray(data.consoleSlice) ? data.consoleSlice : []), networkSummary: reaggregateByOriginPath(Array.isArray(data.networkSummary) ? data.networkSummary : []), }; if (input.includeHtml && typeof data.html === 'string') { result.html = data.html; } if (typeof data.error === 'string' && data.error) { result.error = data.error; } if (typeof data.surferPageUuid === 'string' && data.surferPageUuid) { result.surferPageUuid = data.surferPageUuid; } results.push(result); } const responsePayload = { executionId: executionUuid, durationMs: typeof finalExecution.durationMs === 'number' ? finalExecution.durationMs : duration, results, }; if (finalExecution.browserSession) { responsePayload.browserSession = finalExecution.browserSession; } // Sanitize ngrok URLs from the entire payload — agent-authored strings in // node outputData (titles, HTML, console messages from the page itself) // can occasionally contain the tunnel URL; rewrite to the original // localhost origin per tunnel context. For multi-localhost batches we // run sanitize once per localhost target since each may have its own // tunnel↔origin mapping. let sanitizedPayload = responsePayload; for (const tc of targetContexts) { if (tc.isLocalhost) { sanitizedPayload = sanitizeResponseUrls(sanitizedPayload, tc); } } logger.toolComplete('probe_page', duration); const responseContent = [ { type: 'text', text: JSON.stringify(sanitizedPayload, null, 2) }, ]; // Embed screenshots when captureScreenshots is true. The backend may return // screenshotB64 or a URL-keyed field on browser.capture outputData. if (input.captureScreenshots) { const SCREENSHOT_URL_KEYS = ['screenshotB64', 'screenshot', 'screenshotUrl', 'screenshotUri', 'finalScreenshot']; for (const node of captureNodes) { const data = node?.outputData ?? {}; if (typeof data.screenshotB64 === 'string' && data.screenshotB64) { responseContent.push(imageContentBlock(data.screenshotB64, 'image/png')); } else { let screenshotUrl = null; for (const key of SCREENSHOT_URL_KEYS) { if (key !== 'screenshotB64' && typeof data[key] === 'string' && data[key]) { screenshotUrl = data[key]; break; } } if (screenshotUrl) { const img = await fetchImageAsBase64(screenshotUrl).catch(() => null); if (img) responseContent.push(imageContentBlock(img.data, img.mimeType)); } } } } return { content: responseContent }; } catch (error) { const duration = Date.now() - startTime; logger.toolError('probe_page', error, duration); if (error instanceof Error && (error.message.includes('not found') || error.message.includes('401'))) { invalidateTemplateCache(); } throw handleExternalServiceError(error, 'DebuggAI', 'probe_page execution'); } finally { process.stdin.removeListener('close', onStdinClose); // Tunnels intentionally NOT torn down — reuse pattern (bead vwd) + // 55-min idle auto-shutoff. Revoke only orphaned keys (we acquired the // key but tunnel creation failed before ensureTunnel completed). for (let i = 0; i < acquiredKeyIds.length; i++) { const keyId = acquiredKeyIds[i]; const tc = targetContexts[i]; if (tc && !tc.tunnelId && keyId) { client.revokeNgrokKey(keyId).catch(err => logger.warn(`Failed to revoke unused ngrok key ${keyId}: ${err}`)); } } } }