@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.
- package/CHANGELOG.md +132 -112
- package/README.md +3 -10
- package/package.json +4 -4
- package/src/cli/commands/ci-init.ts +12 -1
- package/src/cli/commands/coverage.ts +21 -1
- package/src/cli/commands/db.ts +121 -0
- package/src/cli/commands/describe.ts +60 -0
- package/src/cli/commands/export.ts +144 -0
- package/src/cli/commands/generate.ts +158 -0
- package/src/cli/commands/guide.ts +127 -0
- package/src/cli/commands/init.ts +57 -0
- package/src/cli/commands/request.ts +57 -0
- package/src/cli/commands/run.ts +74 -14
- package/src/cli/commands/serve.ts +62 -3
- package/src/cli/commands/sync.ts +240 -0
- package/src/cli/commands/validate.ts +18 -2
- package/src/cli/index.ts +258 -17
- package/src/cli/json-envelope.ts +19 -0
- package/src/core/diagnostics/db-analysis.ts +423 -0
- package/src/core/diagnostics/failure-hints.ts +40 -0
- package/src/core/exporter/postman.ts +963 -0
- package/src/core/generator/data-factory.ts +55 -9
- package/src/core/generator/describe.ts +250 -0
- package/src/core/generator/guide-builder.ts +20 -0
- 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 +291 -29
- 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 +35 -8
- package/src/core/runner/http-client.ts +1 -1
- package/src/core/runner/send-request.ts +94 -0
- package/src/core/runner/types.ts +2 -0
- package/src/core/sync/spec-differ.ts +38 -0
- package/src/db/queries.ts +4 -2
- package/src/db/schema.ts +11 -3
- package/src/web/views/suites-tab.ts +1 -1
- package/src/cli/commands/mcp.ts +0 -16
- package/src/mcp/descriptions.ts +0 -71
- package/src/mcp/server.ts +0 -45
- 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 -242
- package/src/mcp/tools/generate-and-save.ts +0 -202
- package/src/mcp/tools/manage-server.ts +0 -86
- package/src/mcp/tools/query-db.ts +0 -300
- package/src/mcp/tools/run-tests.ts +0 -115
- package/src/mcp/tools/save-test-suite.ts +0 -218
- package/src/mcp/tools/send-request.ts +0 -97
- package/src/mcp/tools/set-work-dir.ts +0 -35
- 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
|
+
}
|
package/src/cli/commands/run.ts
CHANGED
|
@@ -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:
|
|
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 =>
|
|
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
|
|
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
|
|
119
|
-
const result = await runSuite(suite,
|
|
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(
|
|
151
|
+
const all = await Promise.all(regularSuites.map((suite) => runSuite(suite, enrichedEnv, dryRun)));
|
|
128
152
|
results.push(...all);
|
|
129
153
|
}
|
|
130
154
|
|
|
131
|
-
// 5.
|
|
132
|
-
const
|
|
133
|
-
|
|
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
|
-
|
|
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(
|
|
146
|
-
saveResults(
|
|
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)
|
|
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
|
}
|