@skyramp/mcp 0.1.8 → 0.2.0-rc.2
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 +4 -2
- package/build/playwright/registerPlaywrightTools.js +12 -0
- package/build/playwright/traceRecordingPrompt.js +15 -0
- package/build/prompts/code-reuse.js +106 -7
- package/build/prompts/pom-aware-code-reuse.js +106 -7
- package/build/prompts/startTraceCollectionPrompts.js +37 -15
- package/build/prompts/test-maintenance/drift-analysis-prompt.js +26 -31
- package/build/prompts/test-maintenance/drift-analysis-prompt.test.js +40 -1
- package/build/prompts/test-maintenance/driftAnalysisSections.js +90 -86
- package/build/prompts/test-recommendation/analysisOutputPrompt.js +286 -163
- package/build/prompts/test-recommendation/analysisOutputPrompt.test.js +154 -45
- package/build/prompts/test-recommendation/diffExecutionPlan.js +246 -117
- package/build/prompts/test-recommendation/promptPlan.js +290 -0
- package/build/prompts/test-recommendation/promptPlan.test.js +336 -0
- package/build/prompts/test-recommendation/recommendationSections.js +4 -3
- package/build/prompts/test-recommendation/recommendationShared.js +23 -1
- package/build/prompts/test-recommendation/scopeAssessment.js +65 -14
- package/build/prompts/test-recommendation/scopeAssessment.test.js +93 -2
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +36 -12
- package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +316 -1
- package/build/prompts/testbot/testbot-prompts.js +73 -13
- package/build/prompts/testbot/testbot-prompts.test.js +114 -1
- package/build/resources/testbotResource.js +1 -1
- package/build/services/ScenarioGenerationService.integration.test.js +158 -0
- package/build/services/ScenarioGenerationService.js +47 -4
- package/build/services/ScenarioGenerationService.test.js +158 -22
- package/build/services/TestExecutionService.js +73 -15
- package/build/services/TestExecutionService.test.js +105 -0
- package/build/services/TestGenerationService.js +11 -1
- package/build/tools/executeSkyrampTestTool.js +1 -10
- package/build/tools/generate-tests/generateBatchScenarioRestTool.js +16 -4
- package/build/tools/generate-tests/generateIntegrationRestTool.js +2 -0
- package/build/tools/generate-tests/generateUIRestTool.js +2 -0
- package/build/tools/test-management/actionsTool.js +152 -63
- package/build/tools/test-management/analyzeChangesTool.js +178 -64
- package/build/tools/test-management/analyzeChangesTool.test.js +103 -16
- package/build/tools/test-management/analyzeTestHealthTool.js +30 -81
- package/build/tools/test-management/index.js +1 -0
- package/build/tools/test-management/uiAnalyzeChangesTool.js +149 -0
- package/build/tools/test-management/uiAnalyzeChangesTool.test.js +100 -0
- package/build/tools/trace/resolveSaveStoragePath.js +16 -0
- package/build/tools/trace/resolveSaveStoragePath.test.js +17 -0
- package/build/tools/trace/resolveSessionPaths.js +39 -0
- package/build/tools/trace/resolveSessionPaths.test.js +103 -0
- package/build/tools/trace/sessionState.js +14 -0
- package/build/tools/trace/sessionState.test.js +17 -0
- package/build/tools/trace/startTraceCollectionTool.js +84 -14
- package/build/tools/trace/stopTraceCollectionTool.js +9 -2
- package/build/types/TestAnalysis.js +50 -0
- package/build/types/TestRecommendation.js +6 -58
- package/build/types/TestTypes.js +1 -1
- package/build/utils/AnalysisStateManager.js +22 -11
- package/build/utils/branchDiff.js +11 -2
- package/build/utils/docker.test.js +1 -1
- package/build/utils/gitStaging.js +52 -3
- package/build/utils/gitStaging.test.js +19 -1
- package/build/utils/repoScanner.js +18 -10
- package/build/utils/repoScanner.test.js +92 -0
- package/build/utils/routeParsers.js +180 -25
- package/build/utils/routeParsers.test.js +180 -1
- package/build/utils/scenarioDrafting.js +220 -17
- package/build/utils/scenarioDrafting.test.js +182 -9
- package/build/utils/sourceRouteExtractor.js +806 -0
- package/build/utils/sourceRouteExtractor.test.js +565 -0
- package/build/utils/uiPageEnumerator.js +319 -0
- package/build/utils/uiPageEnumerator.test.js +422 -0
- package/build/utils/utils.js +27 -0
- package/build/utils/versions.js +1 -1
- package/build/utils/workspaceAuth.js +33 -4
- package/node_modules/playwright/ThirdPartyNotices.txt +6 -6
- package/node_modules/playwright/lib/dom-analyzer/analyze.js +111 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprint.js +1210 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprint.test.js +396 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintCache.js +57 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintCache.test.js +57 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.js +254 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.test.js +304 -0
- package/node_modules/playwright/lib/dom-analyzer/crawler.js +384 -0
- package/node_modules/playwright/lib/dom-analyzer/curatedWidgets.js +73 -0
- package/node_modules/playwright/lib/dom-analyzer/dynamicId.js +43 -0
- package/node_modules/playwright/lib/dom-analyzer/dynamicId.test.js +85 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprint.js +90 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprint.test.js +231 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprintAblation.fixtures.js +145 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprintAblation.test.js +41 -0
- package/node_modules/playwright/lib/dom-analyzer/graph.js +36 -0
- package/node_modules/playwright/lib/dom-analyzer/liveFingerprints.js +43 -0
- package/node_modules/playwright/lib/dom-analyzer/logicalNameResolver.js +72 -0
- package/node_modules/playwright/lib/dom-analyzer/logicalNameResolver.test.js +182 -0
- package/node_modules/playwright/lib/dom-analyzer/possibleAssertions.js +150 -0
- package/node_modules/playwright/lib/dom-analyzer/possibleAssertions.test.js +470 -0
- package/node_modules/playwright/lib/dom-analyzer/sectionGrouper.js +169 -0
- package/node_modules/playwright/lib/dom-analyzer/sectionGrouper.test.js +269 -0
- package/node_modules/playwright/lib/dom-analyzer/serialization.js +75 -0
- package/node_modules/playwright/lib/dom-analyzer/slug.js +30 -0
- package/node_modules/playwright/lib/dom-analyzer/slug.test.js +84 -0
- package/node_modules/playwright/lib/dom-analyzer/widgetContract.js +127 -0
- package/node_modules/playwright/lib/dom-analyzer/widgetContract.test.js +212 -0
- package/node_modules/playwright/lib/mcp/browser/browserContextFactory.js +3 -1
- package/node_modules/playwright/lib/mcp/browser/config.js +1 -1
- package/node_modules/playwright/lib/mcp/browser/context.js +17 -1
- package/node_modules/playwright/lib/mcp/browser/tab.js +38 -0
- package/node_modules/playwright/lib/mcp/browser/tools/domAnalyzer.js +261 -0
- package/node_modules/playwright/lib/mcp/browser/tools/keyboard.js +3 -3
- package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.js +146 -0
- package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.test.js +140 -0
- package/node_modules/playwright/lib/mcp/browser/tools/sitemap.js +226 -0
- package/node_modules/playwright/lib/mcp/browser/tools/snapshot.js +2 -2
- package/node_modules/playwright/lib/mcp/browser/tools/widgetContract.js +168 -0
- package/node_modules/playwright/lib/mcp/browser/tools.js +6 -0
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +52 -12
- package/node_modules/playwright/lib/mcp/test/skyRampExport.js +64 -13
- package/node_modules/playwright/package.json +1 -1
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.3.tgz +0 -0
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.4.tgz +0 -0
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.5.tgz +0 -0
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.6.tgz +0 -0
- package/package.json +3 -3
- package/build/services/TestHealthService.js +0 -694
- package/build/services/TestHealthService.test.js +0 -241
- package/build/types/TestDriftAnalysis.js +0 -1
- package/build/types/TestHealth.js +0 -4
|
@@ -8,8 +8,10 @@ import { simpleGit } from "simple-git";
|
|
|
8
8
|
import { logger } from "../../utils/logger.js";
|
|
9
9
|
import { parseWorkspaceAuthType, getDefaultAuthHeader, WorkspaceAuthType, readWorkspaceConfigRaw } from "../../utils/workspaceAuth.js";
|
|
10
10
|
import { AnalyticsService } from "../../services/AnalyticsService.js";
|
|
11
|
-
import { StateManager, registerSession, storeSessionData, } from "../../utils/AnalysisStateManager.js";
|
|
11
|
+
import { StateManager, registerSession, storeSessionData, setTestsRepoDir, } from "../../utils/AnalysisStateManager.js";
|
|
12
12
|
import { buildRecommendationPrompt } from "../../prompts/test-recommendation/test-recommendation-prompt.js";
|
|
13
|
+
import { isFrontendFile, isTestFile } from "../../prompts/test-recommendation/scopeAssessment.js";
|
|
14
|
+
import { enumerateCandidateUiPages } from "../../utils/uiPageEnumerator.js";
|
|
13
15
|
import { MAX_RECOMMENDATIONS, MAX_TESTS_TO_GENERATE } from "../../prompts/test-recommendation/recommendationSections.js";
|
|
14
16
|
import { TestDiscoveryService } from "../../services/TestDiscoveryService.js";
|
|
15
17
|
import { ScenarioSource, AnalysisScope } from "../../types/RepositoryAnalysis.js";
|
|
@@ -32,6 +34,10 @@ export function buildTraceFileEntry(tracePath, result) {
|
|
|
32
34
|
};
|
|
33
35
|
}
|
|
34
36
|
const TOOL_NAME = "skyramp_analyze_changes";
|
|
37
|
+
const SECURITY_RELEVANT_DIFF_PATTERN = /\b(?:auth|authorization|permission|permissions|admin[-_\s]?key|x-admin-key|role|roles|rbac|owner|ownership|guard|auth[-_\s]?middleware|permission[-_\s]?middleware|require[-_\s]?(?:auth|admin|role)|authorize|authorized|authenticated|require_admin_key|destructive)\b/i;
|
|
38
|
+
export function isSecurityRelevantDiff(diffContent) {
|
|
39
|
+
return SECURITY_RELEVANT_DIFF_PATTERN.test(diffContent);
|
|
40
|
+
}
|
|
35
41
|
// Must match testbot/src/constants.ts BOT_EMAIL
|
|
36
42
|
const BOT_EMAIL = "test-bot@skyramp.dev";
|
|
37
43
|
/**
|
|
@@ -192,23 +198,66 @@ export function filterEndpointsBySpec(scannedEndpoints, specPaths, specPathItems
|
|
|
192
198
|
return filtered;
|
|
193
199
|
}
|
|
194
200
|
const GRAPHQL_EXT = /\.(graphql|gql)$/i;
|
|
195
|
-
const
|
|
201
|
+
const GRAPHQL_SCHEMA_CONTENT_PATTERN = /^\s*(type\s+(Query|Mutation|Subscription)\s*\{|schema\s*\{|extend\s+type|directive\s+@)/m;
|
|
202
|
+
const GRAPHQL_IMPLEMENTATION_CONTENT_PATTERN = /(?:@Resolver\b|from\s+["'](?:@nestjs\/graphql|apollo-server|graphql-yoga|type-graphql)["']|\b(?:ApolloServer|GraphQLObjectType|GraphQLSchema|makeExecutableSchema|buildSchema)\b|(?:\btypeDefs\b[\s\S]{0,200}\bresolvers\b|\bresolvers\b[\s\S]{0,200}\btypeDefs\b))/m;
|
|
203
|
+
/** Path-based detection for non-code GraphQL artifacts.
|
|
204
|
+
* Matches "graphql" as a directory segment for files without a code extension
|
|
205
|
+
* (e.g. services/graphql/schema), but NOT filenames that merely contain
|
|
206
|
+
* "graphql" (e.g. graphql-test-route.ts). */
|
|
207
|
+
const GRAPHQL_DIR_PATTERN = /(?:^|[/\\])graphql(?:[/\\]|$)/i;
|
|
208
|
+
/** Code file extensions — these should use content detection, not directory heuristic,
|
|
209
|
+
* because .ts/.js files in a graphql/ directory are often resolvers or route-adjacent
|
|
210
|
+
* code and should not trigger the GraphQL-only early return by path alone. */
|
|
211
|
+
const CODE_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|py|rb|go|java|kt|rs|cs)$/i;
|
|
196
212
|
export async function isGraphQLFile(filePath, repositoryPath) {
|
|
197
213
|
if (GRAPHQL_EXT.test(filePath))
|
|
198
214
|
return true;
|
|
215
|
+
// Only use directory heuristic for non-code files (e.g. extensionless files).
|
|
216
|
+
// Code files in a graphql/ dir may be REST-reachable resolver glue; use content detection instead.
|
|
217
|
+
if (GRAPHQL_DIR_PATTERN.test(filePath) && !CODE_EXT.test(filePath))
|
|
218
|
+
return true;
|
|
199
219
|
try {
|
|
200
220
|
const absPath = path.join(repositoryPath, filePath);
|
|
201
221
|
const fileContent = await fs.promises.readFile(absPath, "utf-8");
|
|
202
|
-
return
|
|
222
|
+
return GRAPHQL_SCHEMA_CONTENT_PATTERN.test(fileContent) ||
|
|
223
|
+
GRAPHQL_IMPLEMENTATION_CONTENT_PATTERN.test(fileContent);
|
|
203
224
|
}
|
|
204
225
|
catch {
|
|
205
226
|
return false;
|
|
206
227
|
}
|
|
207
228
|
}
|
|
229
|
+
function isGraphQLEndpointPath(endpointPath) {
|
|
230
|
+
return /(?:^|\/)graphql(?:\/|$)/i.test(endpointPath);
|
|
231
|
+
}
|
|
232
|
+
async function isUnsupportedGraphQLEndpoint(endpoint, repositoryPath, checkGraphQLFile = (filePath) => isGraphQLFile(filePath, repositoryPath)) {
|
|
233
|
+
return isGraphQLEndpointPath(endpoint.path) ||
|
|
234
|
+
await checkGraphQLFile(endpoint.sourceFile);
|
|
235
|
+
}
|
|
236
|
+
export async function filterUnsupportedGraphQLEndpoints(endpoints, repositoryPath) {
|
|
237
|
+
const graphqlFileCache = new Map();
|
|
238
|
+
const checkGraphQLFile = (filePath) => {
|
|
239
|
+
if (!filePath)
|
|
240
|
+
return Promise.resolve(false);
|
|
241
|
+
const cached = graphqlFileCache.get(filePath);
|
|
242
|
+
if (cached)
|
|
243
|
+
return cached;
|
|
244
|
+
const result = isGraphQLFile(filePath, repositoryPath);
|
|
245
|
+
graphqlFileCache.set(filePath, result);
|
|
246
|
+
return result;
|
|
247
|
+
};
|
|
248
|
+
const filtered = [];
|
|
249
|
+
for (const endpoint of endpoints) {
|
|
250
|
+
if (!(await isUnsupportedGraphQLEndpoint(endpoint, repositoryPath, checkGraphQLFile))) {
|
|
251
|
+
filtered.push(endpoint);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return filtered;
|
|
255
|
+
}
|
|
208
256
|
function isNonApplicationFile(filePath) {
|
|
209
257
|
return NON_APP_PATTERNS.some((p) => p.test(filePath));
|
|
210
258
|
}
|
|
211
|
-
const ROUTE_FILE_PATTERN = /route|controller|endpoint|handler|view|urls|api|router/i;
|
|
259
|
+
const ROUTE_FILE_PATTERN = /route|controller|endpoint|handler|view|urls|api|router|service|gateway|resolver|\bserver\b/i;
|
|
260
|
+
const ROUTE_FILE_BASENAME_PATTERN = /\bapp\b|\bmain\b/i;
|
|
212
261
|
const SOURCE_EXTS = /\.(ts|tsx|js|jsx|py|java|kt|go|rb|php|rs|cs|ex|exs)$/;
|
|
213
262
|
/**
|
|
214
263
|
* Recover endpoints from files deleted in this branch by reading their
|
|
@@ -216,7 +265,7 @@ const SOURCE_EXTS = /\.(ts|tsx|js|jsx|py|java|kt|go|rb|php|rs|cs|ex|exs)$/;
|
|
|
216
265
|
* name matches route-file heuristics to keep I/O bounded.
|
|
217
266
|
*/
|
|
218
267
|
async function recoverDeletedFileEndpoints(repositoryPath, baseBranch, deletedFiles) {
|
|
219
|
-
const candidates = deletedFiles.filter((f) => SOURCE_EXTS.test(f) && ROUTE_FILE_PATTERN.test(f));
|
|
268
|
+
const candidates = deletedFiles.filter((f) => SOURCE_EXTS.test(f) && (ROUTE_FILE_PATTERN.test(f) || ROUTE_FILE_BASENAME_PATTERN.test(path.basename(f))));
|
|
220
269
|
if (candidates.length === 0)
|
|
221
270
|
return [];
|
|
222
271
|
const git = simpleGit(repositoryPath);
|
|
@@ -226,12 +275,15 @@ async function recoverDeletedFileEndpoints(repositoryPath, baseBranch, deletedFi
|
|
|
226
275
|
try {
|
|
227
276
|
const content = await git.show([`${baseBranch}:${file}`]);
|
|
228
277
|
for (const ep of parseFileEndpoints(content, file)) {
|
|
229
|
-
const
|
|
278
|
+
const normalizedPath = ep.path.startsWith("/") ? ep.path : `/${ep.path}`;
|
|
279
|
+
const key = `${file}::${normalizedPath}`;
|
|
280
|
+
const existing = endpointMap.get(key);
|
|
230
281
|
if (existing) {
|
|
231
282
|
existing.methods.add(ep.method);
|
|
232
283
|
}
|
|
233
284
|
else {
|
|
234
|
-
endpointMap.set(
|
|
285
|
+
endpointMap.set(key, {
|
|
286
|
+
path: normalizedPath,
|
|
235
287
|
methods: new Set([ep.method]),
|
|
236
288
|
sourceFile: file,
|
|
237
289
|
});
|
|
@@ -246,9 +298,9 @@ async function recoverDeletedFileEndpoints(repositoryPath, baseBranch, deletedFi
|
|
|
246
298
|
});
|
|
247
299
|
}
|
|
248
300
|
}
|
|
249
|
-
for (const
|
|
301
|
+
for (const data of endpointMap.values()) {
|
|
250
302
|
results.push({
|
|
251
|
-
path:
|
|
303
|
+
path: data.path,
|
|
252
304
|
methods: Array.from(data.methods),
|
|
253
305
|
sourceFile: data.sourceFile,
|
|
254
306
|
});
|
|
@@ -263,7 +315,7 @@ export const analyzeChangesInputSchema = {
|
|
|
263
315
|
.enum(["full_repo", "branch_diff"])
|
|
264
316
|
.default("branch_diff")
|
|
265
317
|
.optional()
|
|
266
|
-
.describe("Analysis scope
|
|
318
|
+
.describe("Analysis scope. 'full_repo': scans all API endpoints in the repository. 'branch_diff': scans only API endpoints affected by current branch changes — faster for CI use. Default: 'branch_diff'."),
|
|
267
319
|
baseBranch: z
|
|
268
320
|
.string()
|
|
269
321
|
.optional()
|
|
@@ -286,29 +338,31 @@ export const analyzeChangesInputSchema = {
|
|
|
286
338
|
prNumber: z
|
|
287
339
|
.number()
|
|
288
340
|
.optional()
|
|
289
|
-
.describe("GitHub PR number. When provided, fetches previous TestBot comments
|
|
341
|
+
.describe("GitHub PR number. When provided, fetches previous TestBot comments on this PR and skips re-recommending tests already suggested in earlier commits — reduces duplicate recommendations across multiple pushes to the same PR."),
|
|
290
342
|
stateOutputFile: z
|
|
291
343
|
.string()
|
|
292
344
|
.refine((v) => path.isAbsolute(v), { message: "stateOutputFile must be an absolute path" })
|
|
293
345
|
.optional()
|
|
294
346
|
.describe("Absolute path where the state file should be written. When provided, overrides the default auto-generated temp path so the caller can locate it without log parsing."),
|
|
347
|
+
testsRepoDir: z
|
|
348
|
+
.string()
|
|
349
|
+
.refine((v) => path.isAbsolute(v), { message: "testsRepoDir must be an absolute path" })
|
|
350
|
+
.optional()
|
|
351
|
+
.describe("Absolute path to a separate test repository clone. When set, existing test discovery scans this directory instead of repositoryPath. Used in cross-repo test delivery mode where tests live in a separate repo."),
|
|
295
352
|
};
|
|
296
353
|
export function registerAnalyzeChangesTool(server) {
|
|
297
354
|
server.registerTool(TOOL_NAME, {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
355
|
+
annotations: {
|
|
356
|
+
readOnlyHint: false, // writes a state file to disk
|
|
357
|
+
destructiveHint: false,
|
|
358
|
+
idempotentHint: false,
|
|
359
|
+
openWorldHint: true, // may fetch PR comments from GitHub
|
|
360
|
+
},
|
|
361
|
+
description: `Scan repository API endpoints and discover existing tests — first step of the unified Test Health Analysis Flow.
|
|
302
362
|
|
|
303
|
-
|
|
304
|
-
1. Call \`skyramp_analyze_changes\` → discovers existing tests, scans endpoints, computes branch diff → returns a stateFile
|
|
305
|
-
2. Call \`skyramp_analyze_test_health\` with stateFile → drift analysis + health scoring
|
|
306
|
-
3. (Optional) Execute tests using \`skyramp_execute_test\` with \`stateFile\` param for each test file → validates test status live and writes results back for execution-aware health scoring
|
|
307
|
-
4. Call \`skyramp_actions\` with stateFile → execute UPDATE/REGENERATE/ADD recommendations (with execution-aware prioritization if step 3 ran)
|
|
363
|
+
Combines API endpoint scanning, branch diff computation, and test discovery into a single state file consumed by \`skyramp_analyze_test_health\` and \`skyramp_actions\`.
|
|
308
364
|
|
|
309
|
-
**Output:** stateFile path +
|
|
310
|
-
|
|
311
|
-
**Recommendation path:** The response also includes inline ranked test recommendations and source-code enrichment instructions. Follow the enrichment steps (read handler + schema files), draft enrichedScenarios, then call \`skyramp_recommend_tests\` with stateFile and enrichedScenarios for richer, field-accurate recommendations.`,
|
|
365
|
+
**Output:** stateFile path + ranked test recommendations + enrichment instructions for calling \`skyramp_recommend_tests\`.`,
|
|
312
366
|
// TODO: Define outputSchema here instead of embedding structured output format in the
|
|
313
367
|
// description string — per Archit's review comment. outputSchema reduces token usage
|
|
314
368
|
// by letting the MCP client understand the response shape structurally rather than
|
|
@@ -373,6 +427,7 @@ to produce a unified state file for the test health workflow.
|
|
|
373
427
|
}
|
|
374
428
|
// ── Step 2: Scan endpoints ──
|
|
375
429
|
let scannedEndpoints = [];
|
|
430
|
+
let rawRelatedEndpointCount;
|
|
376
431
|
if (analysisScope !== AnalysisScope.CurrentBranchDiff) {
|
|
377
432
|
await sendProgress(25, 100, "Scanning all repository endpoints...");
|
|
378
433
|
try {
|
|
@@ -391,15 +446,25 @@ to produce a unified state file for the test health workflow.
|
|
|
391
446
|
await sendProgress(25, 100, "Scanning related endpoints from diff...");
|
|
392
447
|
try {
|
|
393
448
|
scannedEndpoints = scanRelatedEndpoints(params.repositoryPath, diffData.changedFiles);
|
|
449
|
+
rawRelatedEndpointCount = scannedEndpoints.length;
|
|
394
450
|
logger.info("Scanned related endpoints", {
|
|
395
451
|
count: scannedEndpoints.length,
|
|
396
452
|
});
|
|
397
453
|
}
|
|
398
454
|
catch (err) {
|
|
455
|
+
rawRelatedEndpointCount = 0;
|
|
399
456
|
logger.warning("Related endpoint scan failed", {
|
|
400
457
|
error: err instanceof Error ? err.message : String(err),
|
|
401
458
|
});
|
|
402
459
|
}
|
|
460
|
+
const beforeGraphQLFilter = scannedEndpoints.length;
|
|
461
|
+
scannedEndpoints = await filterUnsupportedGraphQLEndpoints(scannedEndpoints, params.repositoryPath);
|
|
462
|
+
if (scannedEndpoints.length !== beforeGraphQLFilter) {
|
|
463
|
+
logger.info("Filtered unsupported GraphQL endpoints from related scan", {
|
|
464
|
+
before: beforeGraphQLFilter,
|
|
465
|
+
after: scannedEndpoints.length,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
403
468
|
// No fallback to scanAllRepoEndpoints in PR mode.
|
|
404
469
|
// If the scanner found 0 related endpoints, the PR likely touches
|
|
405
470
|
// non-route code (services, models, schemas, client SDK). Flooding
|
|
@@ -419,6 +484,13 @@ to produce a unified state file for the test health workflow.
|
|
|
419
484
|
? await recoverDeletedFileEndpoints(params.repositoryPath, diffData.baseBranch, diffData.deletedFiles)
|
|
420
485
|
: [];
|
|
421
486
|
classifiedEndpoints = classifyEndpointsByChangedFiles(diffData, scannedEndpoints, deletedFileEndpoints);
|
|
487
|
+
classifiedEndpoints = {
|
|
488
|
+
...classifiedEndpoints,
|
|
489
|
+
// changed/new endpoints come from scannedEndpoints, which is already
|
|
490
|
+
// GraphQL-filtered in branch-diff mode. Removed endpoints are recovered
|
|
491
|
+
// from the base branch separately and still need unsupported-protocol filtering.
|
|
492
|
+
removedEndpoints: await filterUnsupportedGraphQLEndpoints(classifiedEndpoints.removedEndpoints, params.repositoryPath),
|
|
493
|
+
};
|
|
422
494
|
logger.info("Classified endpoints from changed files", {
|
|
423
495
|
changed: classifiedEndpoints.changedEndpoints.length,
|
|
424
496
|
new: classifiedEndpoints.newEndpoints.length,
|
|
@@ -452,7 +524,7 @@ to produce a unified state file for the test health workflow.
|
|
|
452
524
|
text: [
|
|
453
525
|
"**GraphQL-only diff detected.**",
|
|
454
526
|
"",
|
|
455
|
-
"The changed files appear to be GraphQL schema or
|
|
527
|
+
"The changed files appear to be GraphQL schema, artifact, or endpoint implementation files.",
|
|
456
528
|
"Skyramp currently supports REST API testing only — GraphQL introspection,",
|
|
457
529
|
"query validation, and type-name grounding are not yet supported.",
|
|
458
530
|
"",
|
|
@@ -504,7 +576,9 @@ to produce a unified state file for the test health workflow.
|
|
|
504
576
|
let discoveredRelevantExternalPaths = [];
|
|
505
577
|
try {
|
|
506
578
|
const testDiscoveryService = new TestDiscoveryService();
|
|
507
|
-
|
|
579
|
+
setTestsRepoDir(params.testsRepoDir);
|
|
580
|
+
const testScanPath = params.testsRepoDir ?? params.repositoryPath;
|
|
581
|
+
const discoveryResult = await testDiscoveryService.discoverTests(testScanPath, { changedResources });
|
|
508
582
|
existingTests = discoveryResult.tests.map((test) => ({
|
|
509
583
|
testFile: test.testFile,
|
|
510
584
|
testType: test.testType,
|
|
@@ -642,15 +716,11 @@ to produce a unified state file for the test health workflow.
|
|
|
642
716
|
}
|
|
643
717
|
}
|
|
644
718
|
// ── Step 4d: Filter unsupported protocol endpoints (GraphQL) ──
|
|
645
|
-
// Must run AFTER spec-merge above —
|
|
646
|
-
//
|
|
647
|
-
// Use segment check to catch /api/graphql, /v1/graphql, etc.
|
|
719
|
+
// Must run AFTER spec-merge above — specs may include /graphql and the
|
|
720
|
+
// merge step would re-add it if this ran earlier.
|
|
648
721
|
{
|
|
649
722
|
const beforeUnsupported = scannedEndpoints.length;
|
|
650
|
-
scannedEndpoints = scannedEndpoints.
|
|
651
|
-
const normalized = ep.path.replace(/\/+$/, "").toLowerCase();
|
|
652
|
-
return !normalized.split("/").some(seg => seg === "graphql");
|
|
653
|
-
});
|
|
723
|
+
scannedEndpoints = await filterUnsupportedGraphQLEndpoints(scannedEndpoints, params.repositoryPath);
|
|
654
724
|
if (scannedEndpoints.length < beforeUnsupported) {
|
|
655
725
|
logger.info("Filtered unsupported protocol endpoints (GraphQL)", {
|
|
656
726
|
removed: beforeUnsupported - scannedEndpoints.length,
|
|
@@ -761,6 +831,12 @@ to produce a unified state file for the test health workflow.
|
|
|
761
831
|
path: ep.path,
|
|
762
832
|
sourceFile: ep.sourceFile,
|
|
763
833
|
}))) ?? [];
|
|
834
|
+
const changedEndpointsForSecurityExpansion = classifiedEndpoints?.changedEndpoints.flatMap((ep) => ep.methods.map((m) => ({
|
|
835
|
+
method: m,
|
|
836
|
+
path: ep.path,
|
|
837
|
+
sourceFile: ep.sourceFile,
|
|
838
|
+
}))) ?? [];
|
|
839
|
+
const securityRelevantDiff = Boolean(diffData?.diffContent && isSecurityRelevantDiff(diffData.diffContent));
|
|
764
840
|
// Full-repo mode: no diff context, so seed scenario drafting from the entire
|
|
765
841
|
// skeletonEndpoints catalog. We gate on analysisScope (not just array length)
|
|
766
842
|
// to avoid drafting catalog-wide scenarios for PR-mode diffs that happened to
|
|
@@ -775,7 +851,10 @@ to produce a unified state file for the test health workflow.
|
|
|
775
851
|
sourceFile: m.sourceFile ?? "",
|
|
776
852
|
})))
|
|
777
853
|
: [];
|
|
778
|
-
const codeInferredScenarios = draftScenariosFromEndpoints(skeletonEndpoints, scenarioDraftSeed
|
|
854
|
+
const codeInferredScenarios = draftScenariosFromEndpoints(skeletonEndpoints, scenarioDraftSeed, wsAuthType, {
|
|
855
|
+
changedEndpoints: changedEndpointsForSecurityExpansion,
|
|
856
|
+
securityRelevantDiff,
|
|
857
|
+
});
|
|
779
858
|
let allDraftedScenarios = codeInferredScenarios;
|
|
780
859
|
if (traceResult && traceResult.userFlows.length > 0) {
|
|
781
860
|
const traceScenarios = traceResult.userFlows
|
|
@@ -979,9 +1058,34 @@ to produce a unified state file for the test health workflow.
|
|
|
979
1058
|
// Without them, analyzeTestHealth would work only off the static catalog
|
|
980
1059
|
// which has wrong paths for nested resources and unsupported frameworks.
|
|
981
1060
|
const routerMountContext = grepRouterMountingContext(params.repositoryPath);
|
|
982
|
-
const
|
|
983
|
-
|
|
984
|
-
|
|
1061
|
+
const routeLikeUnmatchedFiles = [];
|
|
1062
|
+
for (const file of classifiedEndpoints?.unmatchedFiles ?? []) {
|
|
1063
|
+
const routeLike = SOURCE_EXTS.test(file) &&
|
|
1064
|
+
(ROUTE_FILE_PATTERN.test(file) || ROUTE_FILE_BASENAME_PATTERN.test(path.basename(file)));
|
|
1065
|
+
if (routeLike && !(await isGraphQLFile(file, params.repositoryPath))) {
|
|
1066
|
+
routeLikeUnmatchedFiles.push(file);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
const shouldIncludeCandidateRouteFiles = analysisScope !== AnalysisScope.CurrentBranchDiff ||
|
|
1070
|
+
rawRelatedEndpointCount === 0 ||
|
|
1071
|
+
scannedEndpoints.length === 0 ||
|
|
1072
|
+
routeLikeUnmatchedFiles.length > 0;
|
|
1073
|
+
let candidateRouteFiles;
|
|
1074
|
+
if (shouldIncludeCandidateRouteFiles) {
|
|
1075
|
+
candidateRouteFiles = [];
|
|
1076
|
+
for (const file of findCandidateRouteFiles(params.repositoryPath)) {
|
|
1077
|
+
if (!(await isGraphQLFile(file, params.repositoryPath))) {
|
|
1078
|
+
candidateRouteFiles.push(file);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
// Write the full diff to a temp file before building state so the path
|
|
1083
|
+
// can be persisted and read by analyzeTestHealthTool for per-line detection.
|
|
1084
|
+
let diffFilePath;
|
|
1085
|
+
if (diffData?.diffContent) {
|
|
1086
|
+
diffFilePath = path.join(os.tmpdir(), `skyramp-diff-${sessionId}.diff`);
|
|
1087
|
+
await fs.promises.writeFile(diffFilePath, diffData.diffContent, { encoding: "utf-8", mode: 0o600 });
|
|
1088
|
+
}
|
|
985
1089
|
// Read router mount files server-side (size-capped) so the LLM has them
|
|
986
1090
|
// inline and doesn't need an extra read step when no spec is available.
|
|
987
1091
|
const ROUTER_INLINE_LIMIT = 4096; // bytes — skip files larger than ~4 KB
|
|
@@ -1001,10 +1105,38 @@ to produce a unified state file for the test health workflow.
|
|
|
1001
1105
|
return [];
|
|
1002
1106
|
}
|
|
1003
1107
|
});
|
|
1108
|
+
// Compute UI context from the diff's changed files using the shared
|
|
1109
|
+
// `isFrontendFile` classifier. Persisting this in the stateFile lets
|
|
1110
|
+
// skyramp_analyze_test_health and the recommendation prompt consume the
|
|
1111
|
+
// same classification without re-deriving it. Absent on backend-only PRs.
|
|
1112
|
+
//
|
|
1113
|
+
// candidateUiPages is enumerated programmatically via the strategy
|
|
1114
|
+
// ladder in uiPageEnumerator (framework route grep, source-grounded
|
|
1115
|
+
// routes, root fallback). The same enumeration powers the
|
|
1116
|
+
// skyramp_ui_analyze_changes pre-flight tool that the testbot prompt
|
|
1117
|
+
// calls before this tool, so both code paths see the same candidates.
|
|
1118
|
+
const uiContext = await (async () => {
|
|
1119
|
+
const changedFiles = classifiedEndpoints?.changedFiles ?? [];
|
|
1120
|
+
if (changedFiles.length === 0)
|
|
1121
|
+
return undefined;
|
|
1122
|
+
// Filter to frontend source files only — exclude test files, which
|
|
1123
|
+
// pass isFrontendFile (any .ts under a frontend directory matches
|
|
1124
|
+
// the tier-3 rule) but aren't UI source we'd want to ground page
|
|
1125
|
+
// enumeration in.
|
|
1126
|
+
const frontendFiles = changedFiles.filter((f) => isFrontendFile(f) && !isTestFile(f));
|
|
1127
|
+
if (frontendFiles.length === 0)
|
|
1128
|
+
return undefined;
|
|
1129
|
+
const candidateUiPages = await enumerateCandidateUiPages(params.repositoryPath, frontendFiles);
|
|
1130
|
+
return {
|
|
1131
|
+
changedFrontendFiles: frontendFiles,
|
|
1132
|
+
candidateUiPages,
|
|
1133
|
+
};
|
|
1134
|
+
})();
|
|
1004
1135
|
const unifiedState = {
|
|
1005
1136
|
existingTests,
|
|
1006
1137
|
newEndpoints: newEndpointsForDrafting,
|
|
1007
1138
|
analysisScope,
|
|
1139
|
+
...(uiContext ? { uiContext } : {}),
|
|
1008
1140
|
repositoryAnalysis: {
|
|
1009
1141
|
skeletonEndpoints,
|
|
1010
1142
|
projectMeta,
|
|
@@ -1016,6 +1148,7 @@ to produce a unified state file for the test health workflow.
|
|
|
1016
1148
|
wsAuthMethod,
|
|
1017
1149
|
specFetchSucceeded,
|
|
1018
1150
|
scenarios: allDraftedScenarios,
|
|
1151
|
+
diffFilePath,
|
|
1019
1152
|
testLocations: testLocationsByType,
|
|
1020
1153
|
diff: classifiedEndpoints
|
|
1021
1154
|
? {
|
|
@@ -1056,29 +1189,11 @@ to produce a unified state file for the test health workflow.
|
|
|
1056
1189
|
? path.dirname(path.resolve(params.stateOutputFile))
|
|
1057
1190
|
: undefined;
|
|
1058
1191
|
try {
|
|
1059
|
-
await StateManager.
|
|
1192
|
+
await StateManager.cleanupOldFiles(24, stateDir);
|
|
1060
1193
|
}
|
|
1061
1194
|
catch (error) {
|
|
1062
1195
|
logger.warning(`Failed to cleanup old state files: ${error.message}`);
|
|
1063
1196
|
}
|
|
1064
|
-
// Clean up old diff temp files (>24 hours) from previous invocations
|
|
1065
|
-
try {
|
|
1066
|
-
const tmpDir = os.tmpdir();
|
|
1067
|
-
const entries = await fs.promises.readdir(tmpDir);
|
|
1068
|
-
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
1069
|
-
for (const entry of entries) {
|
|
1070
|
-
if (!entry.startsWith("skyramp-diff-") || !entry.endsWith(".diff"))
|
|
1071
|
-
continue;
|
|
1072
|
-
const fullPath = path.join(tmpDir, entry);
|
|
1073
|
-
const stat = await fs.promises.stat(fullPath).catch(() => null);
|
|
1074
|
-
if (stat && stat.mtimeMs < cutoff) {
|
|
1075
|
-
await fs.promises.unlink(fullPath).catch(() => { });
|
|
1076
|
-
}
|
|
1077
|
-
}
|
|
1078
|
-
}
|
|
1079
|
-
catch {
|
|
1080
|
-
// Non-critical — temp cleanup failure should not block analysis
|
|
1081
|
-
}
|
|
1082
1197
|
const stateManager = new StateManager("analysis", sessionId, undefined, params.stateOutputFile);
|
|
1083
1198
|
await stateManager.writeData(unifiedState, {
|
|
1084
1199
|
repositoryPath: params.repositoryPath,
|
|
@@ -1130,7 +1245,10 @@ to produce a unified state file for the test health workflow.
|
|
|
1130
1245
|
}
|
|
1131
1246
|
}
|
|
1132
1247
|
}
|
|
1133
|
-
|
|
1248
|
+
if (uiContext && uiContext.changedFrontendFiles.length > 0) {
|
|
1249
|
+
logger.info("Frontend changes detected — UI rec grounding relies on the agent's own browser_blueprint history", { candidateUiPages: uiContext.candidateUiPages.map((p) => p.url) });
|
|
1250
|
+
}
|
|
1251
|
+
const recommendationPrompt = buildRecommendationPrompt(fullAnalysis, analysisScope, topN, prContext, wsAuthHeader, wsAuthType, wsAuthScheme, params.maxGenerate, sessionId);
|
|
1134
1252
|
await sendProgress(100, 100, "Analysis complete.");
|
|
1135
1253
|
const stateSize = await stateManager.getSizeFormatted();
|
|
1136
1254
|
const structuredSummary = JSON.stringify({
|
|
@@ -1144,6 +1262,10 @@ to produce a unified state file for the test health workflow.
|
|
|
1144
1262
|
newEndpointCount: classifiedEndpoints?.newEndpoints.length ?? 0,
|
|
1145
1263
|
endpointCount: skeletonEndpoints.reduce((acc, ep) => acc + ep.methods.length, 0),
|
|
1146
1264
|
},
|
|
1265
|
+
// Surface uiContext inline so the testbot prompt can iterate
|
|
1266
|
+
// candidateUiPages and inspect changedFrontendFiles without
|
|
1267
|
+
// re-reading the stateFile. Absent on backend-only PRs.
|
|
1268
|
+
...(uiContext ? { uiContext } : {}),
|
|
1147
1269
|
stateFileSize: stateSize,
|
|
1148
1270
|
nextStep: "Call skyramp_analyze_test_health with stateFile to run drift analysis and health scoring",
|
|
1149
1271
|
}, null, 2);
|
|
@@ -1172,13 +1294,6 @@ to produce a unified state file for the test health workflow.
|
|
|
1172
1294
|
affectedServices: classifiedEndpoints.affectedServices,
|
|
1173
1295
|
}
|
|
1174
1296
|
: undefined;
|
|
1175
|
-
// Write the full diff to a temp file so the LLM can read it on demand
|
|
1176
|
-
// rather than embedding potentially large content inline in the prompt.
|
|
1177
|
-
let diffFilePath;
|
|
1178
|
-
if (diffData?.diffContent) {
|
|
1179
|
-
diffFilePath = path.join(os.tmpdir(), `skyramp-diff-${sessionId}.diff`);
|
|
1180
|
-
await fs.promises.writeFile(diffFilePath, diffData.diffContent, { encoding: "utf-8", mode: 0o600 });
|
|
1181
|
-
}
|
|
1182
1297
|
const outputText = buildAnalysisOutputText({
|
|
1183
1298
|
sessionId,
|
|
1184
1299
|
stateFile,
|
|
@@ -1186,7 +1301,6 @@ to produce a unified state file for the test health workflow.
|
|
|
1186
1301
|
analysisScope,
|
|
1187
1302
|
parsedDiff: parsedDiffShim,
|
|
1188
1303
|
diffFilePath,
|
|
1189
|
-
diffContent: diffData?.diffContent,
|
|
1190
1304
|
candidateRouteFiles,
|
|
1191
1305
|
scannedEndpoints,
|
|
1192
1306
|
wsBaseUrl,
|
|
@@ -64,8 +64,20 @@ jest.mock("@modelcontextprotocol/sdk/server/mcp.js", () => ({
|
|
|
64
64
|
jest.mock("@modelcontextprotocol/sdk/types.js", () => ({}));
|
|
65
65
|
jest.mock("@modelcontextprotocol/sdk/shared/protocol.js", () => ({}));
|
|
66
66
|
import { z } from "zod";
|
|
67
|
-
import { analyzeChangesInputSchema } from "./analyzeChangesTool.js";
|
|
67
|
+
import { analyzeChangesInputSchema, isSecurityRelevantDiff } from "./analyzeChangesTool.js";
|
|
68
68
|
const schema = z.object(analyzeChangesInputSchema);
|
|
69
|
+
describe("isSecurityRelevantDiff", () => {
|
|
70
|
+
it("matches auth-specific middleware and admin-key signals", () => {
|
|
71
|
+
expect(isSecurityRelevantDiff("const authMiddleware = requireAuth();")).toBe(true);
|
|
72
|
+
expect(isSecurityRelevantDiff("require_admin_key(request)")).toBe(true);
|
|
73
|
+
expect(isSecurityRelevantDiff("headers['x-admin-key']")).toBe(true);
|
|
74
|
+
expect(isSecurityRelevantDiff("require-role for destructive delete")).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
it("does not match generic middleware-only refactors", () => {
|
|
77
|
+
expect(isSecurityRelevantDiff("refactor logging middleware order")).toBe(false);
|
|
78
|
+
expect(isSecurityRelevantDiff("move compression middleware to server setup")).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
69
81
|
describe("analyzeChangesInputSchema — stateOutputFile validation", () => {
|
|
70
82
|
it("accepts a valid absolute path", () => {
|
|
71
83
|
const result = schema.safeParse({
|
|
@@ -88,31 +100,37 @@ describe("analyzeChangesInputSchema — stateOutputFile validation", () => {
|
|
|
88
100
|
expect(result.success).toBe(true);
|
|
89
101
|
});
|
|
90
102
|
});
|
|
91
|
-
describe("automatic
|
|
103
|
+
describe("automatic old files cleanup", () => {
|
|
92
104
|
let StateManager;
|
|
93
|
-
let
|
|
105
|
+
let cleanupOldFilesSpy;
|
|
94
106
|
beforeEach(() => {
|
|
95
107
|
jest.clearAllMocks();
|
|
96
108
|
StateManager = require("../../utils/AnalysisStateManager.js").StateManager;
|
|
97
|
-
|
|
98
|
-
StateManager.
|
|
109
|
+
cleanupOldFilesSpy = jest.fn().mockResolvedValue(0);
|
|
110
|
+
StateManager.cleanupOldFiles = cleanupOldFilesSpy;
|
|
99
111
|
});
|
|
100
|
-
it("calls
|
|
112
|
+
it("calls cleanupOldFiles with default temp dir when no stateOutputFile provided", async () => {
|
|
101
113
|
// This test verifies the cleanup call is made without stateDir when using default location
|
|
102
|
-
expect(StateManager.
|
|
103
|
-
await StateManager.
|
|
104
|
-
expect(
|
|
114
|
+
expect(StateManager.cleanupOldFiles).toBeDefined();
|
|
115
|
+
await StateManager.cleanupOldFiles(24, undefined);
|
|
116
|
+
expect(cleanupOldFilesSpy).toHaveBeenCalledWith(24, undefined);
|
|
105
117
|
});
|
|
106
|
-
it("calls
|
|
118
|
+
it("calls cleanupOldFiles with custom dir when stateOutputFile is provided", async () => {
|
|
107
119
|
// This test verifies the cleanup call is made with the directory of stateOutputFile
|
|
108
120
|
const customPath = "/custom/dir";
|
|
109
|
-
await StateManager.
|
|
110
|
-
expect(
|
|
121
|
+
await StateManager.cleanupOldFiles(24, customPath);
|
|
122
|
+
expect(cleanupOldFilesSpy).toHaveBeenCalledWith(24, customPath);
|
|
123
|
+
});
|
|
124
|
+
it("calls cleanupOldFiles with empty stateTypes to restrict to diff files only", async () => {
|
|
125
|
+
// analyzeTestHealthTool passes [] so state files (skyramp-analysis-*, skyramp-recommendation-*)
|
|
126
|
+
// are never deleted — the caller still needs args.stateFile for skyramp_actions.
|
|
127
|
+
await StateManager.cleanupOldFiles(24, undefined, []);
|
|
128
|
+
expect(cleanupOldFilesSpy).toHaveBeenCalledWith(24, undefined, []);
|
|
111
129
|
});
|
|
112
130
|
it("continues execution if cleanup fails", async () => {
|
|
113
131
|
// Cleanup failures should not crash the analyze flow
|
|
114
|
-
|
|
115
|
-
await expect(StateManager.
|
|
132
|
+
cleanupOldFilesSpy.mockRejectedValue(new Error("permission denied"));
|
|
133
|
+
await expect(StateManager.cleanupOldFiles(24).catch(() => {
|
|
116
134
|
// In the real code, this is caught and logged as a warning
|
|
117
135
|
return Promise.resolve();
|
|
118
136
|
})).resolves.toBeUndefined();
|
|
@@ -206,7 +224,7 @@ describe("filterEndpointsBySpec", () => {
|
|
|
206
224
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
207
225
|
// isGraphQLFile — unit tests
|
|
208
226
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
209
|
-
import { isGraphQLFile } from "./analyzeChangesTool.js";
|
|
227
|
+
import { filterUnsupportedGraphQLEndpoints, isGraphQLFile } from "./analyzeChangesTool.js";
|
|
210
228
|
import * as os from "os";
|
|
211
229
|
import * as path from "path";
|
|
212
230
|
import * as fsSync from "fs";
|
|
@@ -220,6 +238,10 @@ describe("isGraphQLFile", () => {
|
|
|
220
238
|
const result = await isGraphQLFile("types.gql", "/any/repo");
|
|
221
239
|
expect(result).toBe(true);
|
|
222
240
|
});
|
|
241
|
+
it("returns true for a top-level graphql directory non-code artifact", async () => {
|
|
242
|
+
const result = await isGraphQLFile("graphql/schema", "/any/repo");
|
|
243
|
+
expect(result).toBe(true);
|
|
244
|
+
});
|
|
223
245
|
it("returns true for a file containing a GraphQL type Query block", async () => {
|
|
224
246
|
const file = path.join(tmpDir, "graphql-test-query.ts");
|
|
225
247
|
fsSync.writeFileSync(file, 'type Query {\n hello: String\n}\n');
|
|
@@ -232,17 +254,55 @@ describe("isGraphQLFile", () => {
|
|
|
232
254
|
const result = await isGraphQLFile(path.basename(file), tmpDir);
|
|
233
255
|
expect(result).toBe(true);
|
|
234
256
|
});
|
|
257
|
+
it("returns true for a GraphQL resolver implementation file", async () => {
|
|
258
|
+
const file = path.join(tmpDir, "users.resolver.ts");
|
|
259
|
+
fsSync.writeFileSync(file, 'import { Resolver, Query } from "@nestjs/graphql";\n@Resolver()\nclass UsersResolver { @Query() users() { return []; } }\n');
|
|
260
|
+
const result = await isGraphQLFile(path.basename(file), tmpDir);
|
|
261
|
+
expect(result).toBe(true);
|
|
262
|
+
});
|
|
263
|
+
it("returns false for a Nest REST controller using @Query params", async () => {
|
|
264
|
+
const file = path.join(tmpDir, "users.controller.ts");
|
|
265
|
+
fsSync.writeFileSync(file, 'import { Controller, Get, Query } from "@nestjs/common";\n@Controller("users")\nclass UsersController { @Get() list(@Query("page") page: string) { return page; } }\n');
|
|
266
|
+
const result = await isGraphQLFile(path.basename(file), tmpDir);
|
|
267
|
+
expect(result).toBe(false);
|
|
268
|
+
});
|
|
235
269
|
it("returns false for a regular TypeScript route file", async () => {
|
|
236
270
|
const file = path.join(tmpDir, "graphql-test-route.ts");
|
|
237
271
|
fsSync.writeFileSync(file, 'import express from "express";\nrouter.get("/users", handler);\n');
|
|
238
272
|
const result = await isGraphQLFile(path.basename(file), tmpDir);
|
|
239
273
|
expect(result).toBe(false);
|
|
240
274
|
});
|
|
275
|
+
it("returns false for frontend Apollo gql query templates", async () => {
|
|
276
|
+
const file = path.join(tmpDir, "usersQuery.tsx");
|
|
277
|
+
fsSync.writeFileSync(file, 'import { gql } from "@apollo/client";\nexport const USERS_QUERY = gql`query Users { users { id } }`;\n');
|
|
278
|
+
const result = await isGraphQLFile(path.basename(file), tmpDir);
|
|
279
|
+
expect(result).toBe(false);
|
|
280
|
+
});
|
|
241
281
|
it("returns false (does not throw) when the file does not exist", async () => {
|
|
242
282
|
const result = await isGraphQLFile("nonexistent-file.ts", tmpDir);
|
|
243
283
|
expect(result).toBe(false);
|
|
244
284
|
});
|
|
245
285
|
});
|
|
286
|
+
describe("filterUnsupportedGraphQLEndpoints", () => {
|
|
287
|
+
it("memoizes GraphQL source-file checks within one filter pass", async () => {
|
|
288
|
+
const endpoints = [
|
|
289
|
+
{ path: "/users", methods: ["GET"], sourceFile: "src/routes/users.ts" },
|
|
290
|
+
{ path: "/users/{id}", methods: ["GET"], sourceFile: "src/routes/users.ts" },
|
|
291
|
+
{ path: "/orders", methods: ["GET"], sourceFile: "src/routes/orders.ts" },
|
|
292
|
+
{ path: "/graphql", methods: ["POST"], sourceFile: "src/routes/graphql.ts" },
|
|
293
|
+
{ path: "/openapi-only", methods: ["GET"], sourceFile: "" },
|
|
294
|
+
];
|
|
295
|
+
const readSpy = jest.spyOn(fsModule.promises, "readFile").mockResolvedValue('router.get("/users", handler)');
|
|
296
|
+
const result = await filterUnsupportedGraphQLEndpoints(endpoints, "/repo");
|
|
297
|
+
expect(result.map((endpoint) => endpoint.path)).toEqual(["/users", "/users/{id}", "/orders", "/openapi-only"]);
|
|
298
|
+
expect(readSpy).toHaveBeenCalledTimes(2);
|
|
299
|
+
expect(readSpy.mock.calls.map(([file]) => String(file))).toEqual([
|
|
300
|
+
path.join("/repo", "src/routes/users.ts"),
|
|
301
|
+
path.join("/repo", "src/routes/orders.ts"),
|
|
302
|
+
]);
|
|
303
|
+
readSpy.mockRestore();
|
|
304
|
+
});
|
|
305
|
+
});
|
|
246
306
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
247
307
|
// analyzeChangesTool handler — GraphQL-only early return (handler-level)
|
|
248
308
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -296,6 +356,32 @@ describe("analyzeChangesTool handler — GraphQL-only early return", () => {
|
|
|
296
356
|
expect(result.content[0].text).toContain("schema.graphql");
|
|
297
357
|
expect(result.content[0].text).toContain("REST API testing");
|
|
298
358
|
});
|
|
359
|
+
it("returns the GraphQL-only message when only GraphQL resolver endpoints are scanned", async () => {
|
|
360
|
+
computeBranchDiff.mockResolvedValue({
|
|
361
|
+
currentBranch: "feature",
|
|
362
|
+
baseBranch: "main",
|
|
363
|
+
changedFiles: ["src/graphql/users.resolver.ts"],
|
|
364
|
+
deletedFiles: [],
|
|
365
|
+
diffContent: "",
|
|
366
|
+
});
|
|
367
|
+
scanRelatedEndpoints.mockReturnValue([
|
|
368
|
+
{ path: "/users", methods: ["GET"], sourceFile: "src/graphql/users.resolver.ts" },
|
|
369
|
+
]);
|
|
370
|
+
classifyEndpointsByChangedFiles.mockReturnValue({
|
|
371
|
+
changedEndpoints: [],
|
|
372
|
+
newEndpoints: [],
|
|
373
|
+
removedEndpoints: [],
|
|
374
|
+
unmatchedFiles: [],
|
|
375
|
+
affectedServices: [],
|
|
376
|
+
});
|
|
377
|
+
const readSpy = jest.spyOn(fsModule.promises, "readFile").mockResolvedValue('import { Resolver, Query } from "@nestjs/graphql";\n@Resolver()\nclass UsersResolver { @Query() users() { return []; } }');
|
|
378
|
+
const handler = captureAnalyzeHandler();
|
|
379
|
+
const result = await handler(baseParams);
|
|
380
|
+
expect(classifyEndpointsByChangedFiles).toHaveBeenCalledWith(expect.anything(), [], []);
|
|
381
|
+
expect(result.content[0].text).toContain("GraphQL-only diff detected");
|
|
382
|
+
expect(result.content[0].text).toContain("endpoint implementation");
|
|
383
|
+
readSpy.mockRestore();
|
|
384
|
+
});
|
|
299
385
|
it("does NOT early-return when .graphql files are mixed with REST route files", async () => {
|
|
300
386
|
computeBranchDiff.mockResolvedValue({
|
|
301
387
|
currentBranch: "feature",
|
|
@@ -305,11 +391,12 @@ describe("analyzeChangesTool handler — GraphQL-only early return", () => {
|
|
|
305
391
|
diffContent: "",
|
|
306
392
|
});
|
|
307
393
|
// users.ts is not a GraphQL file — isGraphQLFile will read it and return false
|
|
308
|
-
jest.spyOn(fsModule.promises, "readFile").mockResolvedValue('router.get("/users", handler)');
|
|
394
|
+
const readSpy = jest.spyOn(fsModule.promises, "readFile").mockResolvedValue('router.get("/users", handler)');
|
|
309
395
|
const handler = captureAnalyzeHandler();
|
|
310
396
|
const result = await handler(baseParams);
|
|
311
397
|
// Should not early-return with GraphQL message
|
|
312
398
|
expect(result.content[0].text).not.toContain("GraphQL-only diff detected");
|
|
399
|
+
readSpy.mockRestore();
|
|
313
400
|
});
|
|
314
401
|
it("does NOT early-return when scope is full_repo (only fires for PR diffs)", async () => {
|
|
315
402
|
const { scanAllRepoEndpoints } = require("../../utils/repoScanner.js");
|