@skyramp/mcp 0.1.5 → 0.1.6
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/index.js +6 -5
- package/build/prompts/initialize-workspace/initializeWorkspacePrompt.js +11 -7
- package/build/prompts/personas.js +2 -1
- package/build/prompts/test-maintenance/drift-analysis-prompt.js +2 -1
- package/build/prompts/test-maintenance/drift-analysis-prompt.test.js +28 -0
- package/build/prompts/test-recommendation/analysisOutputPrompt.js +72 -14
- package/build/prompts/test-recommendation/analysisOutputPrompt.test.js +154 -0
- package/build/prompts/test-recommendation/recommendationSections.js +4 -2
- package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +20 -4
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +11 -8
- package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +6 -6
- package/build/prompts/testbot/testbot-prompts.js +7 -5
- package/build/prompts/testbot/testbot-prompts.test.js +2 -2
- package/build/resources/analysisResources.js +1 -0
- package/build/services/ScenarioGenerationService.js +2 -1
- package/build/tools/code-refactor/enhanceAssertionsTool.js +2 -1
- package/build/tools/generate-tests/generateBatchScenarioRestTool.js +123 -1
- package/build/tools/generate-tests/generateBatchScenarioRestTool.test.js +205 -9
- package/build/tools/generate-tests/generateContractRestTool.js +19 -19
- package/build/tools/generate-tests/generateIntegrationRestTool.js +9 -2
- package/build/tools/generate-tests/generateUIRestTool.js +23 -8
- package/build/tools/test-management/analyzeChangesTool.js +218 -2
- package/build/tools/test-management/analyzeChangesTool.test.js +233 -1
- package/build/utils/featureFlags.js +7 -0
- package/build/utils/featureFlags.test.js +81 -0
- package/build/utils/httpDefaults.js +17 -0
- package/build/utils/httpDefaults.test.js +21 -0
- package/build/utils/scenarioDrafting.js +37 -15
- package/build/utils/scenarioDrafting.test.js +66 -0
- package/build/utils/telemetry.js +2 -1
- package/build/utils/utils.js +23 -0
- package/package.json +1 -1
|
@@ -5,9 +5,11 @@ import { MAX_TESTS_TO_GENERATE, MAX_RECOMMENDATIONS, MAX_CRITICAL_TESTS, PATH_PA
|
|
|
5
5
|
import { buildDriftAnalysisPrompt } from "../test-maintenance/drift-analysis-prompt.js";
|
|
6
6
|
import { getTraceRecordingPromptText } from "../../playwright/traceRecordingPrompt.js";
|
|
7
7
|
import { isContractConsumerModeEnabled } from "../../utils/featureFlags.js";
|
|
8
|
+
import { resolveServiceDetailsRef } from "../../utils/utils.js";
|
|
8
9
|
import { readWorkspaceConfigRaw } from "../../utils/workspaceAuth.js";
|
|
9
|
-
// Cached at module-load —
|
|
10
|
+
// Cached at module-load — flags are process-wide and cannot change per call.
|
|
10
11
|
const CONSUMER_MODE_ENABLED = isContractConsumerModeEnabled();
|
|
12
|
+
const SERVICE_REFS = resolveServiceDetailsRef();
|
|
11
13
|
// Mode-aware bullet block that appears inside the "How to generate each type"
|
|
12
14
|
// section. When consumer mode is disabled, only provider-mode guidance is
|
|
13
15
|
// surfaced so the agent never recommends or invokes consumer contract tests.
|
|
@@ -121,8 +123,8 @@ ${userPrompt ? "Generate only the tests that the user requested from the Additio
|
|
|
121
123
|
- Critical-category tests are already ranked first by the pre-computed scores — follow the plan order.
|
|
122
124
|
|
|
123
125
|
**Auth — determine ONCE, apply to EVERY tool call:**
|
|
124
|
-
1. Read auth params from the Execution Plan returned by \`skyramp_analyze_changes\` — they are resolved
|
|
125
|
-
2. If workspace shows \`authType: none\` or \`authHeader: ""\` → proceed with no auth (\`authHeader: ""\`). If tests fail due to 401/403, add to \`issuesFound\`: "Auth may be required — update \`api.authType\` in
|
|
126
|
+
1. Read auth params from the Execution Plan returned by \`skyramp_analyze_changes\` — they are pre-resolved from ${SERVICE_REFS.authSourceRef}. **Use these as-is; do not infer or override.**
|
|
127
|
+
2. If workspace shows \`authType: none\` or \`authHeader: ""\` → proceed with no auth (\`authHeader: ""\`). If tests fail due to 401/403, add to \`issuesFound\`: "Auth may be required — update \`api.authType\` in ${SERVICE_REFS.authSourceRef}."
|
|
126
128
|
3. **Auth params by header type — quick reference:**
|
|
127
129
|
|
|
128
130
|
| \`authHeader\` | \`authType\` examples | \`skyramp_batch_scenario_*\` / \`skyramp_contract_*\` | \`skyramp_integration_test_generation\` (scenarioFile) |
|
|
@@ -133,7 +135,7 @@ ${userPrompt ? "Generate only the tests that the user requested from the Additio
|
|
|
133
135
|
| none / \`""\` | \`none\` | \`authHeader: ""\` only when endpoint confirmed unauthenticated | \`authHeader: ""\` |
|
|
134
136
|
|
|
135
137
|
**Omit \`authToken\` entirely** — \`SKYRAMP_PLACEHOLDER_TOKEN\` is auto-inserted at execution time.
|
|
136
|
-
The \`authScheme\` for \`Authorization\` headers is pre-resolved in the Execution Plan — use it exactly (e.g. \`"Bearer"\`, \`"Token"\`, or a custom scheme from
|
|
138
|
+
The \`authScheme\` for \`Authorization\` headers is pre-resolved in the Execution Plan — use it exactly (e.g. \`"Bearer"\`, \`"Token"\`, or a custom scheme from ${SERVICE_REFS.authSourceRef}).
|
|
137
139
|
|
|
138
140
|
Passing auth alongside workspace \`authType\` on \`skyramp_integration_test_generation\` causes "${AUTH_CONFLICT_ERROR_MSG}" — follow the table.
|
|
139
141
|
4. Only pass \`authHeader: ""\` if you can confirm the endpoint is truly unauthenticated.
|
|
@@ -141,7 +143,7 @@ ${userPrompt ? "Generate only the tests that the user requested from the Additio
|
|
|
141
143
|
**How to generate each type (for ADD):**
|
|
142
144
|
- **Integration**: call \`skyramp_batch_scenario_test_generation\` with ALL steps in a single call (pass the \`steps\` array with method, path, requestBody, statusCode for each step). Then call \`skyramp_integration_test_generation\` with the returned scenario file.
|
|
143
145
|
**Use the pre-built scenario JSON from the Execution Plan** — pass the steps array directly. Do NOT read source code models to construct request bodies if the plan already provides them.
|
|
144
|
-
Scenario JSON and test files go in
|
|
146
|
+
Scenario JSON and test files go in ${SERVICE_REFS.testDirRef}. Do NOT create a new \`tests/\` directory at the repo root — use that path. If no \`testDirectory\` is configured, default to the language-conventional location (e.g. \`src/test/java/...\` for Java, \`tests/\` for Python).
|
|
145
147
|
**Pipeline for speed**: Call ALL \`skyramp_batch_scenario_test_generation\` calls in one batch. When they return, call ALL \`skyramp_integration_test_generation\` calls in the next batch. Do NOT serialize per-scenario (batch→integration→batch→integration) — batch ALL scenarios first, then generate ALL integration tests.
|
|
146
148
|
- **Contract**: call \`skyramp_contract_test_generation\` with \`endpointURL\`, \`method\`, and \`requestData\` for POST/PUT/PATCH.
|
|
147
149
|
Pass \`apiSchema\` if an OpenAPI spec exists.
|
|
@@ -211,12 +211,12 @@ describe("drift analysis inline embedding", () => {
|
|
|
211
211
|
expect(prompt).toContain("<drift_analysis_rules>");
|
|
212
212
|
expect(prompt).toContain("</drift_analysis_rules>");
|
|
213
213
|
});
|
|
214
|
-
it("
|
|
214
|
+
it("does not include a persona statement inside the inline XML block", () => {
|
|
215
215
|
const prompt = basePrompt();
|
|
216
216
|
const start = prompt.indexOf("<drift_analysis_rules>");
|
|
217
217
|
const end = prompt.indexOf("</drift_analysis_rules>");
|
|
218
218
|
const block = prompt.slice(start, end);
|
|
219
|
-
expect(block).toContain("You are acting as a Skyramp Integration Architect");
|
|
219
|
+
expect(block).not.toContain("You are acting as a Skyramp Integration Architect");
|
|
220
220
|
});
|
|
221
221
|
it("drift_analysis_rules block appears inside Task 1, before Task 2", () => {
|
|
222
222
|
const prompt = basePrompt();
|
|
@@ -29,6 +29,7 @@ export function registerAnalysisResources(server) {
|
|
|
29
29
|
return memData;
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
+
logger.warning(`Session not found in memory (sessionId=${sessionId}) — server may have restarted; falling back to state file`);
|
|
32
33
|
// Fall back to state file for backward compatibility.
|
|
33
34
|
// Try both "analysis" and "recommendation" prefixes since the default changed.
|
|
34
35
|
const registeredPath = getSessionFilePath(sessionId);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AUTH_PLACEHOLDER_TOKEN } from "../types/TestTypes.js";
|
|
2
2
|
import { isAuthorizationHeaderName } from "../utils/workspaceAuth.js";
|
|
3
|
+
import { inferExpectedStatus } from "../utils/httpDefaults.js";
|
|
3
4
|
import { logger } from "../utils/logger.js";
|
|
4
5
|
import fs from "fs";
|
|
5
6
|
import path from "path";
|
|
@@ -124,7 +125,7 @@ ${JSON.stringify(traceRequest, null, 2)}
|
|
|
124
125
|
}
|
|
125
126
|
const timestamp = new Date().toISOString();
|
|
126
127
|
const method = params.method;
|
|
127
|
-
const statusCode = params.statusCode ?? (method
|
|
128
|
+
const statusCode = params.statusCode ?? inferExpectedStatus(method);
|
|
128
129
|
const requestBody = params.requestBody ||
|
|
129
130
|
(method === "GET" || method === "DELETE" ? "" : "{}");
|
|
130
131
|
const responseHeaders = params.responseHeaders
|
|
@@ -4,6 +4,7 @@ import { AnalyticsService } from "../../services/AnalyticsService.js";
|
|
|
4
4
|
import { getContractProviderAssertionsPrompt } from "../../prompts/enhance-assertions/contractProviderAssertionsPrompt.js";
|
|
5
5
|
import { getIntegrationAssertionsPrompt } from "../../prompts/enhance-assertions/integrationAssertionsPrompt.js";
|
|
6
6
|
import { getUIAssertionsPrompt } from "../../prompts/enhance-assertions/uiAssertionsPrompt.js";
|
|
7
|
+
import { isTestbotEnabled } from "../../utils/featureFlags.js";
|
|
7
8
|
const TOOL_NAME = "skyramp_enhance_assertions";
|
|
8
9
|
const TESTBOT_UI_CHECKS = `
|
|
9
10
|
### Additional Testbot-Specific Checks
|
|
@@ -37,7 +38,7 @@ export function registerEnhanceAssertionsTool(server) {
|
|
|
37
38
|
let instructions;
|
|
38
39
|
if (testType === TestType.UI) {
|
|
39
40
|
instructions = getUIAssertionsPrompt(testFile, enhanceCtx);
|
|
40
|
-
if (
|
|
41
|
+
if (isTestbotEnabled()) {
|
|
41
42
|
instructions += TESTBOT_UI_CHECKS;
|
|
42
43
|
}
|
|
43
44
|
}
|
|
@@ -5,6 +5,7 @@ import fs from "fs";
|
|
|
5
5
|
import { baseSchema, AUTH_PLACEHOLDER_TOKEN, HttpMethod } from "../../types/TestTypes.js";
|
|
6
6
|
import { AnalyticsService } from "../../services/AnalyticsService.js";
|
|
7
7
|
import { getWorkspaceAuthConfig, WorkspaceAuthType, getDefaultAuthHeader, isAuthorizationHeaderName, getAuthScheme } from "../../utils/workspaceAuth.js";
|
|
8
|
+
import yaml from "js-yaml";
|
|
8
9
|
import { logger } from "../../utils/logger.js";
|
|
9
10
|
function isJsonValue(v) {
|
|
10
11
|
if (v === undefined || v === null)
|
|
@@ -184,6 +185,126 @@ Call \`skyramp_integration_test_generation\` with the returned \`scenarioFile\`
|
|
|
184
185
|
logger.warning("Could not resolve auth from workspace config");
|
|
185
186
|
}
|
|
186
187
|
}
|
|
188
|
+
// Separate try/catch so auth errors don't silently swallow schema population.
|
|
189
|
+
// Walk up from outputDir with a sync existsSync check — one stat per level,
|
|
190
|
+
// one read+parse only when found — instead of an async readWorkspaceConfigRaw
|
|
191
|
+
// call (WorkspaceConfigManager instantiation + 2 async I/O ops) per level.
|
|
192
|
+
if (!params.apiSchema) {
|
|
193
|
+
try {
|
|
194
|
+
const WS_SUBPATH = path.join(".skyramp", "workspace.yml");
|
|
195
|
+
// Assumption: outputDir lives somewhere inside the repo tree so the walk-up
|
|
196
|
+
// eventually reaches .skyramp/workspace.yml. If outputDir is outside the repo
|
|
197
|
+
// (e.g. /tmp/skyramp-test), the loop exits at the filesystem root without finding
|
|
198
|
+
// the config — the surrounding try/catch handles this gracefully (non-critical).
|
|
199
|
+
let searchDir = path.resolve(params.outputDir);
|
|
200
|
+
let wsConfigPath = null;
|
|
201
|
+
while (searchDir !== path.dirname(searchDir)) {
|
|
202
|
+
const candidate = path.join(searchDir, WS_SUBPATH);
|
|
203
|
+
if (fs.existsSync(candidate)) {
|
|
204
|
+
wsConfigPath = candidate;
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
searchDir = path.dirname(searchDir);
|
|
208
|
+
}
|
|
209
|
+
if (wsConfigPath) {
|
|
210
|
+
const wsRaw = yaml.load(fs.readFileSync(wsConfigPath, "utf-8"));
|
|
211
|
+
// Best-effort: picks the first service. Multi-service workspaces may have
|
|
212
|
+
// the wrong schema if outputDir belongs to a later service — acceptable
|
|
213
|
+
// limitation for now; the user can always pass apiSchema explicitly.
|
|
214
|
+
const rawSchemaPath = wsRaw?.services?.[0]?.api?.schemaPath;
|
|
215
|
+
if (rawSchemaPath && typeof rawSchemaPath === "string") {
|
|
216
|
+
const isUrl = rawSchemaPath.startsWith("http://") || rawSchemaPath.startsWith("https://");
|
|
217
|
+
// searchDir is the directory where workspace.yml was found — use it as
|
|
218
|
+
// the resolution base so relative paths like "../openapi.yml" resolve
|
|
219
|
+
// correctly regardless of outputDir depth.
|
|
220
|
+
const schemaPath = isUrl
|
|
221
|
+
? rawSchemaPath
|
|
222
|
+
: path.resolve(searchDir, rawSchemaPath);
|
|
223
|
+
params = { ...params, apiSchema: schemaPath };
|
|
224
|
+
logger.info("Auto-populated apiSchema from workspace config", { schemaPath });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
// non-critical
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// ── Change 10b: Reject GraphQL endpoint steps ──
|
|
233
|
+
// Skyramp supports REST testing only; /graphql* requires introspection not implemented.
|
|
234
|
+
{
|
|
235
|
+
const graphqlSteps = params.steps.filter((s) => {
|
|
236
|
+
if (typeof s.path !== "string")
|
|
237
|
+
return false;
|
|
238
|
+
const normalized = s.path.replace(/\/+$/, "").toLowerCase();
|
|
239
|
+
return normalized.split("/").some((seg) => seg === "graphql");
|
|
240
|
+
});
|
|
241
|
+
if (graphqlSteps.length > 0) {
|
|
242
|
+
return {
|
|
243
|
+
isError: true,
|
|
244
|
+
content: [{ type: "text", text: `GraphQL endpoints are not supported by Skyramp's test generation.\n` +
|
|
245
|
+
`Affected steps: ${graphqlSteps.map((s) => `${s.method} ${s.path}`).join(", ")}\n\n` +
|
|
246
|
+
`Skyramp supports REST API testing only. Remove GraphQL steps and use ` +
|
|
247
|
+
`the REST endpoints for the same resource instead.`,
|
|
248
|
+
}],
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
// ── Change 3: Soft spec path validation ──
|
|
253
|
+
// Warn when step paths are missing from the spec — proceed with generation regardless.
|
|
254
|
+
// Hard rejection was removed: specs frequently lag code (new endpoints, undocumented
|
|
255
|
+
// internal routes, stale auto-gen) so a missing path != a phantom path.
|
|
256
|
+
let specValidationWarning = "";
|
|
257
|
+
if (params.apiSchema) {
|
|
258
|
+
try {
|
|
259
|
+
const isUrl = params.apiSchema.startsWith("http://") || params.apiSchema.startsWith("https://");
|
|
260
|
+
let specText;
|
|
261
|
+
if (isUrl) {
|
|
262
|
+
const specRes = await fetch(params.apiSchema, { signal: AbortSignal.timeout(10_000) });
|
|
263
|
+
if (!specRes.ok) {
|
|
264
|
+
throw new Error(`HTTP ${specRes.status} ${specRes.statusText} fetching spec at ${params.apiSchema}`);
|
|
265
|
+
}
|
|
266
|
+
specText = await specRes.text();
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
// Note: relative apiSchema paths resolve against outputDir, not the workspace root.
|
|
270
|
+
// In practice apiSchema is always absolute (Gap Fix 2 / schema guidance), so this is safe.
|
|
271
|
+
specText = fs.readFileSync(path.resolve(params.outputDir, params.apiSchema), "utf-8");
|
|
272
|
+
}
|
|
273
|
+
// js-yaml handles both JSON and YAML specs
|
|
274
|
+
const specLoaded = yaml.load(specText);
|
|
275
|
+
const specPaths = new Set(Object.keys((specLoaded && typeof specLoaded === "object" ? specLoaded.paths : null) ?? {}));
|
|
276
|
+
if (specPaths.size === 0) {
|
|
277
|
+
logger.warning("Spec loaded but contains no paths — skipping path check", {
|
|
278
|
+
apiSchema: params.apiSchema,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
const unverifiedSteps = params.steps.filter((s) => {
|
|
283
|
+
if (typeof s.path !== "string")
|
|
284
|
+
return false;
|
|
285
|
+
const norm = s.path.replace(/:[a-zA-Z_][a-zA-Z0-9_]*/g, (m) => `{${m.slice(1)}}`);
|
|
286
|
+
return !specPaths.has(s.path) && !specPaths.has(norm);
|
|
287
|
+
});
|
|
288
|
+
if (unverifiedSteps.length > 0) {
|
|
289
|
+
specValidationWarning =
|
|
290
|
+
`\n\n⚠️ **Spec warning** — the following paths were not found in \`${params.apiSchema}\` ` +
|
|
291
|
+
`(spec may be stale or incomplete — verify paths against source before running tests):\n` +
|
|
292
|
+
unverifiedSteps.map((s) => ` ${s.method} ${s.path}`).join("\n") +
|
|
293
|
+
`\n\nKnown spec paths (first 20): ${[...specPaths].slice(0, 20).join(", ")}`;
|
|
294
|
+
logger.warning("Step paths not found in spec — proceeding (spec may lag code)", {
|
|
295
|
+
unverifiedPaths: unverifiedSteps.map((s) => s.path),
|
|
296
|
+
apiSchema: params.apiSchema,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
logger.warning("Spec check skipped — could not load apiSchema", {
|
|
303
|
+
apiSchema: params.apiSchema,
|
|
304
|
+
error: err instanceof Error ? err.message : String(err),
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
187
308
|
const service = new ScenarioGenerationService();
|
|
188
309
|
const steps = params.steps;
|
|
189
310
|
const scenarioSlug = params.scenarioName.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "scenario";
|
|
@@ -261,7 +382,8 @@ Call \`skyramp_integration_test_generation\` with the returned \`scenarioFile\`
|
|
|
261
382
|
+ `**Scenario:** ${params.scenarioName}\n`
|
|
262
383
|
+ `**Steps:**\n${steps.map((s, i) => ` ${i + 1}. ${s.method} ${s.path} → ${s.statusCode ?? "default"}`).join("\n")}\n\n`
|
|
263
384
|
+ `**File:** ${filePath}\n\n`
|
|
264
|
-
+ `**Next:** Call \`skyramp_integration_test_generation\` with \`scenarioFile: "${filePath}"
|
|
385
|
+
+ `**Next:** Call \`skyramp_integration_test_generation\` with \`scenarioFile: "${filePath}"\``
|
|
386
|
+
+ specValidationWarning,
|
|
265
387
|
},
|
|
266
388
|
],
|
|
267
389
|
isError: false,
|
|
@@ -1,9 +1,35 @@
|
|
|
1
|
-
jest.mock("../../services/ScenarioGenerationService.js", () => ({
|
|
2
|
-
jest.
|
|
3
|
-
|
|
1
|
+
jest.mock("../../services/ScenarioGenerationService.js", () => ({
|
|
2
|
+
ScenarioGenerationService: jest.fn().mockImplementation(() => ({
|
|
3
|
+
generateTraceRequestFromInput: jest.fn().mockReturnValue(null),
|
|
4
|
+
})),
|
|
5
|
+
}));
|
|
6
|
+
jest.mock("../../services/AnalyticsService.js", () => ({ AnalyticsService: { pushMCPToolEvent: jest.fn().mockResolvedValue(undefined) } }));
|
|
7
|
+
jest.mock("../../utils/workspaceAuth.js", () => ({
|
|
8
|
+
getWorkspaceAuthConfig: jest.fn().mockResolvedValue({ authHeader: undefined, authType: "none" }),
|
|
9
|
+
WorkspaceAuthType: { None: "none" },
|
|
10
|
+
getDefaultAuthHeader: jest.fn().mockReturnValue(""),
|
|
11
|
+
isAuthorizationHeaderName: jest.fn().mockReturnValue(false),
|
|
12
|
+
getAuthScheme: jest.fn().mockReturnValue(""),
|
|
13
|
+
readWorkspaceConfigRaw: jest.fn().mockResolvedValue(null),
|
|
14
|
+
}));
|
|
4
15
|
jest.mock("../../utils/logger.js", () => ({ logger: { info: jest.fn(), warning: jest.fn(), error: jest.fn() } }));
|
|
5
16
|
jest.mock("../../prompts/personas.js", () => ({ getPersonaPrefix: () => "" }));
|
|
6
|
-
import
|
|
17
|
+
import fs from "fs";
|
|
18
|
+
import { stepSchema, registerBatchScenarioTestTool } from "./generateBatchScenarioRestTool.js";
|
|
19
|
+
/** Build a minimal mock McpServer, register the tool, and return the captured handler. */
|
|
20
|
+
function captureHandler() {
|
|
21
|
+
let capturedHandler;
|
|
22
|
+
const mockServer = {
|
|
23
|
+
registerTool: jest.fn((_name, _meta, handler) => {
|
|
24
|
+
capturedHandler = handler;
|
|
25
|
+
}),
|
|
26
|
+
};
|
|
27
|
+
registerBatchScenarioTestTool(mockServer);
|
|
28
|
+
return capturedHandler;
|
|
29
|
+
}
|
|
30
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
31
|
+
// stepSchema — requestBody validation
|
|
32
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
7
33
|
describe("stepSchema — requestBody validation", () => {
|
|
8
34
|
it("accepts a POST step with a non-empty requestBody", () => {
|
|
9
35
|
const result = stepSchema.safeParse({
|
|
@@ -45,7 +71,6 @@ describe("stepSchema — requestBody validation", () => {
|
|
|
45
71
|
expect(issue?.message).toContain("PUT");
|
|
46
72
|
});
|
|
47
73
|
it("accepts a POST step with requestBody omitted entirely", () => {
|
|
48
|
-
// The validation only rejects {} when present — omitting requestBody is still allowed
|
|
49
74
|
const result = stepSchema.safeParse({
|
|
50
75
|
method: "POST",
|
|
51
76
|
path: "/api/v1/products",
|
|
@@ -53,13 +78,12 @@ describe("stepSchema — requestBody validation", () => {
|
|
|
53
78
|
expect(result.success).toBe(true);
|
|
54
79
|
});
|
|
55
80
|
it("does not throw on requestBody: 'null' (valid JSON null)", () => {
|
|
56
|
-
// parsed === null must not cause Object.keys to throw
|
|
57
81
|
const result = stepSchema.safeParse({
|
|
58
82
|
method: "POST",
|
|
59
83
|
path: "/api/v1/products",
|
|
60
84
|
requestBody: "null",
|
|
61
85
|
});
|
|
62
|
-
expect(result.success).toBe(true);
|
|
86
|
+
expect(result.success).toBe(true);
|
|
63
87
|
});
|
|
64
88
|
it("accepts a GET step with no requestBody", () => {
|
|
65
89
|
const result = stepSchema.safeParse({
|
|
@@ -76,8 +100,6 @@ describe("stepSchema — requestBody validation", () => {
|
|
|
76
100
|
expect(result.success).toBe(true);
|
|
77
101
|
});
|
|
78
102
|
it("does not reject a GET step that happens to have an empty requestBody", () => {
|
|
79
|
-
// GET with empty body is unusual but not blocked — the validation only
|
|
80
|
-
// applies to body methods (POST/PUT/PATCH)
|
|
81
103
|
const result = stepSchema.safeParse({
|
|
82
104
|
method: "GET",
|
|
83
105
|
path: "/api/v1/products",
|
|
@@ -86,3 +108,177 @@ describe("stepSchema — requestBody validation", () => {
|
|
|
86
108
|
expect(result.success).toBe(true);
|
|
87
109
|
});
|
|
88
110
|
});
|
|
111
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
112
|
+
// GraphQL step rejection (Change 10b)
|
|
113
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
114
|
+
describe("generateBatchScenarioRestTool — GraphQL step rejection (Change 10b)", () => {
|
|
115
|
+
let handler;
|
|
116
|
+
const baseParams = {
|
|
117
|
+
scenarioName: "test_scenario",
|
|
118
|
+
destination: "localhost",
|
|
119
|
+
outputDir: "/tmp/skyramp-test",
|
|
120
|
+
};
|
|
121
|
+
beforeEach(() => {
|
|
122
|
+
jest.clearAllMocks();
|
|
123
|
+
handler = captureHandler();
|
|
124
|
+
});
|
|
125
|
+
it("rejects a step list containing /graphql", async () => {
|
|
126
|
+
const result = await handler({
|
|
127
|
+
...baseParams,
|
|
128
|
+
steps: [{ method: "POST", path: "/graphql", requestBody: '{"query":"{ users { id } }"}' }],
|
|
129
|
+
});
|
|
130
|
+
expect(result.isError).toBe(true);
|
|
131
|
+
expect(result.content[0].text).toContain("GraphQL endpoints are not supported");
|
|
132
|
+
expect(result.content[0].text).toContain("POST /graphql");
|
|
133
|
+
});
|
|
134
|
+
it("rejects a step list containing /api/graphql (nested segment)", async () => {
|
|
135
|
+
const result = await handler({
|
|
136
|
+
...baseParams,
|
|
137
|
+
steps: [
|
|
138
|
+
{ method: "GET", path: "/api/v1/users" },
|
|
139
|
+
{ method: "POST", path: "/api/graphql", requestBody: '{"query":"{ id }"}' },
|
|
140
|
+
],
|
|
141
|
+
});
|
|
142
|
+
expect(result.isError).toBe(true);
|
|
143
|
+
expect(result.content[0].text).toContain("GraphQL endpoints are not supported");
|
|
144
|
+
expect(result.content[0].text).toContain("POST /api/graphql");
|
|
145
|
+
});
|
|
146
|
+
it("does not reject a path whose segment is 'graphql-config' (not an exact segment match)", async () => {
|
|
147
|
+
// 'graphql-config' !== 'graphql' — the guard uses exact segment comparison.
|
|
148
|
+
// The GraphQL rejection message must never appear, regardless of whether the
|
|
149
|
+
// handler succeeds or fails for some other reason.
|
|
150
|
+
const result = await handler({
|
|
151
|
+
...baseParams,
|
|
152
|
+
steps: [{ method: "GET", path: "/api/v1/graphql-config" }],
|
|
153
|
+
});
|
|
154
|
+
expect(result.content[0].text).not.toContain("GraphQL endpoints are not supported");
|
|
155
|
+
});
|
|
156
|
+
it("does not throw on a non-string path (typeof guard)", async () => {
|
|
157
|
+
// typeof guard returns false for non-string — no crash from .replace()
|
|
158
|
+
const result = await handler({
|
|
159
|
+
...baseParams,
|
|
160
|
+
steps: [{ method: "GET", path: undefined }],
|
|
161
|
+
});
|
|
162
|
+
expect(result).toBeDefined();
|
|
163
|
+
if (result.isError) {
|
|
164
|
+
expect(result.content[0].text).not.toContain("Cannot read");
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
169
|
+
// Spec path validation (Change 3)
|
|
170
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
171
|
+
describe("generateBatchScenarioRestTool — spec path validation (Change 3)", () => {
|
|
172
|
+
let handler;
|
|
173
|
+
let readFileSyncSpy;
|
|
174
|
+
const fakeSpec = JSON.stringify({
|
|
175
|
+
openapi: "3.0.0",
|
|
176
|
+
paths: {
|
|
177
|
+
"/api/v1/users": {},
|
|
178
|
+
"/api/v1/users/{id}": {},
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
const baseParams = {
|
|
182
|
+
scenarioName: "test_scenario",
|
|
183
|
+
destination: "localhost",
|
|
184
|
+
outputDir: "/tmp/skyramp-test",
|
|
185
|
+
apiSchema: "/tmp/openapi.json",
|
|
186
|
+
};
|
|
187
|
+
let writeFileSyncSpy;
|
|
188
|
+
beforeEach(() => {
|
|
189
|
+
jest.clearAllMocks();
|
|
190
|
+
handler = captureHandler();
|
|
191
|
+
readFileSyncSpy = jest.spyOn(fs, "readFileSync").mockReturnValue(fakeSpec);
|
|
192
|
+
writeFileSyncSpy = jest.spyOn(fs, "writeFileSync").mockImplementation(() => { });
|
|
193
|
+
// Return a valid trace so tests reach the success path (where the warning is appended)
|
|
194
|
+
const ScenarioSvc = require("../../services/ScenarioGenerationService.js").ScenarioGenerationService;
|
|
195
|
+
ScenarioSvc.mockImplementation(() => ({
|
|
196
|
+
generateTraceRequestFromInput: jest.fn().mockReturnValue({ method: "GET", path: "/mock" }),
|
|
197
|
+
}));
|
|
198
|
+
});
|
|
199
|
+
afterEach(() => {
|
|
200
|
+
readFileSyncSpy.mockRestore();
|
|
201
|
+
writeFileSyncSpy.mockRestore();
|
|
202
|
+
});
|
|
203
|
+
it("warns (does not hard-reject) when step paths are not in the OpenAPI spec", async () => {
|
|
204
|
+
// Spec may lag code — missing path != phantom path, so we warn and proceed.
|
|
205
|
+
const result = await handler({
|
|
206
|
+
...baseParams,
|
|
207
|
+
steps: [{ method: "GET", path: "/api/v1/phantom-endpoint" }],
|
|
208
|
+
});
|
|
209
|
+
expect(result.isError).toBeFalsy();
|
|
210
|
+
expect(result.content[0].text).toContain("Spec warning");
|
|
211
|
+
expect(result.content[0].text).toContain("/api/v1/phantom-endpoint");
|
|
212
|
+
expect(result.content[0].text).toContain("Known spec paths");
|
|
213
|
+
});
|
|
214
|
+
it("includes no spec warning when step path is present in the OpenAPI spec", async () => {
|
|
215
|
+
const result = await handler({
|
|
216
|
+
...baseParams,
|
|
217
|
+
steps: [{ method: "GET", path: "/api/v1/users" }],
|
|
218
|
+
});
|
|
219
|
+
expect(result.content[0].text).not.toContain("Spec warning");
|
|
220
|
+
});
|
|
221
|
+
it("does not warn for Express-style :param paths that normalise to a spec path", async () => {
|
|
222
|
+
const result = await handler({
|
|
223
|
+
...baseParams,
|
|
224
|
+
steps: [{ method: "GET", path: "/api/v1/users/:id" }],
|
|
225
|
+
});
|
|
226
|
+
expect(result.content[0].text).not.toContain("Spec warning");
|
|
227
|
+
});
|
|
228
|
+
it("does not throw on a non-string path (typeof guard)", async () => {
|
|
229
|
+
const result = await handler({
|
|
230
|
+
...baseParams,
|
|
231
|
+
steps: [{ method: "GET", path: undefined }],
|
|
232
|
+
});
|
|
233
|
+
expect(result).toBeDefined();
|
|
234
|
+
expect(result.content[0].text).not.toContain("TypeError");
|
|
235
|
+
expect(result.content[0].text).not.toContain("Cannot read");
|
|
236
|
+
});
|
|
237
|
+
it("includes known spec paths hint in the warning message", async () => {
|
|
238
|
+
const result = await handler({
|
|
239
|
+
...baseParams,
|
|
240
|
+
steps: [{ method: "POST", path: "/does/not/exist" }],
|
|
241
|
+
});
|
|
242
|
+
expect(result.isError).toBeFalsy();
|
|
243
|
+
expect(result.content[0].text).toContain("/api/v1/users");
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
describe("generateBatchScenarioRestTool — spec URL fetch safety (comment 3203006665)", () => {
|
|
247
|
+
let handler;
|
|
248
|
+
const baseParams = {
|
|
249
|
+
scenarioName: "test_scenario",
|
|
250
|
+
destination: "localhost",
|
|
251
|
+
outputDir: "/tmp/skyramp-test",
|
|
252
|
+
apiSchema: "https://example.com/openapi.json",
|
|
253
|
+
steps: [{ method: "GET", path: "/api/v1/users" }],
|
|
254
|
+
};
|
|
255
|
+
beforeEach(() => {
|
|
256
|
+
jest.clearAllMocks();
|
|
257
|
+
handler = captureHandler();
|
|
258
|
+
});
|
|
259
|
+
it("skips spec check (no warning emitted) when URL returns non-2xx", async () => {
|
|
260
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
261
|
+
ok: false,
|
|
262
|
+
status: 404,
|
|
263
|
+
statusText: "Not Found",
|
|
264
|
+
text: async () => "<html>Not Found</html>",
|
|
265
|
+
});
|
|
266
|
+
const result = await handler({ ...baseParams });
|
|
267
|
+
// Non-2xx throws → caught → spec check skipped entirely, no warning in output
|
|
268
|
+
expect(result.content[0].text).not.toContain("Spec warning");
|
|
269
|
+
});
|
|
270
|
+
it("skips spec check (no warning emitted) when spec has no paths", async () => {
|
|
271
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
272
|
+
ok: true,
|
|
273
|
+
status: 200,
|
|
274
|
+
statusText: "OK",
|
|
275
|
+
text: async () => JSON.stringify({ openapi: "3.0.0", info: { title: "Test", version: "1.0" } }),
|
|
276
|
+
});
|
|
277
|
+
const result = await handler({ ...baseParams });
|
|
278
|
+
expect(result.content[0].text).not.toContain("Spec warning");
|
|
279
|
+
});
|
|
280
|
+
afterEach(() => {
|
|
281
|
+
// Restore fetch
|
|
282
|
+
delete global.fetch;
|
|
283
|
+
});
|
|
284
|
+
});
|
|
@@ -4,7 +4,7 @@ import { baseTestSchema, TestType } from "../../types/TestTypes.js";
|
|
|
4
4
|
import { TestGenerationService, } from "../../services/TestGenerationService.js";
|
|
5
5
|
import { AnalyticsService } from "../../services/AnalyticsService.js";
|
|
6
6
|
import { getPersonaPrefix } from "../../prompts/personas.js";
|
|
7
|
-
import { isContractConsumerModeEnabled } from "../../utils/featureFlags.js";
|
|
7
|
+
import { isContractConsumerModeEnabled, isTestbotEnabled } from "../../utils/featureFlags.js";
|
|
8
8
|
// SKYRAMP_FEATURE_CONTRACT_CONSUMER_MODE gates BOTH:
|
|
9
9
|
// 1. Consumer-side contract test generation (`consumerMode` /
|
|
10
10
|
// `consumerOutput` schema fields, validation, post-gen instructions,
|
|
@@ -16,6 +16,7 @@ import { isContractConsumerModeEnabled } from "../../utils/featureFlags.js";
|
|
|
16
16
|
//
|
|
17
17
|
// `providerMode` itself is exposed regardless of the flag.
|
|
18
18
|
const CONSUMER_MODE_ENABLED = isContractConsumerModeEnabled();
|
|
19
|
+
const ADD_ASSERTIONS_DEFAULT = isTestbotEnabled();
|
|
19
20
|
// "Requires apiSchema." plus a mode-aware "Not allowed with ..." clause for
|
|
20
21
|
// the parent-provisioning fields. Generated once so wording stays in lockstep
|
|
21
22
|
// with the feature flag.
|
|
@@ -23,6 +24,10 @@ const PARENT_FIELD_NOT_ALLOWED_NOTE = CONSUMER_MODE_ENABLED
|
|
|
23
24
|
? "Requires apiSchema. Not allowed with consumerMode or skipProvisionParents."
|
|
24
25
|
: "Requires apiSchema. Not allowed with skipProvisionParents.";
|
|
25
26
|
const baseContractTestSchema = {
|
|
27
|
+
enhanceAssertions: z
|
|
28
|
+
.boolean()
|
|
29
|
+
.default(ADD_ASSERTIONS_DEFAULT)
|
|
30
|
+
.describe("When true, calls skyramp_enhance_assertions after test generation to add richer response-body assertions. Disabled by default. Automatically enabled when running as testbot-feature. Do not override the default value of this parameter, unless the user explicitly asks to enable it."),
|
|
26
31
|
...baseTestSchema,
|
|
27
32
|
pathParams: z
|
|
28
33
|
.string()
|
|
@@ -149,7 +154,7 @@ export class ContractTestService extends TestGenerationService {
|
|
|
149
154
|
if (params.providerMode) {
|
|
150
155
|
content.push({
|
|
151
156
|
type: "text",
|
|
152
|
-
text: this.buildProviderPostGenInstructions(params),
|
|
157
|
+
text: this.buildProviderPostGenInstructions(params, params.enhanceAssertions),
|
|
153
158
|
});
|
|
154
159
|
}
|
|
155
160
|
else {
|
|
@@ -219,27 +224,22 @@ ${step5}
|
|
|
219
224
|
}
|
|
220
225
|
buildSampleDataInstructions(params) {
|
|
221
226
|
return `
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
**Part 1 — Replace placeholder values in the generated file:**
|
|
227
|
+
### CRITICAL NEXT STEP — Replace placeholder sample data
|
|
225
228
|
|
|
226
229
|
${this.buildPlaceholderReplacementBlock(params)}
|
|
227
230
|
`;
|
|
228
231
|
}
|
|
229
|
-
buildProviderPostGenInstructions(params) {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
Call \`skyramp_enhance_assertions\` with \`testFile\` set to the absolute path of the generated provider contract test file, \`testType: "contract"\`, and \`enhanceType: "generation"\`. Apply every instruction returned to that file.
|
|
242
|
-
`;
|
|
232
|
+
buildProviderPostGenInstructions(params, enhanceAssertions = false) {
|
|
233
|
+
const steps = [];
|
|
234
|
+
const nextStepNote = enhanceAssertions ? "continue to Step 2" : undefined;
|
|
235
|
+
steps.push(`### Step ${steps.length + 1} — Replace placeholder values [REQUIRED]\n\n${this.buildPlaceholderReplacementBlock(params, nextStepNote)}`);
|
|
236
|
+
if (enhanceAssertions) {
|
|
237
|
+
steps.push(`### Step ${steps.length + 1} — Enhance response body assertions [REQUIRED]\nCall \`skyramp_enhance_assertions\` with \`testFile\` set to the absolute path of the generated provider contract test file, \`testType: "contract"\`, and \`enhanceType: "generation"\`. Apply every instruction returned to that file.`);
|
|
238
|
+
}
|
|
239
|
+
const heading = enhanceAssertions
|
|
240
|
+
? "Replace placeholder data and enhance assertions"
|
|
241
|
+
: "Replace placeholder data";
|
|
242
|
+
return `\n### CRITICAL NEXT STEP — ${heading}\n\n${steps.join("\n\n")}`;
|
|
243
243
|
}
|
|
244
244
|
buildConsumerStubReplacementInstructions() {
|
|
245
245
|
return `
|
|
@@ -4,8 +4,14 @@ import { TestGenerationService, } from "../../services/TestGenerationService.js"
|
|
|
4
4
|
import { AnalyticsService } from "../../services/AnalyticsService.js";
|
|
5
5
|
import { AUTH_CONFLICT_ERROR_MSG } from "../../prompts/test-recommendation/recommendationSections.js";
|
|
6
6
|
import { getPersonaPrefix } from "../../prompts/personas.js";
|
|
7
|
+
import { isTestbotEnabled } from "../../utils/featureFlags.js";
|
|
8
|
+
const ADD_ASSERTIONS_DEFAULT = isTestbotEnabled();
|
|
7
9
|
const integrationTestSchema = z
|
|
8
10
|
.object({
|
|
11
|
+
enhanceAssertions: z
|
|
12
|
+
.boolean()
|
|
13
|
+
.default(ADD_ASSERTIONS_DEFAULT)
|
|
14
|
+
.describe("When true, calls skyramp_enhance_assertions after test generation to add richer response-body assertions. Disabled by default. Automatically enabled when running as testbot-feature. Do not override the default value of this parameter, unless the user explicitly asks to enable it."),
|
|
9
15
|
...baseTestSchema,
|
|
10
16
|
chainingKey: z
|
|
11
17
|
.string()
|
|
@@ -41,6 +47,8 @@ export class IntegrationTestService extends TestGenerationService {
|
|
|
41
47
|
const result = await super.generateTest(params);
|
|
42
48
|
if (result.isError)
|
|
43
49
|
return result;
|
|
50
|
+
if (!params.enhanceAssertions)
|
|
51
|
+
return result;
|
|
44
52
|
const content = [...result.content];
|
|
45
53
|
content.push({
|
|
46
54
|
type: "text",
|
|
@@ -50,8 +58,7 @@ export class IntegrationTestService extends TestGenerationService {
|
|
|
50
58
|
}
|
|
51
59
|
buildAssertionEnhancementInstructions() {
|
|
52
60
|
return `
|
|
53
|
-
|
|
54
|
-
|
|
61
|
+
### CRITICAL NEXT STEP — Enhance response body assertions after each request
|
|
55
62
|
Call \`skyramp_enhance_assertions\` with \`testFile\` set to the absolute path of the generated test file, \`testType: "integration"\`, and \`enhanceType: "generation"\`. Apply every instruction returned to that file.
|
|
56
63
|
`;
|
|
57
64
|
}
|