@skyramp/mcp 0.0.44 → 0.0.46
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 +57 -12
- 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/AnalyticsService.js +86 -0
- package/build/services/DriftAnalysisService.js +928 -0
- package/build/services/ModularizationService.js +16 -1
- package/build/services/TestDiscoveryService.js +237 -0
- package/build/services/TestExecutionService.js +504 -0
- package/build/services/TestGenerationService.js +16 -2
- package/build/services/TestHealthService.js +656 -0
- package/build/tools/auth/loginTool.js +13 -3
- package/build/tools/auth/logoutTool.js +13 -3
- package/build/tools/code-refactor/codeReuseTool.js +46 -18
- package/build/tools/code-refactor/modularizationTool.js +44 -11
- package/build/tools/executeSkyrampTestTool.js +29 -125
- package/build/tools/fixErrorTool.js +38 -14
- package/build/tools/generate-tests/generateContractRestTool.js +8 -2
- package/build/tools/generate-tests/generateE2ERestTool.js +9 -3
- package/build/tools/generate-tests/generateFuzzRestTool.js +9 -3
- package/build/tools/generate-tests/generateIntegrationRestTool.js +8 -2
- package/build/tools/generate-tests/generateLoadRestTool.js +9 -3
- package/build/tools/generate-tests/generateScenarioRestTool.js +8 -2
- package/build/tools/generate-tests/generateSmokeRestTool.js +9 -3
- package/build/tools/generate-tests/generateUIRestTool.js +9 -3
- package/build/tools/test-maintenance/actionsTool.js +230 -0
- package/build/tools/test-maintenance/analyzeTestDriftTool.js +197 -0
- package/build/tools/test-maintenance/calculateHealthScoresTool.js +257 -0
- package/build/tools/test-maintenance/discoverTestsTool.js +143 -0
- package/build/tools/test-maintenance/executeBatchTestsTool.js +198 -0
- package/build/tools/test-maintenance/stateCleanupTool.js +153 -0
- package/build/tools/test-recommendation/analyzeRepositoryTool.js +27 -3
- package/build/tools/test-recommendation/mapTestsTool.js +9 -2
- package/build/tools/test-recommendation/recommendTestsTool.js +21 -5
- package/build/tools/trace/startTraceCollectionTool.js +18 -5
- package/build/tools/trace/stopTraceCollectionTool.js +28 -4
- 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 +240 -0
- package/build/utils/utils.test.js +25 -9
- package/package.json +6 -3
|
@@ -0,0 +1,504 @@
|
|
|
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.40";
|
|
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
|
+
/**
|
|
18
|
+
* Find the start index of a comment in a line, ignoring comment delimiters inside strings
|
|
19
|
+
* Returns -1 if no comment is found outside of strings
|
|
20
|
+
*/
|
|
21
|
+
function findCommentStart(line) {
|
|
22
|
+
let inSingleQuote = false;
|
|
23
|
+
let inDoubleQuote = false;
|
|
24
|
+
let escaped = false;
|
|
25
|
+
for (let i = 0; i < line.length; i++) {
|
|
26
|
+
const char = line[i];
|
|
27
|
+
const nextChar = i + 1 < line.length ? line[i + 1] : '';
|
|
28
|
+
// Handle escape sequences
|
|
29
|
+
if (escaped) {
|
|
30
|
+
escaped = false;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (char === '\\') {
|
|
34
|
+
escaped = true;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
// Track string boundaries
|
|
38
|
+
if (char === "'" && !inDoubleQuote) {
|
|
39
|
+
inSingleQuote = !inSingleQuote;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (char === '"' && !inSingleQuote) {
|
|
43
|
+
inDoubleQuote = !inDoubleQuote;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
// Check for comments only if not in a string
|
|
47
|
+
if (!inSingleQuote && !inDoubleQuote) {
|
|
48
|
+
if (char === '/' && nextChar === '/') {
|
|
49
|
+
return i;
|
|
50
|
+
}
|
|
51
|
+
if (char === '#') {
|
|
52
|
+
return i;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return -1; // No comment found
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Filter out comments from code lines
|
|
60
|
+
* Returns array of non-comment lines with inline comments removed
|
|
61
|
+
*/
|
|
62
|
+
function filterComments(lines) {
|
|
63
|
+
const nonCommentLines = [];
|
|
64
|
+
let inMultiLineComment = false;
|
|
65
|
+
let inPythonMultiLineComment = false;
|
|
66
|
+
for (let line of lines) {
|
|
67
|
+
const trimmed = line.trim();
|
|
68
|
+
// Check for Python multi-line string comments (""" or ''')
|
|
69
|
+
// Count occurrences to handle single-line strings like '''comment'''
|
|
70
|
+
const tripleDoubleCount = (trimmed.match(/"""/g) || []).length;
|
|
71
|
+
const tripleSingleCount = (trimmed.match(/'''/g) || []).length;
|
|
72
|
+
if (tripleDoubleCount > 0) {
|
|
73
|
+
// Odd number: opening or closing a multi-line comment (toggle state)
|
|
74
|
+
// Even number: complete string on same line (e.g., """comment""" - don't toggle)
|
|
75
|
+
if (tripleDoubleCount % 2 === 1) {
|
|
76
|
+
inPythonMultiLineComment = !inPythonMultiLineComment;
|
|
77
|
+
}
|
|
78
|
+
continue; // Skip any line with triple quotes
|
|
79
|
+
}
|
|
80
|
+
if (tripleSingleCount > 0) {
|
|
81
|
+
if (tripleSingleCount % 2 === 1) {
|
|
82
|
+
inPythonMultiLineComment = !inPythonMultiLineComment;
|
|
83
|
+
}
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (inPythonMultiLineComment) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
// Check for multi-line comment start/end (/* */)
|
|
90
|
+
if (trimmed.includes('/*')) {
|
|
91
|
+
if (trimmed.includes('*/')) {
|
|
92
|
+
// Single-line multi-line comment (e.g., /* comment */)
|
|
93
|
+
// Don't change state, just skip the line
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
// Opening a multi-line comment
|
|
98
|
+
inMultiLineComment = true;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (inMultiLineComment) {
|
|
103
|
+
if (trimmed.includes('*/')) {
|
|
104
|
+
inMultiLineComment = false;
|
|
105
|
+
}
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
// Skip single-line comments
|
|
109
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('#')) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
// Remove inline comments from the line before processing
|
|
113
|
+
// Use helper function to avoid removing comment delimiters inside strings
|
|
114
|
+
const commentIndex = findCommentStart(line);
|
|
115
|
+
if (commentIndex >= 0) {
|
|
116
|
+
line = line.substring(0, commentIndex);
|
|
117
|
+
}
|
|
118
|
+
nonCommentLines.push(line);
|
|
119
|
+
}
|
|
120
|
+
return nonCommentLines;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Detect session file paths referenced in test files
|
|
124
|
+
* Looks for storageState patterns in TypeScript/JavaScript/Python/Java/C# test files
|
|
125
|
+
* Excludes matches found in comments
|
|
126
|
+
*/
|
|
127
|
+
function detectSessionFiles(testFilePath) {
|
|
128
|
+
try {
|
|
129
|
+
const content = fs.readFileSync(testFilePath, "utf-8");
|
|
130
|
+
const lines = content.split('\n');
|
|
131
|
+
const sessionFiles = [];
|
|
132
|
+
// Pattern for TypeScript/JavaScript: storageState: '/path/to/file' or storageState: "/path/to/file"
|
|
133
|
+
const tsJsPattern = /storageState:\s*['"]([^'"]+)['"]/g;
|
|
134
|
+
// Pattern for Python: storage_state='/path/to/file' or storage_state="/path/to/file"
|
|
135
|
+
const pythonPattern = /storage_state\s*=\s*['"]([^'"]+)['"]/g;
|
|
136
|
+
// Pattern for Java: setStorageState(Paths.get("path")) or setStorageState("path")
|
|
137
|
+
// Enforces proper parenthesis matching: first ) is required, second ) is required when using Paths.get
|
|
138
|
+
const javaPattern = /setStorageState(?:Path)?\s*\(\s*(?:Paths\.get\s*\(\s*)?['"]([^'"]+)['"]\s*\)(?:\s*\))?/g;
|
|
139
|
+
// Pattern for C#: StorageStatePath = "path" or StorageState = "path"
|
|
140
|
+
const csharpPattern = /StorageState(?:Path)?\s*=\s*['"]([^'"]+)['"]/g;
|
|
141
|
+
// Filter out comments
|
|
142
|
+
const codeLines = filterComments(lines);
|
|
143
|
+
// Process each non-comment line
|
|
144
|
+
for (const line of codeLines) {
|
|
145
|
+
// Try all patterns on this line
|
|
146
|
+
let match;
|
|
147
|
+
tsJsPattern.lastIndex = 0;
|
|
148
|
+
while ((match = tsJsPattern.exec(line)) !== null) {
|
|
149
|
+
sessionFiles.push(match[1]);
|
|
150
|
+
}
|
|
151
|
+
pythonPattern.lastIndex = 0;
|
|
152
|
+
while ((match = pythonPattern.exec(line)) !== null) {
|
|
153
|
+
sessionFiles.push(match[1]);
|
|
154
|
+
}
|
|
155
|
+
javaPattern.lastIndex = 0;
|
|
156
|
+
while ((match = javaPattern.exec(line)) !== null) {
|
|
157
|
+
sessionFiles.push(match[1]);
|
|
158
|
+
}
|
|
159
|
+
csharpPattern.lastIndex = 0;
|
|
160
|
+
while ((match = csharpPattern.exec(line)) !== null) {
|
|
161
|
+
sessionFiles.push(match[1]);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return sessionFiles;
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
logger.error(`Failed to detect session files in ${testFilePath}`, { error });
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
export class TestExecutionService {
|
|
172
|
+
docker;
|
|
173
|
+
imageReady;
|
|
174
|
+
constructor() {
|
|
175
|
+
this.docker = new Docker();
|
|
176
|
+
this.imageReady = this.ensureDockerImage();
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Execute multiple tests in parallel batches
|
|
180
|
+
*/
|
|
181
|
+
async executeBatch(testOptions) {
|
|
182
|
+
logger.info(`Starting batch execution of ${testOptions.length} tests`);
|
|
183
|
+
const startTime = Date.now();
|
|
184
|
+
const results = [];
|
|
185
|
+
// Execute tests in batches to control concurrency
|
|
186
|
+
for (let i = 0; i < testOptions.length; i += MAX_CONCURRENT_EXECUTIONS) {
|
|
187
|
+
const batch = testOptions.slice(i, i + MAX_CONCURRENT_EXECUTIONS);
|
|
188
|
+
logger.debug(`Executing batch ${Math.floor(i / MAX_CONCURRENT_EXECUTIONS) + 1} (${batch.length} tests)`);
|
|
189
|
+
const batchPromises = batch.map((options) => this.executeTest(options).catch((error) => {
|
|
190
|
+
// If execution fails completely, return error result
|
|
191
|
+
return {
|
|
192
|
+
testFile: options.testFile,
|
|
193
|
+
passed: false,
|
|
194
|
+
executedAt: new Date().toISOString(),
|
|
195
|
+
duration: 0,
|
|
196
|
+
errors: [error.message],
|
|
197
|
+
warnings: [],
|
|
198
|
+
crashed: true,
|
|
199
|
+
};
|
|
200
|
+
}));
|
|
201
|
+
const batchResults = await Promise.all(batchPromises);
|
|
202
|
+
results.push(...batchResults);
|
|
203
|
+
}
|
|
204
|
+
const totalDuration = Date.now() - startTime;
|
|
205
|
+
const passed = results.filter((r) => r.passed).length;
|
|
206
|
+
const failed = results.filter((r) => !r.passed && !r.crashed).length;
|
|
207
|
+
const crashed = results.filter((r) => r.crashed).length;
|
|
208
|
+
logger.info(`Batch execution complete: ${passed} passed, ${failed} failed, ${crashed} crashed`);
|
|
209
|
+
return {
|
|
210
|
+
totalTests: testOptions.length,
|
|
211
|
+
passed,
|
|
212
|
+
failed,
|
|
213
|
+
crashed,
|
|
214
|
+
totalDuration,
|
|
215
|
+
results,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Execute a single test
|
|
220
|
+
*/
|
|
221
|
+
async executeTest(options) {
|
|
222
|
+
const startTime = Date.now();
|
|
223
|
+
const executedAt = new Date().toISOString();
|
|
224
|
+
logger.debug(`Executing test: ${options.testFile}`);
|
|
225
|
+
// Wait for Docker image to be ready
|
|
226
|
+
await this.imageReady;
|
|
227
|
+
// Validate workspace path - use accessSync for better validation
|
|
228
|
+
const workspacePath = path.resolve(options.workspacePath);
|
|
229
|
+
try {
|
|
230
|
+
fs.accessSync(workspacePath, fs.constants.R_OK);
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
throw new Error(`Workspace path does not exist or is not readable: ${workspacePath}`);
|
|
234
|
+
}
|
|
235
|
+
// Validate test file - use basename for safer filename extraction
|
|
236
|
+
const filename = path.basename(options.testFile);
|
|
237
|
+
if (!filename) {
|
|
238
|
+
throw new Error("Invalid test file path: could not extract filename");
|
|
239
|
+
}
|
|
240
|
+
if (!fs.existsSync(options.testFile)) {
|
|
241
|
+
throw new Error(`Test file does not exist: ${options.testFile}`);
|
|
242
|
+
}
|
|
243
|
+
const containerMountPath = "/home/user";
|
|
244
|
+
const dockerSocketPath = "/var/run/docker.sock";
|
|
245
|
+
// Calculate relative test file path
|
|
246
|
+
let testFilePath = path.relative(workspacePath, options.testFile);
|
|
247
|
+
testFilePath = path.resolve(containerMountPath, testFilePath);
|
|
248
|
+
// Prepare Docker command
|
|
249
|
+
const command = [
|
|
250
|
+
"/root/runner.sh",
|
|
251
|
+
options.language,
|
|
252
|
+
testFilePath,
|
|
253
|
+
options.testType,
|
|
254
|
+
];
|
|
255
|
+
// Prepare host config with mounts
|
|
256
|
+
const hostConfig = {
|
|
257
|
+
ExtraHosts: ["host.docker.internal:host-gateway"],
|
|
258
|
+
Mounts: [
|
|
259
|
+
{
|
|
260
|
+
Type: "bind",
|
|
261
|
+
Target: dockerSocketPath,
|
|
262
|
+
Source: dockerSocketPath,
|
|
263
|
+
},
|
|
264
|
+
],
|
|
265
|
+
};
|
|
266
|
+
// Mount workspace files (excluding unnecessary items)
|
|
267
|
+
const workspaceFiles = fs.readdirSync(workspacePath);
|
|
268
|
+
const filesToMount = workspaceFiles.filter((file) => !EXCLUDED_MOUNT_ITEMS.includes(file));
|
|
269
|
+
hostConfig.Mounts?.push(...filesToMount.map((file) => ({
|
|
270
|
+
Type: "bind",
|
|
271
|
+
Target: path.join(containerMountPath, file),
|
|
272
|
+
Source: path.join(workspacePath, file),
|
|
273
|
+
})));
|
|
274
|
+
// Detect and mount session files
|
|
275
|
+
const sessionFiles = detectSessionFiles(options.testFile);
|
|
276
|
+
const mountedPaths = new Set(); // Track mounted file paths to prevent duplicates
|
|
277
|
+
for (const sessionFile of sessionFiles) {
|
|
278
|
+
let sessionFileSource;
|
|
279
|
+
let sessionFileTarget;
|
|
280
|
+
if (path.isAbsolute(sessionFile)) {
|
|
281
|
+
// Absolute path: mount at the same absolute path in container
|
|
282
|
+
sessionFileSource = sessionFile;
|
|
283
|
+
sessionFileTarget = sessionFile;
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
// Relative path: resolve from test file directory on host
|
|
287
|
+
const testFileDir = path.dirname(options.testFile);
|
|
288
|
+
sessionFileSource = path.resolve(testFileDir, sessionFile);
|
|
289
|
+
// Mount at the corresponding relative path in the container
|
|
290
|
+
const testFileDirInContainer = path.dirname(testFilePath);
|
|
291
|
+
sessionFileTarget = path.resolve(testFileDirInContainer, sessionFile);
|
|
292
|
+
}
|
|
293
|
+
// Check if session file exists on host
|
|
294
|
+
if (fs.existsSync(sessionFileSource)) {
|
|
295
|
+
// Only mount if we haven't already mounted this path
|
|
296
|
+
if (!mountedPaths.has(sessionFileTarget)) {
|
|
297
|
+
logger.info(` docker run -v ${sessionFileSource}:${sessionFileTarget}:ro ...`);
|
|
298
|
+
hostConfig.Mounts?.push({
|
|
299
|
+
Type: "bind",
|
|
300
|
+
Target: sessionFileTarget,
|
|
301
|
+
Source: sessionFileSource,
|
|
302
|
+
ReadOnly: true,
|
|
303
|
+
});
|
|
304
|
+
mountedPaths.add(sessionFileTarget);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
logger.error(`✗ Session file not found: ${sessionFileSource}`);
|
|
309
|
+
logger.error(` Referenced in test as: ${sessionFile}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// Prepare environment variables
|
|
313
|
+
const env = [
|
|
314
|
+
`SKYRAMP_TEST_TOKEN=${options.token || ""}`,
|
|
315
|
+
"SKYRAMP_IN_DOCKER=true",
|
|
316
|
+
];
|
|
317
|
+
if (process.env.SKYRAMP_DEBUG) {
|
|
318
|
+
env.push(`SKYRAMP_DEBUG=${process.env.SKYRAMP_DEBUG}`);
|
|
319
|
+
}
|
|
320
|
+
if (process.env.API_KEY) {
|
|
321
|
+
env.push(`API_KEY=${process.env.API_KEY}`);
|
|
322
|
+
}
|
|
323
|
+
// Capture output
|
|
324
|
+
let output = "";
|
|
325
|
+
class DockerStream extends Writable {
|
|
326
|
+
_write(data, encode, cb) {
|
|
327
|
+
output += data.toString();
|
|
328
|
+
cb();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const stream = new DockerStream();
|
|
332
|
+
try {
|
|
333
|
+
let statusCode = 0;
|
|
334
|
+
let containerRef = null;
|
|
335
|
+
// Run container with timeout
|
|
336
|
+
const executionPromise = this.docker
|
|
337
|
+
.run(EXECUTOR_DOCKER_IMAGE, command, stream, {
|
|
338
|
+
Env: env,
|
|
339
|
+
HostConfig: hostConfig,
|
|
340
|
+
WorkingDir: containerMountPath, // Explicitly set working directory
|
|
341
|
+
})
|
|
342
|
+
.then(function (data) {
|
|
343
|
+
const result = data[0];
|
|
344
|
+
const container = data[1];
|
|
345
|
+
containerRef = container; // Capture container reference for cleanup
|
|
346
|
+
stream.end();
|
|
347
|
+
statusCode = result.StatusCode;
|
|
348
|
+
logger.debug("Docker container execution completed");
|
|
349
|
+
return container.remove();
|
|
350
|
+
})
|
|
351
|
+
.then(function () {
|
|
352
|
+
logger.debug("Docker container removed successfully");
|
|
353
|
+
containerRef = null; // Container already removed
|
|
354
|
+
return statusCode;
|
|
355
|
+
})
|
|
356
|
+
.catch(function (err) {
|
|
357
|
+
logger.error("Docker container execution failed", {
|
|
358
|
+
error: err.message,
|
|
359
|
+
});
|
|
360
|
+
throw err;
|
|
361
|
+
});
|
|
362
|
+
// Apply timeout
|
|
363
|
+
const timeout = options.timeout || DEFAULT_TIMEOUT;
|
|
364
|
+
let timeoutHandle;
|
|
365
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
366
|
+
timeoutHandle = setTimeout(() => reject(new Error(`Test execution timeout after ${timeout}ms`)), timeout);
|
|
367
|
+
});
|
|
368
|
+
try {
|
|
369
|
+
statusCode = await Promise.race([executionPromise, timeoutPromise]);
|
|
370
|
+
// Clear the timeout timer if execution completes successfully
|
|
371
|
+
if (timeoutHandle)
|
|
372
|
+
clearTimeout(timeoutHandle);
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
// Clear the timeout timer on any error
|
|
376
|
+
if (timeoutHandle)
|
|
377
|
+
clearTimeout(timeoutHandle);
|
|
378
|
+
// Cleanup container on timeout or other errors
|
|
379
|
+
if (containerRef !== null) {
|
|
380
|
+
const container = containerRef;
|
|
381
|
+
try {
|
|
382
|
+
logger.warning("Cleaning up orphaned Docker container after error");
|
|
383
|
+
await container.stop({ t: 5 }); // Give 5 seconds to stop gracefully
|
|
384
|
+
await container.remove({ force: true });
|
|
385
|
+
logger.debug("Orphaned container cleaned up successfully");
|
|
386
|
+
}
|
|
387
|
+
catch (cleanupError) {
|
|
388
|
+
logger.error("Failed to cleanup orphaned container", {
|
|
389
|
+
error: cleanupError.message,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
throw error; // Re-throw the original error
|
|
394
|
+
}
|
|
395
|
+
const duration = Date.now() - startTime;
|
|
396
|
+
const cleanOutput = stripVTControlCharacters(output);
|
|
397
|
+
logger.info("Test execution completed", {
|
|
398
|
+
output: cleanOutput.substring(0, 200) +
|
|
399
|
+
(cleanOutput.length > 200 ? "..." : ""),
|
|
400
|
+
statusCode,
|
|
401
|
+
});
|
|
402
|
+
// Parse errors and warnings from output
|
|
403
|
+
const errors = this.parseErrors(cleanOutput);
|
|
404
|
+
const warnings = this.parseWarnings(cleanOutput);
|
|
405
|
+
// Check if test crashed
|
|
406
|
+
const crashed = statusCode !== 0 && statusCode !== 1;
|
|
407
|
+
const result = {
|
|
408
|
+
testFile: options.testFile,
|
|
409
|
+
passed: statusCode === 0,
|
|
410
|
+
executedAt,
|
|
411
|
+
duration,
|
|
412
|
+
errors,
|
|
413
|
+
warnings,
|
|
414
|
+
crashed,
|
|
415
|
+
output: cleanOutput,
|
|
416
|
+
exitCode: statusCode,
|
|
417
|
+
};
|
|
418
|
+
logger.debug(`Test ${result.passed ? "passed" : "failed"}: ${options.testFile}`);
|
|
419
|
+
return result;
|
|
420
|
+
}
|
|
421
|
+
catch (error) {
|
|
422
|
+
const duration = Date.now() - startTime;
|
|
423
|
+
const cleanOutput = stripVTControlCharacters(output);
|
|
424
|
+
logger.error(`Test execution error: ${error.message}`);
|
|
425
|
+
return {
|
|
426
|
+
testFile: options.testFile,
|
|
427
|
+
passed: false,
|
|
428
|
+
executedAt,
|
|
429
|
+
duration,
|
|
430
|
+
errors: [error.message],
|
|
431
|
+
warnings: [],
|
|
432
|
+
crashed: true,
|
|
433
|
+
output: cleanOutput,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Ensure Docker image is available
|
|
439
|
+
*/
|
|
440
|
+
async ensureDockerImage() {
|
|
441
|
+
try {
|
|
442
|
+
const images = await this.docker.listImages();
|
|
443
|
+
const imageExists = images.some((img) => (img.RepoTags && img.RepoTags.includes(EXECUTOR_DOCKER_IMAGE)) ||
|
|
444
|
+
(img.RepoDigests && img.RepoDigests.includes(EXECUTOR_DOCKER_IMAGE)));
|
|
445
|
+
if (imageExists) {
|
|
446
|
+
logger.debug(`Docker image ${EXECUTOR_DOCKER_IMAGE} already available`);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
logger.info(`Pulling Docker image ${EXECUTOR_DOCKER_IMAGE}...`);
|
|
450
|
+
await new Promise((resolve, reject) => {
|
|
451
|
+
this.docker.pull(EXECUTOR_DOCKER_IMAGE, { platform: DOCKER_PLATFORM }, (err, stream) => {
|
|
452
|
+
if (err)
|
|
453
|
+
return reject(err);
|
|
454
|
+
if (!stream)
|
|
455
|
+
return reject(new Error("No stream received from docker pull"));
|
|
456
|
+
this.docker.modem.followProgress(stream, (err, res) => {
|
|
457
|
+
if (err)
|
|
458
|
+
return reject(err);
|
|
459
|
+
logger.info(`Docker image ${EXECUTOR_DOCKER_IMAGE} pulled successfully`);
|
|
460
|
+
resolve(res);
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
catch (error) {
|
|
466
|
+
logger.error(`Failed to ensure Docker image: ${error.message}`);
|
|
467
|
+
throw new Error(`Docker image setup failed: ${error.message}`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Parse errors from test output
|
|
472
|
+
*/
|
|
473
|
+
parseErrors(output) {
|
|
474
|
+
const errors = [];
|
|
475
|
+
const lines = output.split("\n");
|
|
476
|
+
for (const line of lines) {
|
|
477
|
+
// Common error patterns
|
|
478
|
+
if (line.includes("Error:") ||
|
|
479
|
+
line.includes("ERROR") ||
|
|
480
|
+
line.includes("AssertionError") ||
|
|
481
|
+
line.includes("Exception") ||
|
|
482
|
+
line.includes("FAILED") ||
|
|
483
|
+
line.includes("Traceback")) {
|
|
484
|
+
errors.push(line.trim());
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return errors.slice(0, 20); // Limit to first 20 errors
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Parse warnings from test output
|
|
491
|
+
*/
|
|
492
|
+
parseWarnings(output) {
|
|
493
|
+
const warnings = [];
|
|
494
|
+
const lines = output.split("\n");
|
|
495
|
+
for (const line of lines) {
|
|
496
|
+
if (line.includes("Warning:") ||
|
|
497
|
+
line.includes("WARN") ||
|
|
498
|
+
line.includes("Deprecated")) {
|
|
499
|
+
warnings.push(line.trim());
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return warnings.slice(0, 10); // Limit to first 10 warnings
|
|
503
|
+
}
|
|
504
|
+
}
|
|
@@ -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: [
|