@kirrosh/zond 0.16.0 → 0.17.0
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/CHANGELOG.md +132 -112
- package/README.md +3 -10
- package/package.json +2 -3
- package/src/cli/commands/export.ts +144 -0
- package/src/cli/commands/generate.ts +31 -0
- package/src/cli/commands/run.ts +22 -5
- package/src/cli/commands/sync.ts +240 -0
- package/src/cli/index.ts +54 -10
- package/src/core/diagnostics/db-analysis.ts +79 -7
- package/src/core/diagnostics/failure-hints.ts +39 -0
- package/src/core/exporter/postman.ts +963 -0
- package/src/core/generator/data-factory.ts +38 -3
- package/src/core/generator/index.ts +1 -1
- package/src/core/generator/openapi-reader.ts +6 -0
- package/src/core/generator/serializer.ts +17 -2
- package/src/core/generator/suite-generator.ts +163 -14
- package/src/core/generator/types.ts +1 -0
- package/src/core/meta/meta-store.ts +78 -0
- package/src/core/meta/types.ts +21 -0
- package/src/core/parser/schema.ts +12 -2
- package/src/core/parser/types.ts +12 -1
- package/src/core/parser/variables.ts +3 -0
- package/src/core/parser/yaml-parser.ts +2 -1
- package/src/core/runner/assertions.ts +44 -20
- package/src/core/runner/execute-run.ts +31 -8
- package/src/core/runner/executor.ts +34 -8
- package/src/core/runner/http-client.ts +1 -1
- package/src/core/runner/types.ts +1 -0
- package/src/core/sync/spec-differ.ts +38 -0
- package/src/cli/commands/mcp.ts +0 -16
- package/src/mcp/descriptions.ts +0 -47
- package/src/mcp/server.ts +0 -38
- package/src/mcp/tools/ci-init.ts +0 -54
- package/src/mcp/tools/coverage-analysis.ts +0 -141
- package/src/mcp/tools/describe-endpoint.ts +0 -27
- package/src/mcp/tools/manage-server.ts +0 -86
- package/src/mcp/tools/query-db.ts +0 -84
- package/src/mcp/tools/run-tests.ts +0 -116
- package/src/mcp/tools/send-request.ts +0 -51
- package/src/mcp/tools/setup-api.ts +0 -88
|
@@ -0,0 +1,963 @@
|
|
|
1
|
+
import { basename } from "path";
|
|
2
|
+
import type { TestSuite, TestStep, AssertionRule } from "../parser/types.ts";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Postman Collection v2.1 types
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
interface PostmanInfo {
|
|
9
|
+
name: string;
|
|
10
|
+
schema: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface PostmanVariable {
|
|
15
|
+
key: string;
|
|
16
|
+
value: string;
|
|
17
|
+
enabled?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface PostmanHeaderEntry {
|
|
21
|
+
key: string;
|
|
22
|
+
value: string;
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface PostmanQueryEntry {
|
|
27
|
+
key: string;
|
|
28
|
+
value: string;
|
|
29
|
+
disabled?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface PostmanUrlObject {
|
|
33
|
+
raw: string;
|
|
34
|
+
host: string[];
|
|
35
|
+
path: string[];
|
|
36
|
+
query?: PostmanQueryEntry[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface PostmanBodyRaw {
|
|
40
|
+
mode: "raw";
|
|
41
|
+
raw: string;
|
|
42
|
+
options: { raw: { language: "json" } };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface PostmanBodyUrlencoded {
|
|
46
|
+
mode: "urlencoded";
|
|
47
|
+
urlencoded: Array<{ key: string; value: string; enabled: boolean }>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type PostmanBody = PostmanBodyRaw | PostmanBodyUrlencoded;
|
|
51
|
+
|
|
52
|
+
interface PostmanRequest {
|
|
53
|
+
method: string;
|
|
54
|
+
url: PostmanUrlObject;
|
|
55
|
+
header: PostmanHeaderEntry[];
|
|
56
|
+
body?: PostmanBody;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface PostmanScript {
|
|
60
|
+
type: "text/javascript";
|
|
61
|
+
exec: string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface PostmanEvent {
|
|
65
|
+
listen: "test" | "prerequest";
|
|
66
|
+
script: PostmanScript;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface PostmanItem {
|
|
70
|
+
name: string;
|
|
71
|
+
request: PostmanRequest;
|
|
72
|
+
event?: PostmanEvent[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface PostmanFolder {
|
|
76
|
+
name: string;
|
|
77
|
+
description?: string;
|
|
78
|
+
item: PostmanItem[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface PostmanCollection {
|
|
82
|
+
info: PostmanInfo;
|
|
83
|
+
item: PostmanFolder[];
|
|
84
|
+
variable?: PostmanVariable[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface PostmanEnvironment {
|
|
88
|
+
name: string;
|
|
89
|
+
values: Array<{ key: string; value: string; enabled: boolean }>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Dynamic variable mapping: zond → Postman
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
const DYNAMIC_VAR_MAP: Record<string, string> = {
|
|
97
|
+
$randomString: "$randomAlphaNumeric",
|
|
98
|
+
$randomName: "$randomFullName",
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** Replace zond-specific dynamic vars with Postman equivalents inside a string. */
|
|
102
|
+
function mapDynamicVars(str: string): string {
|
|
103
|
+
return str.replace(/\{\{(\$[^}]+)\}\}/g, (_match, varName: string) => {
|
|
104
|
+
const mapped = DYNAMIC_VAR_MAP[varName];
|
|
105
|
+
return mapped ? `{{${mapped}}}` : `{{${varName}}}`;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Apply mapDynamicVars recursively to a JSON value. */
|
|
110
|
+
function mapDynamicVarsInValue(value: unknown): unknown {
|
|
111
|
+
if (typeof value === "string") return mapDynamicVars(value);
|
|
112
|
+
if (Array.isArray(value)) return value.map(mapDynamicVarsInValue);
|
|
113
|
+
if (value !== null && typeof value === "object") {
|
|
114
|
+
const result: Record<string, unknown> = {};
|
|
115
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
116
|
+
result[k] = mapDynamicVarsInValue(v);
|
|
117
|
+
}
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// URL builder
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
function buildUrl(
|
|
128
|
+
baseUrl: string | undefined,
|
|
129
|
+
path: string,
|
|
130
|
+
query?: Record<string, string>
|
|
131
|
+
): PostmanUrlObject {
|
|
132
|
+
const base = baseUrl ?? "";
|
|
133
|
+
// Avoid double slash between base and path
|
|
134
|
+
const raw = base.endsWith("/") && path.startsWith("/")
|
|
135
|
+
? base + path.slice(1)
|
|
136
|
+
: base + path;
|
|
137
|
+
|
|
138
|
+
// host: if base is a template variable like {{base_url}}, keep as single element
|
|
139
|
+
let host: string[];
|
|
140
|
+
if (!base) {
|
|
141
|
+
host = [];
|
|
142
|
+
} else if (/^\{\{[^}]+\}\}$/.test(base)) {
|
|
143
|
+
host = [base];
|
|
144
|
+
} else {
|
|
145
|
+
try {
|
|
146
|
+
const u = new URL(base);
|
|
147
|
+
host = u.hostname.split(".");
|
|
148
|
+
} catch {
|
|
149
|
+
host = [base];
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// path segments
|
|
154
|
+
const pathSegments = path.split("/").filter((s) => s.length > 0);
|
|
155
|
+
|
|
156
|
+
const result: PostmanUrlObject = { raw, host, path: pathSegments };
|
|
157
|
+
|
|
158
|
+
if (query && Object.keys(query).length > 0) {
|
|
159
|
+
result.query = Object.entries(query).map(([key, value]) => ({
|
|
160
|
+
key,
|
|
161
|
+
value: mapDynamicVars(value),
|
|
162
|
+
disabled: false,
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Header builder
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
function buildHeaders(suite: TestSuite, step: TestStep): PostmanHeaderEntry[] {
|
|
174
|
+
const merged: Record<string, string> = {
|
|
175
|
+
...(suite.headers ?? {}),
|
|
176
|
+
...(step.headers ?? {}),
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// Auto-add Content-Type for json body
|
|
180
|
+
const hasJson = step.json !== undefined;
|
|
181
|
+
const hasForm = step.form !== undefined;
|
|
182
|
+
const contentTypeKey = Object.keys(merged).find(
|
|
183
|
+
(k) => k.toLowerCase() === "content-type"
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
if (hasJson && !contentTypeKey) {
|
|
187
|
+
merged["Content-Type"] = "application/json";
|
|
188
|
+
} else if (hasForm && !contentTypeKey) {
|
|
189
|
+
merged["Content-Type"] = "application/x-www-form-urlencoded";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return Object.entries(merged).map(([key, value]) => ({
|
|
193
|
+
key,
|
|
194
|
+
value: mapDynamicVars(value),
|
|
195
|
+
}));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// Body builder
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
function buildBody(step: TestStep): PostmanBody | undefined {
|
|
203
|
+
if (step.json !== undefined) {
|
|
204
|
+
const mapped = mapDynamicVarsInValue(step.json);
|
|
205
|
+
return {
|
|
206
|
+
mode: "raw",
|
|
207
|
+
raw: JSON.stringify(mapped, null, 2),
|
|
208
|
+
options: { raw: { language: "json" } },
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (step.form !== undefined) {
|
|
213
|
+
return {
|
|
214
|
+
mode: "urlencoded",
|
|
215
|
+
urlencoded: Object.entries(step.form).map(([key, value]) => ({
|
|
216
|
+
key,
|
|
217
|
+
value: mapDynamicVars(value),
|
|
218
|
+
enabled: true,
|
|
219
|
+
})),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// Dot-path → JS accessor
|
|
228
|
+
// e.g. "user.email" → "jsonData.user.email"
|
|
229
|
+
// "user.x-field" → "jsonData.user[\"x-field\"]"
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
function dotPathToAccessor(dotPath: string, root: string): string {
|
|
233
|
+
const parts = dotPath.split(".");
|
|
234
|
+
let accessor = root;
|
|
235
|
+
for (const part of parts) {
|
|
236
|
+
if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(part)) {
|
|
237
|
+
accessor += `.${part}`;
|
|
238
|
+
} else {
|
|
239
|
+
accessor += `["${part}"]`;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return accessor;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Get the parent object and property name for has.property assertions. */
|
|
246
|
+
function dotPathToParentAndKey(
|
|
247
|
+
dotPath: string,
|
|
248
|
+
root: string
|
|
249
|
+
): { parent: string; key: string } {
|
|
250
|
+
const parts = dotPath.split(".");
|
|
251
|
+
const key = parts[parts.length - 1]!;
|
|
252
|
+
const parentPath = parts.slice(0, -1).join(".");
|
|
253
|
+
const parent = parentPath ? dotPathToAccessor(parentPath, root) : root;
|
|
254
|
+
return { parent, key };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// Assertion script builder
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
/** Serialize a value for use in a pm.expect assertion. */
|
|
262
|
+
function serializeValue(val: unknown): string {
|
|
263
|
+
return JSON.stringify(val);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Build JS lines for a single body field assertion rule.
|
|
268
|
+
* `root` controls the JS variable used as the object root (default "jsonData",
|
|
269
|
+
* use "item" when generating assertions inside a forEach/find callback).
|
|
270
|
+
*/
|
|
271
|
+
function buildFieldAssertions(
|
|
272
|
+
dotPath: string,
|
|
273
|
+
rule: AssertionRule,
|
|
274
|
+
warnings: string[],
|
|
275
|
+
root = "jsonData"
|
|
276
|
+
): string[] {
|
|
277
|
+
const lines: string[] = [];
|
|
278
|
+
const accessor = dotPathToAccessor(dotPath, root);
|
|
279
|
+
|
|
280
|
+
if (rule.capture !== undefined) {
|
|
281
|
+
lines.push(`pm.environment.set(${JSON.stringify(rule.capture)}, ${accessor});`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (rule.type !== undefined) {
|
|
285
|
+
if (rule.type === "integer") {
|
|
286
|
+
// Number.isInteger() is precise — .be.a('number') would also pass floats
|
|
287
|
+
lines.push(
|
|
288
|
+
`pm.test(${JSON.stringify(`${dotPath} is integer`)}, () => pm.expect(Number.isInteger(${accessor})).to.be.true);`
|
|
289
|
+
);
|
|
290
|
+
} else {
|
|
291
|
+
lines.push(
|
|
292
|
+
`pm.test(${JSON.stringify(`${dotPath} is ${rule.type}`)}, () => pm.expect(${accessor}).to.be.a(${JSON.stringify(rule.type)}));`
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (rule.equals !== undefined) {
|
|
298
|
+
lines.push(
|
|
299
|
+
`pm.test(${JSON.stringify(`${dotPath} equals ${serializeValue(rule.equals)}`)}, () => pm.expect(${accessor}).to.deep.equal(${serializeValue(rule.equals)}));`
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (rule.not_equals !== undefined) {
|
|
304
|
+
lines.push(
|
|
305
|
+
`pm.test(${JSON.stringify(`${dotPath} not equals ${serializeValue(rule.not_equals)}`)}, () => pm.expect(${accessor}).to.not.deep.equal(${serializeValue(rule.not_equals)}));`
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (rule.contains !== undefined) {
|
|
310
|
+
lines.push(
|
|
311
|
+
`pm.test(${JSON.stringify(`${dotPath} contains ${serializeValue(rule.contains)}`)}, () => pm.expect(${accessor}).to.include(${serializeValue(rule.contains)}));`
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (rule.not_contains !== undefined) {
|
|
316
|
+
lines.push(
|
|
317
|
+
`pm.test(${JSON.stringify(`${dotPath} not contains ${serializeValue(rule.not_contains)}`)}, () => pm.expect(${accessor}).to.not.include(${serializeValue(rule.not_contains)}));`
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (rule.matches !== undefined) {
|
|
322
|
+
const escaped = rule.matches.replace(/\//g, "\\/");
|
|
323
|
+
lines.push(
|
|
324
|
+
`pm.test(${JSON.stringify(`${dotPath} matches regex`)}, () => pm.expect(${accessor}).to.match(/${escaped}/));`
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (rule.exists !== undefined) {
|
|
329
|
+
const { parent, key } = dotPathToParentAndKey(dotPath, root);
|
|
330
|
+
if (rule.exists) {
|
|
331
|
+
lines.push(
|
|
332
|
+
`pm.test(${JSON.stringify(`${dotPath} exists`)}, () => pm.expect(${parent}).to.have.property(${JSON.stringify(key)}));`
|
|
333
|
+
);
|
|
334
|
+
} else {
|
|
335
|
+
lines.push(
|
|
336
|
+
`pm.test(${JSON.stringify(`${dotPath} does not exist`)}, () => pm.expect(${parent}).to.not.have.property(${JSON.stringify(key)}));`
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (rule.gt !== undefined) {
|
|
342
|
+
lines.push(
|
|
343
|
+
`pm.test(${JSON.stringify(`${dotPath} > ${rule.gt}`)}, () => pm.expect(${accessor}).to.be.above(${rule.gt}));`
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
if (rule.gte !== undefined) {
|
|
347
|
+
lines.push(
|
|
348
|
+
`pm.test(${JSON.stringify(`${dotPath} >= ${rule.gte}`)}, () => pm.expect(${accessor}).to.be.at.least(${rule.gte}));`
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
if (rule.lt !== undefined) {
|
|
352
|
+
lines.push(
|
|
353
|
+
`pm.test(${JSON.stringify(`${dotPath} < ${rule.lt}`)}, () => pm.expect(${accessor}).to.be.below(${rule.lt}));`
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
if (rule.lte !== undefined) {
|
|
357
|
+
lines.push(
|
|
358
|
+
`pm.test(${JSON.stringify(`${dotPath} <= ${rule.lte}`)}, () => pm.expect(${accessor}).to.be.at.most(${rule.lte}));`
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (rule.length !== undefined) {
|
|
363
|
+
lines.push(
|
|
364
|
+
`pm.test(${JSON.stringify(`${dotPath} length is ${rule.length}`)}, () => pm.expect(${accessor}).to.have.lengthOf(${rule.length}));`
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
if (rule.length_gt !== undefined) {
|
|
368
|
+
lines.push(
|
|
369
|
+
`pm.test(${JSON.stringify(`${dotPath} length > ${rule.length_gt}`)}, () => pm.expect(${accessor}.length).to.be.above(${rule.length_gt}));`
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
if (rule.length_gte !== undefined) {
|
|
373
|
+
lines.push(
|
|
374
|
+
`pm.test(${JSON.stringify(`${dotPath} length >= ${rule.length_gte}`)}, () => pm.expect(${accessor}.length).to.be.at.least(${rule.length_gte}));`
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
if (rule.length_lt !== undefined) {
|
|
378
|
+
lines.push(
|
|
379
|
+
`pm.test(${JSON.stringify(`${dotPath} length < ${rule.length_lt}`)}, () => pm.expect(${accessor}.length).to.be.below(${rule.length_lt}));`
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
if (rule.length_lte !== undefined) {
|
|
383
|
+
lines.push(
|
|
384
|
+
`pm.test(${JSON.stringify(`${dotPath} length <= ${rule.length_lte}`)}, () => pm.expect(${accessor}.length).to.be.at.most(${rule.length_lte}));`
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (rule.each !== undefined) {
|
|
389
|
+
// Generate a forEach loop that applies assertions to every array item.
|
|
390
|
+
// Pass root="item" so all inner accessors reference the loop variable.
|
|
391
|
+
const innerLines: string[] = [];
|
|
392
|
+
for (const [field, fieldRule] of Object.entries(rule.each)) {
|
|
393
|
+
innerLines.push(...buildFieldAssertions(field, fieldRule, warnings, "item"));
|
|
394
|
+
}
|
|
395
|
+
if (innerLines.length > 0) {
|
|
396
|
+
lines.push(`pm.test(${JSON.stringify(`${dotPath} each item assertions`)}, () => {`);
|
|
397
|
+
lines.push(` (${accessor} || []).forEach((item) => {`);
|
|
398
|
+
for (const inner of innerLines) {
|
|
399
|
+
lines.push(` ${inner}`);
|
|
400
|
+
}
|
|
401
|
+
lines.push(` });`);
|
|
402
|
+
lines.push(`});`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (rule.contains_item !== undefined) {
|
|
407
|
+
// Build a JS boolean expression per field-rule pair for use in .some().
|
|
408
|
+
// Covers the most common predicates; unsupported ones fall back to a comment.
|
|
409
|
+
const conditions: string[] = [];
|
|
410
|
+
for (const [field, fieldRule] of Object.entries(rule.contains_item)) {
|
|
411
|
+
const itemAcc = dotPathToAccessor(field, "item");
|
|
412
|
+
if (fieldRule.equals !== undefined) {
|
|
413
|
+
conditions.push(`${itemAcc} === ${serializeValue(fieldRule.equals)}`);
|
|
414
|
+
} else if (fieldRule.not_equals !== undefined) {
|
|
415
|
+
conditions.push(`${itemAcc} !== ${serializeValue(fieldRule.not_equals)}`);
|
|
416
|
+
} else if (fieldRule.type === "integer") {
|
|
417
|
+
conditions.push(`Number.isInteger(${itemAcc})`);
|
|
418
|
+
} else if (fieldRule.type !== undefined) {
|
|
419
|
+
const jsType = fieldRule.type === "array" ? "object" : fieldRule.type;
|
|
420
|
+
conditions.push(`typeof ${itemAcc} === ${JSON.stringify(jsType)}`);
|
|
421
|
+
} else if (fieldRule.exists === true) {
|
|
422
|
+
conditions.push(`${itemAcc} !== undefined && ${itemAcc} !== null`);
|
|
423
|
+
} else if (fieldRule.exists === false) {
|
|
424
|
+
conditions.push(`(${itemAcc} === undefined || ${itemAcc} === null)`);
|
|
425
|
+
} else if (fieldRule.gt !== undefined) {
|
|
426
|
+
conditions.push(`${itemAcc} > ${fieldRule.gt}`);
|
|
427
|
+
} else if (fieldRule.gte !== undefined) {
|
|
428
|
+
conditions.push(`${itemAcc} >= ${fieldRule.gte}`);
|
|
429
|
+
} else if (fieldRule.lt !== undefined) {
|
|
430
|
+
conditions.push(`${itemAcc} < ${fieldRule.lt}`);
|
|
431
|
+
} else if (fieldRule.lte !== undefined) {
|
|
432
|
+
conditions.push(`${itemAcc} <= ${fieldRule.lte}`);
|
|
433
|
+
} else if (fieldRule.contains !== undefined) {
|
|
434
|
+
conditions.push(`typeof ${itemAcc} === "string" && ${itemAcc}.includes(${serializeValue(fieldRule.contains)})`);
|
|
435
|
+
} else if (fieldRule.matches !== undefined) {
|
|
436
|
+
const escaped = fieldRule.matches.replace(/\//g, "\\/");
|
|
437
|
+
conditions.push(`/${escaped}/.test(${itemAcc})`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (conditions.length > 0) {
|
|
441
|
+
lines.push(
|
|
442
|
+
`pm.test(${JSON.stringify(`${dotPath} contains matching item`)}, () => {`,
|
|
443
|
+
` pm.expect((${accessor} || []).some(item => ${conditions.join(" && ")})).to.be.true;`,
|
|
444
|
+
`});`
|
|
445
|
+
);
|
|
446
|
+
} else {
|
|
447
|
+
lines.push(`// contains_item: no translatable conditions for '${dotPath}'`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (rule.set_equals !== undefined) {
|
|
452
|
+
// Sort both arrays and deep-compare — order-independent equality
|
|
453
|
+
const expected = serializeValue(rule.set_equals);
|
|
454
|
+
lines.push(
|
|
455
|
+
`pm.test(${JSON.stringify(`${dotPath} set equals`)}, () => {`,
|
|
456
|
+
` pm.expect([...${accessor}].sort()).to.deep.equal([...${expected}].sort());`,
|
|
457
|
+
`});`
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return lines;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function buildTestScript(
|
|
465
|
+
step: TestStep,
|
|
466
|
+
warnings: string[]
|
|
467
|
+
): PostmanEvent | undefined {
|
|
468
|
+
const exec: string[] = [];
|
|
469
|
+
|
|
470
|
+
const hasBodyAssertions =
|
|
471
|
+
step.expect.body !== undefined && Object.keys(step.expect.body).length > 0;
|
|
472
|
+
|
|
473
|
+
if (hasBodyAssertions) {
|
|
474
|
+
exec.push("let jsonData;");
|
|
475
|
+
exec.push("try { jsonData = pm.response.json(); } catch (e) { jsonData = {}; }");
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Status assertion
|
|
479
|
+
if (step.expect.status !== undefined) {
|
|
480
|
+
if (Array.isArray(step.expect.status)) {
|
|
481
|
+
const codes = step.expect.status;
|
|
482
|
+
const label = codes.join(" or ");
|
|
483
|
+
exec.push(
|
|
484
|
+
`pm.test(${JSON.stringify(`Status is ${label}`)}, () => pm.expect(pm.response.code).to.be.oneOf(${JSON.stringify(codes)}));`
|
|
485
|
+
);
|
|
486
|
+
} else {
|
|
487
|
+
exec.push(
|
|
488
|
+
`pm.test(${JSON.stringify(`Status is ${step.expect.status}`)}, () => pm.response.to.have.status(${step.expect.status}));`
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Duration assertion
|
|
494
|
+
if (step.expect.duration !== undefined) {
|
|
495
|
+
exec.push(
|
|
496
|
+
`pm.test(${JSON.stringify(`Response time < ${step.expect.duration}ms`)}, () => pm.expect(pm.response.responseTime).to.be.below(${step.expect.duration}));`
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Response header assertions
|
|
501
|
+
if (step.expect.headers) {
|
|
502
|
+
for (const [headerName, headerValue] of Object.entries(step.expect.headers)) {
|
|
503
|
+
exec.push(
|
|
504
|
+
`pm.test(${JSON.stringify(`Header ${headerName}`)}, () => pm.response.to.have.header(${JSON.stringify(headerName)}, ${JSON.stringify(headerValue)}));`
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Body field assertions
|
|
510
|
+
if (step.expect.body) {
|
|
511
|
+
for (const [dotPath, rule] of Object.entries(step.expect.body)) {
|
|
512
|
+
const fieldLines = buildFieldAssertions(dotPath, rule, warnings);
|
|
513
|
+
exec.push(...fieldLines);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (exec.length === 0) return undefined;
|
|
518
|
+
|
|
519
|
+
return {
|
|
520
|
+
listen: "test",
|
|
521
|
+
script: { type: "text/javascript", exec },
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ---------------------------------------------------------------------------
|
|
526
|
+
// Collect template variables from suites (for collection.variable)
|
|
527
|
+
// ---------------------------------------------------------------------------
|
|
528
|
+
|
|
529
|
+
const POSTMAN_DYNAMIC_VARS = new Set([
|
|
530
|
+
"$randomAlphaNumeric",
|
|
531
|
+
"$randomFullName",
|
|
532
|
+
"$randomEmail",
|
|
533
|
+
"$randomInt",
|
|
534
|
+
"$timestamp",
|
|
535
|
+
"$isoTimestamp",
|
|
536
|
+
"$guid",
|
|
537
|
+
"$randomBoolean",
|
|
538
|
+
"$randomColor",
|
|
539
|
+
"$randomHexColor",
|
|
540
|
+
"$randomAbbreviation",
|
|
541
|
+
"$randomIP",
|
|
542
|
+
"$randomIPV6",
|
|
543
|
+
"$randomMACAddress",
|
|
544
|
+
"$randomPassword",
|
|
545
|
+
"$randomLocale",
|
|
546
|
+
"$randomUserAgent",
|
|
547
|
+
"$randomProtocol",
|
|
548
|
+
"$randomSemver",
|
|
549
|
+
"$randomFirstName",
|
|
550
|
+
"$randomLastName",
|
|
551
|
+
"$randomNamePrefix",
|
|
552
|
+
"$randomNameSuffix",
|
|
553
|
+
"$randomJobArea",
|
|
554
|
+
"$randomJobDescriptor",
|
|
555
|
+
"$randomJobTitle",
|
|
556
|
+
"$randomJobType",
|
|
557
|
+
"$randomCity",
|
|
558
|
+
"$randomStreetName",
|
|
559
|
+
"$randomStreetAddress",
|
|
560
|
+
"$randomCountry",
|
|
561
|
+
"$randomCountryCode",
|
|
562
|
+
"$randomLatitude",
|
|
563
|
+
"$randomLongitude",
|
|
564
|
+
"$randomPhoneNumber",
|
|
565
|
+
"$randomPhoneNumberExt",
|
|
566
|
+
"$randomWord",
|
|
567
|
+
"$randomWords",
|
|
568
|
+
"$randomLoremWord",
|
|
569
|
+
"$randomLoremWords",
|
|
570
|
+
"$randomLoremSentence",
|
|
571
|
+
"$randomLoremSentences",
|
|
572
|
+
"$randomLoremParagraph",
|
|
573
|
+
"$randomLoremParagraphs",
|
|
574
|
+
"$randomLoremText",
|
|
575
|
+
"$randomLoremSlug",
|
|
576
|
+
"$randomLoremLines",
|
|
577
|
+
"$randomURL",
|
|
578
|
+
"$randomDomainName",
|
|
579
|
+
"$randomDomainSuffix",
|
|
580
|
+
"$randomDomainWord",
|
|
581
|
+
"$randomEmail",
|
|
582
|
+
"$randomExampleEmail",
|
|
583
|
+
"$randomUserName",
|
|
584
|
+
"$randomFileName",
|
|
585
|
+
"$randomFileType",
|
|
586
|
+
"$randomFileExt",
|
|
587
|
+
"$randomCommonFileName",
|
|
588
|
+
"$randomCommonFileType",
|
|
589
|
+
"$randomCommonFileExt",
|
|
590
|
+
"$randomFilePath",
|
|
591
|
+
"$randomDirectoryPath",
|
|
592
|
+
"$randomMimeType",
|
|
593
|
+
"$randomDateFuture",
|
|
594
|
+
"$randomDatePast",
|
|
595
|
+
"$randomDateRecent",
|
|
596
|
+
"$randomMonth",
|
|
597
|
+
"$randomWeekday",
|
|
598
|
+
"$randomBankAccount",
|
|
599
|
+
"$randomBankAccountName",
|
|
600
|
+
"$randomCreditCardMask",
|
|
601
|
+
"$randomBankAccountBic",
|
|
602
|
+
"$randomBankAccountIban",
|
|
603
|
+
"$randomTransactionType",
|
|
604
|
+
"$randomCurrencyCode",
|
|
605
|
+
"$randomCurrencyName",
|
|
606
|
+
"$randomCurrencySymbol",
|
|
607
|
+
"$randomBitcoin",
|
|
608
|
+
"$randomCompanyName",
|
|
609
|
+
"$randomCompanySuffix",
|
|
610
|
+
"$randomBs",
|
|
611
|
+
"$randomBsAdjective",
|
|
612
|
+
"$randomBsBuzz",
|
|
613
|
+
"$randomBsNoun",
|
|
614
|
+
"$randomCatchPhrase",
|
|
615
|
+
"$randomCatchPhraseAdjective",
|
|
616
|
+
"$randomCatchPhraseDescriptor",
|
|
617
|
+
"$randomCatchPhraseNoun",
|
|
618
|
+
"$randomDatabaseColumn",
|
|
619
|
+
"$randomDatabaseType",
|
|
620
|
+
"$randomDatabaseCollation",
|
|
621
|
+
"$randomDatabaseEngine",
|
|
622
|
+
"$randomDatetimeRange",
|
|
623
|
+
"$randomHackerAbbr",
|
|
624
|
+
"$randomHackerAdjective",
|
|
625
|
+
"$randomHackerIngverb",
|
|
626
|
+
"$randomHackerNoun",
|
|
627
|
+
"$randomHackerPhrase",
|
|
628
|
+
"$randomHackerVerb",
|
|
629
|
+
"$randomHexadecimal",
|
|
630
|
+
"$randomAvatarImage",
|
|
631
|
+
"$randomImageUrl",
|
|
632
|
+
"$randomAbstractImage",
|
|
633
|
+
"$randomAnimalsImage",
|
|
634
|
+
"$randomBusinessImage",
|
|
635
|
+
"$randomCatsImage",
|
|
636
|
+
"$randomCityImage",
|
|
637
|
+
"$randomFoodImage",
|
|
638
|
+
"$randomNightlifeImage",
|
|
639
|
+
"$randomFashionImage",
|
|
640
|
+
"$randomPeopleImage",
|
|
641
|
+
"$randomNatureImage",
|
|
642
|
+
"$randomSportsImage",
|
|
643
|
+
"$randomTransportImage",
|
|
644
|
+
"$randomImageDataUri",
|
|
645
|
+
"$randomProduct",
|
|
646
|
+
"$randomProductAdjective",
|
|
647
|
+
"$randomProductMaterial",
|
|
648
|
+
"$randomProductName",
|
|
649
|
+
"$randomDepartment",
|
|
650
|
+
"$randomProductDescription",
|
|
651
|
+
]);
|
|
652
|
+
|
|
653
|
+
const VAR_TOKEN_RE = /\{\{([^}]+)\}\}/g;
|
|
654
|
+
|
|
655
|
+
function extractVarsFromString(str: string, vars: Set<string>): void {
|
|
656
|
+
for (const match of str.matchAll(VAR_TOKEN_RE)) {
|
|
657
|
+
const name = match[1]!.trim();
|
|
658
|
+
if (!name.startsWith("$") && !POSTMAN_DYNAMIC_VARS.has(name)) {
|
|
659
|
+
vars.add(name);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function extractVarsFromValue(val: unknown, vars: Set<string>): void {
|
|
665
|
+
if (typeof val === "string") {
|
|
666
|
+
extractVarsFromString(val, vars);
|
|
667
|
+
} else if (Array.isArray(val)) {
|
|
668
|
+
for (const item of val) extractVarsFromValue(item, vars);
|
|
669
|
+
} else if (val !== null && typeof val === "object") {
|
|
670
|
+
for (const v of Object.values(val as Record<string, unknown>)) {
|
|
671
|
+
extractVarsFromValue(v, vars);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function collectVariables(suites: TestSuite[]): string[] {
|
|
677
|
+
const vars = new Set<string>();
|
|
678
|
+
|
|
679
|
+
for (const suite of suites) {
|
|
680
|
+
if (suite.base_url) extractVarsFromString(suite.base_url, vars);
|
|
681
|
+
if (suite.headers) {
|
|
682
|
+
for (const v of Object.values(suite.headers)) extractVarsFromString(v, vars);
|
|
683
|
+
}
|
|
684
|
+
for (const step of suite.tests) {
|
|
685
|
+
extractVarsFromString(step.path, vars);
|
|
686
|
+
if (step.headers) {
|
|
687
|
+
for (const v of Object.values(step.headers)) extractVarsFromString(v, vars);
|
|
688
|
+
}
|
|
689
|
+
if (step.json !== undefined) extractVarsFromValue(step.json, vars);
|
|
690
|
+
if (step.query) {
|
|
691
|
+
for (const v of Object.values(step.query)) extractVarsFromString(v, vars);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
return Array.from(vars).sort();
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// ---------------------------------------------------------------------------
|
|
700
|
+
// skip_if → pre-request script
|
|
701
|
+
// ---------------------------------------------------------------------------
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Parse a simple zond skip_if expression and return a JS condition string
|
|
705
|
+
* suitable for use in a Postman pre-request script.
|
|
706
|
+
*
|
|
707
|
+
* Supported patterns:
|
|
708
|
+
* "varName == 'value'" → pm.environment.get("varName") == "value"
|
|
709
|
+
* "varName != 'value'" → pm.environment.get("varName") != "value"
|
|
710
|
+
* "varName == \"\"" → !pm.environment.get("varName")
|
|
711
|
+
* "varName" → !!pm.environment.get("varName") (truthy)
|
|
712
|
+
* "!varName" → !pm.environment.get("varName") (falsy)
|
|
713
|
+
*
|
|
714
|
+
* Returns null if the expression cannot be translated.
|
|
715
|
+
*/
|
|
716
|
+
function parseSkipIfCondition(expr: string): string | null {
|
|
717
|
+
const trimmed = expr.trim();
|
|
718
|
+
|
|
719
|
+
// Pattern: varName OP 'value' or varName OP "value" or varName OP number
|
|
720
|
+
const compMatch = trimmed.match(/^(\w+)\s*(==|!=|>=|<=|>|<)\s*(['"])(.*?)\3\s*$/) ||
|
|
721
|
+
trimmed.match(/^(\w+)\s*(==|!=|>=|<=|>|<)\s*(\d+(?:\.\d+)?)\s*$/);
|
|
722
|
+
if (compMatch) {
|
|
723
|
+
const varName = compMatch[1]!;
|
|
724
|
+
const op = compMatch[2]!;
|
|
725
|
+
const rawVal = compMatch[3]!.startsWith("'") || compMatch[3]!.startsWith('"')
|
|
726
|
+
? compMatch[4]! // string value
|
|
727
|
+
: compMatch[3]!; // numeric value (3rd group is digit string here)
|
|
728
|
+
const jsVal = /^\d/.test(rawVal) && !compMatch[3]!.startsWith("'") && !compMatch[3]!.startsWith('"')
|
|
729
|
+
? rawVal
|
|
730
|
+
: JSON.stringify(rawVal);
|
|
731
|
+
// Special case: == "" or == '' → treat as falsy check
|
|
732
|
+
if ((op === "==" || op === "!=") && rawVal === "") {
|
|
733
|
+
return op === "==" ? `!pm.environment.get(${JSON.stringify(varName)})` : `!!pm.environment.get(${JSON.stringify(varName)})`;
|
|
734
|
+
}
|
|
735
|
+
return `pm.environment.get(${JSON.stringify(varName)}) ${op} ${jsVal}`;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Pattern: !varName (falsy)
|
|
739
|
+
const negMatch = trimmed.match(/^!(\w+)$/);
|
|
740
|
+
if (negMatch) {
|
|
741
|
+
return `!pm.environment.get(${JSON.stringify(negMatch[1]!)})`;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Pattern: bare varName (truthy)
|
|
745
|
+
if (/^\w+$/.test(trimmed)) {
|
|
746
|
+
return `!!pm.environment.get(${JSON.stringify(trimmed)})`;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function buildSkipIfEvent(skipIf: string, nextStepName: string | null): PostmanEvent {
|
|
753
|
+
const condition = parseSkipIfCondition(skipIf);
|
|
754
|
+
const exec: string[] = [];
|
|
755
|
+
|
|
756
|
+
if (condition !== null) {
|
|
757
|
+
const target = nextStepName !== null ? JSON.stringify(nextStepName) : "null";
|
|
758
|
+
exec.push(`// skip_if: ${skipIf}`);
|
|
759
|
+
exec.push(`if (${condition}) {`);
|
|
760
|
+
exec.push(` pm.execution.setNextRequest(${target});`);
|
|
761
|
+
exec.push(`}`);
|
|
762
|
+
} else {
|
|
763
|
+
// Cannot parse — emit a comment so the user can fill it in manually
|
|
764
|
+
exec.push(`// skip_if (manual translation needed): ${skipIf}`);
|
|
765
|
+
exec.push(`// if (<condition>) pm.execution.setNextRequest(${nextStepName !== null ? JSON.stringify(nextStepName) : "null"});`);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
return { listen: "prerequest", script: { type: "text/javascript", exec } };
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// ---------------------------------------------------------------------------
|
|
772
|
+
// Item builder
|
|
773
|
+
// ---------------------------------------------------------------------------
|
|
774
|
+
|
|
775
|
+
function isSetOnlyStep(step: TestStep): boolean {
|
|
776
|
+
// A step with set but no actual HTTP semantics: path is empty or method is GET
|
|
777
|
+
// with no status expectation and no body — these come from `set:` steps in YAML
|
|
778
|
+
return (
|
|
779
|
+
step.set !== undefined &&
|
|
780
|
+
step.path === "" &&
|
|
781
|
+
step.expect.status === undefined &&
|
|
782
|
+
step.expect.body === undefined
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function buildItem(
|
|
787
|
+
suite: TestSuite,
|
|
788
|
+
step: TestStep,
|
|
789
|
+
warnings: string[],
|
|
790
|
+
nextStepName: string | null,
|
|
791
|
+
pendingSetVars: Record<string, unknown>
|
|
792
|
+
): PostmanItem | null {
|
|
793
|
+
if (isSetOnlyStep(step)) {
|
|
794
|
+
// set-only steps are absorbed into the pre-request script of the next HTTP step
|
|
795
|
+
return null;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
if (step.for_each !== undefined) {
|
|
799
|
+
warnings.push(`Step "${step.name}" in suite "${suite.name}" uses for_each — converted without loop (Postman has no native for_each)`);
|
|
800
|
+
}
|
|
801
|
+
if (step.retry_until !== undefined) {
|
|
802
|
+
warnings.push(`Step "${step.name}" in suite "${suite.name}" uses retry_until — converted without retry logic (Postman has no native retry_until)`);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const url = buildUrl(suite.base_url, mapDynamicVars(step.path), step.query);
|
|
806
|
+
const header = buildHeaders(suite, step);
|
|
807
|
+
const body = buildBody(step);
|
|
808
|
+
|
|
809
|
+
const request: PostmanRequest = {
|
|
810
|
+
method: step.method,
|
|
811
|
+
url,
|
|
812
|
+
header,
|
|
813
|
+
...(body !== undefined ? { body } : {}),
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
const events: PostmanEvent[] = [];
|
|
817
|
+
|
|
818
|
+
// Pre-request: inject any pending set-step variables
|
|
819
|
+
if (Object.keys(pendingSetVars).length > 0) {
|
|
820
|
+
const setLines = Object.entries(pendingSetVars).map(
|
|
821
|
+
([k, v]) => `pm.environment.set(${JSON.stringify(k)}, ${serializeValue(v)});`
|
|
822
|
+
);
|
|
823
|
+
events.push({ listen: "prerequest", script: { type: "text/javascript", exec: setLines } });
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// Pre-request: skip_if condition
|
|
827
|
+
if (step.skip_if !== undefined) {
|
|
828
|
+
events.push(buildSkipIfEvent(step.skip_if, nextStepName));
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const testEvent = buildTestScript(step, warnings);
|
|
832
|
+
if (testEvent !== undefined) events.push(testEvent);
|
|
833
|
+
|
|
834
|
+
const item: PostmanItem = {
|
|
835
|
+
name: step.name,
|
|
836
|
+
request,
|
|
837
|
+
...(events.length > 0 ? { event: events } : {}),
|
|
838
|
+
};
|
|
839
|
+
|
|
840
|
+
return item;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// ---------------------------------------------------------------------------
|
|
844
|
+
// Public API
|
|
845
|
+
// ---------------------------------------------------------------------------
|
|
846
|
+
|
|
847
|
+
export interface BuildCollectionResult {
|
|
848
|
+
collection: PostmanCollection;
|
|
849
|
+
warnings: string[];
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
export function buildCollection(
|
|
853
|
+
suites: TestSuite[],
|
|
854
|
+
collectionName: string
|
|
855
|
+
): BuildCollectionResult {
|
|
856
|
+
const warnings: string[] = [];
|
|
857
|
+
|
|
858
|
+
// Setup suites run first (mirrors zond runner behaviour).
|
|
859
|
+
// In Postman, folder order = run order, so setup captures are available to later folders.
|
|
860
|
+
const sorted = [
|
|
861
|
+
...suites.filter((s) => s.setup),
|
|
862
|
+
...suites.filter((s) => !s.setup),
|
|
863
|
+
];
|
|
864
|
+
|
|
865
|
+
const folders: PostmanFolder[] = [];
|
|
866
|
+
|
|
867
|
+
// Collect Newman flag hints for non-default suite configs
|
|
868
|
+
const newmanHints: string[] = [];
|
|
869
|
+
|
|
870
|
+
for (const suite of sorted) {
|
|
871
|
+
const items: PostmanItem[] = [];
|
|
872
|
+
|
|
873
|
+
// Collect Newman hints for non-default suite config values
|
|
874
|
+
if (suite.config.timeout !== 30000) {
|
|
875
|
+
newmanHints.push(`--timeout-request ${suite.config.timeout} (suite "${suite.name}")`);
|
|
876
|
+
}
|
|
877
|
+
if (!suite.config.verify_ssl) {
|
|
878
|
+
newmanHints.push(`--insecure (suite "${suite.name}" has verify_ssl: false)`);
|
|
879
|
+
}
|
|
880
|
+
if (suite.config.retries > 0) {
|
|
881
|
+
newmanHints.push(`# retries: ${suite.config.retries} (suite "${suite.name}" — no Newman equivalent)`);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Iterate steps: accumulate set-only vars, pass nextStepName to each item builder
|
|
885
|
+
const httpSteps = suite.tests.filter((s) => !isSetOnlyStep(s));
|
|
886
|
+
let pendingSetVars: Record<string, unknown> = {};
|
|
887
|
+
|
|
888
|
+
for (let i = 0; i < suite.tests.length; i++) {
|
|
889
|
+
const step = suite.tests[i]!;
|
|
890
|
+
|
|
891
|
+
if (isSetOnlyStep(step)) {
|
|
892
|
+
// Merge into pending set vars for the next HTTP step
|
|
893
|
+
Object.assign(pendingSetVars, step.set ?? {});
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Find the next HTTP step name for setNextRequest in skip_if
|
|
898
|
+
const httpIdx = httpSteps.indexOf(step);
|
|
899
|
+
const nextHttpStep = httpSteps[httpIdx + 1] ?? null;
|
|
900
|
+
|
|
901
|
+
const item = buildItem(suite, step, warnings, nextHttpStep?.name ?? null, pendingSetVars);
|
|
902
|
+
pendingSetVars = {}; // consumed
|
|
903
|
+
|
|
904
|
+
if (item !== null) items.push(item);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// If there are leftover set-only steps at the end with no following HTTP step, warn
|
|
908
|
+
if (Object.keys(pendingSetVars).length > 0) {
|
|
909
|
+
warnings.push(`Suite "${suite.name}" has trailing set-only steps with no following HTTP step — variables not exported`);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
folders.push({
|
|
913
|
+
name: suite.name,
|
|
914
|
+
...(suite.description ? { description: suite.description } : {}),
|
|
915
|
+
item: items,
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const varNames = collectVariables(sorted);
|
|
920
|
+
const variables: PostmanVariable[] = varNames.map((key) => ({
|
|
921
|
+
key,
|
|
922
|
+
value: "",
|
|
923
|
+
enabled: true,
|
|
924
|
+
}));
|
|
925
|
+
|
|
926
|
+
// Build collection-level description with Newman hints if needed
|
|
927
|
+
const uniqueHints = [...new Set(newmanHints)];
|
|
928
|
+
const collectionDescription = uniqueHints.length > 0
|
|
929
|
+
? `Generated by zond export postman.\n\nNewman flags required by suite config:\n newman run collection.json \\\n ${uniqueHints.join(" \\\n ")}`
|
|
930
|
+
: undefined;
|
|
931
|
+
|
|
932
|
+
const collection: PostmanCollection = {
|
|
933
|
+
info: {
|
|
934
|
+
name: collectionName,
|
|
935
|
+
schema: "https://schema.postman.com/json/collection/v2.1.0/collection.json",
|
|
936
|
+
...(collectionDescription ? { description: collectionDescription } : {}),
|
|
937
|
+
},
|
|
938
|
+
item: folders,
|
|
939
|
+
...(variables.length > 0 ? { variable: variables } : {}),
|
|
940
|
+
};
|
|
941
|
+
|
|
942
|
+
return { collection, warnings };
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
export function buildEnvironment(
|
|
946
|
+
vars: Record<string, string>,
|
|
947
|
+
name: string
|
|
948
|
+
): PostmanEnvironment {
|
|
949
|
+
return {
|
|
950
|
+
name,
|
|
951
|
+
values: Object.entries(vars).map(([key, value]) => ({
|
|
952
|
+
key,
|
|
953
|
+
value,
|
|
954
|
+
enabled: true,
|
|
955
|
+
})),
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
export function deriveCollectionName(path: string): string {
|
|
960
|
+
const base = basename(path);
|
|
961
|
+
// Strip known extensions
|
|
962
|
+
return base.replace(/\.(yaml|yml)$/, "") || "Zond Collection";
|
|
963
|
+
}
|