@skyramp/mcp 0.3.0 → 0.3.1-ldw-beta
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 +19 -13
- package/build/commands/commandLibrary.test.d.ts +1 -0
- package/build/commands/commandLibrary.test.js +59 -0
- package/build/commands/localDevTestChangesCommand.d.ts +15 -0
- package/build/commands/localDevTestChangesCommand.js +201 -0
- package/build/index.js +80 -6
- package/build/playwright/traceRecordingPrompt.js +2 -0
- package/build/prompts/enhance-assertions/sharedAssertionRules.d.ts +1 -0
- package/build/prompts/enhance-assertions/sharedAssertionRules.js +17 -0
- package/build/prompts/initialize-workspace/initializeWorkspacePrompt.js +11 -9
- package/build/prompts/local-dev/local-dev-plan.d.ts +35 -0
- package/build/prompts/local-dev/local-dev-plan.js +466 -0
- package/build/prompts/local-dev/local-dev-prompts.d.ts +4 -0
- package/build/prompts/local-dev/local-dev-prompts.js +190 -0
- package/build/prompts/prompt-utils.d.ts +8 -0
- package/build/prompts/prompt-utils.js +33 -0
- package/build/prompts/test-recommendation/analysisOutputPrompt.js +21 -29
- package/build/prompts/test-recommendation/analysisOutputPrompt.test.js +0 -28
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +41 -4
- package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +16 -1
- package/build/prompts/testbot/testbot-prompts.d.ts +0 -5
- package/build/prompts/testbot/testbot-prompts.js +5 -34
- package/build/resources/testbotResource.js +2 -1
- package/build/services/AnalyticsService.d.ts +1 -1
- package/build/services/TestExecutionService.d.ts +2 -1
- package/build/services/TestExecutionService.js +8 -3
- package/build/services/TestExecutionService.test.js +83 -3
- package/build/services/TestGenerationService.d.ts +2 -2
- package/build/services/TestGenerationService.js +7 -21
- package/build/services/containerEnv.js +3 -1
- package/build/tool-phases.js +6 -0
- package/build/tools/code-refactor/enhanceAssertionsTool.js +47 -18
- package/build/tools/enrichTestWithMocksTool.d.ts +28 -0
- package/build/tools/enrichTestWithMocksTool.js +726 -0
- package/build/tools/enrichTestWithMocksTool.test.d.ts +1 -0
- package/build/tools/enrichTestWithMocksTool.test.js +266 -0
- package/build/tools/executeSkyrampTestTool.d.ts +11 -0
- package/build/tools/executeSkyrampTestTool.js +63 -22
- package/build/tools/executeSkyrampTestTool.test.d.ts +1 -0
- package/build/tools/executeSkyrampTestTool.test.js +36 -0
- package/build/tools/generate-tests/batchMockGenerationTool.d.ts +85 -0
- package/build/tools/generate-tests/batchMockGenerationTool.js +432 -0
- package/build/tools/generate-tests/generateBatchScenarioRestTool.js +6 -21
- package/build/tools/generate-tests/generateBatchScenarioRestTool.test.js +1 -0
- package/build/tools/generate-tests/generateContractRestTool.js +2 -2
- package/build/tools/generate-tests/generateMockRestTool.d.ts +129 -6
- package/build/tools/generate-tests/generateMockRestTool.js +234 -22
- package/build/tools/generate-tests/loadTestSchema.d.ts +1 -1
- package/build/tools/generateEnrichedIntegrationTestTool.d.ts +25 -0
- package/build/tools/generateEnrichedIntegrationTestTool.js +235 -0
- package/build/tools/generateEnrichedIntegrationTestTool.test.d.ts +1 -0
- package/build/tools/generateEnrichedIntegrationTestTool.test.js +44 -0
- package/build/tools/localDevWorkerComposeTool.d.ts +24 -0
- package/build/tools/localDevWorkerComposeTool.js +224 -0
- package/build/tools/localDevWorkerComposeTool.test.d.ts +1 -0
- package/build/tools/localDevWorkerComposeTool.test.js +129 -0
- package/build/tools/localDevWorkflowFixes.test.d.ts +1 -0
- package/build/tools/localDevWorkflowFixes.test.js +255 -0
- package/build/tools/one-click/oneClickTool.d.ts +13 -0
- package/build/tools/one-click/oneClickTool.js +195 -24
- package/build/tools/one-click/oneClickTool.test.d.ts +1 -0
- package/build/tools/one-click/oneClickTool.test.js +172 -0
- package/build/tools/preflightMockCheckTool.d.ts +2 -0
- package/build/tools/preflightMockCheckTool.js +96 -0
- package/build/tools/queryProxyMocksTool.d.ts +70 -0
- package/build/tools/queryProxyMocksTool.js +522 -0
- package/build/tools/queryProxyMocksTool.test.d.ts +1 -0
- package/build/tools/queryProxyMocksTool.test.js +164 -0
- package/build/tools/test-management/analyzeChangesTool.d.ts +2 -1
- package/build/tools/test-management/analyzeChangesTool.js +61 -38
- package/build/tools/trace/startTraceCollectionTool.js +3 -3
- 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/TestExecution.d.ts +1 -0
- package/build/types/TestTypes.d.ts +25 -7
- package/build/types/TestTypes.js +19 -5
- 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 +99 -14
- package/build/utils/branchDiff.test.d.ts +1 -0
- package/build/utils/branchDiff.test.js +109 -0
- package/build/utils/featureFlags.d.ts +16 -0
- package/build/utils/featureFlags.js +20 -0
- 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 +15 -0
- package/build/utils/mockCompatibility.d.ts +49 -0
- package/build/utils/mockCompatibility.js +82 -0
- package/build/utils/mockCompatibility.test.d.ts +1 -0
- package/build/utils/mockCompatibility.test.js +79 -0
- package/build/utils/progress.js +10 -5
- package/build/utils/versions.d.ts +1 -0
- package/build/utils/versions.js +2 -0
- package/build/utils/workspaceAuth.d.ts +9 -0
- package/build/utils/workspaceAuth.js +51 -1
- package/build/workspace/workspace.d.ts +28 -14
- package/build/workspace/workspace.js +3 -1
- package/node_modules/playwright/lib/mcp/browser/tools/screenshot.js +1 -1
- package/node_modules/playwright/lib/mcp/skyramp/resultCode.js +45 -0
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +6 -22
- package/node_modules/playwright/lib/mcp/test/resultCode.test.js +48 -0
- package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
- package/node_modules/playwright/package.json +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { spawn, execFile } from "child_process";
|
|
3
|
+
import { promisify } from "util";
|
|
4
|
+
import { MockProtocol } from "../types/TestTypes.js";
|
|
5
|
+
import { logger } from "../utils/logger.js";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const TOOL_NAME = "skyramp_query_proxy_mocks";
|
|
8
|
+
// Internal safety cap on total samples fetched per protocol, guards against a
|
|
9
|
+
// runaway pagination loop if the worker keeps reporting hasMore.
|
|
10
|
+
const MAX_SAMPLES_PER_PROTOCOL = 500;
|
|
11
|
+
// Default worker port inside the pod (management API). See skyramp3 mgmt_api.go.
|
|
12
|
+
const DEFAULT_REMOTE_PORT = 35142;
|
|
13
|
+
// How long to wait for `kubectl port-forward` to report a bound local port.
|
|
14
|
+
const PORT_FORWARD_READY_TIMEOUT_MS = 15_000;
|
|
15
|
+
// Grace period between SIGTERM and SIGKILL when tearing a forward down.
|
|
16
|
+
const PORT_FORWARD_KILL_GRACE_MS = 2_000;
|
|
17
|
+
// Per-request timeout for the samples query so a stalled proxy can't hang the tool.
|
|
18
|
+
const SAMPLES_FETCH_TIMEOUT_MS = 10_000;
|
|
19
|
+
// Default label used by the Skyramp worker Helm chart.
|
|
20
|
+
const WORKER_SERVICE_SELECTOR = "app.kubernetes.io/name=skyramp-worker";
|
|
21
|
+
const PROTOCOLS = ["rest", "grpc", "kafka"];
|
|
22
|
+
// ── Pure normalizer (unit tested, no network) ────────────────────────────────
|
|
23
|
+
/**
|
|
24
|
+
* Split a gRPC full method path like "/helloworld.Greeter/SayHello" into its
|
|
25
|
+
* service name ("helloworld.Greeter") and method ("SayHello").
|
|
26
|
+
*/
|
|
27
|
+
function parseGrpcMethod(full) {
|
|
28
|
+
if (!full)
|
|
29
|
+
return {};
|
|
30
|
+
const trimmed = full.replace(/^\//, "");
|
|
31
|
+
const slash = trimmed.lastIndexOf("/");
|
|
32
|
+
if (slash === -1)
|
|
33
|
+
return { method: trimmed };
|
|
34
|
+
return { service: trimmed.slice(0, slash), method: trimmed.slice(slash + 1) };
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Derive a Kafka topic from either explicit metadata.topic or the sample path
|
|
38
|
+
* ("kafka-protocol/topic/orders" -> "orders").
|
|
39
|
+
*/
|
|
40
|
+
function parseKafkaTopic(meta) {
|
|
41
|
+
if (meta.topic)
|
|
42
|
+
return meta.topic;
|
|
43
|
+
if (meta.path) {
|
|
44
|
+
const parts = meta.path.split("/").filter(Boolean);
|
|
45
|
+
return parts[parts.length - 1];
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Combine a bare destination hostname with the separately-reported port
|
|
51
|
+
* (`metadata.port`) into `host:port`, which is what mock generation expects for
|
|
52
|
+
* the endpoint origin. Leaves the destination untouched if it already carries a
|
|
53
|
+
* port or if no usable port was reported.
|
|
54
|
+
*/
|
|
55
|
+
export function withPort(destination, port) {
|
|
56
|
+
if (destination.includes(":"))
|
|
57
|
+
return destination;
|
|
58
|
+
if (!port || port <= 0)
|
|
59
|
+
return destination;
|
|
60
|
+
return `${destination}:${port}`;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Convert raw proxy samples for a single protocol into deduplicated mock specs
|
|
64
|
+
* whose fields map 1:1 onto skyramp_batch_mock_generation. Deduplication keeps
|
|
65
|
+
* one representative per distinct (destination, method/path/topic, status).
|
|
66
|
+
*/
|
|
67
|
+
export function normalizeSamplesToMockSpecs(protocol, samples) {
|
|
68
|
+
const byKey = new Map();
|
|
69
|
+
for (const sample of samples) {
|
|
70
|
+
const meta = sample.metadata ?? {};
|
|
71
|
+
const destination = meta.destination;
|
|
72
|
+
if (!destination)
|
|
73
|
+
continue;
|
|
74
|
+
const host = withPort(destination, meta.port);
|
|
75
|
+
if (protocol === "rest") {
|
|
76
|
+
const scheme = meta.scheme || "http";
|
|
77
|
+
const path = meta.path || "";
|
|
78
|
+
const method = (meta.method || "GET").toUpperCase();
|
|
79
|
+
const statusCode = sample.response?.statusCode !== undefined
|
|
80
|
+
? String(sample.response.statusCode)
|
|
81
|
+
: "";
|
|
82
|
+
const key = `${method} ${host}${path} ${statusCode}`;
|
|
83
|
+
if (byKey.has(key))
|
|
84
|
+
continue;
|
|
85
|
+
const requestBody = sample.request?.body;
|
|
86
|
+
byKey.set(key, {
|
|
87
|
+
protocol: MockProtocol.Rest,
|
|
88
|
+
endpointURL: `${scheme}://${host}${path}`,
|
|
89
|
+
method,
|
|
90
|
+
responseData: sample.response?.body || undefined,
|
|
91
|
+
responseStatusCode: statusCode || undefined,
|
|
92
|
+
requestData: requestBody || undefined,
|
|
93
|
+
requestAware: Boolean(requestBody),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
else if (protocol === "grpc") {
|
|
97
|
+
const { service, method } = parseGrpcMethod(meta.method);
|
|
98
|
+
const key = `${host} ${meta.method || ""}`;
|
|
99
|
+
if (byKey.has(key))
|
|
100
|
+
continue;
|
|
101
|
+
byKey.set(key, {
|
|
102
|
+
protocol: MockProtocol.Grpc,
|
|
103
|
+
endpointURL: host,
|
|
104
|
+
grpcServiceName: service || meta.service || undefined,
|
|
105
|
+
method: method || undefined,
|
|
106
|
+
responseData: sample.response?.body || undefined,
|
|
107
|
+
requestData: sample.request?.body || undefined,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
// kafka
|
|
112
|
+
const topic = parseKafkaTopic(meta);
|
|
113
|
+
const isProduce = (meta.method || "").toLowerCase().includes("produce");
|
|
114
|
+
const key = `${host} ${topic || ""} ${isProduce ? "produce" : "consume"}`;
|
|
115
|
+
if (byKey.has(key))
|
|
116
|
+
continue;
|
|
117
|
+
// Kafka mocks accept exactly one of requestData (producer) or responseData
|
|
118
|
+
// (consumer) — never both.
|
|
119
|
+
const spec = {
|
|
120
|
+
protocol: MockProtocol.Kafka,
|
|
121
|
+
endpointURL: host,
|
|
122
|
+
kafkaTopic: topic,
|
|
123
|
+
};
|
|
124
|
+
if (isProduce) {
|
|
125
|
+
spec.requestData =
|
|
126
|
+
sample.request?.data || sample.request?.body || undefined;
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
spec.responseData =
|
|
130
|
+
sample.response?.data || sample.response?.body || undefined;
|
|
131
|
+
}
|
|
132
|
+
byKey.set(key, spec);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return Array.from(byKey.values());
|
|
136
|
+
}
|
|
137
|
+
// ── Proxy URL normalization ──────────────────────────────────────────────────
|
|
138
|
+
/** Normalize a user-supplied proxy address into a scheme://host:port base. */
|
|
139
|
+
export function normalizeProxyBaseUrl(proxyUrl) {
|
|
140
|
+
let url = proxyUrl.trim();
|
|
141
|
+
if (!/^https?:\/\//i.test(url))
|
|
142
|
+
url = `http://${url}`;
|
|
143
|
+
const parsed = new URL(url);
|
|
144
|
+
// Strip any path/query — the samples endpoint is appended by the caller.
|
|
145
|
+
return `${parsed.protocol}//${parsed.host}`;
|
|
146
|
+
}
|
|
147
|
+
// Registry of live port-forward stop callbacks so a process-level signal can
|
|
148
|
+
// sweep any that are still running (see registerExitSafetyNet).
|
|
149
|
+
const activeForwards = new Set();
|
|
150
|
+
let exitHandlersRegistered = false;
|
|
151
|
+
function registerExitSafetyNet() {
|
|
152
|
+
if (exitHandlersRegistered)
|
|
153
|
+
return;
|
|
154
|
+
exitHandlersRegistered = true;
|
|
155
|
+
const sweep = () => {
|
|
156
|
+
for (const stop of Array.from(activeForwards)) {
|
|
157
|
+
try {
|
|
158
|
+
stop();
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
/* best effort */
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
process.on("exit", sweep);
|
|
166
|
+
process.on("SIGINT", sweep);
|
|
167
|
+
process.on("SIGTERM", sweep);
|
|
168
|
+
}
|
|
169
|
+
async function discoverWorkerPod(kubeconfig, namespace) {
|
|
170
|
+
const { stdout } = await execFileAsync("kubectl", [
|
|
171
|
+
"--kubeconfig",
|
|
172
|
+
kubeconfig,
|
|
173
|
+
"-n",
|
|
174
|
+
namespace,
|
|
175
|
+
"get",
|
|
176
|
+
"pods",
|
|
177
|
+
"-l",
|
|
178
|
+
WORKER_SERVICE_SELECTOR,
|
|
179
|
+
"-o",
|
|
180
|
+
"jsonpath={.items[0].metadata.name}",
|
|
181
|
+
]);
|
|
182
|
+
const name = stdout.trim();
|
|
183
|
+
if (!name) {
|
|
184
|
+
throw new Error(`No pod matching '${WORKER_SERVICE_SELECTOR}' found in namespace '${namespace}'. ` +
|
|
185
|
+
`Pass workerService explicitly.`);
|
|
186
|
+
}
|
|
187
|
+
return name;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Start `kubectl port-forward` to the given target (a pod name, or "svc/<name>")
|
|
191
|
+
* using an OS-chosen local port (":remotePort"), resolving once kubectl reports
|
|
192
|
+
* the bound local port. The returned stop() kills the whole process group and is
|
|
193
|
+
* idempotent. Forwarding to the pod on its container port avoids depending on how
|
|
194
|
+
* the worker Service maps its ports (the Helm chart exposes port 80 → 35142).
|
|
195
|
+
*/
|
|
196
|
+
function startPortForward(kubeconfig, namespace, target, remotePort) {
|
|
197
|
+
registerExitSafetyNet();
|
|
198
|
+
const child = spawn("kubectl", [
|
|
199
|
+
"--kubeconfig",
|
|
200
|
+
kubeconfig,
|
|
201
|
+
"-n",
|
|
202
|
+
namespace,
|
|
203
|
+
"port-forward",
|
|
204
|
+
target,
|
|
205
|
+
`:${remotePort}`,
|
|
206
|
+
], { detached: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
207
|
+
let stopped = false;
|
|
208
|
+
const stop = () => {
|
|
209
|
+
if (stopped)
|
|
210
|
+
return;
|
|
211
|
+
stopped = true;
|
|
212
|
+
activeForwards.delete(stop);
|
|
213
|
+
const pid = child.pid;
|
|
214
|
+
if (!pid)
|
|
215
|
+
return;
|
|
216
|
+
const killGroup = (signal) => {
|
|
217
|
+
try {
|
|
218
|
+
process.kill(-pid, signal);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// Group may already be gone, or on platforms without group semantics
|
|
222
|
+
// fall back to killing the direct child.
|
|
223
|
+
try {
|
|
224
|
+
child.kill(signal);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
/* already dead */
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
killGroup("SIGTERM");
|
|
232
|
+
// Escalate to SIGKILL only if the process hasn't exited. child.killed just
|
|
233
|
+
// records that a signal was *sent* (and stays false for process-group kills
|
|
234
|
+
// via process.kill(-pid)), so check exitCode/signalCode for actual exit.
|
|
235
|
+
const timer = setTimeout(() => {
|
|
236
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
237
|
+
killGroup("SIGKILL");
|
|
238
|
+
}, PORT_FORWARD_KILL_GRACE_MS);
|
|
239
|
+
timer.unref?.();
|
|
240
|
+
};
|
|
241
|
+
activeForwards.add(stop);
|
|
242
|
+
return new Promise((resolve, reject) => {
|
|
243
|
+
const timeout = setTimeout(() => {
|
|
244
|
+
stop();
|
|
245
|
+
reject(new Error(`kubectl port-forward did not become ready within ${PORT_FORWARD_READY_TIMEOUT_MS}ms`));
|
|
246
|
+
}, PORT_FORWARD_READY_TIMEOUT_MS);
|
|
247
|
+
timeout.unref?.();
|
|
248
|
+
let stderr = "";
|
|
249
|
+
child.stdout?.on("data", (chunk) => {
|
|
250
|
+
const text = chunk.toString();
|
|
251
|
+
// kubectl prints "Forwarding from 127.0.0.1:<port>" and/or "[::1]:<port>";
|
|
252
|
+
// accept either (some environments emit only the IPv6 line).
|
|
253
|
+
const match = text.match(/Forwarding from (?:127\.0\.0\.1|\[::1\]):(\d+)/);
|
|
254
|
+
if (match) {
|
|
255
|
+
clearTimeout(timeout);
|
|
256
|
+
resolve({ localPort: Number(match[1]), stop });
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
child.stderr?.on("data", (chunk) => {
|
|
260
|
+
stderr += chunk.toString();
|
|
261
|
+
});
|
|
262
|
+
child.on("error", (err) => {
|
|
263
|
+
clearTimeout(timeout);
|
|
264
|
+
stop();
|
|
265
|
+
reject(err);
|
|
266
|
+
});
|
|
267
|
+
child.on("exit", (code) => {
|
|
268
|
+
clearTimeout(timeout);
|
|
269
|
+
stop();
|
|
270
|
+
reject(new Error(`kubectl port-forward exited early (code ${code}): ${stderr.trim() || "no output"}`));
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
// ── Sample querying ──────────────────────────────────────────────────────────
|
|
275
|
+
async function queryProtocolSamples(baseUrl, protocol, pageSize, destination) {
|
|
276
|
+
const collected = [];
|
|
277
|
+
let offset = 0;
|
|
278
|
+
// eslint-disable-next-line no-constant-condition
|
|
279
|
+
while (true) {
|
|
280
|
+
const params = new URLSearchParams({
|
|
281
|
+
protocol,
|
|
282
|
+
limit: String(pageSize),
|
|
283
|
+
offset: String(offset),
|
|
284
|
+
});
|
|
285
|
+
if (destination)
|
|
286
|
+
params.set("destination", destination);
|
|
287
|
+
const url = `${baseUrl}/skyramp/proxy/samples?${params.toString()}`;
|
|
288
|
+
// Bound each request so a stalled proxy connection can't hang the tool.
|
|
289
|
+
const res = await fetch(url, {
|
|
290
|
+
signal: AbortSignal.timeout(SAMPLES_FETCH_TIMEOUT_MS),
|
|
291
|
+
});
|
|
292
|
+
if (!res.ok) {
|
|
293
|
+
const body = await res.text().catch(() => "");
|
|
294
|
+
throw new Error(`GET ${url} -> ${res.status} ${body.slice(0, 200)}`);
|
|
295
|
+
}
|
|
296
|
+
const data = (await res.json());
|
|
297
|
+
const batch = data.samples ?? [];
|
|
298
|
+
collected.push(...batch);
|
|
299
|
+
// Stop on an empty page, an explicit hasMore=false, the internal cap, or —
|
|
300
|
+
// ONLY when the server reports a totalCount — once we've collected them all.
|
|
301
|
+
// Do NOT derive `total` from collected.length: that would make the "reached
|
|
302
|
+
// total" check trivially true and break pagination when totalCount is absent.
|
|
303
|
+
const reachedReportedTotal = typeof data.totalCount === "number" &&
|
|
304
|
+
collected.length >= data.totalCount;
|
|
305
|
+
if (batch.length === 0 ||
|
|
306
|
+
data.hasMore === false ||
|
|
307
|
+
reachedReportedTotal ||
|
|
308
|
+
collected.length >= MAX_SAMPLES_PER_PROTOCOL) {
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
offset += batch.length;
|
|
312
|
+
}
|
|
313
|
+
return collected.slice(0, MAX_SAMPLES_PER_PROTOCOL);
|
|
314
|
+
}
|
|
315
|
+
// ── Input schema ─────────────────────────────────────────────────────────────
|
|
316
|
+
const inputSchema = {
|
|
317
|
+
proxyUrl: z
|
|
318
|
+
.string()
|
|
319
|
+
.optional()
|
|
320
|
+
.describe("Direct HTTP address of the Skyramp worker proxy management API, as host:port or a full URL " +
|
|
321
|
+
"(e.g. 'localhost:35141' or 'https://skyramp.example.com'). Also covers an ingress-controller " +
|
|
322
|
+
"route. Mutually exclusive with kubeconfig/namespace."),
|
|
323
|
+
kubeconfig: z
|
|
324
|
+
.string()
|
|
325
|
+
.optional()
|
|
326
|
+
.describe("Absolute path to a kubeconfig file. When set (with namespace), the tool runs `kubectl port-forward` " +
|
|
327
|
+
"to reach the worker in-cluster. Mutually exclusive with proxyUrl."),
|
|
328
|
+
namespace: z
|
|
329
|
+
.string()
|
|
330
|
+
.optional()
|
|
331
|
+
.describe("Kubernetes namespace where the Skyramp worker is deployed. Required with kubeconfig."),
|
|
332
|
+
workerService: z
|
|
333
|
+
.string()
|
|
334
|
+
.optional()
|
|
335
|
+
.describe(`Name of the worker Kubernetes Service to port-forward to (forwards to 'svc/<name>' at remotePort). ` +
|
|
336
|
+
`Optional — when omitted, the tool discovers the worker POD by label '${WORKER_SERVICE_SELECTOR}' and ` +
|
|
337
|
+
`forwards to it directly (avoids depending on how the Service maps its ports).`),
|
|
338
|
+
remotePort: z
|
|
339
|
+
.number()
|
|
340
|
+
.int()
|
|
341
|
+
.optional()
|
|
342
|
+
.describe(`Worker management API port inside the pod. Defaults to ${DEFAULT_REMOTE_PORT}.`),
|
|
343
|
+
protocols: z
|
|
344
|
+
.array(z.enum(PROTOCOLS))
|
|
345
|
+
.optional()
|
|
346
|
+
.describe("Which protocols to query. Defaults to all of rest, grpc, kafka."),
|
|
347
|
+
destination: z
|
|
348
|
+
.string()
|
|
349
|
+
.optional()
|
|
350
|
+
.describe("Optional destination filter (e.g. 'ams:4000') passed through to the samples query."),
|
|
351
|
+
limit: z
|
|
352
|
+
.number()
|
|
353
|
+
.int()
|
|
354
|
+
.positive()
|
|
355
|
+
.optional()
|
|
356
|
+
.describe("Page size per samples request (default 50). Total is capped internally."),
|
|
357
|
+
};
|
|
358
|
+
const mockSpecOutputSchema = z.object({
|
|
359
|
+
protocol: z.nativeEnum(MockProtocol),
|
|
360
|
+
endpointURL: z.string(),
|
|
361
|
+
method: z.string().optional(),
|
|
362
|
+
responseData: z.string().optional(),
|
|
363
|
+
responseStatusCode: z.string().optional(),
|
|
364
|
+
requestData: z.string().optional(),
|
|
365
|
+
requestAware: z.boolean().optional(),
|
|
366
|
+
grpcServiceName: z.string().optional(),
|
|
367
|
+
kafkaTopic: z.string().optional(),
|
|
368
|
+
});
|
|
369
|
+
const outputSchema = {
|
|
370
|
+
proxyReachable: z
|
|
371
|
+
.boolean()
|
|
372
|
+
.describe("True if the proxy was reached and queried. When false, fall back to code/OpenAPI inference."),
|
|
373
|
+
sampleCount: z
|
|
374
|
+
.number()
|
|
375
|
+
.describe("Total raw samples retrieved across all queried protocols."),
|
|
376
|
+
endpointsDiscovered: z
|
|
377
|
+
.number()
|
|
378
|
+
.describe("Number of deduplicated mock specs produced."),
|
|
379
|
+
mockSpecs: z
|
|
380
|
+
.array(mockSpecOutputSchema)
|
|
381
|
+
.describe("Normalized mock specs ready to pass to skyramp_batch_mock_generation."),
|
|
382
|
+
message: z
|
|
383
|
+
.string()
|
|
384
|
+
.optional()
|
|
385
|
+
.describe("Human-readable status or error detail."),
|
|
386
|
+
};
|
|
387
|
+
/**
|
|
388
|
+
* Validate that exactly one access mode was provided and return a normalized
|
|
389
|
+
* descriptor. Throws a user-facing error otherwise.
|
|
390
|
+
*/
|
|
391
|
+
export function resolveAccessMode(params) {
|
|
392
|
+
const hasUrl = Boolean(params.proxyUrl);
|
|
393
|
+
const hasKube = Boolean(params.kubeconfig || params.namespace);
|
|
394
|
+
if (hasUrl && hasKube) {
|
|
395
|
+
throw new Error("Provide either proxyUrl OR kubeconfig+namespace, not both.");
|
|
396
|
+
}
|
|
397
|
+
if (!hasUrl && !hasKube) {
|
|
398
|
+
throw new Error("No proxy access provided. Pass proxyUrl, or kubeconfig+namespace.");
|
|
399
|
+
}
|
|
400
|
+
if (hasUrl)
|
|
401
|
+
return { mode: "url", proxyUrl: params.proxyUrl };
|
|
402
|
+
if (!params.kubeconfig || !params.namespace) {
|
|
403
|
+
throw new Error("Kubernetes access requires both kubeconfig and namespace.");
|
|
404
|
+
}
|
|
405
|
+
return {
|
|
406
|
+
mode: "kube",
|
|
407
|
+
kubeconfig: params.kubeconfig,
|
|
408
|
+
namespace: params.namespace,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
export function registerQueryProxyMocksTool(server) {
|
|
412
|
+
server.registerTool(TOOL_NAME, {
|
|
413
|
+
description: "Query a running Skyramp worker proxy for recorded downstream traffic and return normalized mock specs. " +
|
|
414
|
+
"Use during local-dev dependency discovery when a proxy is recording real traffic: the returned mockSpecs " +
|
|
415
|
+
"map directly onto skyramp_batch_mock_generation (real request/response bodies and status codes) instead " +
|
|
416
|
+
"of code/OpenAPI inference. Access the proxy via proxyUrl (host:port or full URL, including an ingress " +
|
|
417
|
+
"route) OR kubeconfig+namespace (the tool runs kubectl port-forward and always tears it down). If the " +
|
|
418
|
+
"proxy is unreachable, proxyReachable is false and you should fall back to inference — the workflow is not " +
|
|
419
|
+
"aborted.",
|
|
420
|
+
inputSchema,
|
|
421
|
+
outputSchema,
|
|
422
|
+
}, async (params) => {
|
|
423
|
+
const protocols = params.protocols ?? [
|
|
424
|
+
...PROTOCOLS,
|
|
425
|
+
];
|
|
426
|
+
const pageSize = params.limit ?? 50;
|
|
427
|
+
const destination = params.destination;
|
|
428
|
+
let access;
|
|
429
|
+
try {
|
|
430
|
+
access = resolveAccessMode(params);
|
|
431
|
+
}
|
|
432
|
+
catch (err) {
|
|
433
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
434
|
+
return unreachable(message);
|
|
435
|
+
}
|
|
436
|
+
let baseUrl;
|
|
437
|
+
let portForward;
|
|
438
|
+
try {
|
|
439
|
+
if (access.mode === "url") {
|
|
440
|
+
baseUrl = normalizeProxyBaseUrl(access.proxyUrl);
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
// If a service name is given, forward to it (remotePort must be that
|
|
444
|
+
// service's port). Otherwise forward to the worker pod on its container
|
|
445
|
+
// port, which is independent of how the Service maps its ports.
|
|
446
|
+
const explicitService = params.workerService;
|
|
447
|
+
const target = explicitService
|
|
448
|
+
? `svc/${explicitService}`
|
|
449
|
+
: await discoverWorkerPod(access.kubeconfig, access.namespace);
|
|
450
|
+
const remotePort = params.remotePort ?? DEFAULT_REMOTE_PORT;
|
|
451
|
+
portForward = await startPortForward(access.kubeconfig, access.namespace, target, remotePort);
|
|
452
|
+
baseUrl = `http://localhost:${portForward.localPort}`;
|
|
453
|
+
}
|
|
454
|
+
const allSpecs = [];
|
|
455
|
+
let sampleCount = 0;
|
|
456
|
+
for (const protocol of protocols) {
|
|
457
|
+
const samples = await queryProtocolSamples(baseUrl, protocol, pageSize, destination);
|
|
458
|
+
sampleCount += samples.length;
|
|
459
|
+
allSpecs.push(...normalizeSamplesToMockSpecs(protocol, samples));
|
|
460
|
+
}
|
|
461
|
+
return {
|
|
462
|
+
structuredContent: {
|
|
463
|
+
proxyReachable: true,
|
|
464
|
+
sampleCount,
|
|
465
|
+
endpointsDiscovered: allSpecs.length,
|
|
466
|
+
mockSpecs: allSpecs,
|
|
467
|
+
},
|
|
468
|
+
content: [
|
|
469
|
+
{ type: "text", text: formatSummary(allSpecs, sampleCount) },
|
|
470
|
+
],
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
catch (err) {
|
|
474
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
475
|
+
logger.warning("skyramp_query_proxy_mocks failed to reach proxy", {
|
|
476
|
+
error: message,
|
|
477
|
+
});
|
|
478
|
+
return unreachable(message);
|
|
479
|
+
}
|
|
480
|
+
finally {
|
|
481
|
+
// Guaranteed teardown of the port-forward on every path.
|
|
482
|
+
portForward?.stop();
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
function unreachable(message) {
|
|
487
|
+
return {
|
|
488
|
+
structuredContent: {
|
|
489
|
+
proxyReachable: false,
|
|
490
|
+
sampleCount: 0,
|
|
491
|
+
endpointsDiscovered: 0,
|
|
492
|
+
mockSpecs: [],
|
|
493
|
+
message,
|
|
494
|
+
},
|
|
495
|
+
content: [
|
|
496
|
+
{
|
|
497
|
+
type: "text",
|
|
498
|
+
text: `⚠️ Proxy not queried: ${message}\n\n` +
|
|
499
|
+
"Fall back to OpenAPI spec / implementation-code inference for downstream mock shapes.",
|
|
500
|
+
},
|
|
501
|
+
],
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
function formatSummary(specs, sampleCount) {
|
|
505
|
+
if (specs.length === 0) {
|
|
506
|
+
return `Proxy reachable but no samples recorded (queried ${sampleCount}). Fall back to code/OpenAPI inference.`;
|
|
507
|
+
}
|
|
508
|
+
const lines = specs.map((s) => {
|
|
509
|
+
if (s.protocol === MockProtocol.Rest) {
|
|
510
|
+
return ` • [rest] ${s.method} ${s.endpointURL} → ${s.responseStatusCode ?? "?"}`;
|
|
511
|
+
}
|
|
512
|
+
if (s.protocol === MockProtocol.Grpc) {
|
|
513
|
+
return ` • [grpc] ${s.endpointURL} ${s.grpcServiceName ?? ""}/${s.method ?? ""}`;
|
|
514
|
+
}
|
|
515
|
+
return ` • [kafka] ${s.endpointURL} topic=${s.kafkaTopic ?? "?"} ${s.requestData ? "(produce)" : "(consume)"}`;
|
|
516
|
+
});
|
|
517
|
+
return [
|
|
518
|
+
`✅ Proxy queried: ${sampleCount} sample(s) → ${specs.length} mock spec(s).`,
|
|
519
|
+
"Secrets are redacted by the proxy. Pass these specs to skyramp_batch_mock_generation:",
|
|
520
|
+
...lines,
|
|
521
|
+
].join("\n");
|
|
522
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { normalizeSamplesToMockSpecs, normalizeProxyBaseUrl, resolveAccessMode, withPort, } from "./queryProxyMocksTool.js";
|
|
2
|
+
import { MockProtocol } from "../types/TestTypes.js";
|
|
3
|
+
describe("normalizeSamplesToMockSpecs", () => {
|
|
4
|
+
it("maps REST samples to mock specs, combining the separate port into endpointURL", () => {
|
|
5
|
+
const specs = normalizeSamplesToMockSpecs("rest", [
|
|
6
|
+
{
|
|
7
|
+
request: { body: '{"name":"acme"}' },
|
|
8
|
+
response: { statusCode: 201, body: '{"id":"acc_1"}' },
|
|
9
|
+
metadata: {
|
|
10
|
+
destination: "ams", // bare hostname; port reported separately
|
|
11
|
+
port: 4000,
|
|
12
|
+
path: "/api/internal/v1/users/validate",
|
|
13
|
+
method: "post",
|
|
14
|
+
scheme: "http",
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
]);
|
|
18
|
+
expect(specs).toHaveLength(1);
|
|
19
|
+
expect(specs[0]).toMatchObject({
|
|
20
|
+
protocol: MockProtocol.Rest,
|
|
21
|
+
endpointURL: "http://ams:4000/api/internal/v1/users/validate",
|
|
22
|
+
method: "POST",
|
|
23
|
+
responseStatusCode: "201",
|
|
24
|
+
responseData: '{"id":"acc_1"}',
|
|
25
|
+
requestData: '{"name":"acme"}',
|
|
26
|
+
requestAware: true,
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
it("keeps distinct REST specs per status code but dedupes identical ones", () => {
|
|
30
|
+
const base = {
|
|
31
|
+
metadata: {
|
|
32
|
+
destination: "billing:5001",
|
|
33
|
+
path: "/accounts/1/billing",
|
|
34
|
+
method: "PUT",
|
|
35
|
+
scheme: "http",
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
const specs = normalizeSamplesToMockSpecs("rest", [
|
|
39
|
+
{ ...base, response: { statusCode: 200, body: "{}" } },
|
|
40
|
+
{ ...base, response: { statusCode: 200, body: "{}" } }, // duplicate → collapsed
|
|
41
|
+
{ ...base, response: { statusCode: 422, body: '{"error":"x"}' } },
|
|
42
|
+
]);
|
|
43
|
+
expect(specs).toHaveLength(2);
|
|
44
|
+
expect(specs.map((s) => s.responseStatusCode).sort()).toEqual([
|
|
45
|
+
"200",
|
|
46
|
+
"422",
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
it("defaults scheme to http and marks requestAware false when no request body", () => {
|
|
50
|
+
const specs = normalizeSamplesToMockSpecs("rest", [
|
|
51
|
+
{
|
|
52
|
+
response: { statusCode: 200, body: "{}" },
|
|
53
|
+
metadata: { destination: "svc:80", path: "/x", method: "GET" },
|
|
54
|
+
},
|
|
55
|
+
]);
|
|
56
|
+
expect(specs[0].endpointURL).toBe("http://svc:80/x");
|
|
57
|
+
expect(specs[0].requestAware).toBe(false);
|
|
58
|
+
expect(specs[0].requestData).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
it("parses gRPC service/method, combines port, and captures requestData", () => {
|
|
61
|
+
const specs = normalizeSamplesToMockSpecs("grpc", [
|
|
62
|
+
{
|
|
63
|
+
request: { body: '{"client_id":"app_x"}' },
|
|
64
|
+
response: { body: '{"ok":true}' },
|
|
65
|
+
metadata: {
|
|
66
|
+
destination: "partner-accounts",
|
|
67
|
+
port: 50051,
|
|
68
|
+
method: "/idl.PartnerAccounts/GetApplication",
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
]);
|
|
72
|
+
expect(specs[0]).toMatchObject({
|
|
73
|
+
protocol: MockProtocol.Grpc,
|
|
74
|
+
endpointURL: "partner-accounts:50051",
|
|
75
|
+
grpcServiceName: "idl.PartnerAccounts",
|
|
76
|
+
method: "GetApplication",
|
|
77
|
+
responseData: '{"ok":true}',
|
|
78
|
+
requestData: '{"client_id":"app_x"}',
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
it("maps Kafka produce to requestData and consume to responseData (never both)", () => {
|
|
82
|
+
const specs = normalizeSamplesToMockSpecs("kafka", [
|
|
83
|
+
{
|
|
84
|
+
request: { data: '{"event":"account.created"}' },
|
|
85
|
+
metadata: {
|
|
86
|
+
destination: "redpanda",
|
|
87
|
+
port: 9092,
|
|
88
|
+
method: "KAFKA_Produce",
|
|
89
|
+
path: "kafka-protocol/topic/events",
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
response: { data: '{"accepted":true}' },
|
|
94
|
+
metadata: {
|
|
95
|
+
destination: "redpanda",
|
|
96
|
+
port: 9092,
|
|
97
|
+
method: "KAFKA_Consume",
|
|
98
|
+
topic: "consent-customer",
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
]);
|
|
102
|
+
const produce = specs.find((s) => s.kafkaTopic === "events");
|
|
103
|
+
const consume = specs.find((s) => s.kafkaTopic === "consent-customer");
|
|
104
|
+
expect(produce.endpointURL).toBe("redpanda:9092");
|
|
105
|
+
expect(produce.requestData).toBe('{"event":"account.created"}');
|
|
106
|
+
expect(produce.responseData).toBeUndefined();
|
|
107
|
+
expect(consume.responseData).toBe('{"accepted":true}');
|
|
108
|
+
expect(consume.requestData).toBeUndefined();
|
|
109
|
+
});
|
|
110
|
+
it("skips samples with no destination", () => {
|
|
111
|
+
const specs = normalizeSamplesToMockSpecs("rest", [
|
|
112
|
+
{
|
|
113
|
+
response: { statusCode: 200 },
|
|
114
|
+
metadata: { path: "/x", method: "GET" },
|
|
115
|
+
},
|
|
116
|
+
]);
|
|
117
|
+
expect(specs).toHaveLength(0);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
describe("withPort", () => {
|
|
121
|
+
it("appends the separate port to a bare host", () => {
|
|
122
|
+
expect(withPort("billing", 5001)).toBe("billing:5001");
|
|
123
|
+
});
|
|
124
|
+
it("leaves a host that already has a port unchanged", () => {
|
|
125
|
+
expect(withPort("billing:5001", 80)).toBe("billing:5001");
|
|
126
|
+
});
|
|
127
|
+
it("omits the port when absent or zero", () => {
|
|
128
|
+
expect(withPort("billing", 0)).toBe("billing");
|
|
129
|
+
expect(withPort("billing", undefined)).toBe("billing");
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
describe("normalizeProxyBaseUrl", () => {
|
|
133
|
+
it("prepends http:// when no scheme and strips any path", () => {
|
|
134
|
+
expect(normalizeProxyBaseUrl("localhost:35141")).toBe("http://localhost:35141");
|
|
135
|
+
expect(normalizeProxyBaseUrl("localhost:35141/skyramp/proxy")).toBe("http://localhost:35141");
|
|
136
|
+
});
|
|
137
|
+
it("preserves an explicit https scheme and host", () => {
|
|
138
|
+
expect(normalizeProxyBaseUrl("https://skyramp.example.com/foo")).toBe("https://skyramp.example.com");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
describe("resolveAccessMode", () => {
|
|
142
|
+
it("accepts proxyUrl alone", () => {
|
|
143
|
+
expect(resolveAccessMode({ proxyUrl: "localhost:35141" })).toEqual({
|
|
144
|
+
mode: "url",
|
|
145
|
+
proxyUrl: "localhost:35141",
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
it("accepts kubeconfig + namespace", () => {
|
|
149
|
+
expect(resolveAccessMode({ kubeconfig: "/kc", namespace: "ns" })).toEqual({
|
|
150
|
+
mode: "kube",
|
|
151
|
+
kubeconfig: "/kc",
|
|
152
|
+
namespace: "ns",
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
it("rejects providing both modes", () => {
|
|
156
|
+
expect(() => resolveAccessMode({ proxyUrl: "x", kubeconfig: "/kc", namespace: "ns" })).toThrow(/not both/);
|
|
157
|
+
});
|
|
158
|
+
it("rejects providing neither mode", () => {
|
|
159
|
+
expect(() => resolveAccessMode({})).toThrow(/No proxy access/);
|
|
160
|
+
});
|
|
161
|
+
it("rejects kubeconfig without namespace", () => {
|
|
162
|
+
expect(() => resolveAccessMode({ kubeconfig: "/kc" })).toThrow(/both kubeconfig and namespace/);
|
|
163
|
+
});
|
|
164
|
+
});
|