@skyramp/mcp 0.1.8 → 0.2.0-rc.2

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 (122) hide show
  1. package/build/index.js +4 -2
  2. package/build/playwright/registerPlaywrightTools.js +12 -0
  3. package/build/playwright/traceRecordingPrompt.js +15 -0
  4. package/build/prompts/code-reuse.js +106 -7
  5. package/build/prompts/pom-aware-code-reuse.js +106 -7
  6. package/build/prompts/startTraceCollectionPrompts.js +37 -15
  7. package/build/prompts/test-maintenance/drift-analysis-prompt.js +26 -31
  8. package/build/prompts/test-maintenance/drift-analysis-prompt.test.js +40 -1
  9. package/build/prompts/test-maintenance/driftAnalysisSections.js +90 -86
  10. package/build/prompts/test-recommendation/analysisOutputPrompt.js +286 -163
  11. package/build/prompts/test-recommendation/analysisOutputPrompt.test.js +154 -45
  12. package/build/prompts/test-recommendation/diffExecutionPlan.js +246 -117
  13. package/build/prompts/test-recommendation/promptPlan.js +290 -0
  14. package/build/prompts/test-recommendation/promptPlan.test.js +336 -0
  15. package/build/prompts/test-recommendation/recommendationSections.js +4 -3
  16. package/build/prompts/test-recommendation/recommendationShared.js +23 -1
  17. package/build/prompts/test-recommendation/scopeAssessment.js +65 -14
  18. package/build/prompts/test-recommendation/scopeAssessment.test.js +93 -2
  19. package/build/prompts/test-recommendation/test-recommendation-prompt.js +36 -12
  20. package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +316 -1
  21. package/build/prompts/testbot/testbot-prompts.js +73 -13
  22. package/build/prompts/testbot/testbot-prompts.test.js +114 -1
  23. package/build/resources/testbotResource.js +1 -1
  24. package/build/services/ScenarioGenerationService.integration.test.js +158 -0
  25. package/build/services/ScenarioGenerationService.js +47 -4
  26. package/build/services/ScenarioGenerationService.test.js +158 -22
  27. package/build/services/TestExecutionService.js +73 -15
  28. package/build/services/TestExecutionService.test.js +105 -0
  29. package/build/services/TestGenerationService.js +11 -1
  30. package/build/tools/executeSkyrampTestTool.js +1 -10
  31. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +16 -4
  32. package/build/tools/generate-tests/generateIntegrationRestTool.js +2 -0
  33. package/build/tools/generate-tests/generateUIRestTool.js +2 -0
  34. package/build/tools/test-management/actionsTool.js +152 -63
  35. package/build/tools/test-management/analyzeChangesTool.js +178 -64
  36. package/build/tools/test-management/analyzeChangesTool.test.js +103 -16
  37. package/build/tools/test-management/analyzeTestHealthTool.js +30 -81
  38. package/build/tools/test-management/index.js +1 -0
  39. package/build/tools/test-management/uiAnalyzeChangesTool.js +149 -0
  40. package/build/tools/test-management/uiAnalyzeChangesTool.test.js +100 -0
  41. package/build/tools/trace/resolveSaveStoragePath.js +16 -0
  42. package/build/tools/trace/resolveSaveStoragePath.test.js +17 -0
  43. package/build/tools/trace/resolveSessionPaths.js +39 -0
  44. package/build/tools/trace/resolveSessionPaths.test.js +103 -0
  45. package/build/tools/trace/sessionState.js +14 -0
  46. package/build/tools/trace/sessionState.test.js +17 -0
  47. package/build/tools/trace/startTraceCollectionTool.js +84 -14
  48. package/build/tools/trace/stopTraceCollectionTool.js +9 -2
  49. package/build/types/TestAnalysis.js +50 -0
  50. package/build/types/TestRecommendation.js +6 -58
  51. package/build/types/TestTypes.js +1 -1
  52. package/build/utils/AnalysisStateManager.js +22 -11
  53. package/build/utils/branchDiff.js +11 -2
  54. package/build/utils/docker.test.js +1 -1
  55. package/build/utils/gitStaging.js +52 -3
  56. package/build/utils/gitStaging.test.js +19 -1
  57. package/build/utils/repoScanner.js +18 -10
  58. package/build/utils/repoScanner.test.js +92 -0
  59. package/build/utils/routeParsers.js +180 -25
  60. package/build/utils/routeParsers.test.js +180 -1
  61. package/build/utils/scenarioDrafting.js +220 -17
  62. package/build/utils/scenarioDrafting.test.js +182 -9
  63. package/build/utils/sourceRouteExtractor.js +806 -0
  64. package/build/utils/sourceRouteExtractor.test.js +565 -0
  65. package/build/utils/uiPageEnumerator.js +319 -0
  66. package/build/utils/uiPageEnumerator.test.js +422 -0
  67. package/build/utils/utils.js +27 -0
  68. package/build/utils/versions.js +1 -1
  69. package/build/utils/workspaceAuth.js +33 -4
  70. package/node_modules/playwright/ThirdPartyNotices.txt +6 -6
  71. package/node_modules/playwright/lib/dom-analyzer/analyze.js +111 -0
  72. package/node_modules/playwright/lib/dom-analyzer/blueprint.js +1210 -0
  73. package/node_modules/playwright/lib/dom-analyzer/blueprint.test.js +396 -0
  74. package/node_modules/playwright/lib/dom-analyzer/blueprintCache.js +57 -0
  75. package/node_modules/playwright/lib/dom-analyzer/blueprintCache.test.js +57 -0
  76. package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.js +254 -0
  77. package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.test.js +304 -0
  78. package/node_modules/playwright/lib/dom-analyzer/crawler.js +384 -0
  79. package/node_modules/playwright/lib/dom-analyzer/curatedWidgets.js +73 -0
  80. package/node_modules/playwright/lib/dom-analyzer/dynamicId.js +43 -0
  81. package/node_modules/playwright/lib/dom-analyzer/dynamicId.test.js +85 -0
  82. package/node_modules/playwright/lib/dom-analyzer/fingerprint.js +90 -0
  83. package/node_modules/playwright/lib/dom-analyzer/fingerprint.test.js +231 -0
  84. package/node_modules/playwright/lib/dom-analyzer/fingerprintAblation.fixtures.js +145 -0
  85. package/node_modules/playwright/lib/dom-analyzer/fingerprintAblation.test.js +41 -0
  86. package/node_modules/playwright/lib/dom-analyzer/graph.js +36 -0
  87. package/node_modules/playwright/lib/dom-analyzer/liveFingerprints.js +43 -0
  88. package/node_modules/playwright/lib/dom-analyzer/logicalNameResolver.js +72 -0
  89. package/node_modules/playwright/lib/dom-analyzer/logicalNameResolver.test.js +182 -0
  90. package/node_modules/playwright/lib/dom-analyzer/possibleAssertions.js +150 -0
  91. package/node_modules/playwright/lib/dom-analyzer/possibleAssertions.test.js +470 -0
  92. package/node_modules/playwright/lib/dom-analyzer/sectionGrouper.js +169 -0
  93. package/node_modules/playwright/lib/dom-analyzer/sectionGrouper.test.js +269 -0
  94. package/node_modules/playwright/lib/dom-analyzer/serialization.js +75 -0
  95. package/node_modules/playwright/lib/dom-analyzer/slug.js +30 -0
  96. package/node_modules/playwright/lib/dom-analyzer/slug.test.js +84 -0
  97. package/node_modules/playwright/lib/dom-analyzer/widgetContract.js +127 -0
  98. package/node_modules/playwright/lib/dom-analyzer/widgetContract.test.js +212 -0
  99. package/node_modules/playwright/lib/mcp/browser/browserContextFactory.js +3 -1
  100. package/node_modules/playwright/lib/mcp/browser/config.js +1 -1
  101. package/node_modules/playwright/lib/mcp/browser/context.js +17 -1
  102. package/node_modules/playwright/lib/mcp/browser/tab.js +38 -0
  103. package/node_modules/playwright/lib/mcp/browser/tools/domAnalyzer.js +261 -0
  104. package/node_modules/playwright/lib/mcp/browser/tools/keyboard.js +3 -3
  105. package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.js +146 -0
  106. package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.test.js +140 -0
  107. package/node_modules/playwright/lib/mcp/browser/tools/sitemap.js +226 -0
  108. package/node_modules/playwright/lib/mcp/browser/tools/snapshot.js +2 -2
  109. package/node_modules/playwright/lib/mcp/browser/tools/widgetContract.js +168 -0
  110. package/node_modules/playwright/lib/mcp/browser/tools.js +6 -0
  111. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +52 -12
  112. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +64 -13
  113. package/node_modules/playwright/package.json +1 -1
  114. package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.3.tgz +0 -0
  115. package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.4.tgz +0 -0
  116. package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.5.tgz +0 -0
  117. package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.6.tgz +0 -0
  118. package/package.json +3 -3
  119. package/build/services/TestHealthService.js +0 -694
  120. package/build/services/TestHealthService.test.js +0 -241
  121. package/build/types/TestDriftAnalysis.js +0 -1
  122. package/build/types/TestHealth.js +0 -4
@@ -203,6 +203,8 @@ describe("uiCredentials in getTestbotPrompt", () => {
203
203
  });
204
204
  });
205
205
  describe("drift analysis inline embedding", () => {
206
+ beforeAll(() => { process.env.SKYRAMP_FEATURE_TESTBOT = "1"; });
207
+ afterAll(() => { delete process.env.SKYRAMP_FEATURE_TESTBOT; });
206
208
  function basePrompt() {
207
209
  return getTestbotPrompt(baseArgs.prTitle, baseArgs.prDescription, baseArgs.summaryOutputFile, baseArgs.repositoryPath);
208
210
  }
@@ -226,11 +228,60 @@ describe("drift analysis inline embedding", () => {
226
228
  expect(rulesPos).toBeGreaterThan(task1Pos);
227
229
  expect(rulesPos).toBeLessThan(task2Pos);
228
230
  });
229
- it("Task 1 step 2 prose references drift_analysis_rules tag", () => {
231
+ it("Task 1 step 3 prose references drift_analysis_rules tag", () => {
230
232
  const prompt = basePrompt();
231
233
  expect(prompt).toContain("rules in `<drift_analysis_rules>`");
232
234
  });
233
235
  });
236
+ describe("UI grounding via Task 2 capture-act-capture", () => {
237
+ it("surfaces uiContext as guidance, not a contract", () => {
238
+ const prompt = getTestbotPrompt(baseArgs.prTitle, baseArgs.prDescription, baseArgs.summaryOutputFile, baseArgs.repositoryPath);
239
+ // uiContext fields are explained inline so the agent knows what to do with
240
+ // them. Step 1 provides candidate URLs but gives fallback instructions
241
+ // ("navigate from the workspace baseUrl and explore") for 404s/redirects,
242
+ // treating candidates as guidance not a rigid contract.
243
+ expect(prompt).toContain("uiContext");
244
+ expect(prompt).toContain("candidateUiPages");
245
+ expect(prompt).toContain("changedFrontendFiles");
246
+ expect(prompt).toMatch(/navigate from the workspace baseUrl and explore/i);
247
+ });
248
+ it("step 5 enforces Blueprint Citation Invariant in natural prose", () => {
249
+ const prompt = getTestbotPrompt(baseArgs.prTitle, baseArgs.prDescription, baseArgs.summaryOutputFile, baseArgs.repositoryPath);
250
+ // Step 5 is the citation-invariant guardrail, not a "fill in tuples"
251
+ // post-processing step (slice 4 cleanup: recs are grounded upstream).
252
+ expect(prompt).toContain("Blueprint Citation Invariant");
253
+ // Reasoning must be natural prose, NOT internal-identifier syntax.
254
+ expect(prompt).toMatch(/natural prose/i);
255
+ expect(prompt).toMatch(/internal-identifier syntax/i);
256
+ });
257
+ it("Task 2 no longer instructs the agent to fill in tuples post-hoc", () => {
258
+ const prompt = getTestbotPrompt(baseArgs.prTitle, baseArgs.prDescription, baseArgs.summaryOutputFile, baseArgs.repositoryPath);
259
+ // After slice 4 cleanup: Task 2 captures are for trace recording's own
260
+ // assertions, not for retroactively rewriting recommendation reasoning.
261
+ // The phrase "fill in tuples" must NOT appear anywhere in the prompt.
262
+ expect(prompt).not.toMatch(/fill in tuples/i);
263
+ expect(prompt).not.toMatch(/return to step 5 and fill/i);
264
+ });
265
+ it("Task 2 step 5 mentions possibleAssertions as available, NOT as required (slice 5.5 softening)", () => {
266
+ const prompt = getTestbotPrompt(baseArgs.prTitle, baseArgs.prDescription, baseArgs.summaryOutputFile, baseArgs.repositoryPath);
267
+ // Slice 5: AFTER-action browser_blueprint response includes
268
+ // possibleAssertions[] — mechanically translated candidates.
269
+ expect(prompt).toContain("possibleAssertions");
270
+ // Slice 5.5: prompt explicitly tells the agent NOT to feel obligated.
271
+ // Two P09 runs with the prior "emit at least one" directive showed the
272
+ // agent over-using shallow visibility assertions at the expense of
273
+ // integration-test depth. The softened version says: read them, use
274
+ // when they happen to match what you'd write anyway, ignore otherwise.
275
+ expect(prompt).toMatch(/do not feel obligated/i);
276
+ expect(prompt).toMatch(/biased toward visibility/i);
277
+ // The candidate format is still documented.
278
+ expect(prompt).toMatch(/\bcode\b.*\brationale\b.*\btier\b/i);
279
+ // The pre-existing "at least one browser_assert per page navigated"
280
+ // rule should be preserved (it's about meaningful business-outcome
281
+ // assertions, not about possibleAssertions).
282
+ expect(prompt).toMatch(/at least one .browser_assert. per page navigated/i);
283
+ });
284
+ });
234
285
  describe("buildWorkspaceRecoveryPrefix", () => {
235
286
  const { buildWorkspaceRecoveryPrefix } = require("./testbot-prompts.js");
236
287
  it("includes repositoryPath in both init_scan and init_workspace instructions", () => {
@@ -248,3 +299,65 @@ describe("buildWorkspaceRecoveryPrefix", () => {
248
299
  expect(prefix).toMatch(/^IMPORTANT:/);
249
300
  });
250
301
  });
302
+ describe("testsRepoDir in getTestbotPrompt", () => {
303
+ function callWithTestsRepoDir(testsRepoDir) {
304
+ return getTestbotPrompt(baseArgs.prTitle, baseArgs.prDescription, baseArgs.summaryOutputFile, baseArgs.repositoryPath, undefined, // baseBranch
305
+ undefined, // maxRecommendations
306
+ undefined, // maxGenerate
307
+ undefined, // maxCritical
308
+ undefined, // prNumber
309
+ undefined, // userPrompt
310
+ undefined, // services
311
+ undefined, // stateOutputFile
312
+ undefined, // uiCredentials
313
+ testsRepoDir);
314
+ }
315
+ function callFollowUpWithTestsRepoDir(testsRepoDir) {
316
+ return getTestbotPrompt(baseArgs.prTitle, baseArgs.prDescription, baseArgs.summaryOutputFile, baseArgs.repositoryPath, undefined, // baseBranch
317
+ undefined, // maxRecommendations
318
+ undefined, // maxGenerate
319
+ undefined, // maxCritical
320
+ undefined, // prNumber
321
+ "add more tests", // userPrompt — triggers follow-up path
322
+ undefined, // services
323
+ undefined, // stateOutputFile
324
+ undefined, // uiCredentials
325
+ testsRepoDir);
326
+ }
327
+ it("includes testsRepoDir in skyramp_analyze_changes call for first-run prompt", () => {
328
+ const dir = "/home/runner/work/_temp/skyramp/test-repo";
329
+ const prompt = callWithTestsRepoDir(dir);
330
+ expect(prompt).toContain(`\`testsRepoDir\`: "${dir}"`);
331
+ });
332
+ it("includes testsRepoDir in skyramp_analyze_changes call for follow-up prompt", () => {
333
+ const dir = "/home/runner/work/_temp/skyramp/test-repo";
334
+ const prompt = callFollowUpWithTestsRepoDir(dir);
335
+ expect(prompt).toContain(`\`testsRepoDir\`: "${dir}"`);
336
+ });
337
+ it("omits testsRepoDir when not provided", () => {
338
+ const prompt = callWithTestsRepoDir(undefined);
339
+ expect(prompt).not.toContain("testsRepoDir");
340
+ });
341
+ it("omits testsRepoDir from follow-up prompt when not provided", () => {
342
+ const prompt = callFollowUpWithTestsRepoDir(undefined);
343
+ expect(prompt).not.toContain("testsRepoDir");
344
+ });
345
+ });
346
+ describe("testbot prompt blueprint-grounded recommendations (slice 4)", () => {
347
+ it("instructs the agent to call skyramp_ui_analyze_changes before skyramp_analyze_changes", () => {
348
+ const prompt = getTestbotPrompt(baseArgs.prTitle, baseArgs.prDescription, baseArgs.summaryOutputFile, baseArgs.repositoryPath);
349
+ const uiacIdx = prompt.indexOf("skyramp_ui_analyze_changes");
350
+ const acIdx = prompt.indexOf("skyramp_analyze_changes");
351
+ expect(uiacIdx).toBeGreaterThan(-1);
352
+ expect(acIdx).toBeGreaterThan(uiacIdx);
353
+ });
354
+ it("Task 1 step 1 instructs the agent to capture blueprints (without threading them through a param)", () => {
355
+ const prompt = getTestbotPrompt(baseArgs.prTitle, baseArgs.prDescription, baseArgs.summaryOutputFile, baseArgs.repositoryPath);
356
+ // Captures stay in tool-result history; analyze_changes returns the
357
+ // authoring rules and the agent supplies the captured vocabulary.
358
+ expect(prompt).toMatch(/browser_blueprint`?\s*to capture/i);
359
+ expect(prompt).toMatch(/tool-result history/i);
360
+ // Make sure we removed the old capturedBlueprints threading directive.
361
+ expect(prompt).not.toMatch(/capturedBlueprints/);
362
+ });
363
+ });
@@ -25,7 +25,7 @@ export function registerTestbotResource(server) {
25
25
  const maxCrit = parseInt(uri.searchParams.get("maxCritical") || "", 10);
26
26
  const repositoryPath = param("repositoryPath", ".");
27
27
  const services = await readWorkspaceServices(repositoryPath);
28
- const prompt = getTestbotPrompt(param("prTitle", ""), param("prDescription", ""), param("summaryOutputFile", ""), repositoryPath, uri.searchParams.get("baseBranch") || undefined, isNaN(maxRec) ? MAX_RECOMMENDATIONS : maxRec, isNaN(maxGen) ? MAX_TESTS_TO_GENERATE : maxGen, isNaN(maxCrit) ? MAX_CRITICAL_TESTS : maxCrit, isNaN(prNum) ? undefined : prNum, uri.searchParams.get("userPrompt") || undefined, services.length ? services : undefined, uri.searchParams.get("stateOutputFile") || undefined, uri.searchParams.get("uiCredentials") || undefined);
28
+ const prompt = getTestbotPrompt(param("prTitle", ""), param("prDescription", ""), param("summaryOutputFile", ""), repositoryPath, uri.searchParams.get("baseBranch") || undefined, isNaN(maxRec) ? MAX_RECOMMENDATIONS : maxRec, isNaN(maxGen) ? MAX_TESTS_TO_GENERATE : maxGen, isNaN(maxCrit) ? MAX_CRITICAL_TESTS : maxCrit, isNaN(prNum) ? undefined : prNum, uri.searchParams.get("userPrompt") || undefined, services.length ? services : undefined, uri.searchParams.get("stateOutputFile") || undefined, uri.searchParams.get("uiCredentials") || undefined, uri.searchParams.get("testsRepoDir") || undefined);
29
29
  AnalyticsService.pushMCPToolEvent("skyramp_testbot_prompt", undefined, {}).catch(() => { });
30
30
  // Return the original URI — clients may use it to re-fetch the resource,
31
31
  // and the caller already has these params. Credentials never appear in
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Integration test: verifies query params survive the full pipeline
3
+ * TS (ScenarioGenerationService) → JSON file → Go binary → generated test code
4
+ *
5
+ * Requires: @skyramp/skyramp native dylib (skips if not available)
6
+ */
7
+ import fs from "fs";
8
+ import path from "path";
9
+ import os from "os";
10
+ import { ScenarioGenerationService } from "./ScenarioGenerationService.js";
11
+ let SkyrampClient;
12
+ try {
13
+ SkyrampClient = require("@skyramp/skyramp").SkyrampClient;
14
+ }
15
+ catch {
16
+ SkyrampClient = null;
17
+ }
18
+ // These tests require the native Go binary which is platform-specific.
19
+ // Skip in CI or when the binary isn't available for this OS/arch.
20
+ const isCI = Boolean(process.env.CI || process.env.GITHUB_ACTIONS);
21
+ const describeIfBinary = SkyrampClient && !isCI ? describe : describe.skip;
22
+ function buildTrace(overrides) {
23
+ const service = new ScenarioGenerationService();
24
+ return service.generateTraceRequestFromInput({
25
+ scenarioName: "integration-qp-test",
26
+ destination: "localhost",
27
+ baseURL: "http://localhost:8080",
28
+ method: "GET",
29
+ path: "/api/v1/items",
30
+ outputDir: os.tmpdir(),
31
+ authHeader: "",
32
+ authScheme: "",
33
+ ...overrides,
34
+ });
35
+ }
36
+ describeIfBinary("QueryParams integration: TS → JSON → Go binary → generated code", () => {
37
+ let tmpDir;
38
+ let outputDir;
39
+ let client;
40
+ // Go dylib cold-starts can take a few seconds
41
+ jest.setTimeout(15000);
42
+ beforeAll(() => {
43
+ client = new SkyrampClient();
44
+ });
45
+ afterAll(() => {
46
+ client?.close?.();
47
+ });
48
+ beforeEach(() => {
49
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "skyramp-qp-integration-"));
50
+ outputDir = path.join(tmpDir, "output");
51
+ fs.mkdirSync(outputDir);
52
+ });
53
+ afterEach(() => {
54
+ fs.rmSync(tmpDir, { recursive: true, force: true });
55
+ });
56
+ async function generateFromTrace(trace) {
57
+ const traceFile = path.join(tmpDir, "scenario.json");
58
+ fs.writeFileSync(traceFile, JSON.stringify([trace], null, 2));
59
+ await client.generateRestTest({
60
+ testType: "integration",
61
+ language: "python",
62
+ framework: "pytest",
63
+ outputDir,
64
+ force: true,
65
+ traceFilePath: traceFile,
66
+ });
67
+ const files = fs.readdirSync(outputDir).filter((f) => f.endsWith(".py"));
68
+ if (files.length === 0)
69
+ throw new Error("No generated test file found");
70
+ return fs.readFileSync(path.join(outputDir, files[0]), "utf8");
71
+ }
72
+ it("bracket-notation keys pass through to generated test code", async () => {
73
+ // Simulates: GET /api/v1/items?filter[status][_eq]=published&limit=25
74
+ const trace = buildTrace({
75
+ queryParams: '{"filter[status][_eq]":"published","limit":25}',
76
+ });
77
+ expect(trace).not.toBeNull();
78
+ expect(trace.QueryParams).toEqual({
79
+ "filter[status][_eq]": ["published"],
80
+ "limit": ["25"],
81
+ });
82
+ const code = await generateFromTrace(trace);
83
+ expect(code).toContain("filter[status][_eq]");
84
+ expect(code).toContain("published");
85
+ expect(code).toContain("limit");
86
+ expect(code).toContain("25");
87
+ });
88
+ it("simple flat params pass through to generated test code", async () => {
89
+ // Simulates: GET /api/v1/items?page=2&status=active
90
+ const trace = buildTrace({
91
+ queryParams: '{"page":2,"status":"active"}',
92
+ });
93
+ expect(trace).not.toBeNull();
94
+ expect(trace.QueryParams).toEqual({
95
+ page: ["2"],
96
+ status: ["active"],
97
+ });
98
+ const code = await generateFromTrace(trace);
99
+ expect(code).toContain("page");
100
+ expect(code).toContain("2");
101
+ expect(code).toContain("status");
102
+ expect(code).toContain("active");
103
+ });
104
+ it("JSON-encoded string value (Elasticsearch-style) passes through intact", async () => {
105
+ // Simulates: GET /search?source={"query":{"match":{"name":"bear"}}}
106
+ const trace = buildTrace({
107
+ queryParams: '{"source":"{\\"query\\":{\\"match\\":{\\"name\\":\\"bear\\"}}}"}',
108
+ });
109
+ expect(trace).not.toBeNull();
110
+ expect(trace.QueryParams).toEqual({
111
+ source: ['{"query":{"match":{"name":"bear"}}}'],
112
+ });
113
+ const code = await generateFromTrace(trace);
114
+ expect(code).toContain("source");
115
+ expect(code).toMatch(/query.*match.*name.*bear/);
116
+ });
117
+ it("array params are comma-joined into a single value in generated code", async () => {
118
+ // Simulates: GET /products?tags=sale,new
119
+ const trace = buildTrace({
120
+ queryParams: '{"tags":["sale","new"]}',
121
+ });
122
+ expect(trace).not.toBeNull();
123
+ expect(trace.QueryParams).toEqual({
124
+ tags: ["sale,new"],
125
+ });
126
+ const code = await generateFromTrace(trace);
127
+ expect(code).toContain("tags");
128
+ expect(code).toContain("sale,new");
129
+ });
130
+ it("null params are omitted — not present in generated code", async () => {
131
+ // null means "don't include this param" — same as axios/fetch omitting undefined
132
+ const trace = buildTrace({
133
+ queryParams: '{"limit":10,"cursor":null}',
134
+ });
135
+ expect(trace).not.toBeNull();
136
+ expect(trace.QueryParams).toEqual({
137
+ limit: ["10"],
138
+ });
139
+ const code = await generateFromTrace(trace);
140
+ expect(code).toContain("limit");
141
+ expect(code).toContain("10");
142
+ expect(code).not.toContain("cursor");
143
+ });
144
+ it("nested object fallback produces valid JSON string in generated code", async () => {
145
+ // LLM mistakenly sends nested object — service JSON-stringifies it
146
+ const trace = buildTrace({
147
+ queryParams: '{"filter":{"status":"active"}}',
148
+ });
149
+ expect(trace).not.toBeNull();
150
+ expect(trace.QueryParams).toEqual({
151
+ filter: ['{"status":"active"}'],
152
+ });
153
+ const code = await generateFromTrace(trace);
154
+ expect(code).toContain("filter");
155
+ expect(code).not.toContain("[object Object]");
156
+ expect(code).not.toContain("map[");
157
+ });
158
+ });
@@ -2,9 +2,14 @@ import { AUTH_PLACEHOLDER_TOKEN } from "../types/TestTypes.js";
2
2
  import { isAuthorizationHeaderName } from "../utils/workspaceAuth.js";
3
3
  import { inferExpectedStatus } from "../utils/httpDefaults.js";
4
4
  import { logger } from "../utils/logger.js";
5
- import { stageGeneratedPaths } from "../utils/gitStaging.js";
5
+ import { stageGeneratedPaths, resolveOutputDir } from "../utils/gitStaging.js";
6
+ import { getTestsRepoDir } from "../utils/AnalysisStateManager.js";
6
7
  import fs from "fs";
7
8
  import path from "path";
9
+ // Keys that trigger built-in prototype setters when used as bracket-notation
10
+ // property names on a plain object — guard against prototype pollution from
11
+ // LLM-controlled or user-controlled JSON input.
12
+ const PROTO_KEYS = new Set(["__proto__", "constructor", "prototype"]);
8
13
  export class ScenarioGenerationService {
9
14
  async parseScenario(params) {
10
15
  try {
@@ -23,6 +28,15 @@ export class ScenarioGenerationService {
23
28
  isError: true,
24
29
  };
25
30
  }
31
+ // In cross-repo mode, redirect outputDir to the test repo clone.
32
+ const resolved = resolveOutputDir(params.outputDir, getTestsRepoDir());
33
+ if (resolved !== params.outputDir) {
34
+ logger.info("Cross-repo: redirecting scenario outputDir to test repo", {
35
+ original: params.outputDir,
36
+ redirected: resolved,
37
+ });
38
+ params.outputDir = resolved;
39
+ }
26
40
  const scenarioName = params.scenarioName.replace(/ /g, "-").toLowerCase();
27
41
  const fileName = `scenario_${scenarioName}.json`;
28
42
  const filePath = path.join(params.outputDir, fileName);
@@ -40,6 +54,12 @@ export class ScenarioGenerationService {
40
54
  existingRequests = [];
41
55
  }
42
56
  }
57
+ if (existingRequests.length > 0) {
58
+ const lastTimestamp = existingRequests[existingRequests.length - 1].Timestamp;
59
+ const lastMs = new Date(lastTimestamp).getTime();
60
+ const newMs = Math.max(lastMs + 1000, Date.now());
61
+ traceRequest.Timestamp = new Date(newMs).toISOString();
62
+ }
43
63
  existingRequests.push(traceRequest);
44
64
  fs.writeFileSync(filePath, JSON.stringify(existingRequests, null, 2), "utf8");
45
65
  // Stage so testbot includes the generated files in its output commit.
@@ -139,15 +159,38 @@ ${JSON.stringify(traceRequest, null, 2)}
139
159
  const requestHeaders = {
140
160
  "Content-Type": ["application/json"],
141
161
  };
162
+ // Go backend expects url.Values (map[string][]string). We always produce
163
+ // single-element arrays — the Go codegen normalises multi-element string arrays
164
+ // into a JSON array string (e.g. ["sale","new"]), which is not what most APIs
165
+ // expect. Comma-joining covers the common case; APIs needing true repeated keys
166
+ // (e.g. ?tags=sale&tags=new) are not supported by the current Go codegen path.
142
167
  const queryParams = {};
143
168
  if (params.queryParams) {
144
169
  try {
145
170
  const parsed = JSON.parse(params.queryParams);
146
171
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
147
172
  for (const [k, v] of Object.entries(parsed)) {
148
- queryParams[k] = Array.isArray(v)
149
- ? v.map((item) => typeof item === "object" && item !== null ? JSON.stringify(item) : String(item))
150
- : [typeof v === "object" && v !== null ? JSON.stringify(v) : String(v)];
173
+ if (PROTO_KEYS.has(k))
174
+ continue;
175
+ if (v === null)
176
+ continue;
177
+ let value;
178
+ if (Array.isArray(v)) {
179
+ const items = v
180
+ .filter((item) => item !== null)
181
+ .map((item) => (typeof item === "object" ? JSON.stringify(item) : String(item)));
182
+ if (items.length === 0)
183
+ continue;
184
+ value = items.join(",");
185
+ }
186
+ else if (typeof v === "object") {
187
+ logger.warning("Nested object in queryParams — JSON-stringifying as fallback; LLM should provide flat keys", { key: k });
188
+ value = JSON.stringify(v);
189
+ }
190
+ else {
191
+ value = String(v);
192
+ }
193
+ queryParams[k] = [value];
151
194
  }
152
195
  }
153
196
  else {
@@ -1,3 +1,6 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
1
4
  import { ScenarioGenerationService } from "./ScenarioGenerationService.js";
2
5
  import { AUTH_PLACEHOLDER_TOKEN } from "../types/TestTypes.js";
3
6
  const BASE_PARAMS = {
@@ -197,39 +200,145 @@ describe("ScenarioGenerationService — auth header flavors", () => {
197
200
  });
198
201
  });
199
202
  describe("ScenarioGenerationService — queryParams handling", () => {
200
- it("serializes a flat primitive object correctly", () => {
201
- const trace = generateTrace({ queryParams: '{"limit":"10","status":"active"}' });
202
- expect(trace.QueryParams).toEqual({ limit: ["10"], status: ["active"] });
203
+ // --- Primitive values (strings, numbers, booleans) ---
204
+ it("coerces string values into single-element arrays", () => {
205
+ const trace = generateTrace({ queryParams: '{"status":"active"}' });
206
+ expect(trace.QueryParams).toEqual({ status: ["active"] });
203
207
  });
204
- it("serializes numeric and boolean primitive values as strings", () => {
205
- const trace = generateTrace({ queryParams: '{"page":2,"active":true}' });
206
- expect(trace.QueryParams).toEqual({ page: ["2"], active: ["true"] });
208
+ it("coerces numeric values to string", () => {
209
+ const trace = generateTrace({ queryParams: '{"limit":10,"offset":0}' });
210
+ expect(trace.QueryParams).toEqual({ limit: ["10"], offset: ["0"] });
207
211
  });
208
- it("JSON-stringifies nested object values instead of producing [object Object]", () => {
209
- const trace = generateTrace({ queryParams: '{"filter":{"status":"active","min_price":10}}' });
210
- expect(trace).not.toBeNull();
211
- const filterVal = trace.QueryParams["filter"][0];
212
- expect(filterVal).not.toBe("[object Object]");
213
- expect(filterVal).toBe('{"status":"active","min_price":10}');
212
+ it("coerces boolean values to string", () => {
213
+ const trace = generateTrace({ queryParams: '{"active":true,"deleted":false}' });
214
+ expect(trace.QueryParams).toEqual({ active: ["true"], deleted: ["false"] });
214
215
  });
215
- it("JSON-stringifies nested objects inside an array value", () => {
216
- const trace = generateTrace({ queryParams: '{"ids":[{"id":1},{"id":2}]}' });
217
- expect(trace).not.toBeNull();
218
- expect(trace.QueryParams["ids"]).toEqual(['{"id":1}', '{"id":2}']);
216
+ it("coerces empty string to single-element array", () => {
217
+ const trace = generateTrace({ queryParams: '{"q":""}' });
218
+ expect(trace.QueryParams).toEqual({ q: [""] });
219
+ });
220
+ it("passes bracket-notation keys through verbatim (Directus regression)", () => {
221
+ // Root cause of the original P0: filter[title][_neq] must reach the Go backend
222
+ // as the literal key string, not as a nested object or [object Object].
223
+ const trace = generateTrace({
224
+ queryParams: '{"filter[status][_eq]":"published","filter[date_created][_gte]":"2024-01-01","limit":25}',
225
+ });
226
+ expect(trace.QueryParams["filter[status][_eq]"]).toEqual(["published"]);
227
+ expect(trace.QueryParams["filter[date_created][_gte]"]).toEqual(["2024-01-01"]);
228
+ expect(trace.QueryParams["limit"]).toEqual(["25"]);
229
+ });
230
+ // --- Null omission ---
231
+ it("omits null values entirely (prevents ?status=null in URL)", () => {
232
+ // null means "omit this param" — same convention as axios/fetch omitting undefined params
233
+ const trace = generateTrace({ queryParams: '{"cursor":null,"limit":20}' });
234
+ expect(trace.QueryParams["cursor"]).toBeUndefined();
235
+ expect(trace.QueryParams["limit"]).toEqual(["20"]);
236
+ });
237
+ it("omits all-null object leaving only non-null keys", () => {
238
+ const trace = generateTrace({ queryParams: '{"a":null,"b":null,"c":"keep"}' });
239
+ expect(trace.QueryParams).toEqual({ c: ["keep"] });
240
+ });
241
+ // --- Array values: filter nulls, comma-join into single element ---
242
+ it("array of strings joins into single comma-separated value", () => {
243
+ const trace = generateTrace({ queryParams: '{"tags":["sale","new","featured"]}' });
244
+ expect(trace.QueryParams["tags"]).toEqual(["sale,new,featured"]);
245
+ });
246
+ it("array of mixed primitives coerces each to string then joins", () => {
247
+ const trace = generateTrace({ queryParams: '{"ids":[1,2,"three",true,0,false]}' });
248
+ expect(trace.QueryParams["ids"]).toEqual(["1,2,three,true,0,false"]);
249
+ });
250
+ it("array with null items filters nulls before joining", () => {
251
+ const trace = generateTrace({ queryParams: '{"tags":[null,"active",null,"pending"]}' });
252
+ expect(trace.QueryParams["tags"]).toEqual(["active,pending"]);
253
+ });
254
+ it("array with all-null items omits the key entirely", () => {
255
+ const trace = generateTrace({ queryParams: '{"tags":[null,null],"limit":5}' });
256
+ expect(trace.QueryParams["tags"]).toBeUndefined();
257
+ expect(trace.QueryParams["limit"]).toEqual(["5"]);
219
258
  });
220
- it("passes through an array of primitive values unchanged", () => {
221
- const trace = generateTrace({ queryParams: '{"tags":["a","b","c"]}' });
222
- expect(trace.QueryParams["tags"]).toEqual(["a", "b", "c"]);
259
+ it("empty array omits the key (no items after filtering)", () => {
260
+ const trace = generateTrace({ queryParams: '{"ids":[],"limit":10}' });
261
+ expect(trace.QueryParams["ids"]).toBeUndefined();
262
+ expect(trace.QueryParams["limit"]).toEqual(["10"]);
223
263
  });
224
- it("produces empty QueryParams when queryParams is omitted", () => {
264
+ it("array containing objects JSON-stringifies each then joins", () => {
265
+ const trace = generateTrace({
266
+ queryParams: '{"filters":[{"field":"status","value":"active"},{"field":"price","op":"gte","value":"10"}]}',
267
+ });
268
+ expect(trace.QueryParams["filters"]).toEqual([
269
+ '{"field":"status","value":"active"},{"field":"price","op":"gte","value":"10"}',
270
+ ]);
271
+ });
272
+ it("array mixing objects, primitives, and nulls: filters nulls, stringifies objects, joins all", () => {
273
+ const trace = generateTrace({
274
+ queryParams: '{"mixed":[null,{"nested":true},"plain",42,null]}',
275
+ });
276
+ expect(trace.QueryParams["mixed"]).toEqual(['{"nested":true},plain,42']);
277
+ });
278
+ // --- Nested object fallback: JSON.stringify ---
279
+ it("nested object is JSON-stringified as fallback", () => {
280
+ const trace = generateTrace({
281
+ queryParams: '{"filter":{"status":"active","min_price":10}}',
282
+ });
283
+ expect(trace.QueryParams["filter"]).toEqual(['{"status":"active","min_price":10}']);
284
+ });
285
+ it("deeply nested object is fully JSON-stringified", () => {
286
+ const trace = generateTrace({
287
+ queryParams: '{"filter":{"title":{"_neq":"not-a-number"}}}',
288
+ });
289
+ expect(trace.QueryParams["filter"]).toEqual(['{"title":{"_neq":"not-a-number"}}']);
290
+ });
291
+ it("empty nested object is JSON-stringified to '{}'", () => {
292
+ const trace = generateTrace({ queryParams: '{"filter":{}}' });
293
+ expect(trace.QueryParams["filter"]).toEqual(["{}"]);
294
+ });
295
+ // --- JSON parsing and object validation ---
296
+ it("omitted queryParams produces empty QueryParams", () => {
225
297
  const trace = generateTrace({});
226
298
  expect(trace.QueryParams).toEqual({});
227
299
  });
228
- it("produces empty QueryParams and does not throw for invalid JSON", () => {
229
- const trace = generateTrace({ queryParams: "not-valid-json" });
300
+ it("invalid JSON produces empty QueryParams without throwing", () => {
301
+ const trace = generateTrace({ queryParams: "not-valid-json{" });
230
302
  expect(trace).not.toBeNull();
231
303
  expect(trace.QueryParams).toEqual({});
232
304
  });
305
+ it("JSON array (not object) produces empty QueryParams", () => {
306
+ const trace = generateTrace({ queryParams: '["limit","10"]' });
307
+ expect(trace.QueryParams).toEqual({});
308
+ });
309
+ it("JSON null produces empty QueryParams", () => {
310
+ const trace = generateTrace({ queryParams: "null" });
311
+ expect(trace.QueryParams).toEqual({});
312
+ });
313
+ it("JSON number produces empty QueryParams", () => {
314
+ const trace = generateTrace({ queryParams: "42" });
315
+ expect(trace.QueryParams).toEqual({});
316
+ });
317
+ it("JSON string produces empty QueryParams", () => {
318
+ const trace = generateTrace({ queryParams: '"just a string"' });
319
+ expect(trace.QueryParams).toEqual({});
320
+ });
321
+ it("empty object produces empty QueryParams", () => {
322
+ const trace = generateTrace({ queryParams: '{}' });
323
+ expect(trace.QueryParams).toEqual({});
324
+ });
325
+ // --- Security: PROTO_KEYS guard ---
326
+ it("skips __proto__ key to prevent prototype pollution via setter", () => {
327
+ const trace = generateTrace({
328
+ queryParams: '{"__proto__":{"polluted":true},"limit":10}',
329
+ });
330
+ expect(Object.hasOwn(trace.QueryParams, "__proto__")).toBe(false);
331
+ expect(Object.getPrototypeOf(trace.QueryParams)).toBe(Object.prototype);
332
+ expect(trace.QueryParams["limit"]).toEqual(["10"]);
333
+ });
334
+ it("skips constructor and prototype keys", () => {
335
+ const trace = generateTrace({
336
+ queryParams: '{"constructor":"pwned","prototype":"evil","valid":"yes"}',
337
+ });
338
+ expect(Object.hasOwn(trace.QueryParams, "constructor")).toBe(false);
339
+ expect(Object.hasOwn(trace.QueryParams, "prototype")).toBe(false);
340
+ expect(trace.QueryParams["valid"]).toEqual(["yes"]);
341
+ });
233
342
  });
234
343
  describe("ScenarioGenerationService — baseURL parsing", () => {
235
344
  it("parses http baseURL correctly", () => {
@@ -262,3 +371,30 @@ describe("ScenarioGenerationService — baseURL parsing", () => {
262
371
  expect(trace.Port).toBe(80);
263
372
  });
264
373
  });
374
+ describe("ScenarioGenerationService — timestamp chaining", () => {
375
+ it("assigns strictly increasing timestamps across multiple parseScenario calls", async () => {
376
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "skyramp-ts-test-"));
377
+ const service = new ScenarioGenerationService();
378
+ const baseParams = {
379
+ scenarioName: "timestamp-chain-test",
380
+ destination: "api.example.com",
381
+ method: "GET",
382
+ path: "/api/v1/items",
383
+ outputDir: tmpDir,
384
+ baseURL: "http://localhost:8000",
385
+ };
386
+ await service.parseScenario({ ...baseParams, method: "POST", path: "/api/v1/items" });
387
+ await service.parseScenario({ ...baseParams, method: "GET", path: "/api/v1/items/1" });
388
+ await service.parseScenario({ ...baseParams, method: "DELETE", path: "/api/v1/items/1" });
389
+ const fileName = "scenario_timestamp-chain-test.json";
390
+ const filePath = path.join(tmpDir, fileName);
391
+ const requests = JSON.parse(fs.readFileSync(filePath, "utf8"));
392
+ expect(requests).toHaveLength(3);
393
+ for (let i = 1; i < requests.length; i++) {
394
+ const prev = new Date(requests[i - 1].Timestamp).getTime();
395
+ const curr = new Date(requests[i].Timestamp).getTime();
396
+ expect(curr).toBeGreaterThan(prev);
397
+ }
398
+ fs.rmSync(tmpDir, { recursive: true, force: true });
399
+ });
400
+ });