@skyramp/mcp 0.2.8 → 0.2.150-rc.ldw-

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 (90) hide show
  1. package/build/commands/commandLibrary.d.ts +1 -0
  2. package/build/commands/commandLibrary.js +17 -14
  3. package/build/commands/localDevTestChangesCommand.d.ts +15 -0
  4. package/build/commands/localDevTestChangesCommand.js +188 -0
  5. package/build/index.js +74 -13
  6. package/build/prompts/enhance-assertions/sharedAssertionRules.d.ts +1 -0
  7. package/build/prompts/enhance-assertions/sharedAssertionRules.js +17 -0
  8. package/build/prompts/initialize-workspace/initializeWorkspacePrompt.js +7 -6
  9. package/build/prompts/local-dev/local-dev-plan.d.ts +25 -0
  10. package/build/prompts/local-dev/local-dev-plan.js +413 -0
  11. package/build/prompts/local-dev/local-dev-prompts.d.ts +4 -0
  12. package/build/prompts/local-dev/local-dev-prompts.js +155 -0
  13. package/build/prompts/prompt-utils.d.ts +8 -0
  14. package/build/prompts/prompt-utils.js +33 -0
  15. package/build/prompts/sut-setup/modes/adaptWorkflowPrompt.js +3 -1
  16. package/build/prompts/sut-setup/modes/dockerComposePrompt.js +3 -1
  17. package/build/prompts/sut-setup/shared.d.ts +20 -0
  18. package/build/prompts/sut-setup/shared.js +69 -7
  19. package/build/prompts/test-recommendation/analysisOutputPrompt.js +21 -29
  20. package/build/prompts/test-recommendation/analysisOutputPrompt.test.js +0 -28
  21. package/build/prompts/test-recommendation/test-recommendation-prompt.js +41 -4
  22. package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +16 -1
  23. package/build/prompts/testbot/testbot-prompts.d.ts +0 -5
  24. package/build/prompts/testbot/testbot-prompts.js +4 -34
  25. package/build/resources/testbotResource.js +2 -1
  26. package/build/services/TestExecutionService.d.ts +2 -1
  27. package/build/services/TestExecutionService.js +9 -9
  28. package/build/services/TestExecutionService.test.js +42 -1
  29. package/build/services/TestGenerationService.d.ts +2 -2
  30. package/build/tool-phases.js +4 -0
  31. package/build/tools/code-refactor/enhanceAssertionsTool.js +42 -18
  32. package/build/tools/enrichTestWithMocksTool.d.ts +15 -0
  33. package/build/tools/enrichTestWithMocksTool.js +413 -0
  34. package/build/tools/enrichTestWithMocksTool.test.d.ts +1 -0
  35. package/build/tools/enrichTestWithMocksTool.test.js +60 -0
  36. package/build/tools/executeSkyrampTestTool.d.ts +4 -0
  37. package/build/tools/executeSkyrampTestTool.js +39 -18
  38. package/build/tools/executeSkyrampTestTool.test.d.ts +1 -0
  39. package/build/tools/executeSkyrampTestTool.test.js +16 -0
  40. package/build/tools/generate-tests/batchMockGenerationTool.d.ts +79 -0
  41. package/build/tools/generate-tests/batchMockGenerationTool.js +395 -0
  42. package/build/tools/generate-tests/generateContractRestTool.js +2 -2
  43. package/build/tools/generate-tests/generateMockRestTool.d.ts +56 -6
  44. package/build/tools/generate-tests/generateMockRestTool.js +193 -21
  45. package/build/tools/generateEnrichedIntegrationTestTool.d.ts +24 -0
  46. package/build/tools/generateEnrichedIntegrationTestTool.js +209 -0
  47. package/build/tools/localDevWorkflowFixes.test.d.ts +1 -0
  48. package/build/tools/localDevWorkflowFixes.test.js +144 -0
  49. package/build/tools/one-click/oneClickTool.d.ts +10 -0
  50. package/build/tools/one-click/oneClickTool.js +177 -21
  51. package/build/tools/one-click/oneClickTool.test.d.ts +1 -0
  52. package/build/tools/one-click/oneClickTool.test.js +172 -0
  53. package/build/tools/preflightMockCheckTool.d.ts +2 -0
  54. package/build/tools/preflightMockCheckTool.js +91 -0
  55. package/build/tools/test-management/analyzeChangesTool.d.ts +2 -1
  56. package/build/tools/test-management/analyzeChangesTool.js +59 -38
  57. package/build/tools/workspace/initializeWorkspaceTool.js +2 -9
  58. package/build/tools/workspace/initializeWorkspaceTool.test.js +9 -4
  59. package/build/types/OneClickCommands.d.ts +1 -1
  60. package/build/types/RepositoryAnalysis.d.ts +117 -0
  61. package/build/types/RepositoryAnalysis.js +16 -2
  62. package/build/types/TestTypes.d.ts +11 -2
  63. package/build/types/TestTypes.js +11 -1
  64. package/build/utils/analyze-openapi.js +18 -1
  65. package/build/utils/analyze-openapi.test.d.ts +1 -0
  66. package/build/utils/analyze-openapi.test.js +19 -0
  67. package/build/utils/branchDiff.d.ts +17 -1
  68. package/build/utils/branchDiff.js +96 -14
  69. package/build/utils/branchDiff.test.d.ts +1 -0
  70. package/build/utils/branchDiff.test.js +107 -0
  71. package/build/utils/docker.test.js +1 -1
  72. package/build/utils/featureFlags.d.ts +15 -1
  73. package/build/utils/featureFlags.js +19 -2
  74. package/build/utils/featureFlags.test.js +57 -2
  75. package/build/utils/grpcMockValidation.d.ts +1 -0
  76. package/build/utils/grpcMockValidation.js +49 -0
  77. package/build/utils/grpcMockValidation.test.d.ts +1 -0
  78. package/build/utils/grpcMockValidation.test.js +41 -0
  79. package/build/utils/httpMethodValidation.d.ts +4 -0
  80. package/build/utils/httpMethodValidation.js +13 -0
  81. package/build/utils/mockCompatibility.d.ts +49 -0
  82. package/build/utils/mockCompatibility.js +80 -0
  83. package/build/utils/mockCompatibility.test.d.ts +1 -0
  84. package/build/utils/mockCompatibility.test.js +88 -0
  85. package/build/utils/versions.d.ts +3 -3
  86. package/build/utils/versions.js +1 -1
  87. package/build/workspace/workspace.d.ts +35 -21
  88. package/build/workspace/workspace.js +8 -4
  89. package/build/workspace/workspace.test.js +65 -6
  90. package/package.json +2 -2
@@ -9,10 +9,10 @@ import { logger } from "../utils/logger.js";
9
9
  import { TestExecutionStatus, } from "../types/TestExecution.js";
10
10
  import { TestType } from "../types/TestTypes.js";
11
11
  import { buildContainerEnv } from "./containerEnv.js";
12
- import { SKYRAMP_IMAGE_VERSION } from "../utils/versions.js";
12
+ import { EXECUTOR_DOCKER_IMAGE } from "../utils/versions.js";
13
+ export { EXECUTOR_DOCKER_IMAGE };
13
14
  const DEFAULT_TIMEOUT = 300000; // 5 minutes
14
15
  const MAX_CONCURRENT_EXECUTIONS = 5;
15
- export const EXECUTOR_DOCKER_IMAGE = `skyramp/executor:${SKYRAMP_IMAGE_VERSION}`;
16
16
  const DOCKER_PLATFORM = "linux/amd64";
17
17
  const EXECUTION_PROGRESS_INTERVAL = 10000; // 10 seconds between progress updates during execution
18
18
  // Temp file with valid empty JSON — used instead of /dev/null for .json config files
@@ -84,14 +84,14 @@ function setupVideoCapture(options, workspacePath, containerMountPath) {
84
84
  };
85
85
  if (!browserTest)
86
86
  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);
87
+ // Each execution gets a unique video directory so there is nothing to clean up
88
+ // between runs. Avoids EACCES when the Docker executor (which runs as root) has
89
+ // left root-owned files in a previous run's directory. The timestamp keeps the
90
+ // name readable/ordered; the random suffix guarantees uniqueness even when two
91
+ // executions land in the same millisecond (retries, parallel runs).
92
+ const videoSubdir = `${getVideoSubdir(options.testFile)}-${Date.now()}-${crypto.randomBytes(3).toString("hex")}`;
90
93
  setup.videoHostDir = path.join(workspacePath, ".skyramp", "videos", videoSubdir);
91
94
  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
95
  fs.mkdirSync(setup.videoHostDir, { recursive: true });
96
96
  // TypeScript/JavaScript: generate a per-execution Playwright config with video.
97
97
  if (options.language === "typescript" || options.language === "javascript") {
@@ -440,7 +440,7 @@ export class TestExecutionService {
440
440
  try {
441
441
  fs.accessSync(workspacePath, fs.constants.R_OK);
442
442
  }
443
- catch (err) {
443
+ catch {
444
444
  throw new Error(`Workspace path does not exist or is not readable: ${workspacePath}`);
445
445
  }
446
446
  // Validate test file - use basename for safer filename extraction
@@ -408,6 +408,44 @@ describe("TestExecutionService.executeTest - Docker env forwarding", () => {
408
408
  }
409
409
  });
410
410
  });
411
+ describe("TestExecutionService.executeBatch", () => {
412
+ let TestExecutionService;
413
+ beforeAll(async () => {
414
+ const mod = await import("./TestExecutionService.js");
415
+ TestExecutionService = mod.TestExecutionService;
416
+ });
417
+ it("executes tests in parallel batches", async () => {
418
+ const service = new TestExecutionService();
419
+ const order = [];
420
+ let resolveFirst;
421
+ jest.spyOn(service, "executeTest").mockImplementation(async (options) => {
422
+ order.push(`start:${options.testFile}`);
423
+ if (options.testFile === "one.py") {
424
+ await new Promise((resolve) => {
425
+ resolveFirst = resolve;
426
+ });
427
+ }
428
+ order.push(`end:${options.testFile}`);
429
+ return {
430
+ testFile: options.testFile,
431
+ status: "pass",
432
+ executedAt: new Date().toISOString(),
433
+ duration: 1,
434
+ errors: [],
435
+ warnings: [],
436
+ };
437
+ });
438
+ const batchPromise = service.executeBatch([
439
+ { testFile: "one.py", workspacePath: "/workspace", language: "python", testType: "smoke" },
440
+ { testFile: "two.py", workspacePath: "/workspace", language: "python", testType: "smoke" },
441
+ ]);
442
+ await Promise.resolve();
443
+ expect(order).toEqual(["start:one.py", "start:two.py", "end:two.py"]);
444
+ resolveFirst?.();
445
+ await batchPromise;
446
+ expect(order).toEqual(["start:one.py", "start:two.py", "end:two.py", "end:one.py"]);
447
+ });
448
+ });
411
449
  describe("TestExecutionService.executeTest - Video capture for browser tests", () => {
412
450
  let TestExecutionService;
413
451
  let getVideoSubdir;
@@ -521,7 +559,10 @@ describe("TestExecutionService.executeTest - Video capture for browser tests", (
521
559
  });
522
560
  const dockerOptions = mockRun.mock.calls[0][3];
523
561
  const videoSubdir = getVideoSubdir("/workspace/tests/login.spec.ts");
524
- const videoMount = dockerOptions.HostConfig.Mounts.find((m) => m.Target === `/home/user/.skyramp/videos/${videoSubdir}`);
562
+ // Each execution gets a unique per-run dir: `${videoSubdir}-<timestamp>`,
563
+ // so match by prefix rather than exact equality.
564
+ const videoMountPrefix = `/home/user/.skyramp/videos/${videoSubdir}`;
565
+ const videoMount = dockerOptions.HostConfig.Mounts.find((m) => m.Target.startsWith(videoMountPrefix));
525
566
  expect(videoMount).toBeDefined();
526
567
  expect(videoMount.Type).toBe("bind");
527
568
  expect(videoMount.ReadOnly).toBe(false);
@@ -1,6 +1,6 @@
1
1
  import { SkyrampClient } from "@skyramp/skyramp";
2
2
  import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
3
- import { TestType } from "../types/TestTypes.js";
3
+ import { TestType, MOCK_TYPE } from "../types/TestTypes.js";
4
4
  export interface BaseTestParams {
5
5
  endpointURL?: string;
6
6
  method?: string;
@@ -42,7 +42,7 @@ export declare abstract class TestGenerationService {
42
42
  generateTest(params: BaseTestParams & Record<string, any>): Promise<CallToolResult>;
43
43
  protected validateInputs(params: BaseTestParams): CallToolResult;
44
44
  protected abstract buildGenerationOptions(params: BaseTestParams & Record<string, any>): any;
45
- protected abstract getTestType(): TestType;
45
+ protected abstract getTestType(): TestType | typeof MOCK_TYPE;
46
46
  protected handleApiAnalysis(params: BaseTestParams): Promise<CallToolResult | null>;
47
47
  private static readonly STANDARD_HEADERS;
48
48
  private static simpleWildcardMatch;
@@ -10,6 +10,9 @@ export const TOOL_PHASE_MAP = {
10
10
  skyramp_ui_test_generation: "generating",
11
11
  skyramp_batch_scenario_test_generation: "generating",
12
12
  skyramp_mock_generation: "generating",
13
+ skyramp_batch_mock_generation: "generating",
14
+ skyramp_generate_enriched_integration_test: "generating",
15
+ skyramp_enrich_test_with_mocks: "generating",
13
16
  skyramp_execute_test: { before: "maintaining", after: "executing" },
14
17
  skyramp_analyze_test_health: "maintaining",
15
18
  skyramp_submit_report: "reporting",
@@ -36,4 +39,5 @@ export const TOOLS_WITHOUT_PHASE = new Set([
36
39
  "skyramp_modularization",
37
40
  "skyramp_reuse_code",
38
41
  "skyramp_enhance_assertions",
42
+ "skyramp_preflight_mock_check",
39
43
  ]);
@@ -12,6 +12,37 @@ const TESTBOT_UI_CHECKS = `
12
12
  - If no suitable selector exists in the generated file for an assertion you need to add, go back and call \`browser_assert\` on the live page to record it with a valid selector, then re-export and regenerate.
13
13
  - **After executing a UI test that documents a bug from \`issuesFound\`**: if it passed when you expected it to fail, the assertions are too weak — add a stronger \`expect()\` that directly targets the buggy behavior. This counts as the single allowed retry under the 2-attempt cap — do NOT re-run more than once.
14
14
  `;
15
+ function buildAssertionInstructions(testFile, testType, enhanceType) {
16
+ if (testType === TestType.UI) {
17
+ let instructions = getUIAssertionsPrompt(testFile, enhanceType);
18
+ if (isTestbotEnabled()) {
19
+ instructions += TESTBOT_UI_CHECKS;
20
+ }
21
+ return instructions;
22
+ }
23
+ if (testType === TestType.CONTRACT) {
24
+ return getContractProviderAssertionsPrompt(testFile, enhanceType);
25
+ }
26
+ if (testType === TestType.INTEGRATION) {
27
+ return getIntegrationAssertionsPrompt(testFile, enhanceType);
28
+ }
29
+ throw new Error(`Unsupported testType for ${TOOL_NAME}: ${testType}`);
30
+ }
31
+ function buildAutoApplyInstructions(testFile, testType, enhanceType) {
32
+ const typeSpecificInstructions = buildAssertionInstructions(testFile, testType, enhanceType);
33
+ return [
34
+ `Enhance response body assertions in: \`${testFile}\``,
35
+ `Test type: ${testType} | Context: ${enhanceType}`,
36
+ ``,
37
+ `Read the file, apply the type-specific assertion guidance below, and write the file back directly.`,
38
+ `Add assertions after each send_request/sendRequest status-code assertion.`,
39
+ `Use SDK helper: Python \`skyramp.get_response_value(response, "json.path")\`, JS \`getValue(response, "json.path")\`.`,
40
+ `Do NOT restructure, reformat, add comments, or change imports. Only add assertion lines.`,
41
+ ``,
42
+ `Type-specific assertion guidance:`,
43
+ typeSpecificInstructions,
44
+ ].join("\n");
45
+ }
15
46
  const enhanceAssertionsSchema = {
16
47
  testFile: z
17
48
  .string()
@@ -24,6 +55,12 @@ const enhanceAssertionsSchema = {
24
55
  .describe("The context of the enhancement. " +
25
56
  "Use 'generation' after generating a new test file (applies to every test function). " +
26
57
  "Use 'maintenance' during drift UPDATE (applies only to new or diff-affected functions)."),
58
+ autoApply: z
59
+ .boolean()
60
+ .default(false)
61
+ .describe("When true, returns a compact instruction set (10 lines) that tells the agent to " +
62
+ "read the test file, enhance assertions per standard rules, and write it back directly. " +
63
+ "When false (default), returns the full verbose instruction set."),
27
64
  };
28
65
  export function registerEnhanceAssertionsTool(server) {
29
66
  server.registerTool(TOOL_NAME, {
@@ -34,26 +71,13 @@ export function registerEnhanceAssertionsTool(server) {
34
71
  - After updating an existing supported test file during maintenance`,
35
72
  inputSchema: enhanceAssertionsSchema,
36
73
  }, async (params) => {
37
- const { testFile, testType, enhanceType } = params;
74
+ const { testFile, testType, enhanceType, autoApply } = params;
38
75
  // Stage so testbot includes the generated files in its output commit.
39
76
  await stageGeneratedPaths(testFile);
40
77
  const enhanceCtx = enhanceType;
41
- let instructions;
42
- if (testType === TestType.UI) {
43
- instructions = getUIAssertionsPrompt(testFile, enhanceCtx);
44
- if (isTestbotEnabled()) {
45
- instructions += TESTBOT_UI_CHECKS;
46
- }
47
- }
48
- else if (testType === TestType.CONTRACT) {
49
- instructions = getContractProviderAssertionsPrompt(testFile, enhanceCtx);
50
- }
51
- else if (testType === TestType.INTEGRATION) {
52
- instructions = getIntegrationAssertionsPrompt(testFile, enhanceCtx);
53
- }
54
- else {
55
- throw new Error(`Unsupported testType for ${TOOL_NAME}: ${testType}`);
56
- }
78
+ const instructions = autoApply
79
+ ? buildAutoApplyInstructions(testFile, testType, enhanceCtx)
80
+ : buildAssertionInstructions(testFile, testType, enhanceCtx);
57
81
  const result = {
58
82
  content: [
59
83
  {
@@ -63,7 +87,7 @@ export function registerEnhanceAssertionsTool(server) {
63
87
  ],
64
88
  isError: false,
65
89
  };
66
- AnalyticsService.pushMCPToolEvent(TOOL_NAME, undefined, params).catch(() => { });
90
+ AnalyticsService.pushMCPToolEvent(TOOL_NAME, undefined, { testFile: params.testFile, testType: params.testType, enhanceType: params.enhanceType, autoApply: String(params.autoApply) }).catch(() => { });
67
91
  return result;
68
92
  });
69
93
  }
@@ -0,0 +1,15 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { ProgrammingLanguage } from "../types/TestTypes.js";
3
+ /**
4
+ * skyramp_enrich_test_with_mocks
5
+ *
6
+ * Injects mock imports and apply_mock() calls into a generated test file.
7
+ * The mock files are generated by skyramp_mock_generation — this tool wires
8
+ * them into the test so the mocks are applied before the test runs.
9
+ *
10
+ * No data duplication — the test imports from the generated mock files.
11
+ */
12
+ export declare function registerEnrichTestWithMocksTool(server: McpServer): void;
13
+ declare function injectMockImports(original: string, testFile: string, scenarioName: string, mockFiles: string[], traceComments?: string[], language?: ProgrammingLanguage): string;
14
+ declare function buildMockImportBlock(testFile: string, scenarioName: string, mockFiles: string[], traceComments?: string[], language?: ProgrammingLanguage): string;
15
+ export { injectMockImports, buildMockImportBlock };
@@ -0,0 +1,413 @@
1
+ import { z } from "zod";
2
+ import { logger } from "../utils/logger.js";
3
+ import * as fs from "fs";
4
+ import * as path from "path";
5
+ import { ProgrammingLanguage, MockProtocol } from "../types/TestTypes.js";
6
+ const TOOL_NAME = "skyramp_enrich_test_with_mocks";
7
+ const enrichSchema = {
8
+ testFile: z
9
+ .string()
10
+ .describe("Absolute path to the generated test file to enrich (e.g., /repo/tests/skyramp/order_happy_path_integration_test.py)"),
11
+ scenarioName: z
12
+ .string()
13
+ .describe("Human-readable scenario name (e.g., order-happy-path, payment-timeout). Used as the SCENARIO_NAME constant."),
14
+ mockFiles: z
15
+ .array(z.string())
16
+ .describe("Absolute paths to the generated mock files (from skyramp_mock_generation) that this test needs. " +
17
+ "The tool will inject import statements and apply_mock() calls for each file. " +
18
+ 'Example: ["/repo/tests/skyramp/mocks/validate_POST_mock.py", "/repo/tests/skyramp/mocks/payment_authorizations_POST_mock.py"]'),
19
+ language: z
20
+ .nativeEnum(ProgrammingLanguage)
21
+ .optional()
22
+ .describe("Programming language of the generated test. Optional for backward compatibility; inferred from testFile when omitted. Mock enrichment currently supports Python tests only."),
23
+ traceComments: z
24
+ .array(z.string())
25
+ .optional()
26
+ .describe("Optional trace comments showing how mock values flow to assertions (e.g., 'pricing-service returns discount=true → creator sets order.discount_applied=true → assert True')"),
27
+ };
28
+ /**
29
+ * skyramp_enrich_test_with_mocks
30
+ *
31
+ * Injects mock imports and apply_mock() calls into a generated test file.
32
+ * The mock files are generated by skyramp_mock_generation — this tool wires
33
+ * them into the test so the mocks are applied before the test runs.
34
+ *
35
+ * No data duplication — the test imports from the generated mock files.
36
+ */
37
+ export function registerEnrichTestWithMocksTool(server) {
38
+ server.registerTool(TOOL_NAME, {
39
+ description: "Enrich a generated INTEGRATION test file by injecting imports from generated mock files. " +
40
+ "Call this after skyramp_mock_generation and skyramp_integration_test_generation. " +
41
+ "The tool adds import statements and apply_mock() calls so the test uses the " +
42
+ "generated mocks directly — no data duplication. " +
43
+ "NOTE: Do NOT call this for contract tests — consumer contract tests already have mocks wired inline by the generation tool.",
44
+ inputSchema: enrichSchema,
45
+ }, async (params) => {
46
+ try {
47
+ const { testFile, scenarioName, mockFiles, traceComments, language } = params;
48
+ if (!fs.existsSync(testFile)) {
49
+ return {
50
+ content: [
51
+ {
52
+ type: "text",
53
+ text: `**Error:** Test file not found: ${testFile}`,
54
+ },
55
+ ],
56
+ isError: true,
57
+ };
58
+ }
59
+ // Validate mock files exist
60
+ const missingFiles = mockFiles.filter((f) => !fs.existsSync(f));
61
+ if (missingFiles.length > 0) {
62
+ return {
63
+ content: [
64
+ {
65
+ type: "text",
66
+ text: `**Error:** Mock files not found:\n` +
67
+ missingFiles.map((f) => ` - ${f}`).join("\n") +
68
+ `\n\nGenerate them first with \`skyramp_mock_generation\`.`,
69
+ },
70
+ ],
71
+ isError: true,
72
+ };
73
+ }
74
+ const resolvedLanguage = resolveMockEnrichmentLanguage(testFile, language);
75
+ if (resolvedLanguage !== ProgrammingLanguage.PYTHON) {
76
+ return unsupportedLanguageResult(testFile, resolvedLanguage);
77
+ }
78
+ const original = fs.readFileSync(testFile, "utf-8");
79
+ const enriched = injectMockImports(original, testFile, scenarioName, mockFiles, traceComments, resolvedLanguage);
80
+ fs.writeFileSync(testFile, enriched);
81
+ logger.info(`Enriched test file with mock imports: ${testFile}`, {
82
+ scenarioName,
83
+ mockFileCount: mockFiles.length,
84
+ });
85
+ return {
86
+ content: [
87
+ {
88
+ type: "text",
89
+ text: [
90
+ `**Test enriched with mock imports for scenario "${scenarioName}".**\n`,
91
+ `**File:** ${testFile}`,
92
+ `**Mock files imported:** ${mockFiles.length}`,
93
+ ...mockFiles.map((f) => ` • ${path.basename(f)}`),
94
+ `\nThe test now imports and applies mocks from the generated files.`,
95
+ "Before execution, stop real services selected for mocking, then execute the test with `skyramp_execute_test`.",
96
+ ].join("\n"),
97
+ },
98
+ ],
99
+ };
100
+ }
101
+ catch (error) {
102
+ logger.error(`Failed to enrich test: ${error.message}`);
103
+ return {
104
+ content: [
105
+ {
106
+ type: "text",
107
+ text: `**Failed to enrich test:** ${error.message}`,
108
+ },
109
+ ],
110
+ isError: true,
111
+ };
112
+ }
113
+ });
114
+ }
115
+ // ── Helpers ──────────────────────────────────────────────────────────────────
116
+ function injectMockImports(original, testFile, scenarioName, mockFiles, traceComments, language) {
117
+ const resolvedLanguage = resolveMockEnrichmentLanguage(testFile, language);
118
+ assertSupportedMockEnrichmentLanguage(testFile, resolvedLanguage);
119
+ const generatedBlock = buildMockImportBlock(testFile, scenarioName, mockFiles, traceComments, resolvedLanguage);
120
+ const existingBlockRange = findGeneratedMockBlockRange(original);
121
+ if (existingBlockRange) {
122
+ const suffix = original.slice(existingBlockRange.end);
123
+ const separator = suffix.length > 0 && !suffix.startsWith("\n") ? "\n" : "";
124
+ original =
125
+ original.slice(0, existingBlockRange.start) +
126
+ generatedBlock +
127
+ separator +
128
+ suffix;
129
+ }
130
+ // Find insertion point: after the last import line
131
+ const lines = original.split("\n");
132
+ let insertIdx = 0;
133
+ for (let i = 0; i < lines.length; i++) {
134
+ const line = lines[i].trim();
135
+ if (line.startsWith("import ") ||
136
+ line.startsWith("from ") ||
137
+ line.startsWith("# Import") ||
138
+ (line === "" &&
139
+ i > 0 &&
140
+ (lines[i - 1].trim().startsWith("import") ||
141
+ lines[i - 1].trim().startsWith("from")))) {
142
+ insertIdx = i + 1;
143
+ }
144
+ }
145
+ // If no imports found, insert after initial comments
146
+ if (insertIdx === 0) {
147
+ for (let i = 0; i < lines.length; i++) {
148
+ if (!lines[i].startsWith("#") && lines[i].trim() !== "") {
149
+ insertIdx = i;
150
+ break;
151
+ }
152
+ }
153
+ }
154
+ // Insert the mock block once. If a previous generated block existed, it was
155
+ // replaced before splitting into lines so stale imports/mappings do not survive.
156
+ if (!existingBlockRange) {
157
+ lines.splice(insertIdx, 0, "", generatedBlock, "");
158
+ }
159
+ // Wire apply_all_mocks(client) after the generated Skyramp client exists.
160
+ for (let i = 0; i < lines.length; i++) {
161
+ const line = lines[i];
162
+ // Match Python test function definitions (def test_integration(): or def test_*():)
163
+ if (/^def test_\w+\(/.test(line.trim())) {
164
+ const functionEnd = findPythonFunctionEnd(lines, i + 1);
165
+ const applyCallIndex = lines.findIndex((l, idx) => idx >= i &&
166
+ idx < functionEnd &&
167
+ (l.trim() === "apply_all_mocks(client)" ||
168
+ l.trim() === "apply_all_mocks()"));
169
+ if (applyCallIndex !== -1) {
170
+ // Existing generated tests may already call apply_all_mocks(client).
171
+ }
172
+ else {
173
+ const clientEnd = findSkyrampClientConstructionEnd(lines, i + 1, functionEnd);
174
+ if (clientEnd !== -1) {
175
+ lines.splice(clientEnd + 1, 0, " apply_all_mocks(client)", "");
176
+ }
177
+ }
178
+ }
179
+ }
180
+ return lines.join("\n");
181
+ }
182
+ function resolveMockEnrichmentLanguage(testFile, language) {
183
+ if (language)
184
+ return language;
185
+ const extension = path.extname(testFile).toLowerCase();
186
+ switch (extension) {
187
+ case ".py":
188
+ return ProgrammingLanguage.PYTHON;
189
+ case ".ts":
190
+ case ".tsx":
191
+ return ProgrammingLanguage.TYPESCRIPT;
192
+ case ".js":
193
+ case ".jsx":
194
+ return ProgrammingLanguage.JAVASCRIPT;
195
+ case ".java":
196
+ return ProgrammingLanguage.JAVA;
197
+ default:
198
+ throw new Error(`Cannot infer mock enrichment language from test file extension: ${testFile}`);
199
+ }
200
+ }
201
+ function assertSupportedMockEnrichmentLanguage(testFile, language) {
202
+ if (language === ProgrammingLanguage.PYTHON)
203
+ return;
204
+ throw new Error(unsupportedLanguageMessage(testFile, language));
205
+ }
206
+ function unsupportedLanguageMessage(testFile, language) {
207
+ return `Mock enrichment is currently supported only for Python tests; received language "${language}" for test file "${testFile}".`;
208
+ }
209
+ function unsupportedLanguageResult(testFile, language) {
210
+ return {
211
+ content: [
212
+ {
213
+ type: "text",
214
+ text: `**Error:** ${unsupportedLanguageMessage(testFile, language)}`,
215
+ },
216
+ ],
217
+ isError: true,
218
+ };
219
+ }
220
+ function findGeneratedMockBlockRange(source) {
221
+ const marker = "# ─── Mock Scenario:";
222
+ const start = source.indexOf(marker);
223
+ if (start === -1)
224
+ return null;
225
+ const segment = source.slice(start);
226
+ const lines = segment.split("\n");
227
+ const applyIdx = lines.findIndex((line) => line.trim() === "def apply_all_mocks(client):");
228
+ if (applyIdx === -1)
229
+ return null;
230
+ let endLine = lines.length;
231
+ for (let i = applyIdx + 1; i < lines.length; i++) {
232
+ const line = lines[i];
233
+ if (line.trim() === "" || /^\s/.test(line))
234
+ continue;
235
+ endLine = i;
236
+ break;
237
+ }
238
+ while (endLine < lines.length) {
239
+ const line = lines[endLine].trim();
240
+ if (line === "" || line.startsWith("# Mock trace:")) {
241
+ endLine += 1;
242
+ continue;
243
+ }
244
+ break;
245
+ }
246
+ const replacementText = lines.slice(0, endLine).join("\n");
247
+ return { start, end: start + replacementText.length };
248
+ }
249
+ function findPythonFunctionEnd(lines, bodyStart) {
250
+ for (let i = bodyStart; i < lines.length; i++) {
251
+ if (/^def\s+\w+\(/.test(lines[i]) || /^class\s+\w+/.test(lines[i])) {
252
+ return i;
253
+ }
254
+ }
255
+ return lines.length;
256
+ }
257
+ function findSkyrampClientConstructionEnd(lines, start, end) {
258
+ for (let i = start; i < end; i++) {
259
+ if (!/^\s*client\s*=\s*skyramp\.Client\s*\(/.test(lines[i])) {
260
+ continue;
261
+ }
262
+ let depth = 0;
263
+ for (let j = i; j < end; j++) {
264
+ depth += countChar(lines[j], "(") - countChar(lines[j], ")");
265
+ if (depth <= 0) {
266
+ return j;
267
+ }
268
+ }
269
+ return i;
270
+ }
271
+ return -1;
272
+ }
273
+ function countChar(value, char) {
274
+ return [...value].filter((current) => current === char).length;
275
+ }
276
+ function buildMockImportBlock(testFile, scenarioName, mockFiles, traceComments, language) {
277
+ const resolvedLanguage = resolveMockEnrichmentLanguage(testFile, language);
278
+ assertSupportedMockEnrichmentLanguage(testFile, resolvedLanguage);
279
+ const lines = [];
280
+ const testDir = path.dirname(testFile);
281
+ lines.push(`# ─── Mock Scenario: ${scenarioName} ──────────────────────────────────────`);
282
+ lines.push(`SCENARIO_NAME = "${scenarioName}"`);
283
+ lines.push(``);
284
+ // Generate path-based mock module loaders so dotted filenames still import correctly.
285
+ const mockEntries = [];
286
+ const usedModuleNames = new Set();
287
+ for (const mockFile of mockFiles) {
288
+ const relativePath = path.relative(testDir, mockFile).replace(/\\/g, "/");
289
+ const baseName = path.basename(mockFile, ".py");
290
+ const moduleName = uniquePythonIdentifier(baseName, usedModuleNames);
291
+ const serviceName = baseName.replace(/_mock$/, "").replace(/_/g, "-");
292
+ // Read each mock file once for both URL and protocol extraction.
293
+ const commandLine = extractMockCommandLine(mockFile);
294
+ const targetUrl = extractBaseUrlFromCommandLine(commandLine);
295
+ const protocol = extractProtocolFromCommandLine(commandLine, mockFile);
296
+ mockEntries.push({
297
+ serviceName,
298
+ moduleName,
299
+ relativePath,
300
+ targetUrl,
301
+ protocol,
302
+ });
303
+ }
304
+ lines.push(`import importlib.util`);
305
+ lines.push(`import os`);
306
+ lines.push(`from pathlib import Path`);
307
+ lines.push(``);
308
+ lines.push(`def _load_mock_factory(relative_path, module_name):`);
309
+ lines.push(` mock_path = Path(__file__).parent / relative_path`);
310
+ lines.push(` spec = importlib.util.spec_from_file_location(module_name, mock_path)`);
311
+ lines.push(` if spec is None or spec.loader is None:`);
312
+ lines.push(` raise ImportError(f"Could not load mock module: {mock_path}")`);
313
+ lines.push(` module = importlib.util.module_from_spec(spec)`);
314
+ lines.push(` spec.loader.exec_module(module)`);
315
+ lines.push(` return module.get_all_mocks`);
316
+ // Generate MOCK_SERVICES dictionary
317
+ lines.push(``);
318
+ lines.push(`MOCK_SERVICES = {`);
319
+ for (const entry of mockEntries) {
320
+ lines.push(` "${entry.serviceName}": {"factory": _load_mock_factory("${entry.relativePath}", "${entry.moduleName}"), "protocol": "${entry.protocol}"},`);
321
+ }
322
+ lines.push(`}`);
323
+ // Always define MOCK_TARGET_URLS so apply_all_mocks can reference it unconditionally.
324
+ // REST targets normalize to URL origins, while gRPC/Kafka targets preserve host:port.
325
+ lines.push(``);
326
+ lines.push(`MOCK_TARGET_URLS = {`);
327
+ for (const entry of mockEntries) {
328
+ if (entry.targetUrl) {
329
+ lines.push(` "${entry.serviceName}": "${entry.targetUrl}",`);
330
+ }
331
+ }
332
+ lines.push(`}`);
333
+ // Generate apply_all_mocks() function.
334
+ lines.push(``);
335
+ lines.push(`def apply_all_mocks(client):`);
336
+ lines.push(` """Apply all generated mocks together after protocol-specific normalization."""`);
337
+ lines.push(` client.remove_all_mocks()`);
338
+ lines.push(``);
339
+ lines.push(` all_mocks = []`);
340
+ lines.push(` for service_name, mock_config in MOCK_SERVICES.items():`);
341
+ lines.push(` get_mocks_fn = mock_config["factory"]`);
342
+ lines.push(` mocks = get_mocks_fn()`);
343
+ lines.push(` target_url = MOCK_TARGET_URLS.get(service_name)`);
344
+ lines.push(` if target_url:`);
345
+ lines.push(` for mock in mocks:`);
346
+ lines.push(` mock.url = target_url`);
347
+ lines.push(` all_mocks.extend(mocks)`);
348
+ lines.push(``);
349
+ lines.push(` if all_mocks:`);
350
+ lines.push(` client.apply_mock(all_mocks)`);
351
+ if (traceComments?.length) {
352
+ lines.push(``);
353
+ for (const comment of traceComments) {
354
+ lines.push(`# Mock trace: ${comment}`);
355
+ }
356
+ }
357
+ return lines.join("\n");
358
+ }
359
+ function extractMockCommandLine(mockFile) {
360
+ if (!fs.existsSync(mockFile))
361
+ return undefined;
362
+ return fs
363
+ .readFileSync(mockFile, "utf-8")
364
+ .split("\n")
365
+ .map((line) => line.replace(/^\s*#\s?/, "").trim())
366
+ .find((line) => line.startsWith("Command: skyramp generate mock "));
367
+ }
368
+ function extractProtocolFromCommandLine(commandLine, mockFile) {
369
+ const protocolMatch = commandLine?.match(/skyramp generate mock (rest|grpc|kafka)\b/);
370
+ if (protocolMatch?.[1])
371
+ return protocolMatch[1];
372
+ const baseName = path.basename(mockFile).toLowerCase();
373
+ if (baseName.includes("kafka"))
374
+ return MockProtocol.Kafka;
375
+ if (baseName.includes("grpc"))
376
+ return MockProtocol.Grpc;
377
+ return MockProtocol.Rest;
378
+ }
379
+ function extractBaseUrlFromCommandLine(commandLine) {
380
+ if (!commandLine)
381
+ return undefined;
382
+ const commandMatch = commandLine.match(/skyramp generate mock (?:rest|grpc|kafka)\s+([^\s\\]+)/);
383
+ if (!commandMatch || commandMatch[1].startsWith("--"))
384
+ return undefined;
385
+ return normalizeMockCommandTarget(commandMatch[1]);
386
+ }
387
+ function uniquePythonIdentifier(raw, used) {
388
+ const base = raw.replace(/\W|^(?=\d)/g, "_");
389
+ let candidate = base;
390
+ let suffix = 2;
391
+ while (used.has(candidate)) {
392
+ candidate = `${base}_${suffix}`;
393
+ suffix += 1;
394
+ }
395
+ used.add(candidate);
396
+ return candidate;
397
+ }
398
+ function normalizeMockCommandTarget(target) {
399
+ if (!target)
400
+ return undefined;
401
+ try {
402
+ const endpointUrl = new URL(target);
403
+ if (endpointUrl.origin !== "null")
404
+ return endpointUrl.origin;
405
+ }
406
+ catch {
407
+ // Fall through to host:port handling below.
408
+ }
409
+ // gRPC and Kafka mock targets are usually service host:port values without a URL scheme.
410
+ return /^[A-Za-z0-9_.-]+:\d+$/.test(target) ? target : undefined;
411
+ }
412
+ // Exported for testing
413
+ export { injectMockImports, buildMockImportBlock };
@@ -0,0 +1 @@
1
+ export {};