@skyramp/mcp 0.0.64-rc.9 → 0.0.64
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/build/index.js +2 -0
- package/build/prompts/test-maintenance/drift-analysis-prompt.js +26 -7
- package/build/prompts/test-maintenance/driftAnalysisSections.js +96 -34
- package/build/prompts/test-maintenance/enhanceAssertionSection.js +99 -0
- package/build/prompts/test-recommendation/recommendationSections.js +24 -9
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +96 -27
- package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +239 -2
- package/build/prompts/testbot/testbot-prompts.js +182 -125
- package/build/services/TestDiscoveryService.js +23 -0
- package/build/services/TestExecutionService.js +1 -1
- package/build/services/TestGenerationService.js +83 -12
- package/build/services/TestGenerationService.test.js +111 -2
- package/build/tool-phase-coverage.test.js +8 -2
- package/build/tool-phases.js +11 -13
- package/build/tools/generate-tests/generateBatchScenarioRestTool.js +203 -0
- package/build/tools/generate-tests/generateContractRestTool.js +3 -73
- package/build/tools/generate-tests/generateIntegrationRestTool.js +11 -61
- package/build/tools/submitReportTool.js +11 -3
- package/build/tools/submitReportTool.test.js +1 -1
- package/build/tools/test-management/analyzeChangesTool.js +14 -4
- package/build/types/RepositoryAnalysis.js +1 -0
- package/build/utils/scenarioDrafting.js +121 -11
- package/build/utils/scenarioDrafting.test.js +266 -3
- package/node_modules/playwright/ThirdPartyNotices.txt +679 -3093
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +117 -11
- package/package.json +2 -2
- package/build/tools/test-recommendation/recommendTestsTool.js +0 -274
|
@@ -102,7 +102,10 @@ class TraceRecordingBackend {
|
|
|
102
102
|
rootPath: this._outputDir,
|
|
103
103
|
harPath: this._harPath
|
|
104
104
|
});
|
|
105
|
-
|
|
105
|
+
const exportResult = await handler(parsed);
|
|
106
|
+
if (!exportResult.isError)
|
|
107
|
+
this._trackedActions = [];
|
|
108
|
+
return exportResult;
|
|
106
109
|
}
|
|
107
110
|
if (name === import_assertTool.assertToolSchema.name) {
|
|
108
111
|
const parsed = import_assertTool.assertToolSchema.inputSchema.parse(args || {});
|
|
@@ -149,6 +152,36 @@ class TraceRecordingBackend {
|
|
|
149
152
|
const result = await this._browserBackend.callTool("browser_select_option", args);
|
|
150
153
|
const resultText = result.content?.[0]?.type === "text" ? result.content[0].text : "";
|
|
151
154
|
if (!result.isError) {
|
|
155
|
+
const code = (0, import_response.parseResponse)(result)?.code ?? "";
|
|
156
|
+
const hasCssSelector = code.includes("page.locator('select") || code.includes('page.locator("select') || code.includes(".selectOption(") && !code.includes("getByTestId") && !code.includes("getByRole") && !code.includes("getByLabel");
|
|
157
|
+
if (hasCssSelector && args.ref) {
|
|
158
|
+
traceDebug("selectOption used CSS selector, trying to resolve a better one via hover");
|
|
159
|
+
const hoverResult = await this._browserBackend.callTool("browser_hover", {
|
|
160
|
+
element: args.element || "select",
|
|
161
|
+
ref: args.ref
|
|
162
|
+
});
|
|
163
|
+
if (!hoverResult.isError) {
|
|
164
|
+
const hoverCode = (0, import_response.parseResponse)(hoverResult)?.code ?? "";
|
|
165
|
+
const locatorMatch = hoverCode.match(/await\s+page\.(.*?)\.hover\(\)/s);
|
|
166
|
+
if (locatorMatch) {
|
|
167
|
+
const locatorExpr = locatorMatch[1].trim();
|
|
168
|
+
const parsed = this._codeToLocator(locatorExpr);
|
|
169
|
+
if (parsed && parsed.locator.kind !== "text") {
|
|
170
|
+
const values2 = args.values || [];
|
|
171
|
+
const selectCode = `await page.${locatorExpr}.selectOption(${JSON.stringify(values2.length === 1 ? values2[0] : values2)});`;
|
|
172
|
+
traceDebug(`Improved select selector: ${selectCode}`);
|
|
173
|
+
const timestamp = Date.now();
|
|
174
|
+
this._trackedActions.push({
|
|
175
|
+
toolName: "browser_select_option",
|
|
176
|
+
args,
|
|
177
|
+
code: selectCode,
|
|
178
|
+
timestamp
|
|
179
|
+
});
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
152
185
|
this._maybeTrackAction("browser_select_option", args, result);
|
|
153
186
|
return result;
|
|
154
187
|
}
|
|
@@ -232,18 +265,26 @@ Could not resolve element ref=${params.ref}. ${errText}` }], isError: true };
|
|
|
232
265
|
Could not extract selector from hover result.` }], isError: true };
|
|
233
266
|
}
|
|
234
267
|
const locatorExpr = locatorMatch[1].trim();
|
|
235
|
-
|
|
268
|
+
let parsed = this._codeToLocator(locatorExpr);
|
|
236
269
|
if (!parsed) {
|
|
237
270
|
return { content: [{ type: "text", text: `### Assertion Failed
|
|
238
271
|
Could not parse locator: ${locatorExpr}` }], isError: true };
|
|
239
272
|
}
|
|
240
273
|
const snapResult = await this._browserBackend.callTool("browser_snapshot", {});
|
|
241
274
|
const snapText = snapResult.content?.map((c) => c.type === "text" ? c.text : "").join("") || "";
|
|
242
|
-
const
|
|
275
|
+
const snapLines = snapText.split("\n");
|
|
276
|
+
const refLine = snapLines.find((l) => l.includes(`[ref=${params.ref}]`)) || "";
|
|
243
277
|
const textMatch = refLine.match(/^\s*-\s*\w+\s+"([^"]*)"/);
|
|
244
278
|
const elementText = textMatch?.[1] || "";
|
|
245
279
|
const valueMatch = refLine.match(/\]:\s*(.+)$/);
|
|
246
280
|
const elementValue = valueMatch?.[1]?.trim() || "";
|
|
281
|
+
if (parsed.locator.kind === "text") {
|
|
282
|
+
const improved = await this._improveTextSelector(snapLines, params.ref, parsed);
|
|
283
|
+
if (improved) {
|
|
284
|
+
traceDebug(`Improved ambiguous text selector to: ${improved.selector}`);
|
|
285
|
+
parsed = improved;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
247
288
|
let passed = false;
|
|
248
289
|
let details = "";
|
|
249
290
|
if (params.type === "text") {
|
|
@@ -254,14 +295,19 @@ Could not parse locator: ${locatorExpr}` }], isError: true };
|
|
|
254
295
|
details = passed ? `Value assertion passed: "${params.element}" has value "${params.expected}".` : `Value assertion FAILED: "${params.element}" has value "${elementValue}", expected "${params.expected}".`;
|
|
255
296
|
}
|
|
256
297
|
if (passed) {
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
298
|
+
const selectorText = parsed.locator.kind === "text" ? parsed.locator.body : parsed.locator.next?.kind === "text" ? parsed.locator.next.body : parsed.locator.options?.name ?? null;
|
|
299
|
+
if (selectorText && selectorText === params.expected) {
|
|
300
|
+
traceDebug(`Skipped tautological assertion: selector already matches "${params.expected}"`);
|
|
301
|
+
} else {
|
|
302
|
+
const assertName = params.type === "value" ? "assertValue" : "assertText";
|
|
303
|
+
this._trackedActions.push({
|
|
304
|
+
toolName: "browser_assert",
|
|
305
|
+
args: { type: params.type, ref: params.ref, expected: params.expected },
|
|
306
|
+
code: `${assertName}:${parsed.selector}:${params.expected}${params.type === "text" ? ":" + params.substring : ""}`,
|
|
307
|
+
timestamp
|
|
308
|
+
});
|
|
309
|
+
traceDebug(`Assert: ${assertName} with selector ${parsed.selector}`);
|
|
310
|
+
}
|
|
265
311
|
}
|
|
266
312
|
return {
|
|
267
313
|
content: [{ type: "text", text: `### ${passed ? "Assertion Passed" : "Assertion Failed"}
|
|
@@ -304,6 +350,66 @@ ${details}` }]
|
|
|
304
350
|
}
|
|
305
351
|
return null;
|
|
306
352
|
}
|
|
353
|
+
/**
|
|
354
|
+
* When hover resolves to a text-based locator (e.g. getByText("$849.98")),
|
|
355
|
+
* check if that text appears multiple times in the snapshot (ambiguous) and
|
|
356
|
+
* hover ancestor refs to find a parent with a test-id, producing a chained
|
|
357
|
+
* selector like `testid >> text` that uniquely identifies the element.
|
|
358
|
+
*/
|
|
359
|
+
async _improveTextSelector(snapLines, ref, original) {
|
|
360
|
+
const textBody = original.locator.body;
|
|
361
|
+
const occurrences = snapLines.filter((l) => l.includes(textBody)).length;
|
|
362
|
+
if (occurrences <= 1)
|
|
363
|
+
return null;
|
|
364
|
+
traceDebug(`Text "${textBody}" appears ${occurrences} times in snapshot \u2014 looking for parent test-id`);
|
|
365
|
+
const refPattern = `[ref=${ref}]`;
|
|
366
|
+
const refIdx = snapLines.findIndex((l) => l.includes(refPattern));
|
|
367
|
+
if (refIdx < 0)
|
|
368
|
+
return null;
|
|
369
|
+
const refIndent = snapLines[refIdx].match(/^(\s*)/)?.[1]?.length ?? 0;
|
|
370
|
+
for (let i = refIdx - 1; i >= 0; i--) {
|
|
371
|
+
const line = snapLines[i];
|
|
372
|
+
const indent = line.match(/^(\s*)/)?.[1]?.length ?? 0;
|
|
373
|
+
if (indent >= refIndent)
|
|
374
|
+
continue;
|
|
375
|
+
const ancestorRefMatch = line.match(/\[ref=(\w+)\]/);
|
|
376
|
+
if (!ancestorRefMatch)
|
|
377
|
+
continue;
|
|
378
|
+
const ancestorRef = ancestorRefMatch[1];
|
|
379
|
+
try {
|
|
380
|
+
const ancestorHover = await this._browserBackend.callTool("browser_hover", {
|
|
381
|
+
element: "parent element",
|
|
382
|
+
ref: ancestorRef
|
|
383
|
+
});
|
|
384
|
+
if (!ancestorHover.isError) {
|
|
385
|
+
const ancestorCode = (0, import_response.parseResponse)(ancestorHover)?.code ?? "";
|
|
386
|
+
const ancestorLocatorMatch = ancestorCode.match(/await\s+page\.(.*?)\.hover\(\)/s);
|
|
387
|
+
if (ancestorLocatorMatch) {
|
|
388
|
+
const ancestorExpr = ancestorLocatorMatch[1].trim();
|
|
389
|
+
const testidMatch = ancestorExpr.match(/getByTestId\(\s*['"]([^'"]+)['"]\s*\)/);
|
|
390
|
+
if (testidMatch) {
|
|
391
|
+
const testid = testidMatch[1];
|
|
392
|
+
const chainedSelector = `internal:testid=[data-testid="${testid}"s] >> internal:text="${textBody}"i`;
|
|
393
|
+
traceDebug(`Improved to chained selector: ${chainedSelector}`);
|
|
394
|
+
return {
|
|
395
|
+
selector: chainedSelector,
|
|
396
|
+
locator: {
|
|
397
|
+
kind: "test-id",
|
|
398
|
+
body: testid,
|
|
399
|
+
options: {},
|
|
400
|
+
next: { kind: "text", body: textBody, options: { exact: false } }
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
} catch {
|
|
407
|
+
}
|
|
408
|
+
if (indent === 0)
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
307
413
|
static {
|
|
308
414
|
/** Extract selector and locator info from a snapshot line for assertion tracking. */
|
|
309
415
|
// Roles that map to valid Playwright getByRole() selectors.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyramp/mcp",
|
|
3
|
-
"version": "0.0.64
|
|
3
|
+
"version": "0.0.64",
|
|
4
4
|
"main": "build/index.js",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./build/index.js",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"dependencies": {
|
|
55
55
|
"@modelcontextprotocol/sdk": "^1.24.3",
|
|
56
56
|
"@playwright/test": "^1.55.0",
|
|
57
|
-
"@skyramp/skyramp": "1.3.
|
|
57
|
+
"@skyramp/skyramp": "1.3.17",
|
|
58
58
|
"dockerode": "^4.0.6",
|
|
59
59
|
"fast-glob": "^3.3.3",
|
|
60
60
|
"playwright": "file:vendor/skyramp-playwright-1.58.2-skyramp.8.9.0.tgz",
|
|
@@ -1,274 +0,0 @@
|
|
|
1
|
-
import { completable } from "@modelcontextprotocol/sdk/server/completable.js";
|
|
2
|
-
import { z } from "zod";
|
|
3
|
-
import { simpleGit } from "simple-git";
|
|
4
|
-
import { StateManager, getSessionFilePath, getRegisteredSessions, getSessionData, hasSessionData, normalizeRecommendationState, } from "../../utils/AnalysisStateManager.js";
|
|
5
|
-
import { logger } from "../../utils/logger.js";
|
|
6
|
-
import { ANALYSIS_URI_PREFIX } from "../../resources/analysisResources.js";
|
|
7
|
-
import { MAX_RECOMMENDATIONS, MAX_TESTS_TO_GENERATE } from "../../prompts/test-recommendation/recommendationSections.js";
|
|
8
|
-
import { AnalyticsService } from "../../services/AnalyticsService.js";
|
|
9
|
-
import { buildRecommendationPrompt } from "../../prompts/test-recommendation/test-recommendation-prompt.js";
|
|
10
|
-
import { repositoryAnalysisSchema } from "../../types/RepositoryAnalysis.js";
|
|
11
|
-
import { parsePRComments } from "../../utils/pr-comment-parser.js";
|
|
12
|
-
import { getWorkspaceAuthConfig } from "../../utils/workspaceAuth.js";
|
|
13
|
-
/**
|
|
14
|
-
* Extract GitHub owner/repo from the origin remote URL.
|
|
15
|
-
* Handles both SSH (git@github.com:owner/repo.git) and
|
|
16
|
-
* HTTPS (https://github.com/owner/repo.git) formats.
|
|
17
|
-
*/
|
|
18
|
-
async function getGitHubSlug(repoPath) {
|
|
19
|
-
try {
|
|
20
|
-
const git = simpleGit(repoPath);
|
|
21
|
-
const remotes = await git.getRemotes(true);
|
|
22
|
-
const origin = remotes.find((r) => r.name === "origin");
|
|
23
|
-
const url = origin?.refs?.fetch;
|
|
24
|
-
if (!url)
|
|
25
|
-
return null;
|
|
26
|
-
const sshMatch = url.match(/github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
|
27
|
-
if (sshMatch)
|
|
28
|
-
return { owner: sshMatch[1], repo: sshMatch[2] };
|
|
29
|
-
const httpsMatch = url.match(/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
|
30
|
-
if (httpsMatch)
|
|
31
|
-
return { owner: httpsMatch[1], repo: httpsMatch[2] };
|
|
32
|
-
return null;
|
|
33
|
-
}
|
|
34
|
-
catch {
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
const recommendTestsSchema = {
|
|
39
|
-
sessionId: completable(z.string().optional().describe("Session ID from skyramp_analyze_repository. Optional if prNumber and repositoryPath are provided."), async (value) => {
|
|
40
|
-
return Array.from(getRegisteredSessions().keys()).filter((id) => id.startsWith(value?.toString() || ""));
|
|
41
|
-
}),
|
|
42
|
-
repositoryPath: z
|
|
43
|
-
.string()
|
|
44
|
-
.optional()
|
|
45
|
-
.describe("Absolute path to the repository. Required if sessionId is missing."),
|
|
46
|
-
topN: z
|
|
47
|
-
.number()
|
|
48
|
-
.default(MAX_RECOMMENDATIONS)
|
|
49
|
-
.describe(`Number of recommendations. Defaults to ${MAX_RECOMMENDATIONS}.`),
|
|
50
|
-
prNumber: z
|
|
51
|
-
.number()
|
|
52
|
-
.optional()
|
|
53
|
-
.describe("GitHub PR number. When provided, fetches previous TestBot comments to use as baseline recommendations."),
|
|
54
|
-
};
|
|
55
|
-
const TOOL_NAME = "skyramp_recommend_tests";
|
|
56
|
-
export function registerRecommendTestsTool(server) {
|
|
57
|
-
server.registerTool(TOOL_NAME, {
|
|
58
|
-
description: `Generate actionable test recommendations based on enriched repository analysis.
|
|
59
|
-
|
|
60
|
-
**PREREQUISITE**: Call skyramp_analyze_repository first to get the sessionId.
|
|
61
|
-
|
|
62
|
-
This tool reads the enriched analysis (endpoints with interactions, drafted scenarios)
|
|
63
|
-
and generates prioritized test recommendations using LLM reasoning over the actual data.
|
|
64
|
-
|
|
65
|
-
For each recommended test, you'll get:
|
|
66
|
-
- Priority (high/medium/low) with reasoning based on actual endpoint data
|
|
67
|
-
- Specific test referencing concrete interactions (request→response pairs)
|
|
68
|
-
- For integration/E2E: references to draftedScenarios by scenarioName
|
|
69
|
-
- Which Skyramp tool to call for generation
|
|
70
|
-
|
|
71
|
-
**Output guidelines:**
|
|
72
|
-
- Use "high", "medium", or "low" for priority.
|
|
73
|
-
- Never mark a test as blocked — recommend it with instructions for missing artifacts.
|
|
74
|
-
- Reference specific interactions by description for contract/fuzz tests.
|
|
75
|
-
- Reference draftedScenarios by scenarioName for integration/E2E tests.
|
|
76
|
-
|
|
77
|
-
** This tool is currently in Early Preview stage. Please verify the results. **`,
|
|
78
|
-
inputSchema: recommendTestsSchema,
|
|
79
|
-
}, async (params) => {
|
|
80
|
-
let errorResult;
|
|
81
|
-
try {
|
|
82
|
-
logger.info("Recommend tests tool invoked", {
|
|
83
|
-
sessionId: params.sessionId,
|
|
84
|
-
topN: params.topN,
|
|
85
|
-
prNumber: params.prNumber,
|
|
86
|
-
});
|
|
87
|
-
let stateData = null;
|
|
88
|
-
let prContext = null;
|
|
89
|
-
let repositoryPath = params.repositoryPath || "";
|
|
90
|
-
let analysisScope = "current_branch_diff";
|
|
91
|
-
if (params.sessionId) {
|
|
92
|
-
// Load session data: try process memory first, then fall back to state file
|
|
93
|
-
if (hasSessionData(params.sessionId)) {
|
|
94
|
-
stateData = getSessionData(params.sessionId);
|
|
95
|
-
logger.info("Loaded analysis from process memory", { sessionId: params.sessionId });
|
|
96
|
-
}
|
|
97
|
-
else {
|
|
98
|
-
// Fall back to state file for backward compatibility
|
|
99
|
-
const registeredPath = getSessionFilePath(params.sessionId);
|
|
100
|
-
const stateManager = registeredPath
|
|
101
|
-
? StateManager.fromStatePath(registeredPath)
|
|
102
|
-
: StateManager.fromSessionId(params.sessionId);
|
|
103
|
-
if (stateManager.exists()) {
|
|
104
|
-
stateData = await stateManager.readData();
|
|
105
|
-
logger.info("Loaded analysis from state file (legacy)", { sessionId: params.sessionId });
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
if (!stateData) {
|
|
109
|
-
throw new Error(`Analysis session not found: ${params.sessionId}.`);
|
|
110
|
-
}
|
|
111
|
-
stateData = normalizeRecommendationState(stateData);
|
|
112
|
-
repositoryPath = stateData.repositoryPath;
|
|
113
|
-
analysisScope = stateData.analysisScope;
|
|
114
|
-
prContext = stateData.prContext;
|
|
115
|
-
}
|
|
116
|
-
else if (params.prNumber && params.repositoryPath) {
|
|
117
|
-
// No sessionId provided — fetch PR context directly to use as baseline
|
|
118
|
-
const slug = await getGitHubSlug(params.repositoryPath);
|
|
119
|
-
if (slug) {
|
|
120
|
-
try {
|
|
121
|
-
prContext = await parsePRComments(slug.owner, slug.repo, params.prNumber);
|
|
122
|
-
logger.info("Fetched PR context for baseline recommendations", { prNumber: params.prNumber });
|
|
123
|
-
}
|
|
124
|
-
catch (err) {
|
|
125
|
-
throw new Error(`Failed to fetch PR context for baseline: ${err instanceof Error ? err.message : String(err)}`);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
else {
|
|
129
|
-
throw new Error(`Could not determine GitHub repository slug for ${params.repositoryPath}`);
|
|
130
|
-
}
|
|
131
|
-
if (!prContext || prContext.previousRecommendations.length === 0) {
|
|
132
|
-
throw new Error(`No previous recommendations found for PR #${params.prNumber}. A full analysis is required first.`);
|
|
133
|
-
}
|
|
134
|
-
// Build a minimal "analysis" from the previous recommendations to satisfy the prompt builder
|
|
135
|
-
stateData = {
|
|
136
|
-
repositoryPath: params.repositoryPath,
|
|
137
|
-
analysisScope: "current_branch_diff",
|
|
138
|
-
analysis: {
|
|
139
|
-
metadata: { repositoryName: slug.repo, analysisDate: new Date().toISOString(), scanDepth: "full", analysisScope: "current_branch_diff" },
|
|
140
|
-
projectClassification: { projectType: "REST API", primaryLanguage: "unknown", primaryFramework: "unknown", deploymentPattern: "monolith" },
|
|
141
|
-
technologyStack: { languages: [], frameworks: [], runtime: "", keyDependencies: [] },
|
|
142
|
-
businessContext: { mainPurpose: "", userFlows: [], dataFlows: [], integrationPatterns: [], draftedScenarios: [] },
|
|
143
|
-
apiEndpoints: { totalCount: 0, baseUrl: "", endpoints: [] },
|
|
144
|
-
authentication: { method: "none", configLocation: "", envVarsRequired: [], setupExample: "" },
|
|
145
|
-
infrastructure: { isContainerized: false, hasDockerCompose: false, hasKubernetes: false, hasCiCd: false },
|
|
146
|
-
existingTests: { frameworks: [], coverage: { unit: 0, integration: 0, e2e: 0, ui: 0, load: 0, contract: 0, smoke: 0 }, testLocations: {}, hasCoverageReports: false },
|
|
147
|
-
},
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
else {
|
|
151
|
-
throw new Error("Either sessionId or (prNumber and repositoryPath) must be provided.");
|
|
152
|
-
}
|
|
153
|
-
if (!stateData) {
|
|
154
|
-
throw new Error(`Failed to read analysis session: ${params.sessionId}. Run skyramp_analyze_repository first.`);
|
|
155
|
-
}
|
|
156
|
-
stateData = normalizeRecommendationState(stateData);
|
|
157
|
-
analysisScope = stateData.analysisScope;
|
|
158
|
-
repositoryPath = stateData.repositoryPath;
|
|
159
|
-
const { analysis } = stateData;
|
|
160
|
-
if (!analysis) {
|
|
161
|
-
throw new Error("Session is missing analysis data.");
|
|
162
|
-
}
|
|
163
|
-
// Validate analysis against the Zod schema to catch malformed LLM output early
|
|
164
|
-
const parseResult = repositoryAnalysisSchema.safeParse(analysis);
|
|
165
|
-
if (!parseResult.success) {
|
|
166
|
-
const issues = parseResult.error.issues.slice(0, 5).map((i) => `${i.path.join(".")}: ${i.message}`);
|
|
167
|
-
logger.warning("Analysis data has schema issues (proceeding with best-effort)", {
|
|
168
|
-
issueCount: parseResult.error.issues.length,
|
|
169
|
-
sample: issues,
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
// Guard critical nested fields the prompt builder accesses
|
|
173
|
-
if (!analysis.apiEndpoints?.endpoints) {
|
|
174
|
-
analysis.apiEndpoints = { totalCount: 0, baseUrl: "", endpoints: [] };
|
|
175
|
-
}
|
|
176
|
-
if (!analysis.businessContext?.draftedScenarios) {
|
|
177
|
-
analysis.businessContext = {
|
|
178
|
-
...(analysis.businessContext || { mainPurpose: "", userFlows: [], dataFlows: [], integrationPatterns: [] }),
|
|
179
|
-
draftedScenarios: analysis.businessContext?.draftedScenarios || [],
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
if (!analysis.authentication) {
|
|
183
|
-
analysis.authentication = { method: "none", configLocation: "", envVarsRequired: [], setupExample: "" };
|
|
184
|
-
}
|
|
185
|
-
if (!analysis.existingTests) {
|
|
186
|
-
analysis.existingTests = {
|
|
187
|
-
frameworks: [], coverage: { unit: 0, integration: 0, e2e: 0, ui: 0, load: 0, contract: 0, smoke: 0 },
|
|
188
|
-
testLocations: {}, hasCoverageReports: false,
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
const scope = analysisScope || "full_repo";
|
|
192
|
-
const effectiveTopN = params.topN ?? MAX_RECOMMENDATIONS;
|
|
193
|
-
// Fetch PR context when prNumber is provided and in diff scope (if not already fetched)
|
|
194
|
-
if (!prContext && params.prNumber && scope === "current_branch_diff") {
|
|
195
|
-
const slug = await getGitHubSlug(repositoryPath);
|
|
196
|
-
if (slug) {
|
|
197
|
-
try {
|
|
198
|
-
prContext = await parsePRComments(slug.owner, slug.repo, params.prNumber);
|
|
199
|
-
}
|
|
200
|
-
catch (err) {
|
|
201
|
-
logger.warning("Failed to fetch PR context", {
|
|
202
|
-
error: err instanceof Error ? err.message : String(err),
|
|
203
|
-
});
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
else {
|
|
207
|
-
logger.debug("Could not determine GitHub owner/repo from git remotes — skipping PR context");
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
const wsAuthConfig = repositoryPath
|
|
211
|
-
? await getWorkspaceAuthConfig(repositoryPath)
|
|
212
|
-
: {};
|
|
213
|
-
const prompt = buildRecommendationPrompt(analysis, scope, effectiveTopN, prContext, wsAuthConfig.authHeader, wsAuthConfig.authType);
|
|
214
|
-
// H3: Mode-specific output header
|
|
215
|
-
const modeLabel = scope === "current_branch_diff" ? "PR Mode" : "Repo Mode";
|
|
216
|
-
const modeDesc = scope === "current_branch_diff"
|
|
217
|
-
? `Focused on branch changes. Top ${effectiveTopN} recommendations — top 4 will be generated, remaining reported.`
|
|
218
|
-
: `Comprehensive test strategy. Top ${effectiveTopN} tests across the entire application.`;
|
|
219
|
-
// J4: PR awareness messaging
|
|
220
|
-
let prAwareness = "";
|
|
221
|
-
if (prContext && prContext.previousRecommendations.length > 0) {
|
|
222
|
-
const implemented = prContext.previousRecommendations.filter((r) => r.status === "implemented").length;
|
|
223
|
-
const pending = prContext.previousRecommendations.filter((r) => r.status === "recommended").length;
|
|
224
|
-
prAwareness = `\n**PR History**: ${implemented} tests already implemented, ${pending} previously recommended.`;
|
|
225
|
-
if (implemented > 0) {
|
|
226
|
-
prAwareness += ` Building on existing coverage — new recommendations complement what\'s already been added.`;
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
// G4: Include totalConsidered instruction in output
|
|
230
|
-
const totalEndpointMethods = analysis.apiEndpoints.endpoints.reduce((acc, ep) => acc + (ep.methods?.length || 0), 0);
|
|
231
|
-
const totalInteractions = analysis.apiEndpoints.endpoints.reduce((acc, ep) => acc + (ep.methods || []).reduce((a2, m) => a2 + (m.interactions?.length || 0), 0), 0);
|
|
232
|
-
const totalScenarios = analysis.businessContext.draftedScenarios.length;
|
|
233
|
-
const resourcesSection = params.sessionId ? `
|
|
234
|
-
## Available Resources
|
|
235
|
-
- Summary: \`${ANALYSIS_URI_PREFIX}/${params.sessionId}/summary\`
|
|
236
|
-
- Endpoints: \`${ANALYSIS_URI_PREFIX}/${params.sessionId}/endpoints\`
|
|
237
|
-
- Scenarios: \`${ANALYSIS_URI_PREFIX}/${params.sessionId}/scenarios\`
|
|
238
|
-
` : '';
|
|
239
|
-
const output = `# Test Recommendations (${modeLabel})
|
|
240
|
-
|
|
241
|
-
${params.sessionId ? `**Session**: \`${params.sessionId}\`\n` : ''}**Repository**: \`${repositoryPath}\`
|
|
242
|
-
**Mode**: ${modeLabel} — ${modeDesc}${prAwareness}
|
|
243
|
-
**Catalog**: ${totalEndpointMethods} endpoint methods, ${totalInteractions} interactions, ${totalScenarios} scenarios
|
|
244
|
-
**Target**: Top ${effectiveTopN} recommendations ranked by value. Top ${MAX_TESTS_TO_GENERATE} = generate & execute. #${MAX_TESTS_TO_GENERATE + 1}-#${effectiveTopN} = report as additional recommendations.
|
|
245
|
-
${resourcesSection}
|
|
246
|
-
---
|
|
247
|
-
|
|
248
|
-
${prompt}
|
|
249
|
-
|
|
250
|
-
** This tool is currently in Early Preview stage. Please verify the results. **`;
|
|
251
|
-
return {
|
|
252
|
-
content: [{ type: "text", text: output }],
|
|
253
|
-
isError: false,
|
|
254
|
-
};
|
|
255
|
-
}
|
|
256
|
-
catch (error) {
|
|
257
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
258
|
-
logger.error("Recommend tests tool failed", { error: errorMessage });
|
|
259
|
-
errorResult = {
|
|
260
|
-
content: [
|
|
261
|
-
{
|
|
262
|
-
type: "text",
|
|
263
|
-
text: `Error generating recommendations: ${errorMessage}`,
|
|
264
|
-
},
|
|
265
|
-
],
|
|
266
|
-
isError: true,
|
|
267
|
-
};
|
|
268
|
-
return errorResult;
|
|
269
|
-
}
|
|
270
|
-
finally {
|
|
271
|
-
AnalyticsService.pushMCPToolEvent(TOOL_NAME, errorResult, {});
|
|
272
|
-
}
|
|
273
|
-
});
|
|
274
|
-
}
|