@debugg-ai/debugg-ai-mcp
Version:
Zero-Config, Fully AI-Managed End-to-End Testing for all code gen platforms.
123 lines (122 loc) • 7.1 kB
JavaScript
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 } from '../utils/tunnelContext.js';
import { config } from '../config/index.js';
import { resolveProject, resolveTestSuite } from '../utils/resolveProject.js';
const logger = new Logger({ module: 'runTestSuiteHandler' });
function errorResp(error, message, extra = {}) {
return { content: [{ type: 'text', text: JSON.stringify({ error, message, ...extra }, null, 2) }], isError: true };
}
export async function runTestSuiteHandler(input, _context) {
const start = Date.now();
logger.toolStart('run_test_suite', input);
const client = new DebuggAIServerClient(config.api.key);
await client.init();
let acquiredKeyId = null;
let tunnelId;
try {
let suiteUuid = input.suiteUuid;
if (!suiteUuid) {
let projectUuid = input.projectUuid;
if (!projectUuid) {
const resolved = await resolveProject(client, input.projectName);
if ('error' in resolved)
return errorResp(resolved.error, resolved.message, { candidates: resolved.candidates });
projectUuid = resolved.uuid;
}
const resolved = await resolveTestSuite(client, input.suiteName, projectUuid);
if ('error' in resolved)
return errorResp(resolved.error, resolved.message, { candidates: resolved.candidates });
suiteUuid = resolved.uuid;
}
// Resolve the effective target URL — tunnel if localhost, pass-through otherwise.
let effectiveTargetUrl = input.targetUrl;
if (input.targetUrl) {
const ctx = buildContext(input.targetUrl);
if (ctx.isLocalhost) {
const port = extractLocalhostPort(ctx.originalUrl);
if (typeof port === 'number') {
const probe = await probeLocalPort(port);
if (!probe.reachable) {
return errorResp('LocalServerUnreachable', `No server listening on 127.0.0.1:${port}. Start your dev server before running the suite. (${probe.code}: ${probe.detail ?? 'no detail'})`, { port, probeCode: probe.code, elapsedMs: probe.elapsedMs });
}
}
if (config.devMode) {
// Dev mode: local backend can reach localhost directly — no tunnel needed.
logger.info(`run_test_suite: dev mode — using localhost URL directly: ${input.targetUrl}`);
}
else {
// Reuse an existing tunnel for this port if one is already active.
const reused = findExistingTunnel(ctx);
if (reused) {
effectiveTargetUrl = reused.targetUrl ?? input.targetUrl;
tunnelId = reused.tunnelId;
}
else {
// Provision a new tunnel.
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()}` : '';
return errorResp('TunnelProvisionFailed', `Failed to provision tunnel for ${input.targetUrl}. (Detail: ${msg})${diag}`);
}
acquiredKeyId = 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);
return errorResp('TunnelCreationFailed', `Tunnel creation failed for ${input.targetUrl}. (Detail: ${msg})`);
}
// Health probe — catches ERR_NGROK_8012 and bind mismatches before
// the remote agent wastes steps trying to reach the server.
if (tunneled.targetUrl) {
const health = await probeTunnelHealth(tunneled.targetUrl);
if (!health.healthy) {
if (tunneled.tunnelId) {
tunnelManager.stopTunnel(tunneled.tunnelId).catch((err) => logger.warn(`Failed to stop broken tunnel ${tunneled.tunnelId}: ${err}`));
}
return errorResp('TunnelTrafficBlocked', `Tunnel established but traffic isn't reaching the dev server. ${health.detail ?? ''}`, { code: health.code, ngrokErrorCode: health.ngrokErrorCode, elapsedMs: health.elapsedMs });
}
}
effectiveTargetUrl = tunneled.targetUrl ?? input.targetUrl;
tunnelId = tunneled.tunnelId;
}
logger.info(`run_test_suite: localhost detected, tunneled ${input.targetUrl} → ${effectiveTargetUrl}`);
}
}
}
const result = await client.runTestSuite(suiteUuid, { targetUrl: effectiveTargetUrl });
logger.toolComplete('run_test_suite', Date.now() - start);
return {
content: [{
type: 'text',
text: JSON.stringify({
...result,
...(tunnelId ? { tunnelActive: true, originalUrl: input.targetUrl } : {}),
note: 'Tests are running asynchronously. Use get_test_suite_results to check progress.',
}, null, 2),
}],
};
}
catch (error) {
logger.toolError('run_test_suite', error, Date.now() - start);
throw handleExternalServiceError(error, 'DebuggAI', 'run_test_suite');
}
finally {
// Tunnels are NOT torn down — reuse pattern + 55-min idle auto-shutoff.
// Only revoke an orphaned key (acquired but tunnel creation failed).
if (acquiredKeyId && !tunnelId) {
client.revokeNgrokKey(acquiredKeyId).catch((err) => logger.warn(`Failed to revoke unused ngrok key ${acquiredKeyId}: ${err}`));
}
}
}