@skyramp/mcp 0.4.4-rc.1 → 0.4.4

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 (148) hide show
  1. package/build/index.js +239 -210
  2. package/build/playwright/PlaywrightTraceService.d.ts +1 -1
  3. package/build/playwright/blueprintDigestStore.d.ts +1 -1
  4. package/build/playwright/registerPlaywrightTools.d.ts +1 -1
  5. package/build/playwright/traceRecordingPrompt.d.ts +1 -1
  6. package/build/playwright/traceRecordingPrompt.js +2 -2
  7. package/build/prompts/local-dev/local-dev-prompts.d.ts +1 -1
  8. package/build/prompts/local-dev/local-dev-prompts.js +2 -2
  9. package/build/prompts/startTraceCollectionPrompts.d.ts +1 -1
  10. package/build/prompts/startTraceCollectionPrompts.js +2 -3
  11. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.d.ts +1 -1
  12. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +2 -2
  13. package/build/prompts/testbot/testbot-prompts.d.ts +1 -1
  14. package/build/prompts/testbot/testbot-prompts.js +4 -7
  15. package/build/recommendation/answers.d.ts +17 -52
  16. package/build/recommendation/answers.js +29 -99
  17. package/build/recommendation/pullRequestText.d.ts +0 -2
  18. package/build/recommendation/pullRequestText.js +0 -16
  19. package/build/recommendation/registerPlan.d.ts +4 -7
  20. package/build/recommendation/registerPlan.js +11 -37
  21. package/build/recommendation/types.d.ts +4 -21
  22. package/build/recommendation/verifierContracts.d.ts +4 -4
  23. package/build/recommendation/verifierContracts.js +4 -4
  24. package/build/recommendation/verifiers/citedPath.d.ts +2 -2
  25. package/build/recommendation/verifiers/citedPath.js +2 -2
  26. package/build/recommendation/verifiers/issueTraceability.d.ts +5 -1
  27. package/build/recommendation/verifiers/issueTraceability.js +35 -4
  28. package/build/resources/analysisResources.d.ts +1 -1
  29. package/build/resources/analysisResources.js +1 -1
  30. package/build/resources/progressResource.d.ts +1 -1
  31. package/build/resources/sutSetupResource.d.ts +1 -1
  32. package/build/resources/sutSetupResource.js +1 -1
  33. package/build/resources/testbotResource.d.ts +1 -1
  34. package/build/resources/testbotResource.js +1 -1
  35. package/build/services/AnalyticsService.d.ts +1 -1
  36. package/build/services/FleetExecutionService.d.ts +130 -0
  37. package/build/services/FleetExecutionService.js +391 -0
  38. package/build/services/ModularizationService.d.ts +1 -1
  39. package/build/services/TestExecutionService.d.ts +7 -0
  40. package/build/services/TestExecutionService.js +21 -0
  41. package/build/services/TestGenerationService.d.ts +1 -1
  42. package/build/tool-phases.js +2 -0
  43. package/build/tools/auth/loginTool.d.ts +1 -1
  44. package/build/tools/auth/loginTool.js +2 -2
  45. package/build/tools/auth/logoutTool.d.ts +1 -1
  46. package/build/tools/auth/logoutTool.js +2 -2
  47. package/build/tools/code-refactor/alignAssertionsTool.d.ts +1 -1
  48. package/build/tools/code-refactor/alignAssertionsTool.js +1 -1
  49. package/build/tools/code-refactor/assertionSkillTools.d.ts +1 -1
  50. package/build/tools/code-refactor/assertionSkillTools.js +2 -2
  51. package/build/tools/code-refactor/codeReuseTool.d.ts +1 -1
  52. package/build/tools/code-refactor/codeReuseTool.js +2 -2
  53. package/build/tools/code-refactor/enhanceAssertionsTool.d.ts +1 -1
  54. package/build/tools/code-refactor/enhanceAssertionsTool.js +1 -1
  55. package/build/tools/code-refactor/modularizationTool.d.ts +1 -1
  56. package/build/tools/code-refactor/modularizationTool.js +2 -2
  57. package/build/tools/enrichTestWithMocksTool.d.ts +1 -1
  58. package/build/tools/enrichTestWithMocksTool.js +2 -2
  59. package/build/tools/executeFleetTool.d.ts +28 -0
  60. package/build/tools/executeFleetTool.js +107 -0
  61. package/build/tools/executeSkyrampTestTool.d.ts +8 -5
  62. package/build/tools/executeSkyrampTestTool.js +5 -5
  63. package/build/tools/fixErrorTool.d.ts +1 -1
  64. package/build/tools/fixErrorTool.js +1 -1
  65. package/build/tools/generate-tests/batchMockGenerationTool.d.ts +6 -57
  66. package/build/tools/generate-tests/batchMockGenerationTool.js +3 -3
  67. package/build/tools/generate-tests/generateBatchScenarioRestTool.d.ts +9 -45
  68. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +29 -11
  69. package/build/tools/generate-tests/generateContractRestTool.d.ts +1 -2
  70. package/build/tools/generate-tests/generateContractRestTool.js +1 -1
  71. package/build/tools/generate-tests/generateE2ERestTool.d.ts +1 -2
  72. package/build/tools/generate-tests/generateE2ERestTool.js +1 -1
  73. package/build/tools/generate-tests/generateFuzzRestTool.d.ts +1 -1
  74. package/build/tools/generate-tests/generateFuzzRestTool.js +1 -1
  75. package/build/tools/generate-tests/generateIntegrationRestTool.d.ts +1 -2
  76. package/build/tools/generate-tests/generateIntegrationRestTool.js +1 -1
  77. package/build/tools/generate-tests/generateLoadRestTool.d.ts +1 -2
  78. package/build/tools/generate-tests/generateLoadRestTool.js +2 -1
  79. package/build/tools/generate-tests/generateMockRestTool.d.ts +1 -2
  80. package/build/tools/generate-tests/generateMockRestTool.js +3 -3
  81. package/build/tools/generate-tests/generateSmokeRestTool.d.ts +1 -1
  82. package/build/tools/generate-tests/generateSmokeRestTool.js +1 -1
  83. package/build/tools/generate-tests/generateUILoadRestTool.d.ts +64 -0
  84. package/build/tools/generate-tests/generateUILoadRestTool.js +512 -0
  85. package/build/tools/generate-tests/generateUIRestTool.d.ts +6 -7
  86. package/build/tools/generate-tests/generateUIRestTool.js +1 -1
  87. package/build/tools/generate-tests/loadTestSchema.d.ts +15 -15
  88. package/build/tools/generate-tests/trace-reuse-guard.d.ts +1 -1
  89. package/build/tools/generateEnrichedIntegrationTestTool.d.ts +1 -2
  90. package/build/tools/generateEnrichedIntegrationTestTool.js +1 -1
  91. package/build/tools/localDevWorkerComposeTool.d.ts +1 -1
  92. package/build/tools/localDevWorkerComposeTool.js +2 -2
  93. package/build/tools/one-click/oneClickTool.d.ts +1 -1
  94. package/build/tools/one-click/oneClickTool.js +1 -1
  95. package/build/tools/preflightMockCheckTool.d.ts +1 -1
  96. package/build/tools/preflightMockCheckTool.js +3 -3
  97. package/build/tools/queryProxyMocksTool.d.ts +1 -1
  98. package/build/tools/queryProxyMocksTool.js +3 -3
  99. package/build/tools/submitReportTool.d.ts +111 -414
  100. package/build/tools/submitReportTool.js +154 -138
  101. package/build/tools/test-management/actionsTool.d.ts +1 -1
  102. package/build/tools/test-management/actionsTool.js +7 -7
  103. package/build/tools/test-management/analyzeChangesTool.d.ts +22 -118
  104. package/build/tools/test-management/analyzeChangesTool.js +4 -4
  105. package/build/tools/test-management/analyzeTestHealthTool.d.ts +1 -1
  106. package/build/tools/test-management/analyzeTestHealthTool.js +4 -4
  107. package/build/tools/test-management/registerTestPlanTool.d.ts +111 -743
  108. package/build/tools/test-management/registerTestPlanTool.js +20 -36
  109. package/build/tools/test-management/resolveScreenTool.d.ts +1 -1
  110. package/build/tools/test-management/resolveScreenTool.js +6 -2
  111. package/build/tools/trace/startTraceCollectionTool.d.ts +1 -1
  112. package/build/tools/trace/startTraceCollectionTool.js +4 -4
  113. package/build/tools/trace/stopTraceCollectionTool.d.ts +1 -1
  114. package/build/tools/trace/stopTraceCollectionTool.js +2 -2
  115. package/build/tools/verifyTestDependenciesTool.d.ts +1 -1
  116. package/build/tools/verifyTestDependenciesTool.js +1 -1
  117. package/build/tools/workspace/initScanWorkspaceTool.d.ts +1 -1
  118. package/build/tools/workspace/initScanWorkspaceTool.js +1 -1
  119. package/build/tools/workspace/initializeWorkspaceTool.d.ts +1 -1
  120. package/build/tools/workspace/initializeWorkspaceTool.js +1 -1
  121. package/build/types/RepositoryAnalysis.d.ts +164 -1170
  122. package/build/types/RepositoryAnalysis.js +12 -10
  123. package/build/types/StepMethod.d.ts +24 -1
  124. package/build/types/StepMethod.js +1 -1
  125. package/build/types/TestTypes.d.ts +34 -133
  126. package/build/types/TestTypes.js +3 -3
  127. package/build/types/TestbotReport.d.ts +37 -0
  128. package/build/types/TestbotReport.js +22 -0
  129. package/build/types/index.d.ts +1 -1
  130. package/build/types/index.js +1 -1
  131. package/build/utils/docker.d.ts +29 -7
  132. package/build/utils/docker.js +83 -27
  133. package/build/utils/initAgent.d.ts +3 -3
  134. package/build/utils/initAgent.js +58 -26
  135. package/build/utils/progress.d.ts +3 -4
  136. package/build/utils/progress.js +4 -4
  137. package/build/utils/utils.d.ts +1 -1
  138. package/build/utils/versions.d.ts +3 -3
  139. package/build/utils/versions.js +1 -1
  140. package/build/workspace/workspace.d.ts +117 -538
  141. package/build/workspace/workspace.js +2 -2
  142. package/package.json +6 -5
  143. package/plugin/prompts/testbot-task1.md +1 -1
  144. package/plugin/skills/repair-staged-files/SKILL.md +17 -0
  145. package/build/recommendation/blockerProbes.d.ts +0 -13
  146. package/build/recommendation/blockerProbes.js +0 -91
  147. package/build/recommendation/blockers.d.ts +0 -65
  148. package/build/recommendation/blockers.js +0 -135
@@ -1,8 +1,7 @@
1
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1
+ import { McpServer, CallToolResult } from "@modelcontextprotocol/server";
2
2
  import { MockProtocol } from "../../types/TestTypes.js";
3
3
  export { MockProtocol } from "../../types/TestTypes.js";
4
4
  import { TestGenerationService } from "../../services/TestGenerationService.js";
5
- import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
6
5
  export declare const MOCK_PROTOCOL_EXAMPLES = "\n<example protocol=\"rest\">\nGenerate a REST mock for one HTTP endpoint. Required fields: protocol=\"rest\", endpointURL, method. Use apiSchema with an empty method only when intentionally generating all schema methods.\n{\n \"protocol\": \"rest\",\n \"endpointURL\": \"http://payment-service:5001/orders/:order_id/payment_authorizations\",\n \"method\": \"PUT\",\n \"requestAware\": true,\n \"requestData\": \"{\"payment_method_id\":\"pm_123\"}\",\n \"responseData\": \"{\"accepted\":true}\",\n \"responseStatusCode\": \"200\"\n}\n</example>\n\n<example protocol=\"rest-traffic-simulation\">\nGenerate a REST mock with traffic simulation (latency + drop rate). minLatency/maxLatency inject a random delay per response; dropRate drops a percentage of requests.\n{\n \"protocol\": \"rest\",\n \"endpointURL\": \"http://payment-service:5001/payments\",\n \"method\": \"POST\",\n \"responseData\": \"{\"status\":\"ok\"}\",\n \"minLatency\": 500,\n \"maxLatency\": 1000,\n \"dropRate\": 10\n}\n</example>\n\n<example protocol=\"rest-conditional\">\nGenerate a conditional REST mock that selects a response based on request data. Use \"when\" to match request fields and \"requestSchema\" to validate the request body. Multiple mocks for the same endpoint+method are evaluated in order; the first matching \"when\" wins.\n{\n \"protocol\": \"rest\",\n \"endpointURL\": \"http://payment-service:5001/api/v1/orders\",\n \"method\": \"POST\",\n \"when\": {\"request.body.payment.method\": \"card\"},\n \"requestSchema\": \"{\"type\":\"object\",\"required\":[\"orderId\",\"payment\"]}\",\n \"responseData\": \"{\"status\":\"confirmed\"}\",\n \"responseStatusCode\": \"201\",\n \"noMatchStatusCode\": 404,\n \"validationFailureStatusCode\": 422\n}\n</example>\n\n<example protocol=\"grpc\">\nGenerate a gRPC mock. Required fields: protocol=\"grpc\", endpointURL as host:port, protoPath, grpcServiceName. method is the RPC method name; omit or leave empty to generate all service methods. use responseData for successful protobuf-shaped responses, or grpcStatusCode/grpcStatusMessage for gRPC status errors.\n{\n \"protocol\": \"grpc\",\n \"endpointURL\": \"profile-service:50051\",\n \"protoPath\": \"/repo/proto/profiles.proto\",\n \"grpcServiceName\": \"ProfileService\",\n \"method\": \"GetProfile\",\n \"responseData\": \"{\"eligible\":true}\"\n}\n</example>\n\n<example protocol=\"grpc-traffic-simulation\">\nGenerate a gRPC mock with traffic simulation. Same latency/drop fields as REST.\n{\n \"protocol\": \"grpc\",\n \"endpointURL\": \"profile-service:50051\",\n \"protoPath\": \"/repo/proto/profiles.proto\",\n \"grpcServiceName\": \"ProfileService\",\n \"method\": \"GetProfile\",\n \"responseData\": \"{\"eligible\":true}\",\n \"minLatency\": 200,\n \"maxLatency\": 800,\n \"dropRate\": 5\n}\n</example>\n\n<example protocol=\"grpc-status-error\">\nGenerate a gRPC status error mock. Do not provide responseData with grpcStatusCode.\n{\n \"protocol\": \"grpc\",\n \"endpointURL\": \"profile-service:50051\",\n \"protoPath\": \"/repo/proto/profiles.proto\",\n \"grpcServiceName\": \"ProfileService\",\n \"method\": \"GetProfile\",\n \"grpcStatusCode\": 5,\n \"grpcStatusMessage\": \"profile not found\"\n}\n</example>\n\n<example protocol=\"kafka-producer\">\nGenerate a Kafka producer validation mock. Required fields: protocol=\"kafka\", endpointURL as broker host:port, kafkaTopic, and requestData. Do not pass method or responseData. Traffic simulation (minLatency/maxLatency/dropRate) is NOT supported for Kafka.\n{\n \"protocol\": \"kafka\",\n \"endpointURL\": \"broker:9092\",\n \"kafkaTopic\": \"events\",\n \"requestData\": \"{\"name\":\"order.created\"}\"\n}\n</example>\n\n<example protocol=\"kafka-consumer\">\nGenerate a Kafka consumer message mock. Required fields: protocol=\"kafka\", endpointURL as broker host:port, kafkaTopic, and responseData. Do not pass method or requestData. Traffic simulation (minLatency/maxLatency/dropRate) is NOT supported for Kafka.\n{\n \"protocol\": \"kafka\",\n \"endpointURL\": \"broker:9092\",\n \"kafkaTopic\": \"events\",\n \"responseData\": \"{\"accepted\":true}\"\n}\n</example>";
7
6
  type MockParams = {
8
7
  protocol?: MockProtocol;
@@ -110,7 +110,7 @@ Generate a Kafka consumer message mock. Required fields: protocol="kafka", endpo
110
110
  </example>`;
111
111
  const mockSchema = {
112
112
  protocol: z
113
- .nativeEnum(MockProtocol)
113
+ .enum(MockProtocol)
114
114
  .default(MockProtocol.Rest)
115
115
  .describe("Protocol for the mock to generate. Use 'rest' for HTTP/OpenAPI mocks, 'grpc' for protobuf/gRPC mocks, and 'kafka' for Kafka topic mocks. See <example> blocks in the tool description for protocol-specific required fields."),
116
116
  endpointURL: z
@@ -209,7 +209,7 @@ const mockSchema = {
209
209
  .optional()
210
210
  .describe("MUST be absolute path to a trace file (e.g., /path/to/trace.json) to generate the mock from captured traffic."),
211
211
  when: z
212
- .record(z.unknown())
212
+ .record(z.string(), z.unknown())
213
213
  .optional()
214
214
  .describe("Conditional-selection predicate (operator map) for REST and gRPC mocks. " +
215
215
  "When multiple mocks share an (endpoint, method), the server evaluates each mock's `when` in declared order " +
@@ -566,7 +566,7 @@ Mocks simulate REST, gRPC, or Kafka dependencies so that consumers can develop a
566
566
  3. Inline response data (\`responseData\`): generates a static mock with a fixed response.
567
567
 
568
568
  ${MOCK_PROTOCOL_EXAMPLES}`,
569
- inputSchema: mockSchema,
569
+ inputSchema: z.object(mockSchema),
570
570
  }, async (params) => {
571
571
  const service = new MockGenerationService();
572
572
  const result = await service.generateTest(params);
@@ -1,4 +1,4 @@
1
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1
+ import { McpServer } from "@modelcontextprotocol/server";
2
2
  import { TestType } from "../../types/TestTypes.js";
3
3
  import { TestGenerationService, BaseTestParams } from "../../services/TestGenerationService.js";
4
4
  export declare class SmokeTestService extends TestGenerationService {
@@ -28,7 +28,7 @@ export function registerSmokeTestTool(server) {
28
28
  Smoke testing is a preliminary check used to verify that an endpoint is accessible and returns a valid response. Smoke tests are useful for quickly identifying critical defects after significant changes. They provide rapid validation for basic functionality verification and endpoint accessibility testing.
29
29
 
30
30
  **IMPORTANT: If an apiSchema parameter (OpenAPI/Swagger file path or URL) is provided, DO NOT attempt to read or analyze the file contents. These files can be very large. Simply pass the path/URL to the tool - the backend will handle reading and processing the schema file.**`,
31
- inputSchema: smokeTestSchema,
31
+ inputSchema: z.object(smokeTestSchema),
32
32
  _meta: {
33
33
  keywords: ["smoke test", "quick test"],
34
34
  },
@@ -0,0 +1,64 @@
1
+ import { McpServer, CallToolResult } from "@modelcontextprotocol/server";
2
+ import { z } from "zod";
3
+ import { ProgrammingLanguage } from "../../types/TestTypes.js";
4
+ /**
5
+ * One recorded journey becomes three files: the browser half, the protocol half, and the
6
+ * fleet manager that runs them together.
7
+ *
8
+ * The split is the point. A ui-load run measures one user flow at two levels at once --
9
+ * a few real browsers for what a person experiences, and cheap protocol traffic for what
10
+ * the backend carries -- so the numbers come from the same journey rather than from two
11
+ * tests that drifted apart.
12
+ */
13
+ export declare const uiLoadTestSchema: {
14
+ playwrightTrace: z.ZodOptional<z.ZodString>;
15
+ playwrightInput: z.ZodOptional<z.ZodString>;
16
+ playwrightStoragePath: z.ZodDefault<z.ZodString>;
17
+ language: z.ZodPreprocess<z.ZodDefault<z.ZodEnum<typeof ProgrammingLanguage>>, unknown>;
18
+ framework: z.ZodPreprocess<z.ZodDefault<z.ZodString>, unknown>;
19
+ hosts: z.ZodArray<z.ZodObject<{
20
+ name: z.ZodString;
21
+ region: z.ZodDefault<z.ZodString>;
22
+ dockerHost: z.ZodDefault<z.ZodString>;
23
+ tls: z.ZodDefault<z.ZodString>;
24
+ hostPort: z.ZodDefault<z.ZodNumber>;
25
+ address: z.ZodDefault<z.ZodString>;
26
+ workerImage: z.ZodDefault<z.ZodString>;
27
+ workerTag: z.ZodDefault<z.ZodString>;
28
+ }, z.core.$strip>>;
29
+ browserCoverage: z.ZodDefault<z.ZodNumber>;
30
+ loadDuration: z.ZodDefault<z.ZodNumber>;
31
+ loadNumThreads: z.ZodDefault<z.ZodNumber>;
32
+ loadCount: z.ZodDefault<z.ZodNumber>;
33
+ loadTargetRPS: z.ZodDefault<z.ZodNumber>;
34
+ loadRampupDuration: z.ZodDefault<z.ZodNumber>;
35
+ loadRampupInterval: z.ZodDefault<z.ZodNumber>;
36
+ thresholds: z.ZodDefault<z.ZodString>;
37
+ deployWorker: z.ZodDefault<z.ZodBoolean>;
38
+ include: z.ZodDefault<z.ZodArray<z.ZodString>>;
39
+ exclude: z.ZodDefault<z.ZodArray<z.ZodString>>;
40
+ output: z.ZodDefault<z.ZodString>;
41
+ outputDir: z.ZodString;
42
+ allowTraceReuse: z.ZodDefault<z.ZodBoolean>;
43
+ force: z.ZodDefault<z.ZodBoolean>;
44
+ };
45
+ type UILoadParams = z.infer<z.ZodObject<typeof uiLoadTestSchema>>;
46
+ /**
47
+ * Generate the three ui-load files from one Playwright trace.
48
+ *
49
+ * Separate from the REST generators because it does not go through
50
+ * generateRestTestWrapper: ui-load has its own FFI export, which is what lets it take
51
+ * hosts and a browser/protocol split that the shared wrapper has no room for.
52
+ */
53
+ export declare function generateUILoadTest(params: UILoadParams): Promise<CallToolResult>;
54
+ /**
55
+ * The tool's entry point: the trace-reuse guard and the usage event around the generation,
56
+ * as skyramp_ui_test_generation has them.
57
+ *
58
+ * The guard is scoped "ui-load", not "ui". It namespaces by generator on purpose -- a UI
59
+ * test and a hybrid trio from one recording are different artifacts -- so what it stops
60
+ * here is a second, differently named TRIO from the same zip.
61
+ */
62
+ export declare function handleUILoadTest(params: UILoadParams): Promise<CallToolResult>;
63
+ export declare function registerUILoadTestTool(server: McpServer): void;
64
+ export {};
@@ -0,0 +1,512 @@
1
+ import { SkyrampClient } from "@skyramp/skyramp";
2
+ import { z } from "zod";
3
+ import { existsSync, readFileSync } from "fs";
4
+ import { ProgrammingLanguage } from "../../types/TestTypes.js";
5
+ import { toolError, validatePath } from "../../utils/utils.js";
6
+ import { logger } from "../../utils/logger.js";
7
+ import { AnalyticsService } from "../../services/AnalyticsService.js";
8
+ import { generatedFilesFrom } from "../../services/TestGenerationService.js";
9
+ import { getTestsRepoDir } from "../../utils/AnalysisStateManager.js";
10
+ import { executorWorkDirRefusal, generationTargets, isInsideExecutorWorkDir, } from "../../utils/executorWorkDir.js";
11
+ import { resolveOutputDir, stageGeneratedPaths, } from "../../utils/gitStaging.js";
12
+ import { normalizeLanguageParams } from "../../utils/normalizeParams.js";
13
+ import { getEntryPoint } from "../../utils/telemetry.js";
14
+ import { checkTraceReuse, recordTraceOutput } from "./trace-reuse-guard.js";
15
+ const TOOL_NAME = "skyramp_ui_load_test_generation";
16
+ /**
17
+ * Language and framework in the spelling the generator takes. Done in the SCHEMA rather than
18
+ * in the handler: input is validated before the handler runs, so "Java" against an enum is
19
+ * refused before any handler code could lower-case it.
20
+ */
21
+ const normalized = (field) => (value) => {
22
+ if (typeof value !== "string")
23
+ return value;
24
+ const params = { [field]: value };
25
+ normalizeLanguageParams(params);
26
+ return params[field];
27
+ };
28
+ /** A Docker Engine reached over the network, which is mTLS (pkg/fleet/types.go). */
29
+ const REMOTE_DOCKER_HOST = /^(tcp|https?):\/\//i;
30
+ /**
31
+ * One recorded journey becomes three files: the browser half, the protocol half, and the
32
+ * fleet manager that runs them together.
33
+ *
34
+ * The split is the point. A ui-load run measures one user flow at two levels at once --
35
+ * a few real browsers for what a person experiences, and cheap protocol traffic for what
36
+ * the backend carries -- so the numbers come from the same journey rather than from two
37
+ * tests that drifted apart.
38
+ */
39
+ export const uiLoadTestSchema = {
40
+ playwrightTrace: z
41
+ .string()
42
+ .min(1)
43
+ .optional()
44
+ .describe("REQUIRED. MUST be an absolute path to the Playwright trace zip, e.g. " +
45
+ "/path/to/trace.zip. This ONE recorded journey drives all three generated files: " +
46
+ "the UI test replays it in a real browser, and its captured backend calls become " +
47
+ "the protocol load scenario. Capture it with the browser_* tools + skyramp_export_zip."),
48
+ // Declared, not resolved in the handler: an undeclared key fails validation before the
49
+ // handler runs, which is why skyramp_ui_test_generation's own in-handler aliases never fire.
50
+ playwrightInput: z
51
+ .string()
52
+ .min(1)
53
+ .optional()
54
+ .describe("The same trace zip under the name skyramp_ui_test_generation uses. Read only when " +
55
+ "playwrightTrace is absent; pass one or the other."),
56
+ playwrightStoragePath: z
57
+ .string()
58
+ .default("")
59
+ .describe("Absolute path to the Playwright storage state the journey was recorded with. Lets the generated fleet seed each virtual user's token where the app keeps its session."),
60
+ language: z
61
+ .preprocess(normalized("language"), z.enum(ProgrammingLanguage).default(ProgrammingLanguage.PYTHON))
62
+ .describe("Language for all three generated files (default: python). For python/typescript/" +
63
+ "javascript the fleet manager is a standalone script; for java it is a JUnit class, " +
64
+ "and the UI test is JUnit + Playwright-for-Java."),
65
+ framework: z
66
+ .preprocess(normalized("framework"), z.string().default(""))
67
+ .describe("Testing framework for the UI test. Empty uses the per-language default: pytest for " +
68
+ "python, playwright for typescript/javascript, junit for java."),
69
+ hosts: z
70
+ .array(z.object({
71
+ name: z
72
+ .string()
73
+ .min(1)
74
+ .describe('Unique label for this load-generating host, e.g. "uk-1". Appears in the ' +
75
+ "generated fleet file and in the aggregated report, so each host's slice is " +
76
+ "identifiable. Required."),
77
+ region: z
78
+ .string()
79
+ .default("")
80
+ .describe('Region this host generates load from, e.g. "uk", "us-east". Metadata only: it ' +
81
+ "labels the host's slice in the report. Recommended when fanning out."),
82
+ dockerHost: z
83
+ .string()
84
+ .default("")
85
+ .describe("Remote Docker Engine where the worker for this host is deployed, e.g. " +
86
+ '"tcp://10.0.1.11:2376". Empty means the local/default Docker context, which ' +
87
+ "skyramp_execute_fleet cannot run: run such a fleet on the host instead."),
88
+ tls: z
89
+ .string()
90
+ .default("")
91
+ .describe("Directory holding the Docker client certificates (ca.pem, cert.pem, key.pem) " +
92
+ 'for dockerHost, e.g. "/certs/uk". A remote Docker Engine is mTLS, so this is ' +
93
+ "REQUIRED whenever dockerHost is set: without it the generated fleet refuses to " +
94
+ "run. The generated file reads SKYRAMP_DOCKER_CERTS at run time and falls back to " +
95
+ "this directory, so the same fleet moves between machines by setting that one " +
96
+ "variable. Ask the user where their Docker client certificates live."),
97
+ hostPort: z
98
+ .number()
99
+ .int()
100
+ .min(0)
101
+ .max(65535)
102
+ .default(0)
103
+ .describe("Port to deploy and drive the worker on, e.g. 35142. REQUIRED when the host has " +
104
+ "no address: the fleet does not choose a port, and refuses a host that carries " +
105
+ "neither. Give each host on the same machine its own port."),
106
+ address: z
107
+ .string()
108
+ .default("")
109
+ .describe('Address of an already-running worker, e.g. "10.10.10.10:35142". The fleet ' +
110
+ "REFUSES a host with neither an address nor a hostPort, so a generated " +
111
+ "fleet manager that carries neither cannot be run without being edited. A " +
112
+ "dockerHost says where to deploy, not how to reach the worker, so it does not " +
113
+ "replace either. Supply this whenever the worker is reachable directly."),
114
+ workerImage: z
115
+ .string()
116
+ .default("")
117
+ .describe("Worker image to deploy, when it is not the default for the installed Skyramp " +
118
+ "version, e.g. a private registry path. Empty uses the library's default. " +
119
+ "skyramp_execute_fleet refuses a fleet that sets it: run such a fleet on the host."),
120
+ workerTag: z
121
+ .string()
122
+ .default("")
123
+ .describe("Tag for workerImage. Empty uses the library's default. skyramp_execute_fleet " +
124
+ "refuses a fleet that sets it: run such a fleet on the host."),
125
+ }))
126
+ .min(1)
127
+ // The fleet runner's own rules (pkg/fleet/runner.go validate), applied here so a host
128
+ // list it would refuse is refused at generation and not after the files are written.
129
+ .superRefine((hosts, ctx) => {
130
+ const seen = new Set();
131
+ hosts.forEach((host, index) => {
132
+ if (seen.has(host.name)) {
133
+ ctx.addIssue({
134
+ code: "custom",
135
+ path: [index, "name"],
136
+ message: `duplicate host name "${host.name}"`,
137
+ });
138
+ }
139
+ seen.add(host.name);
140
+ if (host.address === "" && host.hostPort === 0) {
141
+ ctx.addIssue({
142
+ code: "custom",
143
+ path: [index],
144
+ message: `host "${host.name}" needs an address or a hostPort`,
145
+ });
146
+ }
147
+ // Remote engines only: a unix://, npipe:// or fd:// daemon needs no certificates.
148
+ // A refusal rather than the generator's warning, because with no tls the generated
149
+ // file carries no certificate lookup at all -- not even the SKYRAMP_DOCKER_CERTS
150
+ // fallback -- so it cannot run until someone edits it.
151
+ if (REMOTE_DOCKER_HOST.test(host.dockerHost) && host.tls === "") {
152
+ ctx.addIssue({
153
+ code: "custom",
154
+ path: [index, "tls"],
155
+ message: `host "${host.name}" deploys on the remote Docker Engine ${host.dockerHost}, ` +
156
+ "which is mTLS: set tls to the directory holding ca.pem, cert.pem and key.pem",
157
+ });
158
+ }
159
+ });
160
+ })
161
+ .describe("The load-generating hosts to fan the journey across, one entry per VM or region. " +
162
+ "Each becomes an inline Host(...) in the generated fleet manager. Ask the user which " +
163
+ "regions they want to drive load from; at least one is required."),
164
+ browserCoverage: z
165
+ .number()
166
+ // Strictly inside 0..1, not inclusive: ui-load always emits BOTH halves, and the fleet
167
+ // refuses a run carrying both unless the virtual users actually divide between them. 0
168
+ // gives each half the full count; 1 leaves the protocol half none.
169
+ .gt(0)
170
+ .lt(1)
171
+ .default(0.1)
172
+ .describe("Fraction of the run executed in REAL browsers, strictly between 0 and 1; the rest " +
173
+ "runs as the cheaper protocol load. Default 0.1. Raise toward 1 for browser " +
174
+ "fidelity, lower for mostly protocol load. Do NOT assume a non-default value."),
175
+ loadDuration: z
176
+ .number()
177
+ .int()
178
+ .nonnegative()
179
+ .default(5)
180
+ .describe("Seconds the generated run lasts (default 5). A short run is a smoke test rather " +
181
+ "than a measurement; raise loadNumThreads for more samples, not the duration."),
182
+ loadNumThreads: z
183
+ .number()
184
+ .int()
185
+ .positive()
186
+ // 2, not 1: both halves always ship, so the smallest honest run is one user in each.
187
+ .default(2)
188
+ .describe("Total virtual users (default 2), split between the browser and protocol halves by " +
189
+ "browserCoverage and then across the hosts. Must be large enough to give every " +
190
+ "host at least one user in each half; a smaller count is refused with the minimum."),
191
+ loadCount: z
192
+ .number()
193
+ .int()
194
+ .nonnegative()
195
+ .default(0)
196
+ .describe("Total iterations across the run. 0 means unbounded within loadDuration."),
197
+ loadTargetRPS: z
198
+ .number()
199
+ .int()
200
+ .nonnegative()
201
+ .default(0)
202
+ .describe("Target requests per second. 0 means as fast as the virtual users go."),
203
+ loadRampupDuration: z
204
+ .number()
205
+ .int()
206
+ .nonnegative()
207
+ .default(0)
208
+ .describe("Seconds over which to reach the full virtual-user count. 0 disables ramp-up."),
209
+ loadRampupInterval: z
210
+ .number()
211
+ .int()
212
+ .nonnegative()
213
+ .default(0)
214
+ .describe("Seconds between ramp-up steps."),
215
+ thresholds: z
216
+ .string()
217
+ .default("")
218
+ .describe("CI pass/fail thresholds the generated fleet manager asserts against, e.g. " +
219
+ '"lcp_ms=2500,inp_ms=200,cls=0.1,journey_success_rate=0.99".'),
220
+ deployWorker: z
221
+ .boolean()
222
+ .default(true)
223
+ .describe("Whether the generated fleet manager deploys a worker to each host before running. " +
224
+ "False expects a worker already there."),
225
+ include: z
226
+ .array(z.string())
227
+ .default([])
228
+ .describe("Endpoints or patterns to keep in the generated load scenario."),
229
+ exclude: z
230
+ .array(z.string())
231
+ .default([])
232
+ .describe("Hosts or patterns to drop from the generated load scenario. Matched by EXACT host, " +
233
+ 'not substring: excluding "example.com" does not drop "cdn.example.com". A trace ' +
234
+ "from a real site carries CDN and analytics traffic, and without excluding those the " +
235
+ "protocol half load-tests third parties rather than the application."),
236
+ output: z
237
+ .string()
238
+ .default("")
239
+ .describe("Base name for the three generated files. Empty derives one from the trace filename."),
240
+ outputDir: z
241
+ .string()
242
+ .min(1)
243
+ .describe("Absolute path to the directory the three files are written to."),
244
+ allowTraceReuse: z
245
+ .boolean()
246
+ .default(false)
247
+ .describe("A recording captures ONE flow, so a second, differently named trio from the same " +
248
+ "trace zip is refused. Set true only when this recording genuinely covers the " +
249
+ "scenario the new name claims. Regenerating the SAME trio never needs it."),
250
+ force: z
251
+ .boolean()
252
+ // true, like every other generation tool: regenerating after adjusting hosts or
253
+ // exclude is the normal loop, and a refusal there reads as a failed generation.
254
+ .default(true)
255
+ .describe("Overwrite existing files with the same names (default true). Set false to keep " +
256
+ "files that are already there."),
257
+ };
258
+ /** The line the generator prints for each file it writes. */
259
+ const GENERATED_FILES_LINE = /Successfully generated\/updated tests? files?:/i;
260
+ /**
261
+ * The smallest virtual-user count, at or above `threads`, that gives every host at least
262
+ * one user in each half -- or null when `threads` already does.
263
+ *
264
+ * Mirrors the fleet (pkg/fleet/runner.go): browserShare splits the total between the halves,
265
+ * rounding the browser slice to nearest and flooring it at 1, and splitShare then divides
266
+ * each half across the hosts, which this tool always weights equally. The worker runs a
267
+ * share of zero as ONE session, so a count too small to divide does not fail: it quietly
268
+ * runs more users than the generated file names.
269
+ */
270
+ function minimumThreads(threads, hosts, coverage) {
271
+ const divides = (total) => {
272
+ const browser = Math.min(Math.max(Math.round(total * coverage), 1), total);
273
+ return browser >= hosts && total - browser >= hosts;
274
+ };
275
+ if (divides(threads))
276
+ return null;
277
+ let total = threads + 1;
278
+ while (!divides(total))
279
+ total++;
280
+ return total;
281
+ }
282
+ /**
283
+ * Every file the generator reports writing. ui-load prints one "Successfully generated"
284
+ * block PER FILE, and generatedFilesFrom reads a single block, so on its own it returns the
285
+ * UI test and nothing else. Each block is handed to it in turn, so the parsing stays there.
286
+ */
287
+ function allGeneratedFiles(result) {
288
+ const blocks = result.match(/Successfully generated\/updated tests? files?:\s*\n(?:[ \t]+\S[^\n]*\n?)+/gi) ?? [];
289
+ return blocks.flatMap((block) => generatedFilesFrom(block));
290
+ }
291
+ /**
292
+ * Host settings the caller asked for that are missing from what was written.
293
+ *
294
+ * hostPort and tls travel to the generator inside the hosts JSON, and a native library that
295
+ * predates them ignores the keys without a word. Nothing on the JavaScript side can see
296
+ * that, so the written files are the only evidence. Empty when no file could be read, so an
297
+ * unreadable output never turns a good generation into a failure.
298
+ */
299
+ function droppedHostSettings(files, hosts) {
300
+ let written = "";
301
+ for (const file of files) {
302
+ try {
303
+ written += readFileSync(file, "utf8");
304
+ }
305
+ catch {
306
+ // Unreadable: leave it out rather than guess.
307
+ }
308
+ }
309
+ if (written === "")
310
+ return [];
311
+ const dropped = [];
312
+ for (const host of hosts) {
313
+ // Bounded by non-digits, so 80 is not found inside 8080.
314
+ const port = new RegExp(`(?<!\\d)${host.hostPort}(?!\\d)`);
315
+ if (host.hostPort > 0 && !port.test(written)) {
316
+ dropped.push(`hostPort ${host.hostPort} of host "${host.name}"`);
317
+ }
318
+ // Written as a string literal, so a Windows path appears with its backslashes doubled.
319
+ const escapedTls = host.tls.replace(/\\/g, "\\\\");
320
+ if (host.tls !== "" &&
321
+ !written.includes(host.tls) &&
322
+ !written.includes(escapedTls)) {
323
+ dropped.push(`tls ${host.tls} of host "${host.name}"`);
324
+ }
325
+ }
326
+ return dropped;
327
+ }
328
+ /**
329
+ * Generate the three ui-load files from one Playwright trace.
330
+ *
331
+ * Separate from the REST generators because it does not go through
332
+ * generateRestTestWrapper: ui-load has its own FFI export, which is what lets it take
333
+ * hosts and a browser/protocol split that the shared wrapper has no room for.
334
+ */
335
+ export async function generateUILoadTest(params) {
336
+ const playwrightTrace = params.playwrightTrace ?? params.playwrightInput;
337
+ if (!playwrightTrace) {
338
+ return toolError("Error: playwrightTrace is required: the absolute path to the trace zip exported " +
339
+ "by skyramp_export_zip.");
340
+ }
341
+ const traceError = validatePath(playwrightTrace, "playwrightTrace");
342
+ if (traceError)
343
+ return traceError;
344
+ if (!existsSync(playwrightTrace)) {
345
+ return toolError(`Error: playwrightTrace does not exist: ${playwrightTrace}`);
346
+ }
347
+ // Checked here like the trace: the generator resolves a relative path against the
348
+ // server's own directory, and a missing file yields a fleet with no seeded session.
349
+ if (params.playwrightStoragePath !== "") {
350
+ const storageError = validatePath(params.playwrightStoragePath, "playwrightStoragePath");
351
+ if (storageError)
352
+ return storageError;
353
+ if (!existsSync(params.playwrightStoragePath)) {
354
+ return toolError(`Error: playwrightStoragePath does not exist: ${params.playwrightStoragePath}`);
355
+ }
356
+ }
357
+ const outError = validatePath(params.outputDir, "outputDir");
358
+ if (outError)
359
+ return outError;
360
+ // The filesystem guards every other generator gets from TestGenerationService, which
361
+ // this tool does not pass through because ui-load has its own FFI export.
362
+ // In cross-repo mode the files belong in the tests repo clone, not the source repo.
363
+ const outputDir = resolveOutputDir(params.outputDir, getTestsRepoDir());
364
+ // Nothing written under .skyramp is delivered as a test, and it is the one absolute path
365
+ // an agent already holds, because the trace zip it just exported lives there.
366
+ const badTarget = generationTargets(outputDir, params.output || undefined).find(isInsideExecutorWorkDir);
367
+ if (badTarget)
368
+ return toolError(executorWorkDirRefusal(badTarget));
369
+ const minimum = minimumThreads(params.loadNumThreads, params.hosts.length, params.browserCoverage);
370
+ if (minimum !== null) {
371
+ return toolError(`Error: loadNumThreads ${params.loadNumThreads} cannot give each of the ` +
372
+ `${params.hosts.length} host(s) a virtual user in both halves at browserCoverage ` +
373
+ `${params.browserCoverage}. Use at least ${minimum}, or change browserCoverage.`);
374
+ }
375
+ const client = new SkyrampClient();
376
+ if (typeof client.generateUILoad !== "function") {
377
+ return toolError("Error: the installed @skyramp/skyramp SDK does not expose generateUILoad. " +
378
+ "Upgrade to an SDK that includes the ui-load generator.");
379
+ }
380
+ // The released SDK has generateUILoad but never reads playwrightStorage, so the session
381
+ // would be dropped and every virtual user would run unauthenticated, with a success
382
+ // message on top. The function's own source is the only signal the SDK offers: it has no
383
+ // capability or version call that distinguishes the two.
384
+ if (params.playwrightStoragePath !== "" &&
385
+ !String(client.generateUILoad).includes("playwrightStorage")) {
386
+ return toolError("Error: the installed @skyramp/skyramp SDK does not take playwrightStoragePath for " +
387
+ "ui-load, so the recorded session would be silently ignored. Upgrade to an SDK " +
388
+ "that supports it, or generate without playwrightStoragePath.");
389
+ }
390
+ logger.info("Generating ui-load test", {
391
+ language: params.language,
392
+ hosts: params.hosts.length,
393
+ outputDir,
394
+ });
395
+ let result;
396
+ try {
397
+ result = await client.generateUILoad({
398
+ playwrightTrace,
399
+ playwrightStorage: params.playwrightStoragePath,
400
+ language: params.language,
401
+ framework: params.framework,
402
+ output: params.output,
403
+ outputDir,
404
+ force: params.force,
405
+ include: params.include,
406
+ exclude: params.exclude,
407
+ loadCount: params.loadCount,
408
+ loadDuration: params.loadDuration,
409
+ loadNumThreads: params.loadNumThreads,
410
+ loadRampupDuration: params.loadRampupDuration,
411
+ loadRampupInterval: params.loadRampupInterval,
412
+ loadTargetRPS: params.loadTargetRPS,
413
+ browserCoverage: params.browserCoverage,
414
+ thresholds: params.thresholds,
415
+ hosts: params.hosts,
416
+ deployWorker: params.deployWorker,
417
+ entrypoint: getEntryPoint(),
418
+ });
419
+ }
420
+ catch (error) {
421
+ const message = error instanceof Error ? error.message : String(error);
422
+ return toolError(`Error: ui-load generation failed: ${message}`);
423
+ }
424
+ // The generator prints one line per file it writes. Anything without that line failed in
425
+ // a way the FFI returned as text rather than as a thrown error. The line itself, not the
426
+ // word "success": an error quotes what it was given, and both a path and the documented
427
+ // threshold key journey_success_rate contain that word.
428
+ if (typeof result !== "string" || !GENERATED_FILES_LINE.test(result)) {
429
+ return toolError(`Error: ui-load generation failed: ${String(result)}`);
430
+ }
431
+ // The tool promises a runnable trio, and a partial generation cannot be run.
432
+ const files = [...new Set(allGeneratedFiles(result))];
433
+ if (files.length < 3) {
434
+ return toolError(`Error: ui-load generation wrote ${files.length} of the three files: ${String(result)}`);
435
+ }
436
+ const dropped = droppedHostSettings(files, params.hosts);
437
+ if (dropped.length > 0) {
438
+ return toolError(`Error: the generated files do not carry ${dropped.join(", ")}. The installed ` +
439
+ "@skyramp/skyramp SDK ignored it, so the fleet cannot run as generated. Upgrade to " +
440
+ "an SDK that supports per-host hostPort and tls.");
441
+ }
442
+ // Stage so testbot includes the generated files in its output commit.
443
+ await stageGeneratedPaths(outputDir);
444
+ return {
445
+ content: [
446
+ { type: "text", text: result },
447
+ {
448
+ type: "text",
449
+ text: `Generated three coordinated files in ${outputDir}:\n` +
450
+ `- the UI test, which replays the recorded journey in a real browser\n` +
451
+ `- the load file, whose function RETURNS the protocol scenario (static assets filtered out)\n` +
452
+ `- the fleet manager, which wires the hosts, the UI test and the scenario into one run\n\n` +
453
+ `The fleet manager is the entry point. For python/typescript/javascript run it as a ` +
454
+ `script; for java run it as a JUnit test. It builds the scenario, fans the journey ` +
455
+ `across the hosts at browserCoverage ${params.browserCoverage}, and asserts the ` +
456
+ `thresholds.`,
457
+ },
458
+ ],
459
+ };
460
+ }
461
+ /**
462
+ * The tool's entry point: the trace-reuse guard and the usage event around the generation,
463
+ * as skyramp_ui_test_generation has them.
464
+ *
465
+ * The guard is scoped "ui-load", not "ui". It namespaces by generator on purpose -- a UI
466
+ * test and a hybrid trio from one recording are different artifacts -- so what it stops
467
+ * here is a second, differently named TRIO from the same zip.
468
+ */
469
+ export async function handleUILoadTest(params) {
470
+ const traceReuse = await checkTraceReuse(params.playwrightTrace ?? params.playwrightInput, params.output || undefined, params.outputDir, params.allowTraceReuse, "ui-load");
471
+ if (traceReuse.rejection)
472
+ return toolError(traceReuse.rejection);
473
+ const result = await generateUILoadTest(params);
474
+ // Registered only once generation produced something: recording at check time would
475
+ // refuse the corrected retry of a failed call for reusing its own trace.
476
+ if (!result.isError && traceReuse.key && traceReuse.identity) {
477
+ recordTraceOutput(traceReuse.key, traceReuse.identity);
478
+ }
479
+ AnalyticsService.pushTestGenerationToolEvent(TOOL_NAME, result, params).catch(() => {
480
+ // A usage event never decides whether a generation succeeded.
481
+ });
482
+ return result;
483
+ }
484
+ export function registerUILoadTestTool(server) {
485
+ server.registerTool(TOOL_NAME, {
486
+ description: `Generate a ui-load (hybrid) test from a Playwright trace: one journey measured in real browsers AND as protocol load, in one run.
487
+
488
+ Emits THREE coordinated files:
489
+ - the UI test — replays the recorded journey in a real browser (the frontend metrics)
490
+ - the load file — a function returning the protocol scenario built from the journey's captured API calls (the backend metrics)
491
+ - the fleet manager — the entry point, wiring hosts + UI test + scenario into one \`Fleet.run(...)\`
492
+
493
+ **Use this when the user wants frontend and backend numbers for the same flow.** A plain UI test measures only what a browser sees; a plain load test measures only the API. ui-load measures both, from one recording, so the two cannot drift apart.
494
+
495
+ **Inputs you must gather from the user, not guess:**
496
+ - \`hosts\` — which VMs or regions should generate load. At least one is required; each becomes an inline Host(...) in the fleet manager. A host on a remote Docker Engine (\`dockerHost\`) also needs \`tls\` — the directory holding its client certificates — because that connection is mTLS.
497
+ - \`browserCoverage\` — how much of the run is real browsers. Strictly between 0 and 1. Do NOT assume a non-default value.
498
+
499
+ **Exclude third-party hosts.** A trace from a real site carries CDN, analytics and ad traffic. \`exclude\` matches the EXACT host, so enumerate every one — excluding "example.com" does not drop "cdn.example.com". Without this the protocol half load-tests other people's infrastructure.
500
+
501
+ **Typical pipeline:** \`browser_*\` tools to record → \`skyramp_export_zip\` → this tool with the absolute zip path.`,
502
+ inputSchema: z.object(uiLoadTestSchema),
503
+ _meta: {
504
+ keywords: [
505
+ "ui-load test",
506
+ "hybrid test",
507
+ "fleet test",
508
+ "browser and load",
509
+ ],
510
+ },
511
+ }, async (params) => handleUILoadTest(params));
512
+ }