@skyramp/mcp 0.1.0 → 0.1.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/prompts/enhance-assertions/contractProviderAssertionsPrompt.js +28 -110
- package/build/prompts/enhance-assertions/integrationAssertionsPrompt.js +35 -128
- package/build/prompts/enhance-assertions/sharedAssertionRules.js +212 -0
- package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +217 -78
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +146 -27
- package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +202 -5
- package/build/prompts/testbot/testbot-prompts.js +10 -9
- package/build/services/TestDiscoveryService.js +417 -58
- package/build/services/TestDiscoveryService.test.js +361 -0
- package/build/tools/code-refactor/enhanceAssertionsTool.js +8 -10
- package/build/tools/test-management/actionsTool.js +4 -1
- package/build/tools/test-management/analyzeChangesTool.js +76 -9
- package/build/tools/test-management/analyzeTestHealthTool.js +6 -2
- package/build/types/RepositoryAnalysis.js +1 -0
- package/build/types/TestAnalysis.js +6 -1
- package/build/utils/docker.test.js +1 -1
- package/build/utils/routeParsers.js +7 -0
- package/build/utils/routeParsers.test.js +29 -1
- package/build/utils/versions.js +1 -1
- package/package.json +2 -2
|
@@ -2,6 +2,7 @@ import * as fs from "fs";
|
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import { simpleGit } from "simple-git";
|
|
4
4
|
import { logger } from "../utils/logger.js";
|
|
5
|
+
import { TestSource } from "../types/TestAnalysis.js";
|
|
5
6
|
import fg from "fast-glob";
|
|
6
7
|
export class TestDiscoveryService {
|
|
7
8
|
EXCLUDED_DIRS = [
|
|
@@ -19,18 +20,54 @@ export class TestDiscoveryService {
|
|
|
19
20
|
"target",
|
|
20
21
|
];
|
|
21
22
|
SKYRAMP_MARKER = "Generated by Skyramp";
|
|
22
|
-
// Supported test file extensions —
|
|
23
|
-
SUPPORTED_EXTENSIONS = [
|
|
23
|
+
// Supported test file extensions — covers all common languages/frameworks
|
|
24
|
+
SUPPORTED_EXTENSIONS = [
|
|
25
|
+
".py", ".js", ".ts", ".tsx", ".jsx", ".java",
|
|
26
|
+
".go", ".rb", ".kt", ".kts", ".cs", ".rs",
|
|
27
|
+
".php", ".scala", ".swift", ".m",
|
|
28
|
+
];
|
|
24
29
|
// Concurrency control for parallel operations
|
|
25
30
|
MAX_CONCURRENT_OPERATIONS = 10;
|
|
31
|
+
// Max external files to fully extract in full-repo mode (no diff context).
|
|
32
|
+
// In PR mode this hard limit is replaced by relevance-based partitioning which
|
|
33
|
+
// naturally bounds the read set to files relevant to the changed endpoints.
|
|
34
|
+
MAX_EXTERNAL_FULL_REPO = 100;
|
|
35
|
+
// Test file naming patterns — match against basename.
|
|
36
|
+
// Includes Skyramp-generated suffixes so that files like orders_smoke.py placed
|
|
37
|
+
// outside a recognized test directory are still discovered.
|
|
38
|
+
TEST_FILE_PATTERNS = [
|
|
39
|
+
/^test_.*\.(py|js|ts|rb|go|php)$/, // test_*.py, test_*.rb, test_*.go
|
|
40
|
+
/.*_test\.(py|ts|js|go|rs)$/, // *_test.py, *_test.go, *_test.rs
|
|
41
|
+
/.*\.test\.(ts|js|tsx|jsx)$/, // *.test.ts, *.test.js, *.test.tsx
|
|
42
|
+
/.*\.spec\.(ts|js|tsx|jsx|rb)$/, // *.spec.ts, *.spec.js, *.spec.rb
|
|
43
|
+
/.*Test\.(java|kt|kts|cs|scala|swift|m)$/, // *Test.java, *Test.kt, *Test.m (ObjC)
|
|
44
|
+
/.*Tests\.(cs|swift|m)$/, // *Tests.cs, *Tests.swift, *Tests.m (ObjC)
|
|
45
|
+
/.*_spec\.rb$/, // *_spec.rb (RSpec)
|
|
46
|
+
/.*\.test\.php$/, // *.test.php
|
|
47
|
+
/.*Test\.php$/, // *Test.php (PHPUnit)
|
|
48
|
+
// Skyramp-generated test suffixes — must be matched even outside test dirs
|
|
49
|
+
/.*_(?:smoke|contract|fuzz|integration|load|e2e|ui)\.(py|ts|js|java|rb|go|cs|kt|kts|scala|swift|php|rs)$/i,
|
|
50
|
+
];
|
|
51
|
+
// Directory patterns that signal test directories — works with both / and \ separators
|
|
52
|
+
TEST_DIR_PATTERNS = [
|
|
53
|
+
/[\\/]tests?[\\/]/,
|
|
54
|
+
/[\\/]__tests__[\\/]/,
|
|
55
|
+
/[\\/]spec[\\/]/,
|
|
56
|
+
];
|
|
26
57
|
// Cache git client and repo status per repository
|
|
27
58
|
gitClientCache = new Map();
|
|
28
59
|
isGitRepoCache = new Map();
|
|
29
60
|
/**
|
|
30
|
-
* Discover all
|
|
31
|
-
* Uses fast-glob for cross-platform file scanning
|
|
61
|
+
* Discover all tests in a repository — both Skyramp-generated and external (user-written).
|
|
62
|
+
* Uses fast-glob for cross-platform file scanning, then classifies discovered files
|
|
63
|
+
* as Skyramp-generated tests, external tests, or not-a-test during processing.
|
|
64
|
+
*
|
|
65
|
+
* When `options.changedResources` is provided (PR mode), external files are partitioned
|
|
66
|
+
* by relevance: files whose path/name overlaps with the changed resource names get full
|
|
67
|
+
* endpoint extraction; low-relevance files are returned as name-only entries (no reads).
|
|
68
|
+
* This eliminates the old hard cap while keeping state file size bounded.
|
|
32
69
|
*/
|
|
33
|
-
async discoverTests(repositoryPath) {
|
|
70
|
+
async discoverTests(repositoryPath, options = {}) {
|
|
34
71
|
logger.info(`Starting test discovery in: ${repositoryPath}`);
|
|
35
72
|
if (!fs.existsSync(repositoryPath)) {
|
|
36
73
|
throw new Error(`Repository path does not exist: ${repositoryPath}`);
|
|
@@ -41,19 +78,99 @@ export class TestDiscoveryService {
|
|
|
41
78
|
}
|
|
42
79
|
// Initialize git client cache for this repository
|
|
43
80
|
await this.initializeGitClient(repositoryPath);
|
|
44
|
-
//
|
|
45
|
-
const
|
|
46
|
-
logger.info(`Found ${
|
|
47
|
-
// Process
|
|
48
|
-
const skyrampTests = await this.processFilesInBatches(
|
|
49
|
-
|
|
50
|
-
//
|
|
81
|
+
// File classification: skyramp vs external vs not-a-test (carries content forward)
|
|
82
|
+
const classified = this.classifyTestFiles(repositoryPath);
|
|
83
|
+
logger.info(`Found ${classified.skyramp.length} Skyramp test files, ${classified.external.length} external test files`);
|
|
84
|
+
// Process Skyramp tests (content already cached from classification)
|
|
85
|
+
const skyrampTests = await this.processFilesInBatches(classified.skyramp, false, classified.contentCache);
|
|
86
|
+
skyrampTests.forEach(t => { t.source = TestSource.Skyramp; });
|
|
87
|
+
// Partition external tests into relevant (full extraction) and low-relevance (name-only).
|
|
88
|
+
//
|
|
89
|
+
// PR mode (changedResources provided):
|
|
90
|
+
// Files whose path/name token-overlaps with the changed resource names are "relevant".
|
|
91
|
+
// Only they get full endpoint extraction. Low-relevance files get name-only entries.
|
|
92
|
+
// No hard cap — the relevance filter naturally bounds the read set to PR scope.
|
|
93
|
+
//
|
|
94
|
+
// Full-repo mode (no changedResources):
|
|
95
|
+
// No diff context — all external files treated as potentially relevant.
|
|
96
|
+
// Cap at MAX_EXTERNAL_FULL_REPO to avoid reading hundreds of files.
|
|
97
|
+
const { changedResources } = options;
|
|
98
|
+
let relevantExternal;
|
|
99
|
+
let otherExternal;
|
|
100
|
+
if (changedResources?.length) {
|
|
101
|
+
({ relevant: relevantExternal, other: otherExternal } =
|
|
102
|
+
this.partitionByRelevance(classified.external, changedResources));
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
// Full-repo mode: cap full-extraction set, remaining become name-only
|
|
106
|
+
relevantExternal = classified.external.slice(0, this.MAX_EXTERNAL_FULL_REPO);
|
|
107
|
+
otherExternal = classified.external.slice(this.MAX_EXTERNAL_FULL_REPO);
|
|
108
|
+
}
|
|
109
|
+
logger.info(`External tests: ${relevantExternal.length} relevant (full extraction), ${otherExternal.length} low-relevance (name-only)`);
|
|
110
|
+
// Free cached content for files we will not extract — their content is not needed.
|
|
111
|
+
for (const f of otherExternal) {
|
|
112
|
+
classified.contentCache.delete(f);
|
|
113
|
+
}
|
|
114
|
+
// Full metadata + endpoint extraction only for relevant external files
|
|
115
|
+
const relevantExternalTests = await this.processFilesInBatches(relevantExternal, true, classified.contentCache);
|
|
116
|
+
// Minimal entries for low-relevance files — no content reads, no endpoint extraction
|
|
117
|
+
const otherExternalTests = otherExternal.map(filePath => ({
|
|
118
|
+
testFile: filePath,
|
|
119
|
+
testType: this.detectExternalTestType(filePath, ""),
|
|
120
|
+
language: this.detectLanguage(filePath),
|
|
121
|
+
framework: "",
|
|
122
|
+
apiEndpoint: "",
|
|
123
|
+
source: TestSource.External,
|
|
124
|
+
}));
|
|
125
|
+
const externalTests = [...relevantExternalTests, ...otherExternalTests];
|
|
126
|
+
logger.info(`Discovered ${skyrampTests.length} Skyramp tests, ${externalTests.length} external tests`);
|
|
127
|
+
// Clean up caches to free memory
|
|
51
128
|
this.gitClientCache.clear();
|
|
52
129
|
this.isGitRepoCache.clear();
|
|
53
130
|
return {
|
|
54
|
-
tests: skyrampTests,
|
|
131
|
+
tests: [...skyrampTests, ...externalTests],
|
|
132
|
+
// Expose the relevant file paths so callers can build read instructions for the LLM.
|
|
133
|
+
// These are the files the agent should read in Step 0 for definitive coverage
|
|
134
|
+
// verification — includes files where regex extraction may have failed (e.g. helper
|
|
135
|
+
// abstractions) since those most need semantic inspection.
|
|
136
|
+
relevantExternalTestPaths: relevantExternal,
|
|
55
137
|
};
|
|
56
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* Score an external test file's relevance to a set of changed resource names.
|
|
141
|
+
* Tokenises the last two segments of the file path (split by /, \, -, _, .)
|
|
142
|
+
* and counts overlapping tokens with the changed resources.
|
|
143
|
+
* Example: "test_orders_api.py" vs ["orders"] → score 1.
|
|
144
|
+
*/
|
|
145
|
+
scoreRelevance(filePath, changedResources) {
|
|
146
|
+
const normalized = filePath.toLowerCase().replace(/\\/g, "/");
|
|
147
|
+
// Use the last two path segments to avoid false matches from deeply-nested dirs
|
|
148
|
+
const segments = normalized.split("/").slice(-2).join("/");
|
|
149
|
+
const tokens = new Set(segments.split(/[/\-_.]+/).filter(Boolean));
|
|
150
|
+
return changedResources.filter(r => {
|
|
151
|
+
const rLower = r.toLowerCase();
|
|
152
|
+
// Direct single-token match (e.g. "orders" in file path tokens)
|
|
153
|
+
if (tokens.has(rLower))
|
|
154
|
+
return true;
|
|
155
|
+
// Compound resource names (e.g. "order-items" from /api/order-items) — check
|
|
156
|
+
// that ALL hyphen/underscore-separated parts appear as individual tokens.
|
|
157
|
+
// This handles test_order_items.py correctly matching resource "order-items".
|
|
158
|
+
const parts = rLower.split(/[-_]/);
|
|
159
|
+
return parts.length > 1 && parts.every(p => p.length >= 3 && tokens.has(p));
|
|
160
|
+
}).length;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Partition external test files into relevant (score > 0) and low-relevance (score = 0)
|
|
164
|
+
* based on file path/name overlap with the changed resource names from the PR diff.
|
|
165
|
+
*/
|
|
166
|
+
partitionByRelevance(files, changedResources) {
|
|
167
|
+
const relevant = [];
|
|
168
|
+
const other = [];
|
|
169
|
+
for (const f of files) {
|
|
170
|
+
(this.scoreRelevance(f, changedResources) > 0 ? relevant : other).push(f);
|
|
171
|
+
}
|
|
172
|
+
return { relevant, other };
|
|
173
|
+
}
|
|
57
174
|
/**
|
|
58
175
|
* Initialize git client and check if repository is a git repo
|
|
59
176
|
*/
|
|
@@ -77,15 +194,20 @@ export class TestDiscoveryService {
|
|
|
77
194
|
}
|
|
78
195
|
/**
|
|
79
196
|
* Process test files in parallel batches with concurrency control
|
|
197
|
+
* @param isExternal When true, uses external test metadata extraction
|
|
198
|
+
* @param contentCache Optional pre-read file contents from classification pass
|
|
80
199
|
*/
|
|
81
|
-
async processFilesInBatches(testFiles,
|
|
200
|
+
async processFilesInBatches(testFiles, isExternal = false, contentCache) {
|
|
82
201
|
const results = [];
|
|
83
202
|
// Process files in batches to control concurrency
|
|
84
203
|
for (let i = 0; i < testFiles.length; i += this.MAX_CONCURRENT_OPERATIONS) {
|
|
85
204
|
const batch = testFiles.slice(i, i + this.MAX_CONCURRENT_OPERATIONS);
|
|
86
205
|
const batchResults = await Promise.all(batch.map(async (testFile) => {
|
|
87
206
|
try {
|
|
88
|
-
|
|
207
|
+
const cachedContent = contentCache?.get(testFile);
|
|
208
|
+
return isExternal
|
|
209
|
+
? await this.extractExternalTestMetadata(testFile, cachedContent)
|
|
210
|
+
: await this.extractTestMetadata(testFile, cachedContent);
|
|
89
211
|
}
|
|
90
212
|
catch (error) {
|
|
91
213
|
logger.error(`Error processing test file ${testFile}: ${error}`);
|
|
@@ -98,61 +220,72 @@ export class TestDiscoveryService {
|
|
|
98
220
|
return results;
|
|
99
221
|
}
|
|
100
222
|
/**
|
|
101
|
-
*
|
|
223
|
+
* Classify candidate files as skyramp (has marker), external (test file without
|
|
224
|
+
* marker), or not-a-test. Caches file contents so downstream metadata extraction
|
|
225
|
+
* doesn't need to re-read from disk.
|
|
102
226
|
*/
|
|
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
|
-
|
|
227
|
+
classifyTestFiles(repositoryPath) {
|
|
228
|
+
const globPatterns = this.SUPPORTED_EXTENSIONS.map((ext) => `**/*${ext}`);
|
|
229
|
+
const ignorePatterns = this.EXCLUDED_DIRS.map((dir) => `**/${dir}/**`);
|
|
230
|
+
const allFiles = fg.sync(globPatterns, {
|
|
231
|
+
cwd: repositoryPath,
|
|
232
|
+
ignore: ignorePatterns,
|
|
233
|
+
absolute: true,
|
|
234
|
+
caseSensitiveMatch: false,
|
|
235
|
+
});
|
|
236
|
+
// Pre-filter to likely test files before reading contents.
|
|
237
|
+
// TEST_FILE_PATTERNS includes Skyramp-generated suffixes (_smoke, _contract, etc.)
|
|
238
|
+
// so Skyramp files outside standard test directories are still discovered.
|
|
239
|
+
// This avoids reading every source file in the repo just to check the marker.
|
|
240
|
+
const testCandidates = allFiles.filter(f => this.isExternalTestFile(f));
|
|
241
|
+
logger.debug(`Found ${allFiles.length} candidate files, narrowed to ${testCandidates.length} likely test files`);
|
|
242
|
+
const result = { skyramp: [], external: [] };
|
|
243
|
+
const contentCache = new Map();
|
|
244
|
+
const marker = this.SKYRAMP_MARKER;
|
|
245
|
+
const batchSize = 100;
|
|
246
|
+
for (let i = 0; i < testCandidates.length; i += batchSize) {
|
|
247
|
+
const batch = testCandidates.slice(i, i + batchSize);
|
|
248
|
+
for (const file of batch) {
|
|
249
|
+
try {
|
|
250
|
+
const content = fs.readFileSync(file, "utf-8");
|
|
251
|
+
if (content.includes(marker)) {
|
|
252
|
+
result.skyramp.push(file);
|
|
253
|
+
contentCache.set(file, content);
|
|
129
254
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
return false;
|
|
255
|
+
else {
|
|
256
|
+
result.external.push(file);
|
|
257
|
+
contentCache.set(file, content);
|
|
134
258
|
}
|
|
135
|
-
}
|
|
136
|
-
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
logger.debug(`Skipping file ${file}: ${error}`);
|
|
262
|
+
}
|
|
137
263
|
}
|
|
138
|
-
logger.debug(`Found ${matchingFiles.length} files with Skyramp marker`);
|
|
139
|
-
return matchingFiles;
|
|
140
|
-
}
|
|
141
|
-
catch (error) {
|
|
142
|
-
logger.error(`File search failed: ${error.message}`);
|
|
143
|
-
logger.info("Falling back to directory scanning method");
|
|
144
|
-
return [];
|
|
145
264
|
}
|
|
265
|
+
logger.debug(`Classified ${result.skyramp.length} Skyramp files, ${result.external.length} external test files`);
|
|
266
|
+
return { ...result, contentCache };
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Check if a file is an external (non-Skyramp) test file based on naming convention
|
|
270
|
+
* or directory placement.
|
|
271
|
+
*/
|
|
272
|
+
isExternalTestFile(filePath) {
|
|
273
|
+
const basename = path.basename(filePath);
|
|
274
|
+
if (this.TEST_FILE_PATTERNS.some(p => p.test(basename)))
|
|
275
|
+
return true;
|
|
276
|
+
if (this.TEST_DIR_PATTERNS.some(p => p.test(filePath)))
|
|
277
|
+
return true;
|
|
278
|
+
return false;
|
|
146
279
|
}
|
|
147
280
|
/**
|
|
148
281
|
* Extract metadata from a test file
|
|
149
282
|
* File is already confirmed to contain Skyramp marker by file search
|
|
283
|
+
* @param cachedContent Optional pre-read content from classification pass
|
|
150
284
|
*/
|
|
151
|
-
async extractTestMetadata(testFile) {
|
|
285
|
+
async extractTestMetadata(testFile, cachedContent) {
|
|
152
286
|
let content;
|
|
153
287
|
try {
|
|
154
|
-
|
|
155
|
-
content = fs.readFileSync(testFile, "utf-8");
|
|
288
|
+
content = cachedContent ?? fs.readFileSync(testFile, "utf-8");
|
|
156
289
|
}
|
|
157
290
|
catch (error) {
|
|
158
291
|
logger.debug(`Could not read file ${testFile}: ${error}`);
|
|
@@ -193,6 +326,220 @@ export class TestDiscoveryService {
|
|
|
193
326
|
}
|
|
194
327
|
return [...seen].join(", ");
|
|
195
328
|
}
|
|
329
|
+
/**
|
|
330
|
+
* Extract metadata from an external (non-Skyramp) test file.
|
|
331
|
+
* Uses heuristic patterns for endpoint extraction, test type, and framework detection.
|
|
332
|
+
* @param cachedContent Optional pre-read content from classification pass
|
|
333
|
+
*/
|
|
334
|
+
async extractExternalTestMetadata(testFile, cachedContent) {
|
|
335
|
+
let content;
|
|
336
|
+
try {
|
|
337
|
+
content = cachedContent ?? fs.readFileSync(testFile, "utf-8");
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
const language = this.detectLanguage(testFile);
|
|
343
|
+
const testType = this.detectExternalTestType(testFile, content);
|
|
344
|
+
const framework = this.detectExternalFramework(content);
|
|
345
|
+
const apiEndpoint = this.extractExternalEndpoints(content);
|
|
346
|
+
return {
|
|
347
|
+
testFile,
|
|
348
|
+
testType,
|
|
349
|
+
language,
|
|
350
|
+
framework,
|
|
351
|
+
apiEndpoint,
|
|
352
|
+
source: TestSource.External,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Extract HTTP endpoints from external (non-Skyramp) test files.
|
|
357
|
+
* Matches common patterns: requests.get, axios.post, fetch, supertest, RestAssured.
|
|
358
|
+
*/
|
|
359
|
+
extractExternalEndpoints(content) {
|
|
360
|
+
const seen = new Set();
|
|
361
|
+
// Python requests / test client: requests.get("/path"), client.get("/path"), self.client.get("/path")
|
|
362
|
+
// Also handles f-strings: requests.get(f"{BASE_URL}/path") — strips the variable prefix
|
|
363
|
+
for (const m of content.matchAll(/(?:requests|client|self\.client)\.(get|post|put|patch|delete)\(\s*f?["'`](?:\{[^}]+\})?([^"'`\s]+)["'`]/gi)) {
|
|
364
|
+
const ep = this.normalizeEndpointPath(m[2]);
|
|
365
|
+
if (ep)
|
|
366
|
+
seen.add(`${m[1].toUpperCase()} ${ep}`);
|
|
367
|
+
}
|
|
368
|
+
// JS fetch: fetch("/path", { method: "POST" }) — extract path, try to find method
|
|
369
|
+
for (const m of content.matchAll(/fetch\(\s*["'`]([^"'`\s]+)["'`](?:\s*,\s*\{[^}]*method\s*:\s*["'](\w+)["'])?/g)) {
|
|
370
|
+
const ep = this.normalizeEndpointPath(m[1]);
|
|
371
|
+
if (ep)
|
|
372
|
+
seen.add(`${(m[2] || "GET").toUpperCase()} ${ep}`);
|
|
373
|
+
}
|
|
374
|
+
// Axios: axios.get("/path"), axios.post("/path")
|
|
375
|
+
for (const m of content.matchAll(/axios\.(get|post|put|patch|delete)\(\s*["'`]([^"'`\s]+)["'`]/gi)) {
|
|
376
|
+
const ep = this.normalizeEndpointPath(m[2]);
|
|
377
|
+
if (ep)
|
|
378
|
+
seen.add(`${m[1].toUpperCase()} ${ep}`);
|
|
379
|
+
}
|
|
380
|
+
// Supertest: .get("/path"), .post("/path") — only if file references supertest
|
|
381
|
+
if (/supertest|request\(app\)/i.test(content)) {
|
|
382
|
+
for (const m of content.matchAll(/\.(get|post|put|patch|delete)\(\s*["'`](\/[^"'`\s]+)["'`]/gi)) {
|
|
383
|
+
seen.add(`${m[1].toUpperCase()} ${m[2]}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
// Java RestAssured: when().get("/path"), .get("/path") after given()/when()
|
|
387
|
+
for (const m of content.matchAll(/(?:when|then)\(\)\s*\.(get|post|put|patch|delete)\(\s*["']([^"']+)["']/gi)) {
|
|
388
|
+
const ep = this.normalizeEndpointPath(m[2]);
|
|
389
|
+
if (ep)
|
|
390
|
+
seen.add(`${m[1].toUpperCase()} ${ep}`);
|
|
391
|
+
}
|
|
392
|
+
// Go net/http: http.Get("/path"), http.Post("/path"), http.NewRequest("METHOD", "/path")
|
|
393
|
+
for (const m of content.matchAll(/http\.(Get|Post|Head)\(\s*["'`]([^"'`\s]+)["'`]/g)) {
|
|
394
|
+
const ep = this.normalizeEndpointPath(m[2]);
|
|
395
|
+
if (ep)
|
|
396
|
+
seen.add(`${m[1].toUpperCase()} ${ep}`);
|
|
397
|
+
}
|
|
398
|
+
for (const m of content.matchAll(/http\.NewRequest\(\s*["'`]?(GET|POST|PUT|PATCH|DELETE|HEAD)["'`]?\s*,\s*["'`]([^"'`\s]+)["'`]/gi)) {
|
|
399
|
+
const ep = this.normalizeEndpointPath(m[2]);
|
|
400
|
+
if (ep)
|
|
401
|
+
seen.add(`${m[1].toUpperCase()} ${ep}`);
|
|
402
|
+
}
|
|
403
|
+
// Ruby Net::HTTP / Faraday / HTTParty: get("/path"), post("/path")
|
|
404
|
+
for (const m of content.matchAll(/(?:Net::HTTP|Faraday|HTTParty|RestClient)\.(get|post|put|patch|delete)\(\s*["']([^"'\s]+)["']/gi)) {
|
|
405
|
+
const ep = this.normalizeEndpointPath(m[2]);
|
|
406
|
+
if (ep)
|
|
407
|
+
seen.add(`${m[1].toUpperCase()} ${ep}`);
|
|
408
|
+
}
|
|
409
|
+
// C# HttpClient: client.GetAsync("/path"), client.PostAsync("/path")
|
|
410
|
+
for (const m of content.matchAll(/\.(Get|Post|Put|Patch|Delete)Async\(\s*["']([^"'\s]+)["']/g)) {
|
|
411
|
+
const ep = this.normalizeEndpointPath(m[2]);
|
|
412
|
+
if (ep)
|
|
413
|
+
seen.add(`${m[1].toUpperCase()} ${ep}`);
|
|
414
|
+
}
|
|
415
|
+
// C# RestSharp: new RestRequest("/path", Method.Get)
|
|
416
|
+
for (const m of content.matchAll(/RestRequest\(\s*["']([^"']+)["']\s*,\s*Method\.(Get|Post|Put|Patch|Delete)/gi)) {
|
|
417
|
+
const ep = this.normalizeEndpointPath(m[1]);
|
|
418
|
+
if (ep)
|
|
419
|
+
seen.add(`${m[2].toUpperCase()} ${ep}`);
|
|
420
|
+
}
|
|
421
|
+
// PHP Guzzle/Laravel: $client->get("/path"), $this->getJson("/path"), $this->postJson("/path")
|
|
422
|
+
for (const m of content.matchAll(/(?:->|\$this->)(get|post|put|patch|delete)(?:Json)?\(\s*["']([^"'\s]+)["']/gi)) {
|
|
423
|
+
const ep = this.normalizeEndpointPath(m[2]);
|
|
424
|
+
if (ep)
|
|
425
|
+
seen.add(`${m[1].toUpperCase()} ${ep}`);
|
|
426
|
+
}
|
|
427
|
+
return [...seen].join(", ");
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Normalize an endpoint path extracted from test code.
|
|
431
|
+
* Strips base URL prefixes and returns only the path portion.
|
|
432
|
+
* Returns null if the path doesn't look like an API endpoint.
|
|
433
|
+
*/
|
|
434
|
+
normalizeEndpointPath(raw) {
|
|
435
|
+
let ep = raw;
|
|
436
|
+
// Strip http(s)://host:port prefix
|
|
437
|
+
try {
|
|
438
|
+
const url = new URL(ep);
|
|
439
|
+
ep = url.pathname;
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
// Already a path, not a full URL — strip query string and fragment manually
|
|
443
|
+
const qIdx = ep.indexOf("?");
|
|
444
|
+
if (qIdx !== -1)
|
|
445
|
+
ep = ep.substring(0, qIdx);
|
|
446
|
+
const hIdx = ep.indexOf("#");
|
|
447
|
+
if (hIdx !== -1)
|
|
448
|
+
ep = ep.substring(0, hIdx);
|
|
449
|
+
}
|
|
450
|
+
// Must start with /
|
|
451
|
+
if (!ep.startsWith("/"))
|
|
452
|
+
return null;
|
|
453
|
+
// Skip obviously non-API paths
|
|
454
|
+
if (/^\/(favicon|static|assets|public|_next)\b/.test(ep))
|
|
455
|
+
return null;
|
|
456
|
+
return ep;
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Detect test type from external test file path and content.
|
|
460
|
+
* Falls back to directory/filename heuristics since external tests lack Skyramp command lines.
|
|
461
|
+
*/
|
|
462
|
+
detectExternalTestType(filePath, content) {
|
|
463
|
+
const lowerPath = filePath.toLowerCase();
|
|
464
|
+
if (/integration|_integration/.test(lowerPath))
|
|
465
|
+
return "integration";
|
|
466
|
+
if (/e2e|end[_-]to[_-]end/.test(lowerPath))
|
|
467
|
+
return "e2e";
|
|
468
|
+
if (/contract/.test(lowerPath))
|
|
469
|
+
return "contract";
|
|
470
|
+
if (/smoke/.test(lowerPath))
|
|
471
|
+
return "smoke";
|
|
472
|
+
if (/load|performance/.test(lowerPath))
|
|
473
|
+
return "load";
|
|
474
|
+
if (/[\\/]unit[\\/]|_unit/.test(lowerPath))
|
|
475
|
+
return "unit";
|
|
476
|
+
// Heuristic: files making HTTP calls are likely integration/API tests
|
|
477
|
+
if (/(?:requests|fetch|axios|supertest|RestAssured)/i.test(content))
|
|
478
|
+
return "integration";
|
|
479
|
+
return "unknown";
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Detect test framework from external test file content by checking imports.
|
|
483
|
+
*/
|
|
484
|
+
detectExternalFramework(content) {
|
|
485
|
+
// Python
|
|
486
|
+
if (/import pytest|from pytest/.test(content))
|
|
487
|
+
return "pytest";
|
|
488
|
+
if (/import unittest|from unittest/.test(content))
|
|
489
|
+
return "unittest";
|
|
490
|
+
// JavaScript/TypeScript
|
|
491
|
+
if (/from ['"]vitest['"]|import.*vitest|require\(['"]vitest['"]\)|\bvi\./.test(content))
|
|
492
|
+
return "vitest";
|
|
493
|
+
if (/from ['"]@jest\/globals['"]|from ['"]jest['"]|import.*jest|require\(['"]jest['"]\)|jest\./.test(content) ||
|
|
494
|
+
(/\b(?:test|it)\s*\(/.test(content) && /\bexpect\s*\(/.test(content))) {
|
|
495
|
+
return "jest";
|
|
496
|
+
}
|
|
497
|
+
if (/from ['"]mocha['"]|require\(['"]mocha['"]\)/.test(content))
|
|
498
|
+
return "mocha";
|
|
499
|
+
if (/supertest/.test(content))
|
|
500
|
+
return "supertest";
|
|
501
|
+
if (/from ['"]@playwright/.test(content))
|
|
502
|
+
return "playwright";
|
|
503
|
+
if (/from ['"]cypress/.test(content))
|
|
504
|
+
return "cypress";
|
|
505
|
+
// Java / Kotlin — check TestNG before @Test since TestNG also uses @Test
|
|
506
|
+
if (/import org\.testng/.test(content))
|
|
507
|
+
return "testng";
|
|
508
|
+
if (/@Test|import org\.junit/.test(content))
|
|
509
|
+
return "junit";
|
|
510
|
+
if (/RestAssured/.test(content))
|
|
511
|
+
return "rest-assured";
|
|
512
|
+
// Go
|
|
513
|
+
if (/import.*"testing"|func\s+Test\w+\(t\s+\*testing\.T\)/.test(content))
|
|
514
|
+
return "go-testing";
|
|
515
|
+
if (/httptest\.NewServer|httptest\.NewRequest/.test(content))
|
|
516
|
+
return "go-httptest";
|
|
517
|
+
// Ruby
|
|
518
|
+
if (/require ['"]rspec|RSpec\.describe/.test(content))
|
|
519
|
+
return "rspec";
|
|
520
|
+
if (/require ['"]minitest|Minitest::Test/.test(content))
|
|
521
|
+
return "minitest";
|
|
522
|
+
// C# / .NET
|
|
523
|
+
if (/\[TestMethod\]/.test(content))
|
|
524
|
+
return "mstest";
|
|
525
|
+
if (/\[Fact\]|\[Theory\]/.test(content))
|
|
526
|
+
return "xunit";
|
|
527
|
+
if (/\[Test\]|NUnit/.test(content))
|
|
528
|
+
return "nunit";
|
|
529
|
+
// PHP
|
|
530
|
+
if (/extends TestCase|PHPUnit/.test(content))
|
|
531
|
+
return "phpunit";
|
|
532
|
+
// Rust
|
|
533
|
+
if (/\#\[test\]|#\[cfg\(test\)\]/.test(content))
|
|
534
|
+
return "rust-test";
|
|
535
|
+
// Swift
|
|
536
|
+
if (/import XCTest|XCTestCase/.test(content))
|
|
537
|
+
return "xctest";
|
|
538
|
+
// Scala
|
|
539
|
+
if (/import org\.scalatest|FunSuite|FlatSpec/.test(content))
|
|
540
|
+
return "scalatest";
|
|
541
|
+
return "";
|
|
542
|
+
}
|
|
196
543
|
/**
|
|
197
544
|
* Detect programming language from file extension
|
|
198
545
|
*/
|
|
@@ -202,7 +549,19 @@ export class TestDiscoveryService {
|
|
|
202
549
|
".py": "python",
|
|
203
550
|
".js": "javascript",
|
|
204
551
|
".ts": "typescript",
|
|
552
|
+
".tsx": "typescript",
|
|
553
|
+
".jsx": "javascript",
|
|
205
554
|
".java": "java",
|
|
555
|
+
".kt": "kotlin",
|
|
556
|
+
".kts": "kotlin",
|
|
557
|
+
".go": "go",
|
|
558
|
+
".rb": "ruby",
|
|
559
|
+
".cs": "csharp",
|
|
560
|
+
".rs": "rust",
|
|
561
|
+
".php": "php",
|
|
562
|
+
".scala": "scala",
|
|
563
|
+
".swift": "swift",
|
|
564
|
+
".m": "objective-c",
|
|
206
565
|
};
|
|
207
566
|
return languageMap[ext] || "unknown";
|
|
208
567
|
}
|