@debugg-ai/debugg-ai-mcp 2.6.1 → 2.8.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.
- package/CHANGELOG.md +15 -0
- package/dist/config/index.js +11 -1
- package/dist/handlers/createTestCaseHandler.js +46 -0
- package/dist/handlers/createTestSuiteHandler.js +31 -0
- package/dist/handlers/deleteTestCaseHandler.js +20 -0
- package/dist/handlers/deleteTestSuiteHandler.js +38 -0
- package/dist/handlers/getTestSuiteResultsHandler.js +38 -0
- package/dist/handlers/index.js +8 -0
- package/dist/handlers/probePageHandler.js +48 -41
- package/dist/handlers/runTestSuiteHandler.js +122 -0
- package/dist/handlers/searchTestSuitesHandler.js +36 -0
- package/dist/handlers/testPageChangesHandler.js +63 -57
- package/dist/handlers/triggerCrawlHandler.js +43 -37
- package/dist/handlers/updateTestCaseHandler.js +24 -0
- package/dist/services/index.js +145 -0
- package/dist/services/workflows.js +17 -1
- package/dist/tools/index.js +17 -0
- package/dist/tools/testSuiteTools.js +183 -0
- package/dist/types/index.js +51 -0
- package/dist/utils/resolveProject.js +27 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
### Added — E2E test suite management (8 new MCP tools)
|
|
11
|
+
|
|
12
|
+
Eight new tools for building and managing automated E2E test suites directly via MCP:
|
|
13
|
+
|
|
14
|
+
- `create_test_suite` — Create a named test suite for a project
|
|
15
|
+
- `search_test_suites` — List/search suites for a project with pagination and text filter
|
|
16
|
+
- `delete_test_suite` — Soft-delete (disable) a test suite
|
|
17
|
+
- `create_test_case` — Create a test case assigned to a suite (no auto-run)
|
|
18
|
+
- `update_test_case` — Update a test case's name, description, or agent task description
|
|
19
|
+
- `delete_test_case` — Soft-delete (disable) a test case
|
|
20
|
+
- `run_test_suite` — Trigger all test cases in a suite asynchronously
|
|
21
|
+
- `get_test_suite_results` — Fetch suite with per-test pass/fail outcomes and run history
|
|
22
|
+
|
|
23
|
+
All tools support name-based resolution (projectName, suiteName) with the same case-insensitive exact-match + ambiguity handling as existing tools. All backed by `/api/v1/test-suites/` and `/api/v1/e2e-tests/` endpoints on the DebuggAI backend. 80 new unit + integration tests added.
|
|
24
|
+
|
|
10
25
|
### Fixed — MCP now validates local reachability BEFORE hitting the backend (fixes 5-min false-pass regression)
|
|
11
26
|
|
|
12
27
|
- `check_app_in_browser` and `trigger_crawl` now do a pre-flight TCP probe to `127.0.0.1:<port>` before provisioning a backend tunnel key. If the dev server isn't listening, we return a structured `LocalServerUnreachable` error in ~ms instead of letting the browser agent burn its 5-minute step budget on `ERR_NGROK_8012`. Bead `1om`.
|
package/dist/config/index.js
CHANGED
|
@@ -40,6 +40,10 @@ function isTelemetryDisabled() {
|
|
|
40
40
|
const v = (process.env.DEBUGGAI_TELEMETRY_DISABLED || '').toLowerCase();
|
|
41
41
|
return v === '1' || v === 'true' || v === 'yes' || v === 'on';
|
|
42
42
|
}
|
|
43
|
+
function isDevMode() {
|
|
44
|
+
const v = (process.env.DEBUGGAI_DEV_MODE || '').toLowerCase();
|
|
45
|
+
return v === '1' || v === 'true' || v === 'yes' || v === 'on';
|
|
46
|
+
}
|
|
43
47
|
function resolvePosthogKey() {
|
|
44
48
|
if (isTelemetryDisabled())
|
|
45
49
|
return undefined;
|
|
@@ -50,6 +54,7 @@ const configSchema = z.object({
|
|
|
50
54
|
name: z.string().default('DebuggAI MCP Server'),
|
|
51
55
|
version: z.string(),
|
|
52
56
|
}),
|
|
57
|
+
devMode: z.boolean().default(false),
|
|
53
58
|
api: z.object({
|
|
54
59
|
// key is validated at tool-call time (not at boot) so MCP clients can surface
|
|
55
60
|
// a proper error message instead of seeing the subprocess die → "Failed to
|
|
@@ -74,11 +79,12 @@ export function loadConfig() {
|
|
|
74
79
|
name: 'DebuggAI MCP Server',
|
|
75
80
|
version: _version,
|
|
76
81
|
},
|
|
82
|
+
devMode: isDevMode(),
|
|
77
83
|
api: {
|
|
78
84
|
// Priority: DEBUGGAI_API_TOKEN → DEBUGGAI_JWT_TOKEN → DEBUGGAI_API_KEY
|
|
79
85
|
key: process.env.DEBUGGAI_API_TOKEN || process.env.DEBUGGAI_JWT_TOKEN || process.env.DEBUGGAI_API_KEY || '',
|
|
80
86
|
tokenType: process.env.DEBUGGAI_TOKEN_TYPE || 'token',
|
|
81
|
-
baseUrl: process.env.DEBUGGAI_API_URL || 'https://api.debugg.ai',
|
|
87
|
+
baseUrl: process.env.DEBUGGAI_API_URL || (isDevMode() ? 'http://localhost:8012' : 'https://api.debugg.ai'),
|
|
82
88
|
},
|
|
83
89
|
defaults: {},
|
|
84
90
|
logging: {
|
|
@@ -106,6 +112,7 @@ export function loadConfig() {
|
|
|
106
112
|
let _config;
|
|
107
113
|
export const config = {
|
|
108
114
|
get server() { return getConfig().server; },
|
|
115
|
+
get devMode() { return getConfig().devMode; },
|
|
109
116
|
get api() { return getConfig().api; },
|
|
110
117
|
get defaults() { return getConfig().defaults; },
|
|
111
118
|
get logging() { return getConfig().logging; },
|
|
@@ -117,3 +124,6 @@ function getConfig() {
|
|
|
117
124
|
}
|
|
118
125
|
return _config;
|
|
119
126
|
}
|
|
127
|
+
export function _resetConfigForTest() {
|
|
128
|
+
_config = undefined;
|
|
129
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Logger } from '../utils/logger.js';
|
|
2
|
+
import { handleExternalServiceError } from '../utils/errors.js';
|
|
3
|
+
import { DebuggAIServerClient } from '../services/index.js';
|
|
4
|
+
import { config } from '../config/index.js';
|
|
5
|
+
import { resolveProject, resolveTestSuite } from '../utils/resolveProject.js';
|
|
6
|
+
const logger = new Logger({ module: 'createTestCaseHandler' });
|
|
7
|
+
function errorResp(error, message, extra = {}) {
|
|
8
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error, message, ...extra }, null, 2) }], isError: true };
|
|
9
|
+
}
|
|
10
|
+
export async function createTestCaseHandler(input, _context) {
|
|
11
|
+
const start = Date.now();
|
|
12
|
+
logger.toolStart('create_test_case', input);
|
|
13
|
+
try {
|
|
14
|
+
const client = new DebuggAIServerClient(config.api.key);
|
|
15
|
+
await client.init();
|
|
16
|
+
let projectUuid = input.projectUuid;
|
|
17
|
+
if (!projectUuid) {
|
|
18
|
+
const resolved = await resolveProject(client, input.projectName);
|
|
19
|
+
if ('error' in resolved)
|
|
20
|
+
return errorResp(resolved.error, resolved.message, { candidates: resolved.candidates });
|
|
21
|
+
projectUuid = resolved.uuid;
|
|
22
|
+
}
|
|
23
|
+
let suiteUuid = input.suiteUuid;
|
|
24
|
+
if (!suiteUuid) {
|
|
25
|
+
const resolved = await resolveTestSuite(client, input.suiteName, projectUuid);
|
|
26
|
+
if ('error' in resolved)
|
|
27
|
+
return errorResp(resolved.error, resolved.message, { candidates: resolved.candidates });
|
|
28
|
+
suiteUuid = resolved.uuid;
|
|
29
|
+
}
|
|
30
|
+
const testCase = await client.createTestCase({
|
|
31
|
+
name: input.name,
|
|
32
|
+
description: input.description,
|
|
33
|
+
agentTaskDescription: input.agentTaskDescription,
|
|
34
|
+
suiteUuid,
|
|
35
|
+
projectUuid,
|
|
36
|
+
relativeUrl: input.relativeUrl,
|
|
37
|
+
maxSteps: input.maxSteps,
|
|
38
|
+
});
|
|
39
|
+
logger.toolComplete('create_test_case', Date.now() - start);
|
|
40
|
+
return { content: [{ type: 'text', text: JSON.stringify(testCase, null, 2) }] };
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
logger.toolError('create_test_case', error, Date.now() - start);
|
|
44
|
+
throw handleExternalServiceError(error, 'DebuggAI', 'create_test_case');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Logger } from '../utils/logger.js';
|
|
2
|
+
import { handleExternalServiceError } from '../utils/errors.js';
|
|
3
|
+
import { DebuggAIServerClient } from '../services/index.js';
|
|
4
|
+
import { config } from '../config/index.js';
|
|
5
|
+
import { resolveProject } from '../utils/resolveProject.js';
|
|
6
|
+
const logger = new Logger({ module: 'createTestSuiteHandler' });
|
|
7
|
+
function errorResp(error, message, extra = {}) {
|
|
8
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error, message, ...extra }, null, 2) }], isError: true };
|
|
9
|
+
}
|
|
10
|
+
export async function createTestSuiteHandler(input, _context) {
|
|
11
|
+
const start = Date.now();
|
|
12
|
+
logger.toolStart('create_test_suite', input);
|
|
13
|
+
try {
|
|
14
|
+
const client = new DebuggAIServerClient(config.api.key);
|
|
15
|
+
await client.init();
|
|
16
|
+
let projectUuid = input.projectUuid;
|
|
17
|
+
if (!projectUuid) {
|
|
18
|
+
const resolved = await resolveProject(client, input.projectName);
|
|
19
|
+
if ('error' in resolved)
|
|
20
|
+
return errorResp(resolved.error, resolved.message, { candidates: resolved.candidates });
|
|
21
|
+
projectUuid = resolved.uuid;
|
|
22
|
+
}
|
|
23
|
+
const suite = await client.createTestSuite({ name: input.name, description: input.description, projectUuid });
|
|
24
|
+
logger.toolComplete('create_test_suite', Date.now() - start);
|
|
25
|
+
return { content: [{ type: 'text', text: JSON.stringify(suite, null, 2) }] };
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
logger.toolError('create_test_suite', error, Date.now() - start);
|
|
29
|
+
throw handleExternalServiceError(error, 'DebuggAI', 'create_test_suite');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Logger } from '../utils/logger.js';
|
|
2
|
+
import { handleExternalServiceError } from '../utils/errors.js';
|
|
3
|
+
import { DebuggAIServerClient } from '../services/index.js';
|
|
4
|
+
import { config } from '../config/index.js';
|
|
5
|
+
const logger = new Logger({ module: 'deleteTestCaseHandler' });
|
|
6
|
+
export async function deleteTestCaseHandler(input, _context) {
|
|
7
|
+
const start = Date.now();
|
|
8
|
+
logger.toolStart('delete_test_case', input);
|
|
9
|
+
try {
|
|
10
|
+
const client = new DebuggAIServerClient(config.api.key);
|
|
11
|
+
await client.init();
|
|
12
|
+
await client.disableTestCase(input.testUuid);
|
|
13
|
+
logger.toolComplete('delete_test_case', Date.now() - start);
|
|
14
|
+
return { content: [{ type: 'text', text: JSON.stringify({ deleted: true, testUuid: input.testUuid }, null, 2) }] };
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
logger.toolError('delete_test_case', error, Date.now() - start);
|
|
18
|
+
throw handleExternalServiceError(error, 'DebuggAI', 'delete_test_case');
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Logger } from '../utils/logger.js';
|
|
2
|
+
import { handleExternalServiceError } from '../utils/errors.js';
|
|
3
|
+
import { DebuggAIServerClient } from '../services/index.js';
|
|
4
|
+
import { config } from '../config/index.js';
|
|
5
|
+
import { resolveProject, resolveTestSuite } from '../utils/resolveProject.js';
|
|
6
|
+
const logger = new Logger({ module: 'deleteTestSuiteHandler' });
|
|
7
|
+
function errorResp(error, message, extra = {}) {
|
|
8
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error, message, ...extra }, null, 2) }], isError: true };
|
|
9
|
+
}
|
|
10
|
+
export async function deleteTestSuiteHandler(input, _context) {
|
|
11
|
+
const start = Date.now();
|
|
12
|
+
logger.toolStart('delete_test_suite', input);
|
|
13
|
+
try {
|
|
14
|
+
const client = new DebuggAIServerClient(config.api.key);
|
|
15
|
+
await client.init();
|
|
16
|
+
let suiteUuid = input.suiteUuid;
|
|
17
|
+
if (!suiteUuid) {
|
|
18
|
+
let projectUuid = input.projectUuid;
|
|
19
|
+
if (!projectUuid) {
|
|
20
|
+
const resolved = await resolveProject(client, input.projectName);
|
|
21
|
+
if ('error' in resolved)
|
|
22
|
+
return errorResp(resolved.error, resolved.message, { candidates: resolved.candidates });
|
|
23
|
+
projectUuid = resolved.uuid;
|
|
24
|
+
}
|
|
25
|
+
const resolved = await resolveTestSuite(client, input.suiteName, projectUuid);
|
|
26
|
+
if ('error' in resolved)
|
|
27
|
+
return errorResp(resolved.error, resolved.message, { candidates: resolved.candidates });
|
|
28
|
+
suiteUuid = resolved.uuid;
|
|
29
|
+
}
|
|
30
|
+
await client.disableTestSuite(suiteUuid);
|
|
31
|
+
logger.toolComplete('delete_test_suite', Date.now() - start);
|
|
32
|
+
return { content: [{ type: 'text', text: JSON.stringify({ deleted: true, suiteUuid }, null, 2) }] };
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
logger.toolError('delete_test_suite', error, Date.now() - start);
|
|
36
|
+
throw handleExternalServiceError(error, 'DebuggAI', 'delete_test_suite');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Logger } from '../utils/logger.js';
|
|
2
|
+
import { handleExternalServiceError } from '../utils/errors.js';
|
|
3
|
+
import { DebuggAIServerClient } from '../services/index.js';
|
|
4
|
+
import { config } from '../config/index.js';
|
|
5
|
+
import { resolveProject, resolveTestSuite } from '../utils/resolveProject.js';
|
|
6
|
+
const logger = new Logger({ module: 'getTestSuiteResultsHandler' });
|
|
7
|
+
function errorResp(error, message, extra = {}) {
|
|
8
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error, message, ...extra }, null, 2) }], isError: true };
|
|
9
|
+
}
|
|
10
|
+
export async function getTestSuiteResultsHandler(input, _context) {
|
|
11
|
+
const start = Date.now();
|
|
12
|
+
logger.toolStart('get_test_suite_results', input);
|
|
13
|
+
try {
|
|
14
|
+
const client = new DebuggAIServerClient(config.api.key);
|
|
15
|
+
await client.init();
|
|
16
|
+
let suiteUuid = input.suiteUuid;
|
|
17
|
+
if (!suiteUuid) {
|
|
18
|
+
let projectUuid = input.projectUuid;
|
|
19
|
+
if (!projectUuid) {
|
|
20
|
+
const resolved = await resolveProject(client, input.projectName);
|
|
21
|
+
if ('error' in resolved)
|
|
22
|
+
return errorResp(resolved.error, resolved.message, { candidates: resolved.candidates });
|
|
23
|
+
projectUuid = resolved.uuid;
|
|
24
|
+
}
|
|
25
|
+
const resolved = await resolveTestSuite(client, input.suiteName, projectUuid);
|
|
26
|
+
if ('error' in resolved)
|
|
27
|
+
return errorResp(resolved.error, resolved.message, { candidates: resolved.candidates });
|
|
28
|
+
suiteUuid = resolved.uuid;
|
|
29
|
+
}
|
|
30
|
+
const detail = await client.getTestSuiteDetail(suiteUuid);
|
|
31
|
+
logger.toolComplete('get_test_suite_results', Date.now() - start);
|
|
32
|
+
return { content: [{ type: 'text', text: JSON.stringify(detail, null, 2) }] };
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
logger.toolError('get_test_suite_results', error, Date.now() - start);
|
|
36
|
+
throw handleExternalServiceError(error, 'DebuggAI', 'get_test_suite_results');
|
|
37
|
+
}
|
|
38
|
+
}
|
package/dist/handlers/index.js
CHANGED
|
@@ -11,3 +11,11 @@ export * from './deleteEnvironmentHandler.js';
|
|
|
11
11
|
export * from './updateProjectHandler.js';
|
|
12
12
|
export * from './deleteProjectHandler.js';
|
|
13
13
|
export * from './createProjectHandler.js';
|
|
14
|
+
export * from './createTestSuiteHandler.js';
|
|
15
|
+
export * from './searchTestSuitesHandler.js';
|
|
16
|
+
export * from './deleteTestSuiteHandler.js';
|
|
17
|
+
export * from './createTestCaseHandler.js';
|
|
18
|
+
export * from './updateTestCaseHandler.js';
|
|
19
|
+
export * from './deleteTestCaseHandler.js';
|
|
20
|
+
export * from './runTestSuiteHandler.js';
|
|
21
|
+
export * from './getTestSuiteResultsHandler.js';
|
|
@@ -89,53 +89,60 @@ export async function probePageHandler(input, context, rawProgressCallback) {
|
|
|
89
89
|
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
targetContexts.push(
|
|
92
|
+
if (config.devMode) {
|
|
93
|
+
// Dev mode: local backend can reach localhost directly — no tunnel needed.
|
|
94
|
+
logger.info(`probe_page: dev mode — using localhost URL directly: ${ctx.originalUrl}`);
|
|
95
|
+
targetContexts.push(ctx);
|
|
96
96
|
}
|
|
97
97
|
else {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
98
|
+
// Reuse existing tunnel for this port if any; otherwise provision.
|
|
99
|
+
const reused = findExistingTunnel(ctx);
|
|
100
|
+
if (reused) {
|
|
101
|
+
targetContexts.push(reused);
|
|
101
102
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
103
|
+
else {
|
|
104
|
+
let tunnel;
|
|
105
|
+
try {
|
|
106
|
+
tunnel = await client.tunnels.provisionWithRetry();
|
|
107
|
+
}
|
|
108
|
+
catch (provisionError) {
|
|
109
|
+
const msg = provisionError instanceof Error ? provisionError.message : String(provisionError);
|
|
110
|
+
const diag = provisionError instanceof TunnelProvisionError ? ` ${provisionError.diagnosticSuffix()}` : '';
|
|
111
|
+
throw new Error(`Failed to provision tunnel for ${ctx.originalUrl}. ` +
|
|
112
|
+
`(Detail: ${msg})${diag}`);
|
|
113
|
+
}
|
|
114
|
+
acquiredKeyIds.push(tunnel.keyId);
|
|
115
|
+
let tunneled;
|
|
116
|
+
try {
|
|
117
|
+
tunneled = await ensureTunnel(ctx, tunnel.tunnelKey, tunnel.tunnelId, tunnel.keyId, () => client.revokeNgrokKey(tunnel.keyId));
|
|
118
|
+
}
|
|
119
|
+
catch (tunnelError) {
|
|
120
|
+
const msg = tunnelError instanceof Error ? tunnelError.message : String(tunnelError);
|
|
121
|
+
throw new Error(`Tunnel creation failed for ${ctx.originalUrl}. (Detail: ${msg})`);
|
|
122
|
+
}
|
|
123
|
+
// Tunnel health probe: catch the IPv4/IPv6 bind / dead-server case
|
|
124
|
+
// before committing to a full backend execution.
|
|
125
|
+
if (tunneled.targetUrl) {
|
|
126
|
+
const health = await probeTunnelHealth(tunneled.targetUrl);
|
|
127
|
+
if (!health.healthy) {
|
|
128
|
+
const payload = {
|
|
129
|
+
error: 'TunnelTrafficBlocked',
|
|
130
|
+
message: `Tunnel established but traffic isn't reaching the dev server. ${health.detail ?? ''}`,
|
|
131
|
+
detail: {
|
|
132
|
+
code: health.code,
|
|
133
|
+
status: health.status,
|
|
134
|
+
ngrokErrorCode: health.ngrokErrorCode,
|
|
135
|
+
elapsedMs: health.elapsedMs,
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
if (tunneled.tunnelId) {
|
|
139
|
+
tunnelManager.stopTunnel(tunneled.tunnelId).catch((err) => logger.warn(`Failed to stop broken tunnel ${tunneled.tunnelId}: ${err}`));
|
|
140
|
+
}
|
|
141
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
|
|
134
142
|
}
|
|
135
|
-
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], isError: true };
|
|
136
143
|
}
|
|
144
|
+
targetContexts.push(tunneled);
|
|
137
145
|
}
|
|
138
|
-
targetContexts.push(tunneled);
|
|
139
146
|
}
|
|
140
147
|
}
|
|
141
148
|
else {
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { Logger } from '../utils/logger.js';
|
|
2
|
+
import { handleExternalServiceError } from '../utils/errors.js';
|
|
3
|
+
import { DebuggAIServerClient } from '../services/index.js';
|
|
4
|
+
import { TunnelProvisionError } from '../services/tunnels.js';
|
|
5
|
+
import { tunnelManager } from '../services/ngrok/tunnelManager.js';
|
|
6
|
+
import { probeLocalPort, probeTunnelHealth } from '../utils/localReachability.js';
|
|
7
|
+
import { extractLocalhostPort } from '../utils/urlParser.js';
|
|
8
|
+
import { buildContext, findExistingTunnel, ensureTunnel } from '../utils/tunnelContext.js';
|
|
9
|
+
import { config } from '../config/index.js';
|
|
10
|
+
import { resolveProject, resolveTestSuite } from '../utils/resolveProject.js';
|
|
11
|
+
const logger = new Logger({ module: 'runTestSuiteHandler' });
|
|
12
|
+
function errorResp(error, message, extra = {}) {
|
|
13
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error, message, ...extra }, null, 2) }], isError: true };
|
|
14
|
+
}
|
|
15
|
+
export async function runTestSuiteHandler(input, _context) {
|
|
16
|
+
const start = Date.now();
|
|
17
|
+
logger.toolStart('run_test_suite', input);
|
|
18
|
+
const client = new DebuggAIServerClient(config.api.key);
|
|
19
|
+
await client.init();
|
|
20
|
+
let acquiredKeyId = null;
|
|
21
|
+
let tunnelId;
|
|
22
|
+
try {
|
|
23
|
+
let suiteUuid = input.suiteUuid;
|
|
24
|
+
if (!suiteUuid) {
|
|
25
|
+
let projectUuid = input.projectUuid;
|
|
26
|
+
if (!projectUuid) {
|
|
27
|
+
const resolved = await resolveProject(client, input.projectName);
|
|
28
|
+
if ('error' in resolved)
|
|
29
|
+
return errorResp(resolved.error, resolved.message, { candidates: resolved.candidates });
|
|
30
|
+
projectUuid = resolved.uuid;
|
|
31
|
+
}
|
|
32
|
+
const resolved = await resolveTestSuite(client, input.suiteName, projectUuid);
|
|
33
|
+
if ('error' in resolved)
|
|
34
|
+
return errorResp(resolved.error, resolved.message, { candidates: resolved.candidates });
|
|
35
|
+
suiteUuid = resolved.uuid;
|
|
36
|
+
}
|
|
37
|
+
// Resolve the effective target URL — tunnel if localhost, pass-through otherwise.
|
|
38
|
+
let effectiveTargetUrl = input.targetUrl;
|
|
39
|
+
if (input.targetUrl) {
|
|
40
|
+
const ctx = buildContext(input.targetUrl);
|
|
41
|
+
if (ctx.isLocalhost) {
|
|
42
|
+
const port = extractLocalhostPort(ctx.originalUrl);
|
|
43
|
+
if (typeof port === 'number') {
|
|
44
|
+
const probe = await probeLocalPort(port);
|
|
45
|
+
if (!probe.reachable) {
|
|
46
|
+
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 });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (config.devMode) {
|
|
50
|
+
// Dev mode: local backend can reach localhost directly — no tunnel needed.
|
|
51
|
+
logger.info(`run_test_suite: dev mode — using localhost URL directly: ${input.targetUrl}`);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
// Reuse an existing tunnel for this port if one is already active.
|
|
55
|
+
const reused = findExistingTunnel(ctx);
|
|
56
|
+
if (reused) {
|
|
57
|
+
effectiveTargetUrl = reused.targetUrl ?? input.targetUrl;
|
|
58
|
+
tunnelId = reused.tunnelId;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
// Provision a new tunnel.
|
|
62
|
+
let tunnel;
|
|
63
|
+
try {
|
|
64
|
+
tunnel = await client.tunnels.provisionWithRetry();
|
|
65
|
+
}
|
|
66
|
+
catch (provisionError) {
|
|
67
|
+
const msg = provisionError instanceof Error ? provisionError.message : String(provisionError);
|
|
68
|
+
const diag = provisionError instanceof TunnelProvisionError ? ` ${provisionError.diagnosticSuffix()}` : '';
|
|
69
|
+
return errorResp('TunnelProvisionFailed', `Failed to provision tunnel for ${input.targetUrl}. (Detail: ${msg})${diag}`);
|
|
70
|
+
}
|
|
71
|
+
acquiredKeyId = tunnel.keyId;
|
|
72
|
+
let tunneled;
|
|
73
|
+
try {
|
|
74
|
+
tunneled = await ensureTunnel(ctx, tunnel.tunnelKey, tunnel.tunnelId, tunnel.keyId, () => client.revokeNgrokKey(tunnel.keyId));
|
|
75
|
+
}
|
|
76
|
+
catch (tunnelError) {
|
|
77
|
+
const msg = tunnelError instanceof Error ? tunnelError.message : String(tunnelError);
|
|
78
|
+
return errorResp('TunnelCreationFailed', `Tunnel creation failed for ${input.targetUrl}. (Detail: ${msg})`);
|
|
79
|
+
}
|
|
80
|
+
// Health probe — catches ERR_NGROK_8012 and bind mismatches before
|
|
81
|
+
// the remote agent wastes steps trying to reach the server.
|
|
82
|
+
if (tunneled.targetUrl) {
|
|
83
|
+
const health = await probeTunnelHealth(tunneled.targetUrl);
|
|
84
|
+
if (!health.healthy) {
|
|
85
|
+
if (tunneled.tunnelId) {
|
|
86
|
+
tunnelManager.stopTunnel(tunneled.tunnelId).catch((err) => logger.warn(`Failed to stop broken tunnel ${tunneled.tunnelId}: ${err}`));
|
|
87
|
+
}
|
|
88
|
+
return errorResp('TunnelTrafficBlocked', `Tunnel established but traffic isn't reaching the dev server. ${health.detail ?? ''}`, { code: health.code, ngrokErrorCode: health.ngrokErrorCode, elapsedMs: health.elapsedMs });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
effectiveTargetUrl = tunneled.targetUrl ?? input.targetUrl;
|
|
92
|
+
tunnelId = tunneled.tunnelId;
|
|
93
|
+
}
|
|
94
|
+
logger.info(`run_test_suite: localhost detected, tunneled ${input.targetUrl} → ${effectiveTargetUrl}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const result = await client.runTestSuite(suiteUuid, { targetUrl: effectiveTargetUrl });
|
|
99
|
+
logger.toolComplete('run_test_suite', Date.now() - start);
|
|
100
|
+
return {
|
|
101
|
+
content: [{
|
|
102
|
+
type: 'text',
|
|
103
|
+
text: JSON.stringify({
|
|
104
|
+
...result,
|
|
105
|
+
...(tunnelId ? { tunnelActive: true, originalUrl: input.targetUrl } : {}),
|
|
106
|
+
note: 'Tests are running asynchronously. Use get_test_suite_results to check progress.',
|
|
107
|
+
}, null, 2),
|
|
108
|
+
}],
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
logger.toolError('run_test_suite', error, Date.now() - start);
|
|
113
|
+
throw handleExternalServiceError(error, 'DebuggAI', 'run_test_suite');
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
// Tunnels are NOT torn down — reuse pattern + 55-min idle auto-shutoff.
|
|
117
|
+
// Only revoke an orphaned key (acquired but tunnel creation failed).
|
|
118
|
+
if (acquiredKeyId && !tunnelId) {
|
|
119
|
+
client.revokeNgrokKey(acquiredKeyId).catch((err) => logger.warn(`Failed to revoke unused ngrok key ${acquiredKeyId}: ${err}`));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Logger } from '../utils/logger.js';
|
|
2
|
+
import { handleExternalServiceError } from '../utils/errors.js';
|
|
3
|
+
import { DebuggAIServerClient } from '../services/index.js';
|
|
4
|
+
import { config } from '../config/index.js';
|
|
5
|
+
import { resolveProject } from '../utils/resolveProject.js';
|
|
6
|
+
const logger = new Logger({ module: 'searchTestSuitesHandler' });
|
|
7
|
+
function errorResp(error, message, extra = {}) {
|
|
8
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error, message, ...extra }, null, 2) }], isError: true };
|
|
9
|
+
}
|
|
10
|
+
export async function searchTestSuitesHandler(input, _context) {
|
|
11
|
+
const start = Date.now();
|
|
12
|
+
logger.toolStart('search_test_suites', input);
|
|
13
|
+
try {
|
|
14
|
+
const client = new DebuggAIServerClient(config.api.key);
|
|
15
|
+
await client.init();
|
|
16
|
+
let projectUuid = input.projectUuid;
|
|
17
|
+
if (!projectUuid) {
|
|
18
|
+
const resolved = await resolveProject(client, input.projectName);
|
|
19
|
+
if ('error' in resolved)
|
|
20
|
+
return errorResp(resolved.error, resolved.message, { candidates: resolved.candidates });
|
|
21
|
+
projectUuid = resolved.uuid;
|
|
22
|
+
}
|
|
23
|
+
const result = await client.listTestSuites({
|
|
24
|
+
projectUuid,
|
|
25
|
+
search: input.search,
|
|
26
|
+
page: input.page,
|
|
27
|
+
pageSize: input.pageSize,
|
|
28
|
+
});
|
|
29
|
+
logger.toolComplete('search_test_suites', Date.now() - start);
|
|
30
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
logger.toolError('search_test_suites', error, Date.now() - start);
|
|
34
|
+
throw handleExternalServiceError(error, 'DebuggAI', 'search_test_suites');
|
|
35
|
+
}
|
|
36
|
+
}
|