@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +132 -112
  2. package/README.md +3 -10
  3. package/package.json +2 -3
  4. package/src/cli/commands/export.ts +144 -0
  5. package/src/cli/commands/generate.ts +31 -0
  6. package/src/cli/commands/run.ts +22 -5
  7. package/src/cli/commands/sync.ts +240 -0
  8. package/src/cli/index.ts +54 -10
  9. package/src/core/diagnostics/db-analysis.ts +79 -7
  10. package/src/core/diagnostics/failure-hints.ts +39 -0
  11. package/src/core/exporter/postman.ts +963 -0
  12. package/src/core/generator/data-factory.ts +38 -3
  13. package/src/core/generator/index.ts +1 -1
  14. package/src/core/generator/openapi-reader.ts +6 -0
  15. package/src/core/generator/serializer.ts +17 -2
  16. package/src/core/generator/suite-generator.ts +163 -14
  17. package/src/core/generator/types.ts +1 -0
  18. package/src/core/meta/meta-store.ts +78 -0
  19. package/src/core/meta/types.ts +21 -0
  20. package/src/core/parser/schema.ts +12 -2
  21. package/src/core/parser/types.ts +12 -1
  22. package/src/core/parser/variables.ts +3 -0
  23. package/src/core/parser/yaml-parser.ts +2 -1
  24. package/src/core/runner/assertions.ts +44 -20
  25. package/src/core/runner/execute-run.ts +31 -8
  26. package/src/core/runner/executor.ts +34 -8
  27. package/src/core/runner/http-client.ts +1 -1
  28. package/src/core/runner/types.ts +1 -0
  29. package/src/core/sync/spec-differ.ts +38 -0
  30. package/src/cli/commands/mcp.ts +0 -16
  31. package/src/mcp/descriptions.ts +0 -47
  32. package/src/mcp/server.ts +0 -38
  33. package/src/mcp/tools/ci-init.ts +0 -54
  34. package/src/mcp/tools/coverage-analysis.ts +0 -141
  35. package/src/mcp/tools/describe-endpoint.ts +0 -27
  36. package/src/mcp/tools/manage-server.ts +0 -86
  37. package/src/mcp/tools/query-db.ts +0 -84
  38. package/src/mcp/tools/run-tests.ts +0 -116
  39. package/src/mcp/tools/send-request.ts +0 -51
  40. 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
+ }