@skyramp/mcp 0.1.8 → 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.
- package/build/playwright/registerPlaywrightTools.js +12 -0
- package/build/playwright/traceRecordingPrompt.js +15 -0
- package/build/prompts/test-recommendation/diffExecutionPlan.js +31 -0
- package/build/prompts/test-recommendation/recommendationSections.js +1 -2
- package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +94 -0
- package/build/prompts/testbot/testbot-prompts.js +115 -11
- package/build/prompts/testbot/testbot-prompts.test.js +79 -0
- package/build/resources/testbotResource.js +1 -1
- package/build/services/ScenarioGenerationService.integration.test.js +158 -0
- package/build/services/ScenarioGenerationService.js +36 -3
- package/build/services/ScenarioGenerationService.test.js +158 -22
- package/build/tools/generate-tests/generateBatchScenarioRestTool.js +16 -4
- package/build/tools/generate-tests/generateIntegrationRestTool.js +2 -0
- package/build/tools/generate-tests/generateUIRestTool.js +2 -0
- package/build/tools/test-management/analyzeChangesTool.js +7 -1
- package/build/utils/routeParsers.js +12 -0
- package/node_modules/playwright/ThirdPartyNotices.txt +6 -6
- package/node_modules/playwright/lib/dom-analyzer/analyze.js +111 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprint.js +1161 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprint.test.js +396 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintCache.js +57 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintCache.test.js +57 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.js +250 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.test.js +298 -0
- package/node_modules/playwright/lib/dom-analyzer/crawler.js +384 -0
- package/node_modules/playwright/lib/dom-analyzer/curatedWidgets.js +73 -0
- package/node_modules/playwright/lib/dom-analyzer/dynamicId.js +43 -0
- package/node_modules/playwright/lib/dom-analyzer/dynamicId.test.js +85 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprint.js +90 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprint.test.js +231 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprintAblation.fixtures.js +145 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprintAblation.test.js +41 -0
- package/node_modules/playwright/lib/dom-analyzer/graph.js +36 -0
- package/node_modules/playwright/lib/dom-analyzer/liveFingerprints.js +43 -0
- package/node_modules/playwright/lib/dom-analyzer/logicalNameResolver.js +72 -0
- package/node_modules/playwright/lib/dom-analyzer/logicalNameResolver.test.js +182 -0
- package/node_modules/playwright/lib/dom-analyzer/sectionGrouper.js +169 -0
- package/node_modules/playwright/lib/dom-analyzer/sectionGrouper.test.js +269 -0
- package/node_modules/playwright/lib/dom-analyzer/serialization.js +75 -0
- package/node_modules/playwright/lib/dom-analyzer/slug.js +30 -0
- package/node_modules/playwright/lib/dom-analyzer/slug.test.js +84 -0
- package/node_modules/playwright/lib/dom-analyzer/widgetContract.js +127 -0
- package/node_modules/playwright/lib/dom-analyzer/widgetContract.test.js +212 -0
- package/node_modules/playwright/lib/mcp/browser/browserContextFactory.js +3 -1
- package/node_modules/playwright/lib/mcp/browser/config.js +1 -1
- package/node_modules/playwright/lib/mcp/browser/context.js +17 -1
- package/node_modules/playwright/lib/mcp/browser/tab.js +38 -0
- package/node_modules/playwright/lib/mcp/browser/tools/domAnalyzer.js +261 -0
- package/node_modules/playwright/lib/mcp/browser/tools/keyboard.js +3 -3
- package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.js +129 -0
- package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.test.js +137 -0
- package/node_modules/playwright/lib/mcp/browser/tools/sitemap.js +226 -0
- package/node_modules/playwright/lib/mcp/browser/tools/snapshot.js +2 -2
- package/node_modules/playwright/lib/mcp/browser/tools/widgetContract.js +168 -0
- package/node_modules/playwright/lib/mcp/browser/tools.js +6 -0
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +52 -12
- package/node_modules/playwright/lib/mcp/test/skyRampExport.js +64 -13
- package/node_modules/playwright/package.json +1 -1
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.3.tgz +0 -0
- 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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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("
|
|
205
|
-
const trace = generateTrace({ queryParams: '{"
|
|
206
|
-
expect(trace.QueryParams).toEqual({
|
|
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("
|
|
209
|
-
const trace = generateTrace({ queryParams: '{"
|
|
210
|
-
expect(trace).
|
|
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("
|
|
216
|
-
const trace = generateTrace({ queryParams: '{"
|
|
217
|
-
expect(trace).
|
|
218
|
-
|
|
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("
|
|
221
|
-
const trace = generateTrace({ queryParams: '{"
|
|
222
|
-
expect(trace.QueryParams["
|
|
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("
|
|
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
|
|
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(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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.
|
|
157
|
-
- picomatch@4.0.
|
|
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.
|
|
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.
|
|
4518
|
+
END OF picomatch@2.3.2 AND INFORMATION
|
|
4519
4519
|
|
|
4520
|
-
%% picomatch@4.0.
|
|
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.
|
|
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
|
=========================================
|