@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.
Files changed (32) hide show
  1. package/build/index.js +6 -5
  2. package/build/prompts/initialize-workspace/initializeWorkspacePrompt.js +11 -7
  3. package/build/prompts/personas.js +2 -1
  4. package/build/prompts/test-maintenance/drift-analysis-prompt.js +2 -1
  5. package/build/prompts/test-maintenance/drift-analysis-prompt.test.js +28 -0
  6. package/build/prompts/test-recommendation/analysisOutputPrompt.js +72 -14
  7. package/build/prompts/test-recommendation/analysisOutputPrompt.test.js +154 -0
  8. package/build/prompts/test-recommendation/recommendationSections.js +4 -2
  9. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +20 -4
  10. package/build/prompts/test-recommendation/test-recommendation-prompt.js +11 -8
  11. package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +6 -6
  12. package/build/prompts/testbot/testbot-prompts.js +7 -5
  13. package/build/prompts/testbot/testbot-prompts.test.js +2 -2
  14. package/build/resources/analysisResources.js +1 -0
  15. package/build/services/ScenarioGenerationService.js +2 -1
  16. package/build/tools/code-refactor/enhanceAssertionsTool.js +2 -1
  17. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +123 -1
  18. package/build/tools/generate-tests/generateBatchScenarioRestTool.test.js +205 -9
  19. package/build/tools/generate-tests/generateContractRestTool.js +19 -19
  20. package/build/tools/generate-tests/generateIntegrationRestTool.js +9 -2
  21. package/build/tools/generate-tests/generateUIRestTool.js +23 -8
  22. package/build/tools/test-management/analyzeChangesTool.js +218 -2
  23. package/build/tools/test-management/analyzeChangesTool.test.js +233 -1
  24. package/build/utils/featureFlags.js +7 -0
  25. package/build/utils/featureFlags.test.js +81 -0
  26. package/build/utils/httpDefaults.js +17 -0
  27. package/build/utils/httpDefaults.test.js +21 -0
  28. package/build/utils/scenarioDrafting.js +37 -15
  29. package/build/utils/scenarioDrafting.test.js +66 -0
  30. package/build/utils/telemetry.js +2 -1
  31. package/build/utils/utils.js +23 -0
  32. 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 — the flag is process-wide and cannot change per call.
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 directly from workspace.yml. **Use these as-is; do not infer or override.**
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 workspace.yml."
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 \`api.authScheme\` in workspace.yml).
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 the \`testDirectory\` from \`workspace.yml\` (visible in the service context block at the top of this prompt). Do NOT create a new \`tests/\` directory at the repo root — use the path the workspace config specifies. If no \`testDirectory\` is configured, default to the language-conventional location (e.g. \`src/test/java/...\` for Java, \`tests/\` for Python).
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("includes persona inside the XML block", () => {
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 === "POST" ? 201 : method === "DELETE" ? 204 : 200);
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 (process.env.SKYRAMP_FEATURE_TESTBOT === "1") {
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", () => ({ ScenarioGenerationService: jest.fn() }));
2
- jest.mock("../../services/AnalyticsService.js", () => ({ AnalyticsService: { pushMCPToolEvent: jest.fn() } }));
3
- jest.mock("../../utils/workspaceAuth.js", () => ({ getWorkspaceAuthConfig: jest.fn(), WorkspaceAuthType: { None: "none" } }));
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 { stepSchema } from "./generateBatchScenarioRestTool.js";
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); // null is not an empty object — not rejected
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
- ⏭️ **CRITICAL NEXT STEP — Replace placeholder sample data:**
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
- return `
231
- ⏭️ **CRITICAL NEXT STEP Replace placeholder data and enhance assertions:**
232
-
233
- ⏭️ **CRITICAL — Part 1 — Replace placeholder values in the generated file:**
234
-
235
- ${this.buildPlaceholderReplacementBlock(params, "continue to Part 2")}
236
-
237
- ---
238
-
239
- ⏭️ **CRITICAL Part 2 Enhance response body assertions:**
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
- **CRITICAL NEXT STEP — Enhance response body assertions after each request:**
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
  }