@skyramp/mcp 0.1.7 → 0.2.0-rc.1

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 (61) hide show
  1. package/build/playwright/registerPlaywrightTools.js +12 -0
  2. package/build/playwright/traceRecordingPrompt.js +15 -0
  3. package/build/prompts/initialize-workspace/initializeWorkspacePrompt.js +1 -1
  4. package/build/prompts/test-recommendation/diffExecutionPlan.js +31 -0
  5. package/build/prompts/test-recommendation/recommendationSections.js +1 -2
  6. package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +94 -0
  7. package/build/prompts/testbot/testbot-prompts.js +115 -11
  8. package/build/prompts/testbot/testbot-prompts.test.js +79 -0
  9. package/build/resources/testbotResource.js +1 -1
  10. package/build/services/ScenarioGenerationService.integration.test.js +158 -0
  11. package/build/services/ScenarioGenerationService.js +36 -3
  12. package/build/services/ScenarioGenerationService.test.js +158 -22
  13. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +16 -4
  14. package/build/tools/generate-tests/generateIntegrationRestTool.js +2 -0
  15. package/build/tools/generate-tests/generateUIRestTool.js +2 -0
  16. package/build/tools/test-management/analyzeChangesTool.js +7 -1
  17. package/build/utils/routeParsers.js +12 -0
  18. package/node_modules/playwright/ThirdPartyNotices.txt +6 -6
  19. package/node_modules/playwright/lib/dom-analyzer/analyze.js +111 -0
  20. package/node_modules/playwright/lib/dom-analyzer/blueprint.js +1161 -0
  21. package/node_modules/playwright/lib/dom-analyzer/blueprint.test.js +396 -0
  22. package/node_modules/playwright/lib/dom-analyzer/blueprintCache.js +57 -0
  23. package/node_modules/playwright/lib/dom-analyzer/blueprintCache.test.js +57 -0
  24. package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.js +250 -0
  25. package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.test.js +298 -0
  26. package/node_modules/playwright/lib/dom-analyzer/crawler.js +384 -0
  27. package/node_modules/playwright/lib/dom-analyzer/curatedWidgets.js +73 -0
  28. package/node_modules/playwright/lib/dom-analyzer/dynamicId.js +43 -0
  29. package/node_modules/playwright/lib/dom-analyzer/dynamicId.test.js +85 -0
  30. package/node_modules/playwright/lib/dom-analyzer/fingerprint.js +90 -0
  31. package/node_modules/playwright/lib/dom-analyzer/fingerprint.test.js +231 -0
  32. package/node_modules/playwright/lib/dom-analyzer/fingerprintAblation.fixtures.js +145 -0
  33. package/node_modules/playwright/lib/dom-analyzer/fingerprintAblation.test.js +41 -0
  34. package/node_modules/playwright/lib/dom-analyzer/graph.js +36 -0
  35. package/node_modules/playwright/lib/dom-analyzer/liveFingerprints.js +43 -0
  36. package/node_modules/playwright/lib/dom-analyzer/logicalNameResolver.js +72 -0
  37. package/node_modules/playwright/lib/dom-analyzer/logicalNameResolver.test.js +182 -0
  38. package/node_modules/playwright/lib/dom-analyzer/sectionGrouper.js +169 -0
  39. package/node_modules/playwright/lib/dom-analyzer/sectionGrouper.test.js +269 -0
  40. package/node_modules/playwright/lib/dom-analyzer/serialization.js +75 -0
  41. package/node_modules/playwright/lib/dom-analyzer/slug.js +30 -0
  42. package/node_modules/playwright/lib/dom-analyzer/slug.test.js +84 -0
  43. package/node_modules/playwright/lib/dom-analyzer/widgetContract.js +127 -0
  44. package/node_modules/playwright/lib/dom-analyzer/widgetContract.test.js +212 -0
  45. package/node_modules/playwright/lib/mcp/browser/browserContextFactory.js +3 -1
  46. package/node_modules/playwright/lib/mcp/browser/config.js +1 -1
  47. package/node_modules/playwright/lib/mcp/browser/context.js +17 -1
  48. package/node_modules/playwright/lib/mcp/browser/tab.js +38 -0
  49. package/node_modules/playwright/lib/mcp/browser/tools/domAnalyzer.js +261 -0
  50. package/node_modules/playwright/lib/mcp/browser/tools/keyboard.js +3 -3
  51. package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.js +129 -0
  52. package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.test.js +137 -0
  53. package/node_modules/playwright/lib/mcp/browser/tools/sitemap.js +226 -0
  54. package/node_modules/playwright/lib/mcp/browser/tools/snapshot.js +2 -2
  55. package/node_modules/playwright/lib/mcp/browser/tools/widgetContract.js +168 -0
  56. package/node_modules/playwright/lib/mcp/browser/tools.js +6 -0
  57. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +52 -12
  58. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +64 -13
  59. package/node_modules/playwright/package.json +1 -1
  60. package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.3.tgz +0 -0
  61. package/package.json +2 -2
@@ -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
+ });
@@ -5,6 +5,10 @@ import { logger } from "../utils/logger.js";
5
5
  import { stageGeneratedPaths } from "../utils/gitStaging.js";
6
6
  import fs from "fs";
7
7
  import path from "path";
8
+ // Keys that trigger built-in prototype setters when used as bracket-notation
9
+ // property names on a plain object — guard against prototype pollution from
10
+ // LLM-controlled or user-controlled JSON input.
11
+ const PROTO_KEYS = new Set(["__proto__", "constructor", "prototype"]);
8
12
  export class ScenarioGenerationService {
9
13
  async parseScenario(params) {
10
14
  try {
@@ -40,6 +44,12 @@ export class ScenarioGenerationService {
40
44
  existingRequests = [];
41
45
  }
42
46
  }
47
+ if (existingRequests.length > 0) {
48
+ const lastTimestamp = existingRequests[existingRequests.length - 1].Timestamp;
49
+ const lastMs = new Date(lastTimestamp).getTime();
50
+ const newMs = Math.max(lastMs + 1000, Date.now());
51
+ traceRequest.Timestamp = new Date(newMs).toISOString();
52
+ }
43
53
  existingRequests.push(traceRequest);
44
54
  fs.writeFileSync(filePath, JSON.stringify(existingRequests, null, 2), "utf8");
45
55
  // Stage so testbot includes the generated files in its output commit.
@@ -139,15 +149,38 @@ ${JSON.stringify(traceRequest, null, 2)}
139
149
  const requestHeaders = {
140
150
  "Content-Type": ["application/json"],
141
151
  };
152
+ // Go backend expects url.Values (map[string][]string). We always produce
153
+ // single-element arrays — the Go codegen normalises multi-element string arrays
154
+ // into a JSON array string (e.g. ["sale","new"]), which is not what most APIs
155
+ // expect. Comma-joining covers the common case; APIs needing true repeated keys
156
+ // (e.g. ?tags=sale&tags=new) are not supported by the current Go codegen path.
142
157
  const queryParams = {};
143
158
  if (params.queryParams) {
144
159
  try {
145
160
  const parsed = JSON.parse(params.queryParams);
146
161
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
147
162
  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)];
163
+ if (PROTO_KEYS.has(k))
164
+ continue;
165
+ if (v === null)
166
+ continue;
167
+ let value;
168
+ if (Array.isArray(v)) {
169
+ const items = v
170
+ .filter((item) => item !== null)
171
+ .map((item) => (typeof item === "object" ? JSON.stringify(item) : String(item)));
172
+ if (items.length === 0)
173
+ continue;
174
+ value = items.join(",");
175
+ }
176
+ else if (typeof v === "object") {
177
+ logger.warning("Nested object in queryParams — JSON-stringifying as fallback; LLM should provide flat keys", { key: k });
178
+ value = JSON.stringify(v);
179
+ }
180
+ else {
181
+ value = String(v);
182
+ }
183
+ queryParams[k] = [value];
151
184
  }
152
185
  }
153
186
  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
+ });
@@ -48,10 +48,22 @@ export const stepSchema = z.object({
48
48
  .string()
49
49
  .optional()
50
50
  .refine(isJsonObject, { message: "queryParams must be a JSON object string (e.g. '{\"limit\":\"10\"}')." })
51
- .describe("JSON string of URL query parameters (e.g., '{\"q\": \"bear\", \"limit\": 10}'). "
52
- + "Use this for GET request filters, search terms, pagination, sorting — any parameter that belongs in the URL query string. "
53
- + "CRITICAL: For search/filter/list endpoints (e.g., GET /products/search?q=bear&limit=10), parameters MUST go here, NOT in requestBody. "
54
- + "GET request bodies are non-standard and may be ignored or rejected by servers and frameworks, so always encode these parameters in the URL query string instead of the request body."),
51
+ .describe(`JSON string of URL query parameters. Provide a FLAT object where each key is the exact URL parameter name and each value is a string or number (single value per key).
52
+
53
+ <examples>
54
+ <example label="simple params">{"q": "bear", "limit": 10}</example>
55
+ <example label="bracket-notation keys">{"filter[title][_neq]": "not-a-number"}</example>
56
+ <example label="multi-value as comma-separated">{"tags": "sale,new,featured"}</example>
57
+ <example label="JSON-encoded string value">{"filter": "{\\\"status\\\":\\\"active\\\"}"}</example>
58
+ </examples>
59
+
60
+ For multi-value params (e.g. ?tags=sale,new), provide a comma-separated string: "tags": "sale,new". Arrays are accepted but will be comma-joined into one value — note this produces a single ?tags=sale,new param, not repeated ?tags=sale&tags=new keys.
61
+
62
+ If you provide nested objects, the service JSON-stringifies them as a fallback, but you should inspect the target API source to determine the correct key format and use that directly.
63
+
64
+ Use this for GET request filters, search terms, pagination, sorting — any parameter that belongs in the URL query string.
65
+
66
+ CRITICAL: For search/filter/list endpoints (e.g., GET /products/search?q=bear&limit=10), parameters MUST go here, NOT in requestBody. GET request bodies are non-standard and may be ignored or rejected by servers and frameworks.`),
55
67
  responseBody: z
56
68
  .string()
57
69
  .optional()
@@ -28,6 +28,8 @@ const integrationTestSchema = z
28
28
  "When provided, DO NOT also pass apiSchema or endpointURL — the scenario file already contains all endpoint information."),
29
29
  ...codeRefactoringSchema.shape,
30
30
  ...baseTestSchema,
31
+ apiSchema: baseTestSchema.apiSchema.describe("MUST be absolute path(/path/to/openapi.json) to the OpenAPI/Swagger schema file or a URL to the OpenAPI/Swagger schema file(e.g. https://demoshop.skyramp.dev/openapi.json). DO NOT TRY TO ASSUME THE OPENAPI SCHEMA IF NOT PROVIDED. NOTE TO AI ASSISTANTS: You do not need to read the contents of this file - simply pass the file path as the backend will read and process it. " +
32
+ "When an OpenAPI schema is provided, this tool automatically derives a CRUD scenario flow (Create → Read → Update → Delete) directly from the schema."),
31
33
  output: baseTestSchema.output.describe("Name of the output test file. " +
32
34
  "When scenarioFile is provided and user did not specify a name, derive it: " +
33
35
  "strip the path and 'scenario_' prefix, replace hyphens/non-alphanum with underscores, append '_integration_test' + language extension. " +
@@ -96,6 +96,8 @@ This tells you exactly which frontend files changed so you record traces for the
96
96
 
97
97
  **Typical pipeline:** Use the \`browser_*\` tools (\`browser_navigate\`, \`browser_click\`, \`browser_type\`, etc.) to record user interactions, then call \`skyramp_export_zip\` to export a trace zip, then pass the absolute path to that zip as \`playwrightInput\` here.
98
98
 
99
+ **DOM Analyzer tools for blueprint-aware recording:** alongside the basic \`browser_*\` interaction tools, the Skyramp MCP exposes \`browser_blueprint\` (canonical PageBlueprint capture), \`browser_blueprint_diff\` (structured before/after delta), \`browser_widget_contract_lookup\` (interaction recipe for custom widgets by fingerprint), and the sitemap tools \`browser_sitemap_build\` / \`browser_sitemap_query\`. These enable semantic target selection and delta-derived assertions: capture a blueprint before each meaningful action, perform the action, then capture again — the diff between the two grounds your assertions in observable state changes rather than author guesses about what "success" looks like.
100
+
99
101
  **CRITICAL: Do NOT use skyramp_start_trace_collection/skyramp_stop_trace_collection for UI test recording — use browser_* tools + skyramp_export_zip instead.**`,
100
102
  inputSchema: uiTestSchema,
101
103
  _meta: {
@@ -292,6 +292,11 @@ export const analyzeChangesInputSchema = {
292
292
  .refine((v) => path.isAbsolute(v), { message: "stateOutputFile must be an absolute path" })
293
293
  .optional()
294
294
  .describe("Absolute path where the state file should be written. When provided, overrides the default auto-generated temp path so the caller can locate it without log parsing."),
295
+ testsRepoDir: z
296
+ .string()
297
+ .refine((v) => path.isAbsolute(v), { message: "testsRepoDir must be an absolute path" })
298
+ .optional()
299
+ .describe("Absolute path to a separate test repository clone. When set, existing test discovery scans this directory instead of repositoryPath. Used in cross-repo test delivery mode where tests live in a separate repo."),
295
300
  };
296
301
  export function registerAnalyzeChangesTool(server) {
297
302
  server.registerTool(TOOL_NAME, {
@@ -504,7 +509,8 @@ to produce a unified state file for the test health workflow.
504
509
  let discoveredRelevantExternalPaths = [];
505
510
  try {
506
511
  const testDiscoveryService = new TestDiscoveryService();
507
- const discoveryResult = await testDiscoveryService.discoverTests(params.repositoryPath, { changedResources });
512
+ const testScanPath = params.testsRepoDir ?? params.repositoryPath;
513
+ const discoveryResult = await testDiscoveryService.discoverTests(testScanPath, { changedResources });
508
514
  existingTests = discoveryResult.tests.map((test) => ({
509
515
  testFile: test.testFile,
510
516
  testType: test.testType,
@@ -20,6 +20,18 @@ export function nextjsFileToApiPath(filePath) {
20
20
  }
21
21
  // UI component file extensions are unambiguously frontend — never route definitions.
22
22
  const UI_COMPONENT_EXT = /\.(jsx|tsx|vue|svelte)$/i;
23
+ // Frontend-file extensions for UI-change detection (broader than UI_COMPONENT_EXT —
24
+ // includes template/markup files that backend-only PRs won't touch). Used by
25
+ // testbot-prompts.ts to gate the UI pre-scan and capture-act-capture sections,
26
+ // and anywhere else that needs to ask "does this PR touch the frontend?".
27
+ // Single source of truth for that question.
28
+ export const UI_FILE_EXTENSIONS = ['tsx', 'jsx', 'vue', 'svelte', 'html', 'xml'];
29
+ // Git pathspec form of the UI-file filter — usable directly with `git diff`'s
30
+ // trailing pathspec args, no external tool needed. Avoids the "agent shell
31
+ // has grep aliased to rg" failure mode where `grep -E` becomes `rg -E`
32
+ // (which means `--encoding`, not extended regex). Each entry is `*.ext`;
33
+ // pass them as `-- '*.tsx' '*.jsx' ...` to git diff.
34
+ export const UI_FILE_GIT_PATHSPEC = UI_FILE_EXTENSIONS.map(ext => `'*.${ext}'`).join(' ');
23
35
  export function parseRouteLine(line, sourceFile) {
24
36
  const stripped = line.replace(/^[+-]\s*/, "").trim();
25
37
  const isComponent = UI_COMPONENT_EXT.test(sourceFile);
@@ -153,8 +153,8 @@ This project incorporates components from the projects listed below. The origina
153
153
  - node-releases@2.0.19 (https://github.com/chicoxyzzy/node-releases)
154
154
  - normalize-path@3.0.0 (https://github.com/jonschlinkert/normalize-path)
155
155
  - picocolors@1.1.1 (https://github.com/alexeyraspopov/picocolors)
156
- - picomatch@2.3.1 (https://github.com/micromatch/picomatch)
157
- - picomatch@4.0.3 (https://github.com/micromatch/picomatch)
156
+ - picomatch@2.3.2 (https://github.com/micromatch/picomatch)
157
+ - picomatch@4.0.4 (https://github.com/micromatch/picomatch)
158
158
  - pretty-format@30.2.0 (https://github.com/jestjs/jest)
159
159
  - react-is@18.3.1 (https://github.com/facebook/react)
160
160
  - readdirp@3.6.0 (https://github.com/paulmillr/readdirp)
@@ -4491,7 +4491,7 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
4491
4491
  =========================================
4492
4492
  END OF picocolors@1.1.1 AND INFORMATION
4493
4493
 
4494
- %% picomatch@2.3.1 NOTICES AND INFORMATION BEGIN HERE
4494
+ %% picomatch@2.3.2 NOTICES AND INFORMATION BEGIN HERE
4495
4495
  =========================================
4496
4496
  The MIT License (MIT)
4497
4497
 
@@ -4515,9 +4515,9 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
4515
4515
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
4516
4516
  THE SOFTWARE.
4517
4517
  =========================================
4518
- END OF picomatch@2.3.1 AND INFORMATION
4518
+ END OF picomatch@2.3.2 AND INFORMATION
4519
4519
 
4520
- %% picomatch@4.0.3 NOTICES AND INFORMATION BEGIN HERE
4520
+ %% picomatch@4.0.4 NOTICES AND INFORMATION BEGIN HERE
4521
4521
  =========================================
4522
4522
  The MIT License (MIT)
4523
4523
 
@@ -4541,7 +4541,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
4541
4541
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
4542
4542
  THE SOFTWARE.
4543
4543
  =========================================
4544
- END OF picomatch@4.0.3 AND INFORMATION
4544
+ END OF picomatch@4.0.4 AND INFORMATION
4545
4545
 
4546
4546
  %% pretty-format@30.2.0 NOTICES AND INFORMATION BEGIN HERE
4547
4547
  =========================================