@skyramp/mcp 0.0.43 → 0.0.45
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 +15 -0
- package/build/prompts/code-reuse.js +1 -1
- package/build/prompts/driftAnalysisPrompt.js +159 -0
- package/build/prompts/modularization/ui-test-modularization.js +2 -0
- package/build/prompts/testGenerationPrompt.js +2 -2
- package/build/prompts/testHealthPrompt.js +82 -0
- package/build/services/DriftAnalysisService.js +924 -0
- package/build/services/ModularizationService.js +16 -1
- package/build/services/TestDiscoveryService.js +237 -0
- package/build/services/TestExecutionService.js +311 -0
- package/build/services/TestGenerationService.js +16 -2
- package/build/services/TestHealthService.js +653 -0
- package/build/tools/auth/loginTool.js +1 -1
- package/build/tools/auth/logoutTool.js +1 -1
- package/build/tools/code-refactor/codeReuseTool.js +5 -3
- package/build/tools/code-refactor/modularizationTool.js +8 -2
- package/build/tools/executeSkyrampTestTool.js +12 -122
- package/build/tools/fixErrorTool.js +1 -1
- package/build/tools/generate-tests/generateE2ERestTool.js +1 -1
- package/build/tools/generate-tests/generateFuzzRestTool.js +1 -1
- package/build/tools/generate-tests/generateLoadRestTool.js +1 -1
- package/build/tools/generate-tests/generateSmokeRestTool.js +1 -1
- package/build/tools/generate-tests/generateUIRestTool.js +1 -1
- package/build/tools/test-maintenance/actionsTool.js +202 -0
- package/build/tools/test-maintenance/analyzeTestDriftTool.js +188 -0
- package/build/tools/test-maintenance/calculateHealthScoresTool.js +248 -0
- package/build/tools/test-maintenance/discoverTestsTool.js +135 -0
- package/build/tools/test-maintenance/executeBatchTestsTool.js +188 -0
- package/build/tools/test-maintenance/stateCleanupTool.js +145 -0
- package/build/tools/test-recommendation/analyzeRepositoryTool.js +16 -1
- package/build/tools/test-recommendation/recommendTestsTool.js +6 -2
- package/build/tools/trace/startTraceCollectionTool.js +1 -1
- package/build/tools/trace/stopTraceCollectionTool.js +1 -1
- package/build/types/TestAnalysis.js +1 -0
- package/build/types/TestDriftAnalysis.js +1 -0
- package/build/types/TestExecution.js +6 -0
- package/build/types/TestHealth.js +4 -0
- package/build/utils/AnalysisStateManager.js +238 -0
- package/build/utils/utils.test.js +25 -9
- package/package.json +6 -3
|
@@ -9,8 +9,23 @@ export class ModularizationService {
|
|
|
9
9
|
testType: params.testType,
|
|
10
10
|
language: params.language,
|
|
11
11
|
});
|
|
12
|
-
let prompt = "";
|
|
13
12
|
const testType = params.testType;
|
|
13
|
+
// Check if the test type is one that should not be modularized
|
|
14
|
+
if (!params.isTraceBased) {
|
|
15
|
+
return {
|
|
16
|
+
content: [
|
|
17
|
+
{
|
|
18
|
+
type: "text",
|
|
19
|
+
text: `⚠️ **MODULARIZATION NOT APPLICABLE**
|
|
20
|
+
|
|
21
|
+
Modularization is intentionally NOT applied to ${testType.toUpperCase()} tests if the test is not generated from traces.
|
|
22
|
+
The test file \`${params.testFile}\` will remain unchanged. No further action is needed.`,
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
isError: false,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
let prompt = "";
|
|
14
29
|
switch (testType) {
|
|
15
30
|
case TestType.UI:
|
|
16
31
|
prompt = getModularizationPromptForUI(params.testFile);
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { simpleGit } from "simple-git";
|
|
4
|
+
import { logger } from "../utils/logger.js";
|
|
5
|
+
import fg from "fast-glob";
|
|
6
|
+
export class TestDiscoveryService {
|
|
7
|
+
EXCLUDED_DIRS = [
|
|
8
|
+
"node_modules",
|
|
9
|
+
"venv",
|
|
10
|
+
".venv",
|
|
11
|
+
"build",
|
|
12
|
+
"dist",
|
|
13
|
+
".git",
|
|
14
|
+
"__pycache__",
|
|
15
|
+
".pytest_cache",
|
|
16
|
+
"coverage",
|
|
17
|
+
".next",
|
|
18
|
+
"out",
|
|
19
|
+
"target",
|
|
20
|
+
];
|
|
21
|
+
SKYRAMP_MARKER = "Generated by Skyramp";
|
|
22
|
+
// Supported test file extensions
|
|
23
|
+
SUPPORTED_EXTENSIONS = [".py", ".js", ".ts", ".java"];
|
|
24
|
+
// Concurrency control for parallel operations
|
|
25
|
+
MAX_CONCURRENT_OPERATIONS = 10;
|
|
26
|
+
// Cache git client and repo status per repository
|
|
27
|
+
gitClientCache = new Map();
|
|
28
|
+
isGitRepoCache = new Map();
|
|
29
|
+
/**
|
|
30
|
+
* Discover all Skyramp tests in a repository
|
|
31
|
+
* Uses fast-glob for cross-platform file scanning
|
|
32
|
+
*/
|
|
33
|
+
async discoverTests(repositoryPath) {
|
|
34
|
+
logger.info(`Starting test discovery in: ${repositoryPath}`);
|
|
35
|
+
if (!fs.existsSync(repositoryPath)) {
|
|
36
|
+
throw new Error(`Repository path does not exist: ${repositoryPath}`);
|
|
37
|
+
}
|
|
38
|
+
const stats = fs.statSync(repositoryPath);
|
|
39
|
+
if (!stats.isDirectory()) {
|
|
40
|
+
throw new Error(`Path is not a directory: ${repositoryPath}`);
|
|
41
|
+
}
|
|
42
|
+
// Initialize git client cache for this repository
|
|
43
|
+
await this.initializeGitClient(repositoryPath);
|
|
44
|
+
// Use cross-platform file search to find files containing Skyramp marker
|
|
45
|
+
const testFiles = this.findSkyrampTestsWithGrep(repositoryPath);
|
|
46
|
+
logger.info(`Found ${testFiles.length} Skyramp test files`);
|
|
47
|
+
// Process files in parallel with concurrency control
|
|
48
|
+
const skyrampTests = await this.processFilesInBatches(testFiles, repositoryPath);
|
|
49
|
+
logger.info(`Discovered ${skyrampTests.length} Skyramp tests`);
|
|
50
|
+
// Clean up cache to free memory
|
|
51
|
+
this.gitClientCache.clear();
|
|
52
|
+
this.isGitRepoCache.clear();
|
|
53
|
+
return {
|
|
54
|
+
tests: skyrampTests,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Initialize git client and check if repository is a git repo
|
|
59
|
+
*/
|
|
60
|
+
async initializeGitClient(repositoryPath) {
|
|
61
|
+
try {
|
|
62
|
+
const git = simpleGit(repositoryPath);
|
|
63
|
+
this.gitClientCache.set(repositoryPath, git);
|
|
64
|
+
const isRepo = await git.checkIsRepo();
|
|
65
|
+
this.isGitRepoCache.set(repositoryPath, isRepo);
|
|
66
|
+
if (isRepo) {
|
|
67
|
+
logger.debug(`Git repository detected at: ${repositoryPath}`);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
logger.debug(`Not a git repository: ${repositoryPath}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
logger.debug(`Could not initialize git client: ${error.message}`);
|
|
75
|
+
this.isGitRepoCache.set(repositoryPath, false);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Process test files in parallel batches with concurrency control
|
|
80
|
+
*/
|
|
81
|
+
async processFilesInBatches(testFiles, repositoryPath) {
|
|
82
|
+
const results = [];
|
|
83
|
+
// Process files in batches to control concurrency
|
|
84
|
+
for (let i = 0; i < testFiles.length; i += this.MAX_CONCURRENT_OPERATIONS) {
|
|
85
|
+
const batch = testFiles.slice(i, i + this.MAX_CONCURRENT_OPERATIONS);
|
|
86
|
+
const batchResults = await Promise.all(batch.map(async (testFile) => {
|
|
87
|
+
try {
|
|
88
|
+
return await this.extractTestMetadata(testFile, repositoryPath);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
logger.error(`Error processing test file ${testFile}: ${error}`);
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}));
|
|
95
|
+
// Filter out null results and add to final results
|
|
96
|
+
results.push(...batchResults.filter((test) => test !== null));
|
|
97
|
+
}
|
|
98
|
+
return results;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Find files containing Skyramp marker using cross-platform Node.js file search
|
|
102
|
+
*/
|
|
103
|
+
findSkyrampTestsWithGrep(repositoryPath) {
|
|
104
|
+
try {
|
|
105
|
+
// Build glob patterns for supported extensions
|
|
106
|
+
const globPatterns = this.SUPPORTED_EXTENSIONS.map((ext) => `**/*${ext}`);
|
|
107
|
+
// Build ignore patterns for excluded directories
|
|
108
|
+
const ignorePatterns = this.EXCLUDED_DIRS.map((dir) => `**/${dir}/**`);
|
|
109
|
+
// Use fast-glob to find all matching files
|
|
110
|
+
const allFiles = fg.sync(globPatterns, {
|
|
111
|
+
cwd: repositoryPath,
|
|
112
|
+
ignore: ignorePatterns,
|
|
113
|
+
absolute: true,
|
|
114
|
+
caseSensitiveMatch: false,
|
|
115
|
+
});
|
|
116
|
+
logger.debug(`Found ${allFiles.length} candidate files to search`);
|
|
117
|
+
// Read files and check for Skyramp marker
|
|
118
|
+
const matchingFiles = [];
|
|
119
|
+
const marker = this.SKYRAMP_MARKER;
|
|
120
|
+
// Process files in batches to avoid memory issues
|
|
121
|
+
const batchSize = 100;
|
|
122
|
+
for (let i = 0; i < allFiles.length; i += batchSize) {
|
|
123
|
+
const batch = allFiles.slice(i, i + batchSize);
|
|
124
|
+
const batchResults = batch.filter((file) => {
|
|
125
|
+
try {
|
|
126
|
+
// Read file and check for marker
|
|
127
|
+
const content = fs.readFileSync(file, "utf-8");
|
|
128
|
+
return content.includes(marker);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
// Skip files that can't be read (permissions, etc.)
|
|
132
|
+
logger.debug(`Skipping file ${file}: ${error}`);
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
matchingFiles.push(...batchResults);
|
|
137
|
+
}
|
|
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
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Extract metadata from a test file
|
|
149
|
+
* File is already confirmed to contain Skyramp marker by file search
|
|
150
|
+
*/
|
|
151
|
+
async extractTestMetadata(testFile, repositoryPath) {
|
|
152
|
+
let content;
|
|
153
|
+
try {
|
|
154
|
+
// Read full file (file search already confirmed it has Skyramp marker)
|
|
155
|
+
content = fs.readFileSync(testFile, "utf-8");
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
logger.debug(`Could not read file ${testFile}: ${error}`);
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
const language = this.detectLanguage(testFile);
|
|
162
|
+
const testType = this.detectTestType(content, testFile);
|
|
163
|
+
const apiSchema = this.extractApiSchema(content);
|
|
164
|
+
const framework = this.extractFramework(content);
|
|
165
|
+
return {
|
|
166
|
+
testFile,
|
|
167
|
+
testType,
|
|
168
|
+
language,
|
|
169
|
+
framework,
|
|
170
|
+
apiSchema,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Detect programming language from file extension
|
|
175
|
+
*/
|
|
176
|
+
detectLanguage(testFile) {
|
|
177
|
+
const ext = path.extname(testFile);
|
|
178
|
+
const languageMap = {
|
|
179
|
+
".py": "python",
|
|
180
|
+
".js": "javascript",
|
|
181
|
+
".ts": "typescript",
|
|
182
|
+
".java": "java",
|
|
183
|
+
};
|
|
184
|
+
return languageMap[ext] || "unknown";
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Detect test type from file content and name
|
|
188
|
+
* Checks Skyramp command line, filename, and content patterns
|
|
189
|
+
*/
|
|
190
|
+
detectTestType(content, testFile) {
|
|
191
|
+
const testTypes = [
|
|
192
|
+
"smoke",
|
|
193
|
+
"integration",
|
|
194
|
+
"load",
|
|
195
|
+
"contract",
|
|
196
|
+
"fuzz",
|
|
197
|
+
"e2e",
|
|
198
|
+
"ui",
|
|
199
|
+
];
|
|
200
|
+
// First, try to extract from Skyramp command line
|
|
201
|
+
// Pattern: skyramp generate smoke rest ...
|
|
202
|
+
const commandMatch = content.match(/skyramp generate (\w+)/i);
|
|
203
|
+
if (commandMatch && commandMatch[1]) {
|
|
204
|
+
const type = commandMatch[1].toLowerCase();
|
|
205
|
+
if (testTypes.includes(type)) {
|
|
206
|
+
return type;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return "";
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Extract API schema path from test content
|
|
213
|
+
* Looks for Skyramp command line first, then fallback to other patterns
|
|
214
|
+
*/
|
|
215
|
+
extractApiSchema(content) {
|
|
216
|
+
// First, try to extract from Skyramp command line
|
|
217
|
+
// Pattern: --api-schema http://localhost:8000/openapi.json
|
|
218
|
+
const apiSchemaMatch = content.match(/--api-schema\s+([^\s\\]+)/);
|
|
219
|
+
if (apiSchemaMatch && apiSchemaMatch[1]) {
|
|
220
|
+
return apiSchemaMatch[1];
|
|
221
|
+
}
|
|
222
|
+
return "";
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Extract Framework from test content
|
|
226
|
+
* Looks for Skyramp command line first, then fallback to other patterns
|
|
227
|
+
*/
|
|
228
|
+
extractFramework(content) {
|
|
229
|
+
// First, try to extract from Skyramp command line
|
|
230
|
+
// Pattern: --framework pytest
|
|
231
|
+
const frameworkMatch = content.match(/--framework\s+([^\s\\]+)/);
|
|
232
|
+
if (frameworkMatch && frameworkMatch[1]) {
|
|
233
|
+
return frameworkMatch[1];
|
|
234
|
+
}
|
|
235
|
+
return "";
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import Docker from "dockerode";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import { Writable } from "stream";
|
|
5
|
+
import { stripVTControlCharacters } from "util";
|
|
6
|
+
import { logger } from "../utils/logger.js";
|
|
7
|
+
const DEFAULT_TIMEOUT = 300000; // 5 minutes
|
|
8
|
+
const MAX_CONCURRENT_EXECUTIONS = 5;
|
|
9
|
+
const EXECUTOR_DOCKER_IMAGE = "skyramp/executor:v1.2.38";
|
|
10
|
+
const DOCKER_PLATFORM = "linux/amd64";
|
|
11
|
+
// Files and directories to exclude when mounting workspace to Docker container
|
|
12
|
+
export const EXCLUDED_MOUNT_ITEMS = [
|
|
13
|
+
"package-lock.json",
|
|
14
|
+
"package.json",
|
|
15
|
+
"node_modules",
|
|
16
|
+
];
|
|
17
|
+
export class TestExecutionService {
|
|
18
|
+
docker;
|
|
19
|
+
imageReady;
|
|
20
|
+
constructor() {
|
|
21
|
+
this.docker = new Docker();
|
|
22
|
+
this.imageReady = this.ensureDockerImage();
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Execute multiple tests in parallel batches
|
|
26
|
+
*/
|
|
27
|
+
async executeBatch(testOptions) {
|
|
28
|
+
logger.info(`Starting batch execution of ${testOptions.length} tests`);
|
|
29
|
+
const startTime = Date.now();
|
|
30
|
+
const results = [];
|
|
31
|
+
// Execute tests in batches to control concurrency
|
|
32
|
+
for (let i = 0; i < testOptions.length; i += MAX_CONCURRENT_EXECUTIONS) {
|
|
33
|
+
const batch = testOptions.slice(i, i + MAX_CONCURRENT_EXECUTIONS);
|
|
34
|
+
logger.debug(`Executing batch ${Math.floor(i / MAX_CONCURRENT_EXECUTIONS) + 1} (${batch.length} tests)`);
|
|
35
|
+
const batchPromises = batch.map((options) => this.executeTest(options).catch((error) => {
|
|
36
|
+
// If execution fails completely, return error result
|
|
37
|
+
return {
|
|
38
|
+
testFile: options.testFile,
|
|
39
|
+
passed: false,
|
|
40
|
+
executedAt: new Date().toISOString(),
|
|
41
|
+
duration: 0,
|
|
42
|
+
errors: [error.message],
|
|
43
|
+
warnings: [],
|
|
44
|
+
crashed: true,
|
|
45
|
+
};
|
|
46
|
+
}));
|
|
47
|
+
const batchResults = await Promise.all(batchPromises);
|
|
48
|
+
results.push(...batchResults);
|
|
49
|
+
}
|
|
50
|
+
const totalDuration = Date.now() - startTime;
|
|
51
|
+
const passed = results.filter((r) => r.passed).length;
|
|
52
|
+
const failed = results.filter((r) => !r.passed && !r.crashed).length;
|
|
53
|
+
const crashed = results.filter((r) => r.crashed).length;
|
|
54
|
+
logger.info(`Batch execution complete: ${passed} passed, ${failed} failed, ${crashed} crashed`);
|
|
55
|
+
return {
|
|
56
|
+
totalTests: testOptions.length,
|
|
57
|
+
passed,
|
|
58
|
+
failed,
|
|
59
|
+
crashed,
|
|
60
|
+
totalDuration,
|
|
61
|
+
results,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Execute a single test
|
|
66
|
+
*/
|
|
67
|
+
async executeTest(options) {
|
|
68
|
+
const startTime = Date.now();
|
|
69
|
+
const executedAt = new Date().toISOString();
|
|
70
|
+
logger.debug(`Executing test: ${options.testFile}`);
|
|
71
|
+
// Wait for Docker image to be ready
|
|
72
|
+
await this.imageReady;
|
|
73
|
+
// Validate workspace path - use accessSync for better validation
|
|
74
|
+
const workspacePath = path.resolve(options.workspacePath);
|
|
75
|
+
try {
|
|
76
|
+
fs.accessSync(workspacePath, fs.constants.R_OK);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
throw new Error(`Workspace path does not exist or is not readable: ${workspacePath}`);
|
|
80
|
+
}
|
|
81
|
+
// Validate test file - use basename for safer filename extraction
|
|
82
|
+
const filename = path.basename(options.testFile);
|
|
83
|
+
if (!filename) {
|
|
84
|
+
throw new Error("Invalid test file path: could not extract filename");
|
|
85
|
+
}
|
|
86
|
+
if (!fs.existsSync(options.testFile)) {
|
|
87
|
+
throw new Error(`Test file does not exist: ${options.testFile}`);
|
|
88
|
+
}
|
|
89
|
+
const containerMountPath = "/home/user";
|
|
90
|
+
const dockerSocketPath = "/var/run/docker.sock";
|
|
91
|
+
// Calculate relative test file path
|
|
92
|
+
let testFilePath = path.relative(workspacePath, options.testFile);
|
|
93
|
+
testFilePath = path.resolve(containerMountPath, testFilePath);
|
|
94
|
+
// Prepare Docker command
|
|
95
|
+
const command = [
|
|
96
|
+
"./root/runner.sh",
|
|
97
|
+
options.language,
|
|
98
|
+
testFilePath,
|
|
99
|
+
options.testType,
|
|
100
|
+
];
|
|
101
|
+
// Prepare host config with mounts
|
|
102
|
+
const hostConfig = {
|
|
103
|
+
ExtraHosts: ["host.docker.internal:host-gateway"],
|
|
104
|
+
Mounts: [
|
|
105
|
+
{
|
|
106
|
+
Type: "bind",
|
|
107
|
+
Target: dockerSocketPath,
|
|
108
|
+
Source: dockerSocketPath,
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
// Mount workspace files (excluding unnecessary items)
|
|
113
|
+
const workspaceFiles = fs.readdirSync(workspacePath);
|
|
114
|
+
const filesToMount = workspaceFiles.filter((file) => !EXCLUDED_MOUNT_ITEMS.includes(file));
|
|
115
|
+
hostConfig.Mounts?.push(...filesToMount.map((file) => ({
|
|
116
|
+
Type: "bind",
|
|
117
|
+
Target: path.join(containerMountPath, file),
|
|
118
|
+
Source: path.join(workspacePath, file),
|
|
119
|
+
})));
|
|
120
|
+
// Prepare environment variables
|
|
121
|
+
const env = [
|
|
122
|
+
`SKYRAMP_TEST_TOKEN=${options.token || ""}`,
|
|
123
|
+
"SKYRAMP_IN_DOCKER=true",
|
|
124
|
+
];
|
|
125
|
+
if (process.env.SKYRAMP_DEBUG) {
|
|
126
|
+
env.push(`SKYRAMP_DEBUG=${process.env.SKYRAMP_DEBUG}`);
|
|
127
|
+
}
|
|
128
|
+
if (process.env.API_KEY) {
|
|
129
|
+
env.push(`API_KEY=${process.env.API_KEY}`);
|
|
130
|
+
}
|
|
131
|
+
// Capture output
|
|
132
|
+
let output = "";
|
|
133
|
+
class DockerStream extends Writable {
|
|
134
|
+
_write(data, encode, cb) {
|
|
135
|
+
output += data.toString();
|
|
136
|
+
cb();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const stream = new DockerStream();
|
|
140
|
+
try {
|
|
141
|
+
let statusCode = 0;
|
|
142
|
+
let containerRef = null;
|
|
143
|
+
// Run container with timeout
|
|
144
|
+
const executionPromise = this.docker
|
|
145
|
+
.run(EXECUTOR_DOCKER_IMAGE, command, stream, {
|
|
146
|
+
Env: env,
|
|
147
|
+
HostConfig: hostConfig,
|
|
148
|
+
})
|
|
149
|
+
.then(function (data) {
|
|
150
|
+
const result = data[0];
|
|
151
|
+
const container = data[1];
|
|
152
|
+
containerRef = container; // Capture container reference for cleanup
|
|
153
|
+
stream.end();
|
|
154
|
+
statusCode = result.StatusCode;
|
|
155
|
+
logger.debug("Docker container execution completed");
|
|
156
|
+
return container.remove();
|
|
157
|
+
})
|
|
158
|
+
.then(function () {
|
|
159
|
+
logger.debug("Docker container removed successfully");
|
|
160
|
+
containerRef = null; // Container already removed
|
|
161
|
+
return statusCode;
|
|
162
|
+
})
|
|
163
|
+
.catch(function (err) {
|
|
164
|
+
logger.error("Docker container execution failed", {
|
|
165
|
+
error: err.message,
|
|
166
|
+
});
|
|
167
|
+
throw err;
|
|
168
|
+
});
|
|
169
|
+
// Apply timeout
|
|
170
|
+
const timeout = options.timeout || DEFAULT_TIMEOUT;
|
|
171
|
+
let timeoutHandle;
|
|
172
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
173
|
+
timeoutHandle = setTimeout(() => reject(new Error(`Test execution timeout after ${timeout}ms`)), timeout);
|
|
174
|
+
});
|
|
175
|
+
try {
|
|
176
|
+
statusCode = await Promise.race([executionPromise, timeoutPromise]);
|
|
177
|
+
// Clear the timeout timer if execution completes successfully
|
|
178
|
+
if (timeoutHandle)
|
|
179
|
+
clearTimeout(timeoutHandle);
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
// Clear the timeout timer on any error
|
|
183
|
+
if (timeoutHandle)
|
|
184
|
+
clearTimeout(timeoutHandle);
|
|
185
|
+
// Cleanup container on timeout or other errors
|
|
186
|
+
if (containerRef !== null) {
|
|
187
|
+
const container = containerRef;
|
|
188
|
+
try {
|
|
189
|
+
logger.warning("Cleaning up orphaned Docker container after error");
|
|
190
|
+
await container.stop({ t: 5 }); // Give 5 seconds to stop gracefully
|
|
191
|
+
await container.remove({ force: true });
|
|
192
|
+
logger.debug("Orphaned container cleaned up successfully");
|
|
193
|
+
}
|
|
194
|
+
catch (cleanupError) {
|
|
195
|
+
logger.error("Failed to cleanup orphaned container", {
|
|
196
|
+
error: cleanupError.message,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
throw error; // Re-throw the original error
|
|
201
|
+
}
|
|
202
|
+
const duration = Date.now() - startTime;
|
|
203
|
+
const cleanOutput = stripVTControlCharacters(output);
|
|
204
|
+
logger.info("Test execution completed", {
|
|
205
|
+
output: cleanOutput.substring(0, 200) +
|
|
206
|
+
(cleanOutput.length > 200 ? "..." : ""),
|
|
207
|
+
statusCode,
|
|
208
|
+
});
|
|
209
|
+
// Parse errors and warnings from output
|
|
210
|
+
const errors = this.parseErrors(cleanOutput);
|
|
211
|
+
const warnings = this.parseWarnings(cleanOutput);
|
|
212
|
+
// Check if test crashed
|
|
213
|
+
const crashed = statusCode !== 0 && statusCode !== 1;
|
|
214
|
+
const result = {
|
|
215
|
+
testFile: options.testFile,
|
|
216
|
+
passed: statusCode === 0,
|
|
217
|
+
executedAt,
|
|
218
|
+
duration,
|
|
219
|
+
errors,
|
|
220
|
+
warnings,
|
|
221
|
+
crashed,
|
|
222
|
+
output: cleanOutput,
|
|
223
|
+
exitCode: statusCode,
|
|
224
|
+
};
|
|
225
|
+
logger.debug(`Test ${result.passed ? "passed" : "failed"}: ${options.testFile}`);
|
|
226
|
+
return result;
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
const duration = Date.now() - startTime;
|
|
230
|
+
const cleanOutput = stripVTControlCharacters(output);
|
|
231
|
+
logger.error(`Test execution error: ${error.message}`);
|
|
232
|
+
return {
|
|
233
|
+
testFile: options.testFile,
|
|
234
|
+
passed: false,
|
|
235
|
+
executedAt,
|
|
236
|
+
duration,
|
|
237
|
+
errors: [error.message],
|
|
238
|
+
warnings: [],
|
|
239
|
+
crashed: true,
|
|
240
|
+
output: cleanOutput,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Ensure Docker image is available
|
|
246
|
+
*/
|
|
247
|
+
async ensureDockerImage() {
|
|
248
|
+
try {
|
|
249
|
+
const images = await this.docker.listImages();
|
|
250
|
+
const imageExists = images.some((img) => (img.RepoTags && img.RepoTags.includes(EXECUTOR_DOCKER_IMAGE)) ||
|
|
251
|
+
(img.RepoDigests && img.RepoDigests.includes(EXECUTOR_DOCKER_IMAGE)));
|
|
252
|
+
if (imageExists) {
|
|
253
|
+
logger.debug(`Docker image ${EXECUTOR_DOCKER_IMAGE} already available`);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
logger.info(`Pulling Docker image ${EXECUTOR_DOCKER_IMAGE}...`);
|
|
257
|
+
await new Promise((resolve, reject) => {
|
|
258
|
+
this.docker.pull(EXECUTOR_DOCKER_IMAGE, { platform: DOCKER_PLATFORM }, (err, stream) => {
|
|
259
|
+
if (err)
|
|
260
|
+
return reject(err);
|
|
261
|
+
if (!stream)
|
|
262
|
+
return reject(new Error("No stream received from docker pull"));
|
|
263
|
+
this.docker.modem.followProgress(stream, (err, res) => {
|
|
264
|
+
if (err)
|
|
265
|
+
return reject(err);
|
|
266
|
+
logger.info(`Docker image ${EXECUTOR_DOCKER_IMAGE} pulled successfully`);
|
|
267
|
+
resolve(res);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
logger.error(`Failed to ensure Docker image: ${error.message}`);
|
|
274
|
+
throw new Error(`Docker image setup failed: ${error.message}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Parse errors from test output
|
|
279
|
+
*/
|
|
280
|
+
parseErrors(output) {
|
|
281
|
+
const errors = [];
|
|
282
|
+
const lines = output.split("\n");
|
|
283
|
+
for (const line of lines) {
|
|
284
|
+
// Common error patterns
|
|
285
|
+
if (line.includes("Error:") ||
|
|
286
|
+
line.includes("ERROR") ||
|
|
287
|
+
line.includes("AssertionError") ||
|
|
288
|
+
line.includes("Exception") ||
|
|
289
|
+
line.includes("FAILED") ||
|
|
290
|
+
line.includes("Traceback")) {
|
|
291
|
+
errors.push(line.trim());
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return errors.slice(0, 20); // Limit to first 20 errors
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Parse warnings from test output
|
|
298
|
+
*/
|
|
299
|
+
parseWarnings(output) {
|
|
300
|
+
const warnings = [];
|
|
301
|
+
const lines = output.split("\n");
|
|
302
|
+
for (const line of lines) {
|
|
303
|
+
if (line.includes("Warning:") ||
|
|
304
|
+
line.includes("WARN") ||
|
|
305
|
+
line.includes("Deprecated")) {
|
|
306
|
+
warnings.push(line.trim());
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return warnings.slice(0, 10); // Limit to first 10 warnings
|
|
310
|
+
}
|
|
311
|
+
}
|
|
@@ -44,12 +44,26 @@ export class TestGenerationService {
|
|
|
44
44
|
`;
|
|
45
45
|
}
|
|
46
46
|
else if (params.modularizeCode) {
|
|
47
|
-
//
|
|
48
|
-
|
|
47
|
+
// Check if test type should be modularized
|
|
48
|
+
if (!params.trace && !params.scenarioFile && !params.playwrightInput) {
|
|
49
|
+
postGenerationMessage += `
|
|
50
|
+
Test generated successfully!
|
|
51
|
+
|
|
52
|
+
**NOTE**: Modularization is intentionally NOT applied to ${testType.toUpperCase()} tests.
|
|
53
|
+
${testType.toUpperCase()} tests are designed to be simple, straightforward, and deterministic.
|
|
54
|
+
Modularization can introduce unpredictable results and unnecessary complexity for these test types.
|
|
55
|
+
|
|
56
|
+
The generated test file remains unchanged and ready to use as-is.
|
|
57
|
+
`;
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
// Only modularization for UI, E2E, Integration tests
|
|
61
|
+
postGenerationMessage += `
|
|
49
62
|
Test generated successfully!
|
|
50
63
|
|
|
51
64
|
⏭️**CRITICAL NEXT STEP**: Running modularization with skyramp_modularization tool.
|
|
52
65
|
`;
|
|
66
|
+
}
|
|
53
67
|
}
|
|
54
68
|
return {
|
|
55
69
|
content: [
|