@kirrosh/zond 0.14.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 (59) hide show
  1. package/CHANGELOG.md +132 -112
  2. package/README.md +3 -10
  3. package/package.json +4 -4
  4. package/src/cli/commands/ci-init.ts +12 -1
  5. package/src/cli/commands/coverage.ts +21 -1
  6. package/src/cli/commands/db.ts +121 -0
  7. package/src/cli/commands/describe.ts +60 -0
  8. package/src/cli/commands/export.ts +144 -0
  9. package/src/cli/commands/generate.ts +158 -0
  10. package/src/cli/commands/guide.ts +127 -0
  11. package/src/cli/commands/init.ts +57 -0
  12. package/src/cli/commands/request.ts +57 -0
  13. package/src/cli/commands/run.ts +74 -14
  14. package/src/cli/commands/serve.ts +62 -3
  15. package/src/cli/commands/sync.ts +240 -0
  16. package/src/cli/commands/validate.ts +18 -2
  17. package/src/cli/index.ts +258 -17
  18. package/src/cli/json-envelope.ts +19 -0
  19. package/src/core/diagnostics/db-analysis.ts +423 -0
  20. package/src/core/diagnostics/failure-hints.ts +40 -0
  21. package/src/core/exporter/postman.ts +963 -0
  22. package/src/core/generator/data-factory.ts +55 -9
  23. package/src/core/generator/describe.ts +250 -0
  24. package/src/core/generator/guide-builder.ts +20 -0
  25. package/src/core/generator/index.ts +1 -1
  26. package/src/core/generator/openapi-reader.ts +6 -0
  27. package/src/core/generator/serializer.ts +17 -2
  28. package/src/core/generator/suite-generator.ts +291 -29
  29. package/src/core/generator/types.ts +1 -0
  30. package/src/core/meta/meta-store.ts +78 -0
  31. package/src/core/meta/types.ts +21 -0
  32. package/src/core/parser/schema.ts +12 -2
  33. package/src/core/parser/types.ts +12 -1
  34. package/src/core/parser/variables.ts +3 -0
  35. package/src/core/parser/yaml-parser.ts +2 -1
  36. package/src/core/runner/assertions.ts +44 -20
  37. package/src/core/runner/execute-run.ts +31 -8
  38. package/src/core/runner/executor.ts +35 -8
  39. package/src/core/runner/http-client.ts +1 -1
  40. package/src/core/runner/send-request.ts +94 -0
  41. package/src/core/runner/types.ts +2 -0
  42. package/src/core/sync/spec-differ.ts +38 -0
  43. package/src/db/queries.ts +4 -2
  44. package/src/db/schema.ts +11 -3
  45. package/src/web/views/suites-tab.ts +1 -1
  46. package/src/cli/commands/mcp.ts +0 -16
  47. package/src/mcp/descriptions.ts +0 -71
  48. package/src/mcp/server.ts +0 -45
  49. package/src/mcp/tools/ci-init.ts +0 -54
  50. package/src/mcp/tools/coverage-analysis.ts +0 -141
  51. package/src/mcp/tools/describe-endpoint.ts +0 -242
  52. package/src/mcp/tools/generate-and-save.ts +0 -202
  53. package/src/mcp/tools/manage-server.ts +0 -86
  54. package/src/mcp/tools/query-db.ts +0 -300
  55. package/src/mcp/tools/run-tests.ts +0 -115
  56. package/src/mcp/tools/save-test-suite.ts +0 -218
  57. package/src/mcp/tools/send-request.ts +0 -97
  58. package/src/mcp/tools/set-work-dir.ts +0 -35
  59. package/src/mcp/tools/setup-api.ts +0 -88
@@ -0,0 +1,144 @@
1
+ import { dirname, basename, join } from "path";
2
+ import { parse } from "../../core/parser/yaml-parser.ts";
3
+ import {
4
+ buildCollection,
5
+ buildEnvironment,
6
+ deriveCollectionName,
7
+ } from "../../core/exporter/postman.ts";
8
+ import { printError, printSuccess, printWarning } from "../output.ts";
9
+ import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
10
+
11
+ export interface ExportOptions {
12
+ testsPath: string;
13
+ output: string;
14
+ env?: string;
15
+ collectionName?: string;
16
+ json?: boolean;
17
+ }
18
+
19
+ export async function exportCommand(options: ExportOptions): Promise<number> {
20
+ // 1. Parse test suites
21
+ let suites;
22
+ try {
23
+ suites = await parse(options.testsPath);
24
+ } catch (err) {
25
+ const msg = `Failed to parse tests: ${(err as Error).message}`;
26
+ if (options.json) {
27
+ printJson(jsonError("export postman", [msg]));
28
+ } else {
29
+ printError(msg);
30
+ }
31
+ return 2;
32
+ }
33
+
34
+ if (suites.length === 0) {
35
+ const msg = "No test suites found";
36
+ if (options.json) {
37
+ printJson(jsonError("export postman", [msg]));
38
+ } else {
39
+ printError(msg);
40
+ }
41
+ return 1;
42
+ }
43
+
44
+ // 2. Derive collection name
45
+ const collectionName =
46
+ options.collectionName ?? deriveCollectionName(options.testsPath);
47
+
48
+ // 3. Build collection
49
+ const { collection, warnings } = buildCollection(suites, collectionName);
50
+
51
+ // Count total items across all folders
52
+ const totalItems = collection.item.reduce((sum, folder) => sum + folder.item.length, 0);
53
+
54
+ // 4. Write collection file
55
+ try {
56
+ await Bun.write(options.output, JSON.stringify(collection, null, 2));
57
+ } catch (err) {
58
+ const msg = `Failed to write collection file: ${(err as Error).message}`;
59
+ if (options.json) {
60
+ printJson(jsonError("export postman", [msg], warnings));
61
+ } else {
62
+ printError(msg);
63
+ }
64
+ return 2;
65
+ }
66
+
67
+ // 5. Optional env export
68
+ let envOutput: string | undefined;
69
+ if (options.env) {
70
+ let envVars: Record<string, string>;
71
+ try {
72
+ const text = await Bun.file(options.env).text();
73
+ const parsed = Bun.YAML.parse(text);
74
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
75
+ throw new Error("Environment file must be a YAML object");
76
+ }
77
+ // Convert all values to strings
78
+ envVars = {};
79
+ for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
80
+ envVars[k] = String(v);
81
+ }
82
+ } catch (err) {
83
+ const msg = `Failed to read env file: ${(err as Error).message}`;
84
+ if (options.json) {
85
+ printJson(jsonError("export postman", [msg], warnings));
86
+ } else {
87
+ printError(msg);
88
+ }
89
+ return 2;
90
+ }
91
+
92
+ // Derive env name: e.g. ".env.staging.yaml" → "staging", ".env.yaml" → collectionName
93
+ const envBasename = basename(options.env);
94
+ const envNameMatch = envBasename.match(/^\.?env\.(.+?)\.ya?ml$/);
95
+ const envName = envNameMatch ? envNameMatch[1]! : collectionName;
96
+
97
+ const environment = buildEnvironment(envVars, envName);
98
+
99
+ // Output path: same directory as output, same base name with .postman_environment.json
100
+ const outBase = basename(options.output).replace(/\.postman\.json$/, "").replace(/\.json$/, "");
101
+ const outDir = dirname(options.output);
102
+ envOutput = join(outDir, `${outBase}.postman_environment.json`);
103
+
104
+ try {
105
+ await Bun.write(envOutput, JSON.stringify(environment, null, 2));
106
+ } catch (err) {
107
+ const msg = `Failed to write environment file: ${(err as Error).message}`;
108
+ if (options.json) {
109
+ printJson(jsonError("export postman", [msg], warnings));
110
+ } else {
111
+ printError(msg);
112
+ }
113
+ return 2;
114
+ }
115
+ }
116
+
117
+ // 6. Output result
118
+ if (options.json) {
119
+ printJson(
120
+ jsonOk(
121
+ "export postman",
122
+ {
123
+ output: options.output,
124
+ suites: suites.length,
125
+ items: totalItems,
126
+ ...(envOutput !== undefined ? { envOutput } : {}),
127
+ },
128
+ warnings
129
+ )
130
+ );
131
+ } else {
132
+ for (const w of warnings) {
133
+ printWarning(w);
134
+ }
135
+ printSuccess(
136
+ `Exported ${suites.length} suite(s) / ${totalItems} request(s) → ${options.output}`
137
+ );
138
+ if (envOutput) {
139
+ printSuccess(`Environment exported → ${envOutput}`);
140
+ }
141
+ }
142
+
143
+ return 0;
144
+ }
@@ -0,0 +1,158 @@
1
+ import { join, dirname } from "path";
2
+ import { mkdir } from "fs/promises";
3
+ import {
4
+ readOpenApiSpec,
5
+ extractEndpoints,
6
+ extractSecuritySchemes,
7
+ scanCoveredEndpoints,
8
+ filterUncoveredEndpoints,
9
+ serializeSuite,
10
+ } from "../../core/generator/index.ts";
11
+ import { generateSuites } from "../../core/generator/suite-generator.ts";
12
+ import { filterByTag } from "../../core/generator/chunker.ts";
13
+ import { parse } from "../../core/parser/yaml-parser.ts";
14
+ import { printError, printSuccess } from "../output.ts";
15
+ import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
16
+ import { readMeta, writeMeta, hashSpec, buildFileMeta } from "../../core/meta/meta-store.ts";
17
+ import { version as ZOND_VERSION } from "../../../package.json";
18
+ import { getDb } from "../../db/schema.ts";
19
+ import { findCollectionByTestPath, updateCollection } from "../../db/queries.ts";
20
+
21
+ export interface GenerateOptions {
22
+ specPath: string;
23
+ output: string;
24
+ tag?: string;
25
+ uncoveredOnly?: boolean;
26
+ json?: boolean;
27
+ }
28
+
29
+ export async function generateCommand(options: GenerateOptions): Promise<number> {
30
+ try {
31
+ const doc = await readOpenApiSpec(options.specPath);
32
+ let endpoints = extractEndpoints(doc);
33
+ const securitySchemes = extractSecuritySchemes(doc);
34
+ const baseUrl = ((doc as any).servers?.[0]?.url) as string | undefined;
35
+ const warnings: string[] = [];
36
+
37
+ // Filter to uncovered only
38
+ if (options.uncoveredOnly) {
39
+ const covered = await scanCoveredEndpoints(options.output);
40
+ const before = endpoints.length;
41
+ endpoints = filterUncoveredEndpoints(endpoints, covered);
42
+ const coveredCount = before - endpoints.length;
43
+ if (coveredCount > 0) {
44
+ warnings.push(`Skipped ${coveredCount} already-covered endpoints`);
45
+ }
46
+ }
47
+
48
+ // Filter by tag
49
+ if (options.tag) {
50
+ endpoints = filterByTag(endpoints, options.tag);
51
+ }
52
+
53
+ if (endpoints.length === 0) {
54
+ if (options.json) {
55
+ printJson(jsonOk("generate", { files: [], message: "No endpoints to generate tests for" }, warnings));
56
+ } else {
57
+ console.log("No endpoints to generate tests for.");
58
+ }
59
+ return 0;
60
+ }
61
+
62
+ // Generate suites
63
+ const suites = generateSuites({ endpoints, securitySchemes });
64
+
65
+ // Ensure output directory exists
66
+ await mkdir(options.output, { recursive: true });
67
+
68
+ // Write suite files
69
+ const createdFiles: Array<{ file: string; suite: string; tests: number }> = [];
70
+
71
+ // Build metadata for written files
72
+ const metaFiles: Record<string, import("../../core/meta/types.ts").FileMeta> = {};
73
+
74
+ for (const suite of suites) {
75
+ const yaml = serializeSuite(suite);
76
+ const fileName = `${suite.fileStem ?? suite.name}.yaml`;
77
+ const filePath = join(options.output, fileName);
78
+ await Bun.write(filePath, yaml);
79
+ createdFiles.push({ file: filePath, suite: suite.name, tests: suite.tests.length });
80
+ metaFiles[fileName] = buildFileMeta(suite, ZOND_VERSION);
81
+ }
82
+
83
+ // Write .zond-meta.json (merge with existing meta to preserve info about prior files)
84
+ const existingMeta = await readMeta(options.output);
85
+ const specContent = typeof doc === "object" ? JSON.stringify(doc) : String(doc);
86
+ await writeMeta(options.output, {
87
+ zondVersion: ZOND_VERSION,
88
+ lastSyncedAt: new Date().toISOString(),
89
+ specUrl: options.specPath,
90
+ specHash: hashSpec(specContent),
91
+ files: { ...(existingMeta?.files ?? {}), ...metaFiles },
92
+ });
93
+
94
+ // Sync DB collection spec reference if one is registered for this output directory
95
+ try {
96
+ getDb();
97
+ const collection = findCollectionByTestPath(options.output);
98
+ if (collection && collection.openapi_spec !== options.specPath) {
99
+ updateCollection(collection.id, { openapi_spec: options.specPath });
100
+ warnings.push(`Updated collection '${collection.name}' spec reference → ${options.specPath}`);
101
+ }
102
+ } catch {
103
+ // DB unavailable — not fatal
104
+ }
105
+
106
+ // Create .env.yaml with base_url if it doesn't exist
107
+ const envPath = join(options.output, ".env.yaml");
108
+ const envFile = Bun.file(envPath);
109
+ if (!(await envFile.exists()) && baseUrl) {
110
+ await Bun.write(envPath, `base_url: ${baseUrl}\n`);
111
+ warnings.push(`Created ${envPath} with base_url from spec`);
112
+ }
113
+
114
+ // Validate generated files
115
+ const validationErrors: string[] = [];
116
+ try {
117
+ await parse(options.output);
118
+ } catch (err) {
119
+ validationErrors.push(err instanceof Error ? err.message : String(err));
120
+ }
121
+
122
+ if (validationErrors.length > 0) {
123
+ warnings.push(`Validation warnings: ${validationErrors.join("; ")}`);
124
+ }
125
+
126
+ // Output
127
+ const totalTests = createdFiles.reduce((sum, f) => sum + f.tests, 0);
128
+
129
+ if (options.json) {
130
+ printJson(jsonOk("generate", {
131
+ files: createdFiles,
132
+ totalSuites: suites.length,
133
+ totalTests,
134
+ outputDir: options.output,
135
+ }, warnings));
136
+ } else {
137
+ printSuccess(`Generated ${suites.length} suite(s) with ${totalTests} test(s) in ${options.output}`);
138
+ for (const f of createdFiles) {
139
+ console.log(` ${f.file} (${f.tests} tests)`);
140
+ }
141
+ if (warnings.length > 0) {
142
+ for (const w of warnings) {
143
+ console.log(` ⚠ ${w}`);
144
+ }
145
+ }
146
+ }
147
+
148
+ return 0;
149
+ } catch (err) {
150
+ const message = err instanceof Error ? err.message : String(err);
151
+ if (options.json) {
152
+ printJson(jsonError("generate", [message]));
153
+ } else {
154
+ printError(message);
155
+ }
156
+ return 2;
157
+ }
158
+ }
@@ -0,0 +1,127 @@
1
+ import {
2
+ readOpenApiSpec,
3
+ extractEndpoints,
4
+ extractSecuritySchemes,
5
+ scanCoveredEndpoints,
6
+ filterUncoveredEndpoints,
7
+ } from "../../core/generator/index.ts";
8
+ import { compressEndpointsWithSchemas, buildGenerationGuide } from "../../core/generator/guide-builder.ts";
9
+ import { planChunks, filterByTag } from "../../core/generator/chunker.ts";
10
+ import { findCollectionBySpec } from "../../db/queries.ts";
11
+ import { printError } from "../output.ts";
12
+ import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
13
+
14
+ export interface GuideOptions {
15
+ specPath: string;
16
+ testsDir?: string;
17
+ tag?: string;
18
+ json?: boolean;
19
+ }
20
+
21
+ export async function guideCommand(options: GuideOptions): Promise<number> {
22
+ try {
23
+ const doc = await readOpenApiSpec(options.specPath);
24
+ let endpoints = extractEndpoints(doc);
25
+ const securitySchemes = extractSecuritySchemes(doc);
26
+ const baseUrl = ((doc as any).servers?.[0]?.url) as string | undefined;
27
+ const title = (doc as any).info?.title as string | undefined;
28
+
29
+ let outputDir = options.testsDir;
30
+ if (!outputDir) {
31
+ try {
32
+ const collection = findCollectionBySpec(options.specPath);
33
+ outputDir = collection?.test_path ?? "./tests/";
34
+ } catch {
35
+ outputDir = "./tests/";
36
+ }
37
+ }
38
+
39
+ let coverageInfo: { covered: number; total: number; percentage: number } | undefined;
40
+ if (options.testsDir) {
41
+ const totalBefore = endpoints.length;
42
+ const covered = await scanCoveredEndpoints(options.testsDir);
43
+ const uncovered = filterUncoveredEndpoints(endpoints, covered);
44
+ const coveredCount = totalBefore - uncovered.length;
45
+ const percentage = totalBefore > 0 ? Math.round((coveredCount / totalBefore) * 100) : 100;
46
+ coverageInfo = { covered: coveredCount, total: totalBefore, percentage };
47
+ endpoints = uncovered;
48
+ }
49
+
50
+ if (endpoints.length === 0) {
51
+ if (options.json) {
52
+ printJson(jsonOk("guide", { fullyCovered: true, ...coverageInfo }));
53
+ } else {
54
+ console.log("All endpoints are covered.");
55
+ }
56
+ return 0;
57
+ }
58
+
59
+ if (options.tag) {
60
+ endpoints = filterByTag(endpoints, options.tag);
61
+ if (endpoints.length === 0) {
62
+ const msg = `No endpoints found for tag "${options.tag}"`;
63
+ if (options.json) printJson(jsonError("guide", [msg]));
64
+ else printError(msg);
65
+ return 1;
66
+ }
67
+ }
68
+
69
+ const plan = planChunks(endpoints);
70
+
71
+ if (plan.needsChunking && !options.tag) {
72
+ if (options.json) {
73
+ printJson(jsonOk("guide", {
74
+ mode: "plan",
75
+ title: title ?? "API",
76
+ totalEndpoints: plan.totalEndpoints,
77
+ chunks: plan.chunks,
78
+ ...(coverageInfo ? { coverage: coverageInfo } : {}),
79
+ }));
80
+ } else {
81
+ console.log(`API has ${plan.totalEndpoints} endpoints across ${plan.chunks.length} tags.`);
82
+ console.log("Generate per-tag with --tag <name>:\n");
83
+ for (const chunk of plan.chunks) {
84
+ console.log(` --tag ${chunk.tag} (${chunk.count} endpoints)`);
85
+ }
86
+ }
87
+ return 0;
88
+ }
89
+
90
+ const coverageHeader = coverageInfo
91
+ ? `## Coverage: ${coverageInfo.covered}/${coverageInfo.total} endpoints covered (${coverageInfo.percentage}%). Generating tests for ${endpoints.length} uncovered endpoints:`
92
+ : undefined;
93
+
94
+ const apiContext = compressEndpointsWithSchemas(endpoints, securitySchemes);
95
+ const guide = buildGenerationGuide({
96
+ title: options.tag ? `${title ?? "API"} — tag: ${options.tag}` : (title ?? "API"),
97
+ baseUrl,
98
+ apiContext,
99
+ outputDir,
100
+ securitySchemes,
101
+ endpointCount: endpoints.length,
102
+ coverageHeader,
103
+ includeFormat: true,
104
+ });
105
+
106
+ if (options.json) {
107
+ printJson(jsonOk("guide", {
108
+ title: title ?? "API",
109
+ endpointCount: endpoints.length,
110
+ outputDir,
111
+ guide,
112
+ ...(coverageInfo ? { coverage: coverageInfo } : {}),
113
+ }));
114
+ } else {
115
+ console.log(guide);
116
+ }
117
+ return 0;
118
+ } catch (err) {
119
+ const message = err instanceof Error ? err.message : String(err);
120
+ if (options.json) {
121
+ printJson(jsonError("guide", [message]));
122
+ } else {
123
+ printError(message);
124
+ }
125
+ return 2;
126
+ }
127
+ }
@@ -0,0 +1,57 @@
1
+ import { setupApi } from "../../core/setup-api.ts";
2
+ import { printError, printSuccess } from "../output.ts";
3
+ import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
4
+
5
+ export interface InitOptions {
6
+ name?: string;
7
+ spec?: string;
8
+ baseUrl?: string;
9
+ dir?: string;
10
+ force?: boolean;
11
+ insecure?: boolean;
12
+ dbPath?: string;
13
+ json?: boolean;
14
+ }
15
+
16
+ export async function initCommand(options: InitOptions): Promise<number> {
17
+ try {
18
+ const envVars: Record<string, string> = {};
19
+ if (options.baseUrl) envVars.base_url = options.baseUrl;
20
+
21
+ const result = await setupApi({
22
+ name: options.name,
23
+ spec: options.spec,
24
+ dir: options.dir,
25
+ envVars: Object.keys(envVars).length > 0 ? envVars : undefined,
26
+ dbPath: options.dbPath,
27
+ force: options.force,
28
+ insecure: options.insecure,
29
+ });
30
+
31
+ if (options.json) {
32
+ printJson(jsonOk("init", {
33
+ collectionId: result.collectionId,
34
+ baseDir: result.baseDir,
35
+ testPath: result.testPath,
36
+ endpoints: result.specEndpoints,
37
+ warnings: result.warnings ?? [],
38
+ }, result.warnings));
39
+ } else {
40
+ printSuccess(`Created API '${options.name ?? "api"}' at ${result.baseDir} (${result.specEndpoints} endpoints)`);
41
+ if (result.warnings) {
42
+ for (const w of result.warnings) {
43
+ process.stderr.write(`Warning: ${w}\n`);
44
+ }
45
+ }
46
+ }
47
+ return 0;
48
+ } catch (err) {
49
+ const message = err instanceof Error ? err.message : String(err);
50
+ if (options.json) {
51
+ printJson(jsonError("init", [message]));
52
+ } else {
53
+ printError(message);
54
+ }
55
+ return 2;
56
+ }
57
+ }
@@ -0,0 +1,57 @@
1
+ import { sendAdHocRequest } from "../../core/runner/send-request.ts";
2
+ import { printError } from "../output.ts";
3
+ import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
4
+
5
+ export interface RequestOptions {
6
+ method: string;
7
+ url: string;
8
+ headers?: string[];
9
+ body?: string;
10
+ timeout?: number;
11
+ env?: string;
12
+ api?: string;
13
+ jsonPath?: string;
14
+ dbPath?: string;
15
+ json?: boolean;
16
+ }
17
+
18
+ export async function requestCommand(options: RequestOptions): Promise<number> {
19
+ try {
20
+ const headers: Record<string, string> = {};
21
+ if (options.headers) {
22
+ for (const h of options.headers) {
23
+ const colonIdx = h.indexOf(":");
24
+ if (colonIdx > 0) {
25
+ headers[h.slice(0, colonIdx).trim()] = h.slice(colonIdx + 1).trim();
26
+ }
27
+ }
28
+ }
29
+
30
+ const result = await sendAdHocRequest({
31
+ method: options.method.toUpperCase(),
32
+ url: options.url,
33
+ headers: Object.keys(headers).length > 0 ? headers : undefined,
34
+ body: options.body,
35
+ timeout: options.timeout,
36
+ envName: options.env,
37
+ collectionName: options.api,
38
+ jsonPath: options.jsonPath,
39
+ dbPath: options.dbPath,
40
+ });
41
+
42
+ if (options.json) {
43
+ printJson(jsonOk("request", result));
44
+ } else {
45
+ console.log(JSON.stringify(result, null, 2));
46
+ }
47
+ return 0;
48
+ } catch (err) {
49
+ const message = err instanceof Error ? err.message : String(err);
50
+ if (options.json) {
51
+ printJson(jsonError("request", [message]));
52
+ } else {
53
+ printError(message);
54
+ }
55
+ return 1;
56
+ }
57
+ }
@@ -9,8 +9,10 @@ import type { ReporterName } from "../../core/reporter/types.ts";
9
9
  import type { TestSuite } from "../../core/parser/types.ts";
10
10
  import type { TestRunResult } from "../../core/runner/types.ts";
11
11
  import { printError, printWarning } from "../output.ts";
12
+ import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
12
13
  import { getDb } from "../../db/schema.ts";
13
14
  import { createRun, finalizeRun, saveResults, findCollectionByTestPath } from "../../db/queries.ts";
15
+ import { AUTH_PATH_RE } from "../../core/runner/execute-run.ts";
14
16
 
15
17
  export interface RunOptions {
16
18
  path: string;
@@ -25,6 +27,7 @@ export interface RunOptions {
25
27
  tag?: string[];
26
28
  envVars?: string[];
27
29
  dryRun?: boolean;
30
+ json?: boolean;
28
31
  }
29
32
 
30
33
  export async function runCommand(options: RunOptions): Promise<number> {
@@ -51,14 +54,18 @@ export async function runCommand(options: RunOptions): Promise<number> {
51
54
  }
52
55
  }
53
56
 
54
- // 1c. Safe mode: filter to GET-only tests
57
+ // 1c. Safe mode: keep GET, set-only steps, and auth-related requests
55
58
  if (options.safe) {
56
59
  for (const suite of suites) {
57
- suite.tests = suite.tests.filter(t => t.method === "GET");
60
+ suite.tests = suite.tests.filter(t => {
61
+ if (t.method === "GET" || !t.method) return true;
62
+ if (AUTH_PATH_RE.test(t.path)) return true;
63
+ return false;
64
+ });
58
65
  }
59
66
  suites = suites.filter(s => s.tests.length > 0);
60
67
  if (suites.length === 0) {
61
- printWarning("No GET tests found. Nothing to run in safe mode.");
68
+ printWarning("No safe tests found. Nothing to run in safe mode.");
62
69
  return 0;
63
70
  }
64
71
  }
@@ -110,13 +117,30 @@ export async function runCommand(options: RunOptions): Promise<number> {
110
117
  }
111
118
  }
112
119
 
113
- // 4. Run suites
120
+ // 4. Run suites — setup suites run first (sequentially), their captures flow into regular suites
114
121
  const results: TestRunResult[] = [];
115
122
  const dryRun = options.dryRun === true;
123
+
124
+ const setupSuites = suites.filter(s => s.setup);
125
+ const regularSuites = suites.filter(s => !s.setup);
126
+ const setupCaptures: Record<string, string> = {};
127
+
128
+ for (const suite of setupSuites) {
129
+ const result = await runSuite(suite, env, dryRun);
130
+ results.push(result);
131
+ for (const step of result.steps) {
132
+ for (const [k, v] of Object.entries(step.captures)) {
133
+ setupCaptures[k] = String(v);
134
+ }
135
+ }
136
+ }
137
+
138
+ const enrichedEnv = { ...env, ...setupCaptures };
139
+
116
140
  if (options.bail) {
117
141
  // Sequential with bail at suite level
118
- for (const suite of suites) {
119
- const result = await runSuite(suite, env, dryRun);
142
+ for (const suite of regularSuites) {
143
+ const result = await runSuite(suite, enrichedEnv, dryRun);
120
144
  results.push(result);
121
145
  if (!dryRun && (result.failed > 0 || result.steps.some((s) => s.status === "error"))) {
122
146
  break;
@@ -124,33 +148,69 @@ export async function runCommand(options: RunOptions): Promise<number> {
124
148
  }
125
149
  } else {
126
150
  // Parallel
127
- const all = await Promise.all(suites.map((suite) => runSuite(suite, env, dryRun)));
151
+ const all = await Promise.all(regularSuites.map((suite) => runSuite(suite, enrichedEnv, dryRun)));
128
152
  results.push(...all);
129
153
  }
130
154
 
131
- // 5. Report
132
- const reporter = getReporter(options.report);
133
- reporter.report(results);
155
+ // 5. Collect warnings
156
+ const warnings: string[] = [];
157
+ const rateLimited = results.flatMap(r => r.steps)
158
+ .filter(s => s.response?.status === 429);
159
+ if (rateLimited.length > 0) {
160
+ warnings.push(`${rateLimited.length} request(s) hit rate limit (429). Consider: consolidating login steps, adding --bail, or using retry_until with delay.`);
161
+ }
162
+
163
+ // 5b. Report
164
+ if (!options.json) {
165
+ const reporter = getReporter(options.report);
166
+ reporter.report(results);
167
+ for (const w of warnings) {
168
+ printWarning(w);
169
+ }
170
+ }
134
171
 
135
172
  // 6. Save to DB
173
+ let savedRunId: number | undefined;
136
174
  if (!options.noDb) {
137
175
  try {
138
176
  getDb(options.dbPath);
139
177
  const collection = findCollectionByTestPath(options.path);
140
- const runId = createRun({
178
+ savedRunId = createRun({
141
179
  started_at: results[0]?.started_at ?? new Date().toISOString(),
142
180
  environment: options.env,
143
181
  collection_id: collection?.id,
144
182
  });
145
- finalizeRun(runId, results);
146
- saveResults(runId, results);
183
+ finalizeRun(savedRunId, results);
184
+ saveResults(savedRunId, results);
147
185
  } catch (err) {
148
186
  printWarning(`Failed to save results to DB: ${(err as Error).message}`);
149
187
  }
150
188
  }
151
189
 
152
190
  // 7. Exit code (always 0 in dry-run mode)
153
- if (dryRun) return 0;
191
+ if (dryRun) {
192
+ if (options.json) {
193
+ printJson(jsonOk("run", { summary: { total: results.length, passed: 0, failed: 0 }, dryRun: true }));
194
+ }
195
+ return 0;
196
+ }
154
197
  const hasFailures = results.some((r) => r.failed > 0 || r.steps.some((s) => s.status === "error"));
198
+
199
+ if (options.json) {
200
+ const total = results.reduce((s, r) => s + r.total, 0);
201
+ const passed = results.reduce((s, r) => s + r.passed, 0);
202
+ const failed = results.reduce((s, r) => s + r.failed, 0);
203
+ const failures = results.flatMap(r =>
204
+ r.steps.filter(s => s.status === "fail" || s.status === "error").map(s => ({
205
+ suite: r.suite_name,
206
+ test: s.name,
207
+ ...(r.suite_file ? { file: r.suite_file } : {}),
208
+ status: s.status,
209
+ error: s.error,
210
+ }))
211
+ );
212
+ printJson(jsonOk("run", { summary: { total, passed, failed }, failures, warnings, runId: savedRunId }));
213
+ }
214
+
155
215
  return hasFailures ? 1 : 0;
156
216
  }