@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.
- package/build/commands/commandLibrary.d.ts +1 -0
- package/build/commands/commandLibrary.js +18 -17
- package/build/commands/localDevTestChangesCommand.d.ts +15 -0
- package/build/commands/localDevTestChangesCommand.js +175 -0
- package/build/index.js +74 -13
- package/build/prompts/enhance-assertions/sharedAssertionRules.js +1 -0
- package/build/prompts/initialize-workspace/initializeWorkspacePrompt.js +7 -6
- package/build/prompts/local-dev/local-dev-plan.d.ts +25 -0
- package/build/prompts/local-dev/local-dev-plan.js +414 -0
- package/build/prompts/local-dev/local-dev-prompts.d.ts +4 -0
- package/build/prompts/local-dev/local-dev-prompts.js +152 -0
- package/build/prompts/prompt-utils.d.ts +8 -0
- package/build/prompts/prompt-utils.js +33 -0
- package/build/prompts/sut-setup/modes/adaptWorkflowPrompt.js +3 -1
- package/build/prompts/sut-setup/modes/dockerComposePrompt.js +3 -1
- package/build/prompts/sut-setup/shared.d.ts +20 -0
- package/build/prompts/sut-setup/shared.js +69 -7
- package/build/prompts/test-recommendation/analysisOutputPrompt.js +21 -29
- package/build/prompts/test-recommendation/analysisOutputPrompt.test.js +79 -16
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +41 -4
- package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +34 -1
- package/build/prompts/testbot/testbot-prompts.d.ts +0 -5
- package/build/prompts/testbot/testbot-prompts.js +4 -34
- package/build/resources/testbotResource.js +2 -1
- package/build/services/TestExecutionService.d.ts +10 -2
- package/build/services/TestExecutionService.js +220 -28
- package/build/services/TestExecutionService.test.js +447 -16
- package/build/services/TestGenerationService.d.ts +2 -2
- package/build/tool-phases.js +4 -0
- package/build/tools/code-refactor/enhanceAssertionsTool.js +34 -2
- package/build/tools/enrichTestWithMocksTool.d.ts +15 -0
- package/build/tools/enrichTestWithMocksTool.js +414 -0
- package/build/tools/enrichTestWithMocksTool.test.d.ts +1 -0
- package/build/tools/enrichTestWithMocksTool.test.js +397 -0
- package/build/tools/executeSkyrampTestTool.d.ts +3 -0
- package/build/tools/executeSkyrampTestTool.js +73 -18
- package/build/tools/executeSkyrampTestTool.test.d.ts +1 -0
- package/build/tools/executeSkyrampTestTool.test.js +111 -0
- package/build/tools/generate-tests/batchMockGenerationTool.d.ts +72 -0
- package/build/tools/generate-tests/batchMockGenerationTool.js +382 -0
- package/build/tools/generate-tests/batchMockGenerationTool.test.d.ts +1 -0
- package/build/tools/generate-tests/batchMockGenerationTool.test.js +514 -0
- package/build/tools/generate-tests/generateContractRestTool.js +2 -2
- package/build/tools/generate-tests/generateMockRestTool.d.ts +56 -6
- package/build/tools/generate-tests/generateMockRestTool.js +193 -21
- package/build/tools/generate-tests/generateMockRestTool.test.d.ts +1 -0
- package/build/tools/generate-tests/generateMockRestTool.test.js +340 -0
- package/build/tools/generateEnrichedIntegrationTestTool.d.ts +24 -0
- package/build/tools/generateEnrichedIntegrationTestTool.js +209 -0
- package/build/tools/generateEnrichedIntegrationTestTool.test.d.ts +1 -0
- package/build/tools/generateEnrichedIntegrationTestTool.test.js +199 -0
- package/build/tools/one-click/oneClickTool.d.ts +10 -0
- package/build/tools/one-click/oneClickTool.js +182 -40
- package/build/tools/one-click/oneClickTool.test.d.ts +1 -0
- package/build/tools/one-click/oneClickTool.test.js +204 -0
- package/build/tools/preflightMockCheckTool.d.ts +2 -0
- package/build/tools/preflightMockCheckTool.js +91 -0
- package/build/tools/test-management/analyzeChangesTool.d.ts +2 -1
- package/build/tools/test-management/analyzeChangesTool.js +59 -38
- package/build/tools/workspace/initializeWorkspaceTool.js +2 -9
- package/build/tools/workspace/initializeWorkspaceTool.test.js +9 -4
- package/build/types/OneClickCommands.d.ts +1 -1
- package/build/types/RepositoryAnalysis.d.ts +117 -0
- package/build/types/RepositoryAnalysis.js +16 -2
- package/build/types/TestTypes.d.ts +11 -2
- package/build/types/TestTypes.js +11 -1
- package/build/utils/analyze-openapi.js +18 -1
- package/build/utils/analyze-openapi.test.d.ts +1 -0
- package/build/utils/analyze-openapi.test.js +19 -0
- package/build/utils/branchDiff.d.ts +17 -1
- package/build/utils/branchDiff.js +96 -14
- package/build/utils/branchDiff.test.d.ts +1 -0
- package/build/utils/branchDiff.test.js +107 -0
- package/build/utils/docker.test.js +1 -1
- package/build/utils/featureFlags.d.ts +15 -1
- package/build/utils/featureFlags.js +19 -2
- package/build/utils/featureFlags.test.js +57 -2
- package/build/utils/grpcMockValidation.d.ts +1 -0
- package/build/utils/grpcMockValidation.js +49 -0
- package/build/utils/grpcMockValidation.test.d.ts +1 -0
- package/build/utils/grpcMockValidation.test.js +41 -0
- package/build/utils/httpMethodValidation.d.ts +4 -0
- package/build/utils/httpMethodValidation.js +13 -0
- package/build/utils/mockCompatibility.d.ts +49 -0
- package/build/utils/mockCompatibility.js +80 -0
- package/build/utils/mockCompatibility.test.d.ts +1 -0
- package/build/utils/mockCompatibility.test.js +88 -0
- package/build/utils/versions.d.ts +3 -3
- package/build/utils/versions.js +1 -1
- package/build/workspace/workspace.d.ts +35 -21
- package/build/workspace/workspace.js +8 -4
- package/build/workspace/workspace.test.js +65 -6
- 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 {
|
|
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
|
-
**
|
|
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('&', '&')
|
|
598
|
-
.replaceAll('<', '<')
|
|
599
|
-
.replaceAll('>', '>')
|
|
600
|
-
.replaceAll('"', '"')
|
|
601
|
-
.replaceAll("'", ''');
|
|
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,
|
|
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
|
-
|
|
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 {
|
|
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
|
|
15
|
-
|
|
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
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
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
|
|
369
|
-
for (
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
|
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({
|
|
744
|
+
pushMount({
|
|
745
|
+
Type: "bind",
|
|
746
|
+
Source: playwrightConfigPath,
|
|
747
|
+
Target: path.join(containerMountPath, rel),
|
|
748
|
+
});
|
|
566
749
|
if (mirrorAtHostPath) {
|
|
567
|
-
pushMount({
|
|
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}`);
|