@skyramp/mcp 0.2.8 → 0.2.150-rc.local.de

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 (93) hide show
  1. package/build/commands/commandLibrary.d.ts +1 -0
  2. package/build/commands/commandLibrary.js +18 -17
  3. package/build/commands/localDevTestChangesCommand.d.ts +15 -0
  4. package/build/commands/localDevTestChangesCommand.js +175 -0
  5. package/build/index.js +74 -13
  6. package/build/prompts/enhance-assertions/sharedAssertionRules.js +1 -0
  7. package/build/prompts/initialize-workspace/initializeWorkspacePrompt.js +7 -6
  8. package/build/prompts/local-dev/local-dev-plan.d.ts +25 -0
  9. package/build/prompts/local-dev/local-dev-plan.js +414 -0
  10. package/build/prompts/local-dev/local-dev-prompts.d.ts +4 -0
  11. package/build/prompts/local-dev/local-dev-prompts.js +152 -0
  12. package/build/prompts/prompt-utils.d.ts +8 -0
  13. package/build/prompts/prompt-utils.js +33 -0
  14. package/build/prompts/sut-setup/modes/adaptWorkflowPrompt.js +3 -1
  15. package/build/prompts/sut-setup/modes/dockerComposePrompt.js +3 -1
  16. package/build/prompts/sut-setup/shared.d.ts +20 -0
  17. package/build/prompts/sut-setup/shared.js +69 -7
  18. package/build/prompts/test-recommendation/analysisOutputPrompt.js +21 -29
  19. package/build/prompts/test-recommendation/analysisOutputPrompt.test.js +79 -16
  20. package/build/prompts/test-recommendation/test-recommendation-prompt.js +41 -4
  21. package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +34 -1
  22. package/build/prompts/testbot/testbot-prompts.d.ts +0 -5
  23. package/build/prompts/testbot/testbot-prompts.js +4 -34
  24. package/build/resources/testbotResource.js +2 -1
  25. package/build/services/TestExecutionService.d.ts +10 -2
  26. package/build/services/TestExecutionService.js +220 -28
  27. package/build/services/TestExecutionService.test.js +447 -16
  28. package/build/services/TestGenerationService.d.ts +2 -2
  29. package/build/tool-phases.js +4 -0
  30. package/build/tools/code-refactor/enhanceAssertionsTool.js +34 -2
  31. package/build/tools/enrichTestWithMocksTool.d.ts +15 -0
  32. package/build/tools/enrichTestWithMocksTool.js +414 -0
  33. package/build/tools/enrichTestWithMocksTool.test.d.ts +1 -0
  34. package/build/tools/enrichTestWithMocksTool.test.js +397 -0
  35. package/build/tools/executeSkyrampTestTool.d.ts +3 -0
  36. package/build/tools/executeSkyrampTestTool.js +73 -18
  37. package/build/tools/executeSkyrampTestTool.test.d.ts +1 -0
  38. package/build/tools/executeSkyrampTestTool.test.js +111 -0
  39. package/build/tools/generate-tests/batchMockGenerationTool.d.ts +72 -0
  40. package/build/tools/generate-tests/batchMockGenerationTool.js +382 -0
  41. package/build/tools/generate-tests/batchMockGenerationTool.test.d.ts +1 -0
  42. package/build/tools/generate-tests/batchMockGenerationTool.test.js +514 -0
  43. package/build/tools/generate-tests/generateContractRestTool.js +2 -2
  44. package/build/tools/generate-tests/generateMockRestTool.d.ts +56 -6
  45. package/build/tools/generate-tests/generateMockRestTool.js +193 -21
  46. package/build/tools/generate-tests/generateMockRestTool.test.d.ts +1 -0
  47. package/build/tools/generate-tests/generateMockRestTool.test.js +340 -0
  48. package/build/tools/generateEnrichedIntegrationTestTool.d.ts +24 -0
  49. package/build/tools/generateEnrichedIntegrationTestTool.js +209 -0
  50. package/build/tools/generateEnrichedIntegrationTestTool.test.d.ts +1 -0
  51. package/build/tools/generateEnrichedIntegrationTestTool.test.js +199 -0
  52. package/build/tools/one-click/oneClickTool.d.ts +10 -0
  53. package/build/tools/one-click/oneClickTool.js +182 -40
  54. package/build/tools/one-click/oneClickTool.test.d.ts +1 -0
  55. package/build/tools/one-click/oneClickTool.test.js +204 -0
  56. package/build/tools/preflightMockCheckTool.d.ts +2 -0
  57. package/build/tools/preflightMockCheckTool.js +91 -0
  58. package/build/tools/test-management/analyzeChangesTool.d.ts +2 -1
  59. package/build/tools/test-management/analyzeChangesTool.js +59 -38
  60. package/build/tools/workspace/initializeWorkspaceTool.js +2 -9
  61. package/build/tools/workspace/initializeWorkspaceTool.test.js +9 -4
  62. package/build/types/OneClickCommands.d.ts +1 -1
  63. package/build/types/RepositoryAnalysis.d.ts +117 -0
  64. package/build/types/RepositoryAnalysis.js +16 -2
  65. package/build/types/TestTypes.d.ts +11 -2
  66. package/build/types/TestTypes.js +11 -1
  67. package/build/utils/analyze-openapi.js +18 -1
  68. package/build/utils/analyze-openapi.test.d.ts +1 -0
  69. package/build/utils/analyze-openapi.test.js +19 -0
  70. package/build/utils/branchDiff.d.ts +17 -1
  71. package/build/utils/branchDiff.js +96 -14
  72. package/build/utils/branchDiff.test.d.ts +1 -0
  73. package/build/utils/branchDiff.test.js +107 -0
  74. package/build/utils/docker.test.js +1 -1
  75. package/build/utils/featureFlags.d.ts +15 -1
  76. package/build/utils/featureFlags.js +19 -2
  77. package/build/utils/featureFlags.test.js +57 -2
  78. package/build/utils/grpcMockValidation.d.ts +1 -0
  79. package/build/utils/grpcMockValidation.js +49 -0
  80. package/build/utils/grpcMockValidation.test.d.ts +1 -0
  81. package/build/utils/grpcMockValidation.test.js +41 -0
  82. package/build/utils/httpMethodValidation.d.ts +4 -0
  83. package/build/utils/httpMethodValidation.js +13 -0
  84. package/build/utils/mockCompatibility.d.ts +49 -0
  85. package/build/utils/mockCompatibility.js +80 -0
  86. package/build/utils/mockCompatibility.test.d.ts +1 -0
  87. package/build/utils/mockCompatibility.test.js +88 -0
  88. package/build/utils/versions.d.ts +3 -3
  89. package/build/utils/versions.js +1 -1
  90. package/build/workspace/workspace.d.ts +35 -21
  91. package/build/workspace/workspace.js +8 -4
  92. package/build/workspace/workspace.test.js +65 -6
  93. package/package.json +2 -2
@@ -17,10 +17,5 @@ export interface RelatedRepository {
17
17
  export declare function parseRelatedRepositories(raw: string | undefined): RelatedRepository[] | undefined;
18
18
  export declare function getTestbotPrompt(prTitle: string, prDescription: string, summaryOutputFile: string, repositoryPath: string, baseBranch?: string, maxRecommendations?: number, maxGenerate?: number, _maxCritical?: number, // Reserved — accepted for API compat but not yet wired into prompt
19
19
  prNumber?: number, userPrompt?: string, services?: Service[], uiCredentials?: string, testsRepoDir?: string, relatedRepositories?: RelatedRepository[], primaryRepo?: string): string;
20
- /**
21
- * Read services from .skyramp/workspace.yml. Returns empty array if
22
- * the workspace file doesn't exist or can't be parsed.
23
- */
24
- export declare function readWorkspaceServices(repositoryPath: string): Promise<Service[]>;
25
20
  export declare function buildWorkspaceRecoveryPrefix(repositoryPath: string): string;
26
21
  export declare function registerTestbotPrompt(server: McpServer): void;
@@ -5,7 +5,7 @@ import { MAX_TESTS_TO_GENERATE, MAX_RECOMMENDATIONS, MAX_CRITICAL_TESTS, PATH_PA
5
5
  import { getTraceRecordingPromptText } from "../../playwright/traceRecordingPrompt.js";
6
6
  import { isContractConsumerModeEnabled } from "../../utils/featureFlags.js";
7
7
  import { resolveServiceDetailsRef } from "../../utils/utils.js";
8
- import { readWorkspaceConfigRaw } from "../../utils/workspaceAuth.js";
8
+ import { buildServiceContext, readWorkspaceServices, } from "../prompt-utils.js";
9
9
  // Cached at module-load — flags are process-wide and cannot change per call.
10
10
  const CONSUMER_MODE_ENABLED = isContractConsumerModeEnabled();
11
11
  const SERVICE_REFS = resolveServiceDetailsRef();
@@ -547,7 +547,9 @@ Do NOT use \`page.waitForTimeout()\` with fixed delays. Do NOT retry more than o
547
547
  4. **Wait**: Do NOT proceed to test execution until steps 1–3 are complete and the verification checklist in the \`skyramp_enhance_assertions\` tool result has been validated for EVERY generated test file.
548
548
  Do not make any changes other than the assertion enhancements described above. For example: do not modify auth headers, cookies, tokens, env vars, or imports that the generation tool already set correctly — those are correct by construction and changing them breaks auth or execution.
549
549
 
550
- **Final execution (mandatory):** Do NOT call \`skyramp_execute_test\` until ALL maintenance edits AND ALL new test generation/enhancement are complete.
550
+ **Execution timing:**
551
+ - **beforeStatus** (maintained tests only): execute each maintained test file **once at the start** (before any edits) to capture \`beforeStatus\`. This is the only execution allowed before edits.
552
+ - **Final execution**: Do NOT call \`skyramp_execute_test\` again until ALL maintenance edits AND ALL new test generation/enhancement are complete. Then execute every test file once — maintained files (for \`afterStatus\`) and new files together. **Execute tests SEQUENTIALLY (one at a time)** — do NOT send multiple \`skyramp_execute_test\` calls in the same tool call batch, as concurrent execution overwhelms the stdio transport and causes MCP disconnection.
551
553
  - Only report test results for files you actually ran.
552
554
  **Auth**: If \`skyramp_analyze_changes\` reports an auth token or \`SKYRAMP_TEST_TOKEN\` is set, pass it in **every** \`skyramp_execute_test\` call from the first attempt — do NOT wait for a 401/403 to discover auth is needed.
553
555
 
@@ -592,38 +594,6 @@ ${getTraceRecordingPromptText({ outputDir: `${repositoryPath}/.skyramp`, modular
592
594
  // removing stateOutputFile from the prompt schema. Remove the RUNNER_TEMP branch in
593
595
  // AnalysisStateManager.ts when this is done.
594
596
  }
595
- function escapeXml(value) {
596
- return value
597
- .replaceAll('&', '&amp;')
598
- .replaceAll('<', '&lt;')
599
- .replaceAll('>', '&gt;')
600
- .replaceAll('"', '&quot;')
601
- .replaceAll("'", '&apos;');
602
- }
603
- function buildServiceContext(services) {
604
- const blocks = services.map(svc => {
605
- const parts = [`<service name="${escapeXml(svc.serviceName)}">`];
606
- if (svc.language)
607
- parts.push(` <language>${escapeXml(svc.language)}</language>`);
608
- if (svc.framework)
609
- parts.push(` <framework>${escapeXml(svc.framework)}</framework>`);
610
- if (svc.api?.baseUrl)
611
- parts.push(` <base_url>${escapeXml(svc.api.baseUrl)}</base_url>`);
612
- if (svc.testDirectory)
613
- parts.push(` <test_directory>${escapeXml(svc.testDirectory)}</test_directory>`);
614
- parts.push('</service>');
615
- return parts.join('\n');
616
- });
617
- return `<services>\n${blocks.join('\n')}\n</services>`;
618
- }
619
- /**
620
- * Read services from .skyramp/workspace.yml. Returns empty array if
621
- * the workspace file doesn't exist or can't be parsed.
622
- */
623
- export async function readWorkspaceServices(repositoryPath) {
624
- const rawConfig = await readWorkspaceConfigRaw(repositoryPath);
625
- return (rawConfig?.services ?? []);
626
- }
627
597
  export function buildWorkspaceRecoveryPrefix(repositoryPath) {
628
598
  return `IMPORTANT: The existing .skyramp/workspace.yml failed to parse or validate. Before proceeding with any tasks below, you MUST call skyramp_init_scan with workspacePath "${repositoryPath}" and force: true, then call skyramp_init_workspace with workspacePath "${repositoryPath}", the discovered services, scanToken, and force: true to regenerate the workspace file.\n\n`;
629
599
  }
@@ -2,7 +2,8 @@ import { ResourceTemplate, } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { logger } from "../utils/logger.js";
3
3
  import { AnalyticsService } from "../services/AnalyticsService.js";
4
4
  import { MAX_TESTS_TO_GENERATE, MAX_RECOMMENDATIONS, MAX_CRITICAL_TESTS, } from "../prompts/test-recommendation/recommendationSections.js";
5
- import { getTestbotPrompt, readWorkspaceServices, parseRelatedRepositories, } from "../prompts/testbot/testbot-prompts.js";
5
+ import { getTestbotPrompt, parseRelatedRepositories, } from "../prompts/testbot/testbot-prompts.js";
6
+ import { readWorkspaceServices } from "../prompts/prompt-utils.js";
6
7
  export function registerTestbotResource(server) {
7
8
  logger.info("Registering testbot resource");
8
9
  // RFC 6570 {+rest} (reserved expansion) captures the entire query string
@@ -1,5 +1,6 @@
1
1
  import { TestExecutionResult, BatchExecutionResult, TestExecutionOptions, ProgressCallback } from "../types/TestExecution.js";
2
- export declare const EXECUTOR_DOCKER_IMAGE = "skyramp/executor:v1.3.29";
2
+ import { EXECUTOR_DOCKER_IMAGE } from "../utils/versions.js";
3
+ export { EXECUTOR_DOCKER_IMAGE };
3
4
  export declare const PLAYWRIGHT_CONFIG_FILES: string[];
4
5
  export declare const EXCLUDED_MOUNT_ITEMS: string[];
5
6
  export declare const MOUNT_NULL_ITEMS: string[];
@@ -25,19 +26,26 @@ export declare function getVideoSubdir(testFile: string): string;
25
26
  */
26
27
  export declare function detectSessionFiles(testFilePath: string): string[];
27
28
  export declare class TestExecutionService {
29
+ private static executionQueue;
28
30
  private docker;
29
31
  private imageReady;
30
32
  constructor();
33
+ private cleanupSkyrampExecutionContainers;
34
+ private prepareSutForExecution;
35
+ private stopSutAfterExecution;
31
36
  /**
32
37
  * Execute multiple tests in parallel batches
33
38
  */
34
39
  executeBatch(testOptions: TestExecutionOptions[]): Promise<BatchExecutionResult>;
35
40
  /**
36
- * Execute a single test
41
+ * Execute a single test. Executions are serialized within one MCP server
42
+ * process because pre-run cleanup intentionally removes stale Skyramp
43
+ * worker/executor containers before each test.
37
44
  * @param options Test execution options
38
45
  * @param onProgress Optional callback for progress updates
39
46
  */
40
47
  executeTest(options: TestExecutionOptions, onProgress?: ProgressCallback): Promise<TestExecutionResult>;
48
+ private executeTestInternal;
41
49
  /**
42
50
  * Ensure Docker image is available
43
51
  * @param onProgress Optional callback for progress updates during pull
@@ -4,17 +4,24 @@ import fs from "fs";
4
4
  import os from "os";
5
5
  import crypto from "crypto";
6
6
  import { Writable } from "stream";
7
+ import { exec } from "child_process";
8
+ import { promisify } from "util";
7
9
  import { stripVTControlCharacters } from "util";
8
10
  import { logger } from "../utils/logger.js";
9
11
  import { TestExecutionStatus, } from "../types/TestExecution.js";
10
12
  import { TestType } from "../types/TestTypes.js";
11
13
  import { buildContainerEnv } from "./containerEnv.js";
12
- import { SKYRAMP_IMAGE_VERSION } from "../utils/versions.js";
14
+ import { readWorkspaceConfigRaw } from "../utils/workspaceAuth.js";
15
+ import { EXECUTOR_DOCKER_IMAGE } from "../utils/versions.js";
16
+ export { EXECUTOR_DOCKER_IMAGE };
13
17
  const DEFAULT_TIMEOUT = 300000; // 5 minutes
14
- const MAX_CONCURRENT_EXECUTIONS = 5;
15
- export const EXECUTOR_DOCKER_IMAGE = `skyramp/executor:${SKYRAMP_IMAGE_VERSION}`;
18
+ const OWNED_EXECUTION_CONTAINER_LABEL = "dev.skyramp.mcp.execution";
19
+ const OWNED_EXECUTION_CONTAINER_LABEL_VALUE = "true";
16
20
  const DOCKER_PLATFORM = "linux/amd64";
17
21
  const EXECUTION_PROGRESS_INTERVAL = 10000; // 10 seconds between progress updates during execution
22
+ const execAsync = promisify(exec);
23
+ const SUT_READY_TIMEOUT_MS = 15000;
24
+ const SUT_READY_INTERVAL_MS = 1000;
18
25
  // Temp file with valid empty JSON — used instead of /dev/null for .json config files
19
26
  // so Node.js doesn't throw ERR_INVALID_PACKAGE_CONFIG when reading them.
20
27
  const EMPTY_JSON_PATH = path.join(os.tmpdir(), "skyramp-empty.json");
@@ -32,9 +39,7 @@ export const PLAYWRIGHT_CONFIG_FILES = [
32
39
  "playwright.config.mjs",
33
40
  ];
34
41
  // Directories to skip mounting entirely (cannot bind-mount /dev/null to a directory)
35
- export const EXCLUDED_MOUNT_ITEMS = [
36
- "node_modules",
37
- ];
42
+ export const EXCLUDED_MOUNT_ITEMS = ["node_modules"];
38
43
  // Files to shadow with /dev/null recursively so the container ignores them
39
44
  export const MOUNT_NULL_ITEMS = [
40
45
  "package-lock.json",
@@ -65,6 +70,60 @@ export function getVideoSubdir(testFile) {
65
70
  const hash = crypto.createHash("sha256").update(testFile).digest("hex").slice(0, 8);
66
71
  return `${basename}-${hash}`;
67
72
  }
73
+ function isOwnedStoppedExecutionContainer(container) {
74
+ return (container.Labels?.[OWNED_EXECUTION_CONTAINER_LABEL] ===
75
+ OWNED_EXECUTION_CONTAINER_LABEL_VALUE && container.State !== "running");
76
+ }
77
+ function isDockerRuntimeCommand(command) {
78
+ const trimmed = command.trim();
79
+ if (!trimmed)
80
+ return false;
81
+ if (/[;&|`<>\r\n]/.test(trimmed) || trimmed.includes("$("))
82
+ return false;
83
+ return /^(docker\s+(compose|run|start|stop|rm|pull|build|network|volume)\b|docker-compose\b)/.test(trimmed);
84
+ }
85
+ function assertDockerRuntimeCommand(command, fieldName) {
86
+ if (!isDockerRuntimeCommand(command)) {
87
+ throw new Error(`${fieldName} must be a Docker command for docker runtime services. Use docker compose/docker-compose or docker run/start/stop/rm/pull/build/network/volume.`);
88
+ }
89
+ }
90
+ async function getWorkspaceExecutionService(workspacePath, testFile, language) {
91
+ const rawConfig = await readWorkspaceConfigRaw(workspacePath);
92
+ const services = rawConfig?.services ?? [];
93
+ if (!services.length)
94
+ return undefined;
95
+ const absTestFile = path.isAbsolute(testFile)
96
+ ? testFile
97
+ : path.resolve(workspacePath, testFile);
98
+ let matches = services.filter((service) => {
99
+ if (!service.testDirectory)
100
+ return false;
101
+ const absTestDirectory = path.resolve(workspacePath, service.testDirectory);
102
+ return (absTestFile === absTestDirectory ||
103
+ absTestFile.startsWith(absTestDirectory + path.sep));
104
+ });
105
+ if (matches.length > 1 && language) {
106
+ const byLanguage = matches.filter((service) => service.language?.toLowerCase() === language.toLowerCase());
107
+ if (byLanguage.length > 0)
108
+ matches = byLanguage;
109
+ }
110
+ return matches.length === 1 ? matches[0] : undefined;
111
+ }
112
+ function sleep(ms) {
113
+ return new Promise((resolve) => setTimeout(resolve, ms));
114
+ }
115
+ async function probeHttpUrl(url, timeoutMs) {
116
+ const controller = new AbortController();
117
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
118
+ try {
119
+ // Any HTTP response means the SUT accepted and handled a request. We do not
120
+ // require 2xx because many APIs intentionally return 404/401 at the root.
121
+ await fetch(url, { method: "GET", signal: controller.signal });
122
+ }
123
+ finally {
124
+ clearTimeout(timeout);
125
+ }
126
+ }
68
127
  /**
69
128
  * For UI/E2E tests, create a per-execution video output directory under the
70
129
  * workspace and generate the language-specific runner config that enables
@@ -84,14 +143,14 @@ function setupVideoCapture(options, workspacePath, containerMountPath) {
84
143
  };
85
144
  if (!browserTest)
86
145
  return setup;
87
- // Per-execution video output directory on host (cleared so a retry never
88
- // reports a stale recording from a prior run).
89
- const videoSubdir = getVideoSubdir(options.testFile);
146
+ // Each execution gets a unique video directory so there is nothing to clean up
147
+ // between runs. Avoids EACCES when the Docker executor (which runs as root) has
148
+ // left root-owned files in a previous run's directory. The timestamp keeps the
149
+ // name readable/ordered; the random suffix guarantees uniqueness even when two
150
+ // executions land in the same millisecond (retries, parallel runs).
151
+ const videoSubdir = `${getVideoSubdir(options.testFile)}-${Date.now()}-${crypto.randomBytes(3).toString("hex")}`;
90
152
  setup.videoHostDir = path.join(workspacePath, ".skyramp", "videos", videoSubdir);
91
153
  setup.videoContainerDir = path.join(containerMountPath, ".skyramp", "videos", videoSubdir);
92
- if (fs.existsSync(setup.videoHostDir)) {
93
- fs.rmSync(setup.videoHostDir, { recursive: true, force: true });
94
- }
95
154
  fs.mkdirSync(setup.videoHostDir, { recursive: true });
96
155
  // TypeScript/JavaScript: generate a per-execution Playwright config with video.
97
156
  if (options.language === "typescript" || options.language === "javascript") {
@@ -353,11 +412,102 @@ function findExcludedPaths(dir, excludedItems) {
353
412
  return results;
354
413
  }
355
414
  export class TestExecutionService {
415
+ static executionQueue = Promise.resolve();
356
416
  docker;
357
417
  imageReady = null;
358
418
  constructor() {
359
419
  this.docker = new Docker();
360
420
  }
421
+ async cleanupSkyrampExecutionContainers() {
422
+ const containers = await this.docker.listContainers({ all: true });
423
+ const staleContainers = containers.filter(isOwnedStoppedExecutionContainer);
424
+ await Promise.all(staleContainers.map(async (containerInfo) => {
425
+ try {
426
+ await this.docker
427
+ .getContainer(containerInfo.Id)
428
+ .remove();
429
+ }
430
+ catch (error) {
431
+ logger.warning("Failed to remove stale Skyramp execution container", {
432
+ containerId: containerInfo.Id,
433
+ error: error.message,
434
+ });
435
+ }
436
+ }));
437
+ return staleContainers.length;
438
+ }
439
+ async prepareSutForExecution(options, workspacePath) {
440
+ if (options.testType === TestType.CONTRACT)
441
+ return undefined;
442
+ const service = await getWorkspaceExecutionService(workspacePath, options.testFile, options.language);
443
+ if (!service)
444
+ return undefined;
445
+ const startCommand = service.runtimeDetails?.serverStartCommand;
446
+ const stopCommand = service.runtimeDetails?.serverStopCommand;
447
+ if (service.runtimeDetails?.runtime === "docker") {
448
+ if (startCommand)
449
+ assertDockerRuntimeCommand(startCommand, "serverStartCommand");
450
+ if (stopCommand)
451
+ assertDockerRuntimeCommand(stopCommand, "serverStopCommand");
452
+ }
453
+ let shouldStop = false;
454
+ const teardown = async () => {
455
+ if (stopCommand) {
456
+ await this.stopSutAfterExecution(stopCommand, workspacePath);
457
+ }
458
+ };
459
+ try {
460
+ if (service.runtimeDetails?.runtime === "docker" && startCommand) {
461
+ try {
462
+ await execAsync(startCommand, {
463
+ cwd: workspacePath,
464
+ timeout: 60000,
465
+ env: process.env,
466
+ });
467
+ shouldStop = Boolean(stopCommand);
468
+ }
469
+ catch (error) {
470
+ throw new Error(`SUT setup command failed before test execution (${startCommand}): ${error.message}`);
471
+ }
472
+ }
473
+ const baseUrl = service.api?.baseUrl;
474
+ if (!baseUrl)
475
+ return shouldStop ? teardown : undefined;
476
+ const deadline = Date.now() + SUT_READY_TIMEOUT_MS;
477
+ let lastError;
478
+ while (Date.now() < deadline) {
479
+ try {
480
+ await probeHttpUrl(baseUrl, Math.min(5000, SUT_READY_TIMEOUT_MS));
481
+ return shouldStop ? teardown : undefined;
482
+ }
483
+ catch (error) {
484
+ lastError = error;
485
+ await sleep(SUT_READY_INTERVAL_MS);
486
+ }
487
+ }
488
+ throw new Error(`SUT did not become reachable at ${baseUrl} before test execution: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
489
+ }
490
+ catch (error) {
491
+ if (shouldStop)
492
+ await teardown();
493
+ throw error;
494
+ }
495
+ }
496
+ async stopSutAfterExecution(stopCommand, workspacePath) {
497
+ try {
498
+ await execAsync(stopCommand, {
499
+ cwd: workspacePath,
500
+ timeout: 60000,
501
+ env: process.env,
502
+ });
503
+ }
504
+ catch (error) {
505
+ logger.warning("SUT teardown command failed after test execution", {
506
+ command: stopCommand,
507
+ error: error.message,
508
+ });
509
+ }
510
+ }
361
511
  /**
362
512
  * Execute multiple tests in parallel batches
363
513
  */
@@ -365,23 +515,21 @@ export class TestExecutionService {
365
515
  logger.info(`Starting batch execution of ${testOptions.length} tests`);
366
516
  const startTime = Date.now();
367
517
  const results = [];
368
- // Execute tests in batches to control concurrency
369
- for (let i = 0; i < testOptions.length; i += MAX_CONCURRENT_EXECUTIONS) {
370
- const batch = testOptions.slice(i, i + MAX_CONCURRENT_EXECUTIONS);
371
- logger.debug(`Executing batch ${Math.floor(i / MAX_CONCURRENT_EXECUTIONS) + 1} (${batch.length} tests)`);
372
- const batchPromises = batch.map((options) => this.executeTest(options).catch((error) => {
373
- // If execution fails completely, return error result
374
- return {
518
+ // Execute serially because each run owns shared Docker worker/executor state.
519
+ for (const options of testOptions) {
520
+ try {
521
+ results.push(await this.executeTest(options));
522
+ }
523
+ catch (error) {
524
+ results.push({
375
525
  testFile: options.testFile,
376
526
  status: TestExecutionStatus.Error,
377
527
  executedAt: new Date().toISOString(),
378
528
  duration: 0,
379
529
  errors: [error.message],
380
530
  warnings: [],
381
- };
382
- }));
383
- const batchResults = await Promise.all(batchPromises);
384
- results.push(...batchResults);
531
+ });
532
+ }
385
533
  }
386
534
  const totalDuration = Date.now() - startTime;
387
535
  const passed = results.filter((r) => r.status === TestExecutionStatus.Pass).length;
@@ -398,11 +546,29 @@ export class TestExecutionService {
398
546
  };
399
547
  }
400
548
  /**
401
- * Execute a single test
549
+ * Execute a single test. Executions are serialized within one MCP server
550
+ * process because pre-run cleanup intentionally removes stale Skyramp
551
+ * worker/executor containers before each test.
402
552
  * @param options Test execution options
403
553
  * @param onProgress Optional callback for progress updates
404
554
  */
405
555
  async executeTest(options, onProgress) {
556
+ const previousExecution = TestExecutionService.executionQueue;
557
+ let releaseCurrentExecution;
558
+ TestExecutionService.executionQueue = new Promise((resolve) => {
559
+ releaseCurrentExecution = resolve;
560
+ });
561
+ await previousExecution.catch(() => {
562
+ // Keep the queue moving even if an earlier execution failed.
563
+ });
564
+ try {
565
+ return await this.executeTestInternal(options, onProgress);
566
+ }
567
+ finally {
568
+ releaseCurrentExecution();
569
+ }
570
+ }
571
+ async executeTestInternal(options, onProgress) {
406
572
  const startTime = Date.now();
407
573
  const executedAt = new Date().toISOString();
408
574
  logger.debug(`Executing test: ${options.testFile}`);
@@ -440,7 +606,7 @@ export class TestExecutionService {
440
606
  try {
441
607
  fs.accessSync(workspacePath, fs.constants.R_OK);
442
608
  }
443
- catch (err) {
609
+ catch {
444
610
  throw new Error(`Workspace path does not exist or is not readable: ${workspacePath}`);
445
611
  }
446
612
  // Validate test file - use basename for safer filename extraction
@@ -451,6 +617,19 @@ export class TestExecutionService {
451
617
  if (!fs.existsSync(options.testFile)) {
452
618
  throw new Error(`Test file does not exist: ${options.testFile}`);
453
619
  }
620
+ await onProgress?.({
621
+ phase: "preparing",
622
+ message: "Cleaning up stale Skyramp execution containers...",
623
+ percent: 28,
624
+ });
625
+ const cleanedContainers = await this.cleanupSkyrampExecutionContainers();
626
+ logger.info(`Removed ${cleanedContainers} stale Skyramp execution container(s) before test execution`);
627
+ await onProgress?.({
628
+ phase: "preparing",
629
+ message: "Preparing SUT for test execution...",
630
+ percent: 29,
631
+ });
632
+ const teardownSut = await this.prepareSutForExecution(options, workspacePath);
454
633
  await onProgress?.({
455
634
  phase: "preparing",
456
635
  message: "Preparing Docker container configuration...",
@@ -462,7 +641,7 @@ export class TestExecutionService {
462
641
  let testFilePath = path.relative(workspacePath, options.testFile);
463
642
  testFilePath = path.resolve(containerMountPath, testFilePath);
464
643
  // Video recording setup for UI/E2E browser tests (no-op for API tests).
465
- const { browserTest, videoHostDir, videoContainerDir, playwrightConfigPath, pytestConfigPath } = setupVideoCapture(options, workspacePath, containerMountPath);
644
+ const { browserTest, videoHostDir, videoContainerDir, playwrightConfigPath, pytestConfigPath, } = setupVideoCapture(options, workspacePath, containerMountPath);
466
645
  // Prepare Docker command
467
646
  const command = [
468
647
  "/root/runner.sh",
@@ -562,9 +741,17 @@ export class TestExecutionService {
562
741
  const playwrightConfigPaths = findExcludedPaths(workspacePath, PLAYWRIGHT_CONFIG_FILES);
563
742
  for (const absolutePath of playwrightConfigPaths) {
564
743
  const rel = path.relative(workspacePath, absolutePath);
565
- pushMount({ Type: "bind", Source: playwrightConfigPath, Target: path.join(containerMountPath, rel) });
744
+ pushMount({
745
+ Type: "bind",
746
+ Source: playwrightConfigPath,
747
+ Target: path.join(containerMountPath, rel),
748
+ });
566
749
  if (mirrorAtHostPath) {
567
- pushMount({ Type: "bind", Source: playwrightConfigPath, Target: absolutePath });
750
+ pushMount({
751
+ Type: "bind",
752
+ Source: playwrightConfigPath,
753
+ Target: absolutePath,
754
+ });
568
755
  }
569
756
  }
570
757
  // Mount video output directory for browser tests (writable)
@@ -694,6 +881,9 @@ export class TestExecutionService {
694
881
  .run(EXECUTOR_DOCKER_IMAGE, command, stream, {
695
882
  Env: env,
696
883
  HostConfig: hostConfig,
884
+ Labels: {
885
+ [OWNED_EXECUTION_CONTAINER_LABEL]: OWNED_EXECUTION_CONTAINER_LABEL_VALUE,
886
+ },
697
887
  WorkingDir: containerMountPath, // Explicitly set working directory
698
888
  })
699
889
  .then(function (data) {
@@ -791,9 +981,11 @@ export class TestExecutionService {
791
981
  percent: 100,
792
982
  });
793
983
  logger.debug(`Test ${result.status}: ${options.testFile}`);
984
+ await teardownSut?.();
794
985
  return result;
795
986
  }
796
987
  catch (error) {
988
+ await teardownSut?.();
797
989
  const duration = Date.now() - startTime;
798
990
  const cleanOutput = stripVTControlCharacters(output);
799
991
  logger.error(`Test execution error: ${error.message}`);