@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.
Files changed (45) hide show
  1. package/build/index.js +57 -12
  2. package/build/prompts/code-reuse.js +1 -1
  3. package/build/prompts/driftAnalysisPrompt.js +159 -0
  4. package/build/prompts/modularization/ui-test-modularization.js +2 -0
  5. package/build/prompts/testGenerationPrompt.js +2 -2
  6. package/build/prompts/testHealthPrompt.js +82 -0
  7. package/build/services/AnalyticsService.js +86 -0
  8. package/build/services/DriftAnalysisService.js +928 -0
  9. package/build/services/ModularizationService.js +16 -1
  10. package/build/services/TestDiscoveryService.js +237 -0
  11. package/build/services/TestExecutionService.js +504 -0
  12. package/build/services/TestGenerationService.js +16 -2
  13. package/build/services/TestHealthService.js +656 -0
  14. package/build/tools/auth/loginTool.js +13 -3
  15. package/build/tools/auth/logoutTool.js +13 -3
  16. package/build/tools/code-refactor/codeReuseTool.js +46 -18
  17. package/build/tools/code-refactor/modularizationTool.js +44 -11
  18. package/build/tools/executeSkyrampTestTool.js +29 -125
  19. package/build/tools/fixErrorTool.js +38 -14
  20. package/build/tools/generate-tests/generateContractRestTool.js +8 -2
  21. package/build/tools/generate-tests/generateE2ERestTool.js +9 -3
  22. package/build/tools/generate-tests/generateFuzzRestTool.js +9 -3
  23. package/build/tools/generate-tests/generateIntegrationRestTool.js +8 -2
  24. package/build/tools/generate-tests/generateLoadRestTool.js +9 -3
  25. package/build/tools/generate-tests/generateScenarioRestTool.js +8 -2
  26. package/build/tools/generate-tests/generateSmokeRestTool.js +9 -3
  27. package/build/tools/generate-tests/generateUIRestTool.js +9 -3
  28. package/build/tools/test-maintenance/actionsTool.js +230 -0
  29. package/build/tools/test-maintenance/analyzeTestDriftTool.js +197 -0
  30. package/build/tools/test-maintenance/calculateHealthScoresTool.js +257 -0
  31. package/build/tools/test-maintenance/discoverTestsTool.js +143 -0
  32. package/build/tools/test-maintenance/executeBatchTestsTool.js +198 -0
  33. package/build/tools/test-maintenance/stateCleanupTool.js +153 -0
  34. package/build/tools/test-recommendation/analyzeRepositoryTool.js +27 -3
  35. package/build/tools/test-recommendation/mapTestsTool.js +9 -2
  36. package/build/tools/test-recommendation/recommendTestsTool.js +21 -5
  37. package/build/tools/trace/startTraceCollectionTool.js +18 -5
  38. package/build/tools/trace/stopTraceCollectionTool.js +28 -4
  39. package/build/types/TestAnalysis.js +1 -0
  40. package/build/types/TestDriftAnalysis.js +1 -0
  41. package/build/types/TestExecution.js +6 -0
  42. package/build/types/TestHealth.js +4 -0
  43. package/build/utils/AnalysisStateManager.js +240 -0
  44. package/build/utils/utils.test.js +25 -9
  45. 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
- // Only modularization
48
- postGenerationMessage += `
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: [