@kirrosh/zond 0.17.0 → 0.19.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/package.json +4 -1
- package/src/cli/commands/coverage.ts +47 -42
- package/src/cli/commands/db.ts +20 -5
- package/src/cli/commands/describe.ts +26 -1
- package/src/cli/commands/generate.ts +18 -6
- package/src/cli/commands/run.ts +21 -1
- package/src/cli/commands/sync.ts +2 -1
- package/src/cli/commands/update.ts +189 -0
- package/src/cli/index.ts +38 -3
- package/src/core/diagnostics/db-analysis.ts +4 -4
- package/src/core/generator/data-factory.ts +9 -6
- package/src/core/generator/describe.ts +52 -0
- package/src/core/generator/endpoint-warnings.ts +10 -1
- package/src/core/generator/suite-generator.ts +31 -12
- package/src/core/parser/filter.ts +26 -0
- package/src/db/queries.ts +24 -0
- package/src/web/server.ts +1 -1
- /package/src/web/static/{htmx.min.js → htmx.min.cjs} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kirrosh/zond",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "API testing platform — define tests in YAML, run from CLI or WebUI, generate from OpenAPI specs",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"module": "index.ts",
|
|
@@ -37,6 +37,9 @@
|
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@types/bun": "latest"
|
|
39
39
|
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"bun": ">=1.1.0"
|
|
42
|
+
},
|
|
40
43
|
"peerDependencies": {
|
|
41
44
|
"typescript": "^5"
|
|
42
45
|
},
|
|
@@ -50,6 +50,10 @@ export async function coverageCommand(options: CoverageOptions): Promise<number>
|
|
|
50
50
|
const color = useColor();
|
|
51
51
|
|
|
52
52
|
// Enriched mode with run results
|
|
53
|
+
let passing = 0;
|
|
54
|
+
let apiError = 0;
|
|
55
|
+
let testFailed = 0;
|
|
56
|
+
|
|
53
57
|
if (options.runId != null) {
|
|
54
58
|
getDb();
|
|
55
59
|
const run = getRunById(options.runId);
|
|
@@ -88,62 +92,63 @@ export async function coverageCommand(options: CoverageOptions): Promise<number>
|
|
|
88
92
|
}
|
|
89
93
|
}
|
|
90
94
|
|
|
91
|
-
let passing = 0;
|
|
92
|
-
let apiError = 0;
|
|
93
|
-
let testFailed = 0;
|
|
94
95
|
for (const status of endpointStatus.values()) {
|
|
95
96
|
if (status === "passing") passing++;
|
|
96
97
|
else if (status === "api_error") apiError++;
|
|
97
98
|
else if (status === "test_failed") testFailed++;
|
|
98
99
|
}
|
|
100
|
+
}
|
|
99
101
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
+
if (!options.json) {
|
|
103
|
+
if (options.runId != null) {
|
|
104
|
+
console.log(`Coverage: ${coveredCount}/${allEndpoints.length} endpoints (${percentage}%) — Run #${options.runId}`);
|
|
105
|
+
console.log("");
|
|
102
106
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
107
|
+
if (passing > 0) {
|
|
108
|
+
console.log(` ${color ? GREEN : ""}✅ ${passing} covered and passing${color ? RESET : ""}`);
|
|
109
|
+
}
|
|
110
|
+
if (apiError > 0) {
|
|
111
|
+
console.log(` ${color ? YELLOW : ""}⚠️ ${apiError} covered but returning 5xx (possibly broken API)${color ? RESET : ""}`);
|
|
112
|
+
}
|
|
113
|
+
if (testFailed > 0) {
|
|
114
|
+
console.log(` ${color ? RED : ""}❌ ${testFailed} covered, test assertions failed${color ? RESET : ""}`);
|
|
115
|
+
}
|
|
116
|
+
if (uncovered.length > 0) {
|
|
117
|
+
console.log(` ${color ? DIM : ""}⬜ ${uncovered.length} not covered${color ? RESET : ""}`);
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
// Standard mode
|
|
121
|
+
console.log(`Coverage: ${coveredCount}/${allEndpoints.length} endpoints (${percentage}%)`);
|
|
122
|
+
console.log("");
|
|
119
123
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
// Covered endpoints
|
|
125
|
+
if (coveredCount > 0) {
|
|
126
|
+
console.log(`${color ? GREEN : ""}Covered:${color ? RESET : ""}`);
|
|
127
|
+
for (const ep of allEndpoints) {
|
|
128
|
+
if (!uncovered.includes(ep)) {
|
|
129
|
+
console.log(` ${color ? GREEN : ""}✓${color ? RESET : ""} ${ep.method.padEnd(7)} ${ep.path}`);
|
|
130
|
+
}
|
|
126
131
|
}
|
|
132
|
+
console.log("");
|
|
127
133
|
}
|
|
128
|
-
console.log("");
|
|
129
|
-
}
|
|
130
134
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
135
|
+
// Uncovered endpoints
|
|
136
|
+
if (uncovered.length > 0) {
|
|
137
|
+
console.log(`${color ? RED : ""}Uncovered:${color ? RESET : ""}`);
|
|
138
|
+
for (const ep of uncovered) {
|
|
139
|
+
console.log(` ${color ? RED : ""}✗${color ? RESET : ""} ${ep.method.padEnd(7)} ${ep.path}`);
|
|
140
|
+
}
|
|
136
141
|
}
|
|
137
142
|
}
|
|
138
|
-
}
|
|
139
143
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
144
|
+
// Static warnings (always shown in human-readable mode)
|
|
145
|
+
const warnings = analyzeEndpoints(allEndpoints);
|
|
146
|
+
if (warnings.length > 0) {
|
|
147
|
+
console.log("");
|
|
148
|
+
console.log(`${color ? YELLOW : ""}Spec warnings:${color ? RESET : ""}`);
|
|
149
|
+
for (const w of warnings) {
|
|
150
|
+
console.log(` ${color ? YELLOW : ""}⚠${color ? RESET : ""} ${w.method.padEnd(7)} ${w.path}: ${w.warnings.join(", ")}`);
|
|
151
|
+
}
|
|
147
152
|
}
|
|
148
153
|
}
|
|
149
154
|
|
package/src/cli/commands/db.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { getCollections, getRuns, getRunDetail, diagnoseRun, compareRuns } from "../../core/diagnostics/db-analysis.ts";
|
|
2
|
+
import { getFilteredResults } from "../../db/queries.ts";
|
|
3
|
+
import { getDb } from "../../db/schema.ts";
|
|
2
4
|
import { printError } from "../output.ts";
|
|
3
5
|
import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
|
|
4
6
|
|
|
@@ -9,6 +11,8 @@ export interface DbOptions {
|
|
|
9
11
|
verbose?: boolean;
|
|
10
12
|
dbPath?: string;
|
|
11
13
|
json?: boolean;
|
|
14
|
+
method?: string;
|
|
15
|
+
status?: number;
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
export async function dbCommand(options: DbOptions): Promise<number> {
|
|
@@ -58,11 +62,22 @@ export async function dbCommand(options: DbOptions): Promise<number> {
|
|
|
58
62
|
else printError(msg);
|
|
59
63
|
return 2;
|
|
60
64
|
}
|
|
61
|
-
|
|
62
|
-
if (
|
|
63
|
-
|
|
65
|
+
// If filtering by method/status, show filtered results instead of full detail
|
|
66
|
+
if (options.method || options.status !== undefined) {
|
|
67
|
+
getDb(options.dbPath);
|
|
68
|
+
const results = getFilteredResults(id, { method: options.method, status: options.status });
|
|
69
|
+
if (json) {
|
|
70
|
+
printJson(jsonOk("db run", { run_id: id, count: results.length, results }));
|
|
71
|
+
} else {
|
|
72
|
+
console.log(JSON.stringify({ run_id: id, count: results.length, results }, null, 2));
|
|
73
|
+
}
|
|
64
74
|
} else {
|
|
65
|
-
|
|
75
|
+
const detail = getRunDetail(id, options.verbose, options.dbPath);
|
|
76
|
+
if (json) {
|
|
77
|
+
printJson(jsonOk("db run", detail));
|
|
78
|
+
} else {
|
|
79
|
+
console.log(JSON.stringify(detail, null, 2));
|
|
80
|
+
}
|
|
66
81
|
}
|
|
67
82
|
return 0;
|
|
68
83
|
}
|
|
@@ -75,7 +90,7 @@ export async function dbCommand(options: DbOptions): Promise<number> {
|
|
|
75
90
|
else printError(msg);
|
|
76
91
|
return 2;
|
|
77
92
|
}
|
|
78
|
-
const result = diagnoseRun(id, options.verbose, options.dbPath);
|
|
93
|
+
const result = diagnoseRun(id, options.verbose, options.dbPath, options.limit);
|
|
79
94
|
if (json) {
|
|
80
95
|
printJson(jsonOk("db diagnose", result));
|
|
81
96
|
} else {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describeEndpoint, describeCompact } from "../../core/generator/describe.ts";
|
|
1
|
+
import { describeEndpoint, describeCompact, describeAllParams } from "../../core/generator/describe.ts";
|
|
2
2
|
import { printError } from "../output.ts";
|
|
3
3
|
import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
|
|
4
4
|
|
|
@@ -7,11 +7,36 @@ export interface DescribeOptions {
|
|
|
7
7
|
method?: string;
|
|
8
8
|
path?: string;
|
|
9
9
|
compact?: boolean;
|
|
10
|
+
listParams?: boolean;
|
|
10
11
|
json?: boolean;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export async function describeCommand(options: DescribeOptions): Promise<number> {
|
|
14
15
|
try {
|
|
16
|
+
if (options.listParams) {
|
|
17
|
+
const params = await describeAllParams(options.specPath);
|
|
18
|
+
if (options.json) {
|
|
19
|
+
printJson(jsonOk("describe", { params }));
|
|
20
|
+
} else {
|
|
21
|
+
const grouped = new Map<string, typeof params>();
|
|
22
|
+
for (const p of params) {
|
|
23
|
+
const arr = grouped.get(p.in) ?? [];
|
|
24
|
+
arr.push(p);
|
|
25
|
+
grouped.set(p.in, arr);
|
|
26
|
+
}
|
|
27
|
+
for (const [location, locationParams] of grouped) {
|
|
28
|
+
console.log(`\n${location.toUpperCase()} parameters:`);
|
|
29
|
+
for (const p of locationParams) {
|
|
30
|
+
const req = p.required ? " (required)" : "";
|
|
31
|
+
const type = p.type ? ` [${p.type}]` : "";
|
|
32
|
+
console.log(` ${p.name}${type}${req} — used in ${p.usedBy.length} endpoint(s)`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
console.log(`\n${params.length} unique parameter(s)`);
|
|
36
|
+
}
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
15
40
|
if (options.compact) {
|
|
16
41
|
const endpoints = await describeCompact(options.specPath);
|
|
17
42
|
|
|
@@ -8,9 +8,10 @@ import {
|
|
|
8
8
|
filterUncoveredEndpoints,
|
|
9
9
|
serializeSuite,
|
|
10
10
|
} from "../../core/generator/index.ts";
|
|
11
|
-
import { generateSuites } from "../../core/generator/suite-generator.ts";
|
|
11
|
+
import { generateSuites, findUnresolvedVars } from "../../core/generator/suite-generator.ts";
|
|
12
12
|
import { filterByTag } from "../../core/generator/chunker.ts";
|
|
13
13
|
import { parse } from "../../core/parser/yaml-parser.ts";
|
|
14
|
+
import { decycleSchema } from "../../core/generator/schema-utils.ts";
|
|
14
15
|
import { printError, printSuccess } from "../output.ts";
|
|
15
16
|
import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
|
|
16
17
|
import { readMeta, writeMeta, hashSpec, buildFileMeta } from "../../core/meta/meta-store.ts";
|
|
@@ -82,7 +83,7 @@ export async function generateCommand(options: GenerateOptions): Promise<number>
|
|
|
82
83
|
|
|
83
84
|
// Write .zond-meta.json (merge with existing meta to preserve info about prior files)
|
|
84
85
|
const existingMeta = await readMeta(options.output);
|
|
85
|
-
const specContent = typeof doc === "object" ? JSON.stringify(doc) : String(doc);
|
|
86
|
+
const specContent = typeof doc === "object" ? JSON.stringify(decycleSchema(doc)) : String(doc);
|
|
86
87
|
await writeMeta(options.output, {
|
|
87
88
|
zondVersion: ZOND_VERSION,
|
|
88
89
|
lastSyncedAt: new Date().toISOString(),
|
|
@@ -103,12 +104,23 @@ export async function generateCommand(options: GenerateOptions): Promise<number>
|
|
|
103
104
|
// DB unavailable — not fatal
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
// Create .env.yaml with base_url
|
|
107
|
+
// Create .env.yaml with base_url and unresolved variables as placeholders
|
|
107
108
|
const envPath = join(options.output, ".env.yaml");
|
|
108
109
|
const envFile = Bun.file(envPath);
|
|
109
|
-
if (!(await envFile.exists())
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
if (!(await envFile.exists())) {
|
|
111
|
+
const unresolvedVars = new Set<string>();
|
|
112
|
+
for (const suite of suites) {
|
|
113
|
+
for (const v of findUnresolvedVars(suite)) unresolvedVars.add(v);
|
|
114
|
+
}
|
|
115
|
+
const lines: string[] = [];
|
|
116
|
+
if (baseUrl) lines.push(`base_url: ${baseUrl}`);
|
|
117
|
+
for (const v of [...unresolvedVars].sort()) {
|
|
118
|
+
lines.push(`${v}: "" # TODO: fill in`);
|
|
119
|
+
}
|
|
120
|
+
if (lines.length > 0) {
|
|
121
|
+
await Bun.write(envPath, lines.join("\n") + "\n");
|
|
122
|
+
warnings.push(`Created ${envPath} with ${unresolvedVars.size} placeholder variable(s)`);
|
|
123
|
+
}
|
|
112
124
|
}
|
|
113
125
|
|
|
114
126
|
// Validate generated files
|
package/src/cli/commands/run.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { dirname } from "path";
|
|
|
2
2
|
import { stat } from "node:fs/promises";
|
|
3
3
|
import { parse } from "../../core/parser/yaml-parser.ts";
|
|
4
4
|
import { loadEnvironment } from "../../core/parser/variables.ts";
|
|
5
|
-
import { filterSuitesByTags } from "../../core/parser/filter.ts";
|
|
5
|
+
import { filterSuitesByTags, excludeSuitesByTags, filterSuitesByMethod } from "../../core/parser/filter.ts";
|
|
6
6
|
import { runSuite } from "../../core/runner/executor.ts";
|
|
7
7
|
import { getReporter } from "../../core/reporter/index.ts";
|
|
8
8
|
import type { ReporterName } from "../../core/reporter/types.ts";
|
|
@@ -25,6 +25,8 @@ export interface RunOptions {
|
|
|
25
25
|
authToken?: string;
|
|
26
26
|
safe?: boolean;
|
|
27
27
|
tag?: string[];
|
|
28
|
+
excludeTag?: string[];
|
|
29
|
+
method?: string;
|
|
28
30
|
envVars?: string[];
|
|
29
31
|
dryRun?: boolean;
|
|
30
32
|
json?: boolean;
|
|
@@ -54,6 +56,24 @@ export async function runCommand(options: RunOptions): Promise<number> {
|
|
|
54
56
|
}
|
|
55
57
|
}
|
|
56
58
|
|
|
59
|
+
// 1b2. Exclude-tag filter
|
|
60
|
+
if (options.excludeTag && options.excludeTag.length > 0) {
|
|
61
|
+
suites = excludeSuitesByTags(suites, options.excludeTag);
|
|
62
|
+
if (suites.length === 0) {
|
|
63
|
+
printWarning("All suites excluded by --exclude-tag");
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 1b3. Method filter
|
|
69
|
+
if (options.method) {
|
|
70
|
+
suites = filterSuitesByMethod(suites, options.method);
|
|
71
|
+
if (suites.length === 0) {
|
|
72
|
+
printWarning(`No tests found with method ${options.method.toUpperCase()}`);
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
57
77
|
// 1c. Safe mode: keep GET, set-only steps, and auth-related requests
|
|
58
78
|
if (options.safe) {
|
|
59
79
|
for (const suite of suites) {
|
package/src/cli/commands/sync.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { generateSuites } from "../../core/generator/suite-generator.ts";
|
|
|
10
10
|
import { filterByTag } from "../../core/generator/chunker.ts";
|
|
11
11
|
import { readMeta, writeMeta, hashSpec, buildFileMeta } from "../../core/meta/meta-store.ts";
|
|
12
12
|
import { diffEndpoints } from "../../core/sync/spec-differ.ts";
|
|
13
|
+
import { decycleSchema } from "../../core/generator/schema-utils.ts";
|
|
13
14
|
import { printError, printSuccess, printWarning } from "../output.ts";
|
|
14
15
|
import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
|
|
15
16
|
import { version as ZOND_VERSION } from "../../../package.json";
|
|
@@ -41,7 +42,7 @@ export async function syncCommand(options: SyncOptions): Promise<number> {
|
|
|
41
42
|
|
|
42
43
|
// Load current spec
|
|
43
44
|
const doc = await readOpenApiSpec(options.specPath);
|
|
44
|
-
const specContent = JSON.stringify(doc);
|
|
45
|
+
const specContent = JSON.stringify(decycleSchema(doc));
|
|
45
46
|
const currentHash = hashSpec(specContent);
|
|
46
47
|
|
|
47
48
|
if (currentHash === meta.specHash) {
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { VERSION } from "../index.ts";
|
|
2
|
+
import { isCompiledBinary } from "../runtime.ts";
|
|
3
|
+
import { printError, printSuccess, printWarning } from "../output.ts";
|
|
4
|
+
import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
|
|
5
|
+
|
|
6
|
+
export interface UpdateOptions {
|
|
7
|
+
json?: boolean;
|
|
8
|
+
check?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const REPO = "kirrosh/zond";
|
|
12
|
+
const GITHUB_API = `https://api.github.com/repos/${REPO}/releases/latest`;
|
|
13
|
+
|
|
14
|
+
interface GitHubRelease {
|
|
15
|
+
tag_name: string;
|
|
16
|
+
assets: { name: string; browser_download_url: string }[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getTarget(): { target: string; ext: string } | null {
|
|
20
|
+
const platform = process.platform;
|
|
21
|
+
const arch = process.arch;
|
|
22
|
+
|
|
23
|
+
if (platform === "linux" && arch === "x64") return { target: "linux-x64", ext: "tar.gz" };
|
|
24
|
+
if (platform === "darwin" && arch === "arm64") return { target: "darwin-arm64", ext: "tar.gz" };
|
|
25
|
+
if (platform === "win32" && arch === "x64") return { target: "win-x64", ext: "zip" };
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function fetchLatestRelease(): Promise<GitHubRelease> {
|
|
30
|
+
const resp = await fetch(GITHUB_API, {
|
|
31
|
+
headers: { "User-Agent": `zond/${VERSION}` },
|
|
32
|
+
});
|
|
33
|
+
if (!resp.ok) {
|
|
34
|
+
throw new Error(`GitHub API returned ${resp.status}: ${resp.statusText}`);
|
|
35
|
+
}
|
|
36
|
+
return resp.json() as Promise<GitHubRelease>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function updateCommand(options: UpdateOptions): Promise<number> {
|
|
40
|
+
try {
|
|
41
|
+
if (!isCompiledBinary()) {
|
|
42
|
+
const msg = "Self-update is only available for standalone binaries. Install binary: curl -fsSL https://raw.githubusercontent.com/kirrosh/zond/master/install.sh | sh";
|
|
43
|
+
if (options.json) {
|
|
44
|
+
printJson(jsonOk("update", { action: "skip", reason: "not-standalone", installHint: "curl -fsSL https://raw.githubusercontent.com/kirrosh/zond/master/install.sh | sh" }, [msg]));
|
|
45
|
+
} else {
|
|
46
|
+
printWarning(msg);
|
|
47
|
+
}
|
|
48
|
+
return 3;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const target = getTarget();
|
|
52
|
+
if (!target) {
|
|
53
|
+
const msg = `Unsupported platform: ${process.platform}-${process.arch}`;
|
|
54
|
+
if (options.json) {
|
|
55
|
+
printJson(jsonError("update", [msg]));
|
|
56
|
+
} else {
|
|
57
|
+
printError(msg);
|
|
58
|
+
}
|
|
59
|
+
return 2;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const release = await fetchLatestRelease();
|
|
63
|
+
const latest = release.tag_name.replace(/^v/, "");
|
|
64
|
+
|
|
65
|
+
if (latest === VERSION) {
|
|
66
|
+
const msg = `Already up to date (${VERSION})`;
|
|
67
|
+
if (options.json) {
|
|
68
|
+
printJson(jsonOk("update", { action: "none", currentVersion: VERSION, latestVersion: latest }));
|
|
69
|
+
} else {
|
|
70
|
+
console.log(msg);
|
|
71
|
+
}
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (options.check) {
|
|
76
|
+
const msg = `Update available: ${VERSION} → ${latest}`;
|
|
77
|
+
if (options.json) {
|
|
78
|
+
printJson(jsonOk("update", { action: "available", currentVersion: VERSION, latestVersion: latest }));
|
|
79
|
+
} else {
|
|
80
|
+
console.log(msg);
|
|
81
|
+
}
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Find the right asset
|
|
86
|
+
const assetName = `zond-${target.target}.${target.ext}`;
|
|
87
|
+
const asset = release.assets.find(a => a.name === assetName);
|
|
88
|
+
if (!asset) {
|
|
89
|
+
const msg = `Binary not found for ${target.target} in release ${release.tag_name}`;
|
|
90
|
+
if (options.json) {
|
|
91
|
+
printJson(jsonError("update", [msg]));
|
|
92
|
+
} else {
|
|
93
|
+
printError(msg);
|
|
94
|
+
}
|
|
95
|
+
return 2;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
console.log(`Updating zond ${VERSION} → ${latest}...`);
|
|
99
|
+
console.log(`Downloading ${assetName}...`);
|
|
100
|
+
|
|
101
|
+
// Download the archive
|
|
102
|
+
const resp = await fetch(asset.browser_download_url, {
|
|
103
|
+
headers: { "User-Agent": `zond/${VERSION}` },
|
|
104
|
+
});
|
|
105
|
+
if (!resp.ok) {
|
|
106
|
+
throw new Error(`Download failed: ${resp.status} ${resp.statusText}`);
|
|
107
|
+
}
|
|
108
|
+
const archiveData = new Uint8Array(await resp.arrayBuffer());
|
|
109
|
+
|
|
110
|
+
const currentBinary = process.execPath;
|
|
111
|
+
const { join, dirname } = await import("path");
|
|
112
|
+
const tmpDir = join(dirname(currentBinary), `.zond-update-${Date.now()}`);
|
|
113
|
+
const { mkdir, rm, rename, chmod } = await import("fs/promises");
|
|
114
|
+
await mkdir(tmpDir, { recursive: true });
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const archivePath = join(tmpDir, assetName);
|
|
118
|
+
await Bun.write(archivePath, archiveData);
|
|
119
|
+
|
|
120
|
+
// Extract
|
|
121
|
+
if (target.ext === "tar.gz") {
|
|
122
|
+
const proc = Bun.spawn(["tar", "-xzf", archivePath, "-C", tmpDir]);
|
|
123
|
+
const exitCode = await proc.exited;
|
|
124
|
+
if (exitCode !== 0) throw new Error(`tar extraction failed (exit ${exitCode})`);
|
|
125
|
+
} else {
|
|
126
|
+
// Windows zip
|
|
127
|
+
const proc = Bun.spawn([
|
|
128
|
+
"powershell", "-NoProfile", "-Command",
|
|
129
|
+
`Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}' -Force`,
|
|
130
|
+
]);
|
|
131
|
+
const exitCode = await proc.exited;
|
|
132
|
+
if (exitCode !== 0) throw new Error(`Zip extraction failed (exit ${exitCode})`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Find the extracted binary
|
|
136
|
+
const binaryName = process.platform === "win32" ? "zond.exe" : "zond";
|
|
137
|
+
const newBinary = join(tmpDir, binaryName);
|
|
138
|
+
const file = Bun.file(newBinary);
|
|
139
|
+
if (!await file.exists()) {
|
|
140
|
+
throw new Error(`Binary '${binaryName}' not found in archive`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Replace current binary
|
|
144
|
+
try {
|
|
145
|
+
if (process.platform === "win32") {
|
|
146
|
+
// Windows: rename current to .old, move new, clean up
|
|
147
|
+
const oldBinary = currentBinary + ".old";
|
|
148
|
+
try { await rm(oldBinary, { force: true }); } catch {}
|
|
149
|
+
await rename(currentBinary, oldBinary);
|
|
150
|
+
await rename(newBinary, currentBinary);
|
|
151
|
+
try { await rm(oldBinary, { force: true }); } catch {}
|
|
152
|
+
} else {
|
|
153
|
+
await rename(newBinary, currentBinary);
|
|
154
|
+
await chmod(currentBinary, 0o755);
|
|
155
|
+
}
|
|
156
|
+
} catch (replaceErr: any) {
|
|
157
|
+
if (replaceErr?.code === "EACCES" || replaceErr?.code === "EPERM") {
|
|
158
|
+
const hint = process.platform === "win32"
|
|
159
|
+
? `Permission denied. Run the terminal as Administrator.`
|
|
160
|
+
: `Permission denied writing to ${currentBinary}. Run: sudo zond update`;
|
|
161
|
+
if (options.json) {
|
|
162
|
+
printJson(jsonError("update", [hint]));
|
|
163
|
+
} else {
|
|
164
|
+
printError(hint);
|
|
165
|
+
}
|
|
166
|
+
return 2;
|
|
167
|
+
}
|
|
168
|
+
throw replaceErr;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (options.json) {
|
|
172
|
+
printJson(jsonOk("update", { action: "updated", previousVersion: VERSION, newVersion: latest }));
|
|
173
|
+
} else {
|
|
174
|
+
printSuccess(`Updated zond ${VERSION} → ${latest}`);
|
|
175
|
+
}
|
|
176
|
+
return 0;
|
|
177
|
+
} finally {
|
|
178
|
+
try { await rm(tmpDir, { recursive: true, force: true }); } catch {}
|
|
179
|
+
}
|
|
180
|
+
} catch (err) {
|
|
181
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
182
|
+
if (options.json) {
|
|
183
|
+
printJson(jsonError("update", [message]));
|
|
184
|
+
} else {
|
|
185
|
+
printError(message);
|
|
186
|
+
}
|
|
187
|
+
return 2;
|
|
188
|
+
}
|
|
189
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { guideCommand } from "./commands/guide.ts";
|
|
|
13
13
|
import { generateCommand } from "./commands/generate.ts";
|
|
14
14
|
import { exportCommand } from "./commands/export.ts";
|
|
15
15
|
import { syncCommand } from "./commands/sync.ts";
|
|
16
|
+
import { updateCommand } from "./commands/update.ts";
|
|
16
17
|
import { printError } from "./output.ts";
|
|
17
18
|
import { getRuntimeInfo } from "./runtime.ts";
|
|
18
19
|
import { getDb } from "../db/schema.ts";
|
|
@@ -107,6 +108,7 @@ Usage:
|
|
|
107
108
|
zond ci init Generate CI/CD workflow (GitHub Actions, GitLab CI)
|
|
108
109
|
zond export postman <path> Export YAML tests as Postman Collection v2.1
|
|
109
110
|
zond sync <spec> Detect new/removed endpoints and generate tests for new ones
|
|
111
|
+
zond update Check for updates and self-update the binary
|
|
110
112
|
|
|
111
113
|
Options for 'run':
|
|
112
114
|
--dry-run Show requests without sending them (exit code always 0)
|
|
@@ -121,6 +123,8 @@ Options for 'run':
|
|
|
121
123
|
--auth-token <token> Auth token injected as {{auth_token}} variable
|
|
122
124
|
--safe Run only GET tests (read-only, safe mode)
|
|
123
125
|
--tag <tag> Filter suites by tag (repeatable, comma-separated, OR logic)
|
|
126
|
+
--exclude-tag <tag> Exclude suites by tag (repeatable, comma-separated)
|
|
127
|
+
--method <method> Filter tests by HTTP method (e.g. GET, POST)
|
|
124
128
|
|
|
125
129
|
Options for 'init':
|
|
126
130
|
--name <name> API name (auto-detected from spec title if omitted)
|
|
@@ -130,14 +134,15 @@ Options for 'init':
|
|
|
130
134
|
|
|
131
135
|
Options for 'describe':
|
|
132
136
|
--compact List all endpoints briefly
|
|
137
|
+
--list-params List all unique parameters across all endpoints
|
|
133
138
|
--method <method> HTTP method for single endpoint detail
|
|
134
139
|
--path <path> Endpoint path for single endpoint detail
|
|
135
140
|
|
|
136
141
|
Options for 'db':
|
|
137
142
|
zond db collections List all API collections
|
|
138
143
|
zond db runs [--limit N] List recent test runs
|
|
139
|
-
zond db run <id> [--verbose] Show run details
|
|
140
|
-
zond db diagnose <id> Diagnose run failures
|
|
144
|
+
zond db run <id> [--verbose] Show run details (--method GET, --status 403 to filter)
|
|
145
|
+
zond db diagnose <id> Diagnose run failures (--limit N examples per group, --verbose for all)
|
|
141
146
|
zond db compare <idA> <idB> Compare two runs
|
|
142
147
|
|
|
143
148
|
Options for 'request':
|
|
@@ -186,6 +191,9 @@ Options for 'sync':
|
|
|
186
191
|
--dry-run Show what would be generated without writing files
|
|
187
192
|
--tag <tag> Limit sync to endpoints with this tag
|
|
188
193
|
|
|
194
|
+
Options for 'update':
|
|
195
|
+
--check Only check for updates, do not download
|
|
196
|
+
|
|
189
197
|
General:
|
|
190
198
|
--json Output in JSON envelope format (available for all commands)
|
|
191
199
|
--help, -h Show this help
|
|
@@ -251,8 +259,9 @@ async function main(): Promise<number> {
|
|
|
251
259
|
}
|
|
252
260
|
}
|
|
253
261
|
|
|
254
|
-
// Collect all --tag and --env-var flags (parseArgs only stores last one, so re-parse)
|
|
262
|
+
// Collect all --tag, --exclude-tag, and --env-var flags (parseArgs only stores last one, so re-parse)
|
|
255
263
|
const tagValues: string[] = [];
|
|
264
|
+
const excludeTagValues: string[] = [];
|
|
256
265
|
const envVarValues: string[] = [];
|
|
257
266
|
const rawRunArgs = process.argv.slice(2);
|
|
258
267
|
for (let i = 0; i < rawRunArgs.length; i++) {
|
|
@@ -262,6 +271,11 @@ async function main(): Promise<number> {
|
|
|
262
271
|
i++;
|
|
263
272
|
} else if (arg.startsWith("--tag=")) {
|
|
264
273
|
tagValues.push(arg.slice("--tag=".length));
|
|
274
|
+
} else if (arg === "--exclude-tag" && rawRunArgs[i + 1]) {
|
|
275
|
+
excludeTagValues.push(rawRunArgs[i + 1]!);
|
|
276
|
+
i++;
|
|
277
|
+
} else if (arg.startsWith("--exclude-tag=")) {
|
|
278
|
+
excludeTagValues.push(arg.slice("--exclude-tag=".length));
|
|
265
279
|
} else if (arg === "--env-var" && rawRunArgs[i + 1]) {
|
|
266
280
|
envVarValues.push(rawRunArgs[i + 1]!);
|
|
267
281
|
i++;
|
|
@@ -271,6 +285,7 @@ async function main(): Promise<number> {
|
|
|
271
285
|
}
|
|
272
286
|
// Support comma-separated: --tag smoke,crud → ["smoke", "crud"]
|
|
273
287
|
const tags = tagValues.flatMap(v => v.split(",")).filter(Boolean);
|
|
288
|
+
const excludeTags = excludeTagValues.flatMap(v => v.split(",")).filter(Boolean);
|
|
274
289
|
|
|
275
290
|
return runCommand({
|
|
276
291
|
path,
|
|
@@ -283,6 +298,8 @@ async function main(): Promise<number> {
|
|
|
283
298
|
authToken: typeof flags["auth-token"] === "string" ? flags["auth-token"] : undefined,
|
|
284
299
|
safe: flags["safe"] === true,
|
|
285
300
|
tag: tags.length > 0 ? tags : undefined,
|
|
301
|
+
excludeTag: excludeTags.length > 0 ? excludeTags : undefined,
|
|
302
|
+
method: typeof flags["method"] === "string" ? flags["method"] : undefined,
|
|
286
303
|
envVars: envVarValues.length > 0 ? envVarValues : undefined,
|
|
287
304
|
dryRun: flags["dry-run"] === true,
|
|
288
305
|
json: jsonFlag,
|
|
@@ -405,6 +422,7 @@ async function main(): Promise<number> {
|
|
|
405
422
|
return describeCommand({
|
|
406
423
|
specPath,
|
|
407
424
|
compact: flags["compact"] === true,
|
|
425
|
+
listParams: flags["list-params"] === true,
|
|
408
426
|
method: typeof flags["method"] === "string" ? flags["method"] : undefined,
|
|
409
427
|
path: typeof flags["path"] === "string" ? flags["path"] : undefined,
|
|
410
428
|
json: jsonFlag,
|
|
@@ -423,6 +441,13 @@ async function main(): Promise<number> {
|
|
|
423
441
|
limit = parseInt(limitRaw, 10);
|
|
424
442
|
if (isNaN(limit) || limit <= 0) limit = undefined;
|
|
425
443
|
}
|
|
444
|
+
const statusRaw = flags["status"];
|
|
445
|
+
let statusFilter: number | undefined;
|
|
446
|
+
if (typeof statusRaw === "string") {
|
|
447
|
+
statusFilter = parseInt(statusRaw, 10);
|
|
448
|
+
if (isNaN(statusFilter)) statusFilter = undefined;
|
|
449
|
+
}
|
|
450
|
+
|
|
426
451
|
return dbCommand({
|
|
427
452
|
subcommand: dbSub,
|
|
428
453
|
positional: positional.slice(1),
|
|
@@ -430,6 +455,8 @@ async function main(): Promise<number> {
|
|
|
430
455
|
verbose: flags["verbose"] === true,
|
|
431
456
|
dbPath: typeof flags["db"] === "string" ? flags["db"] : undefined,
|
|
432
457
|
json: jsonFlag,
|
|
458
|
+
method: typeof flags["method"] === "string" ? flags["method"] : undefined,
|
|
459
|
+
status: statusFilter,
|
|
433
460
|
});
|
|
434
461
|
}
|
|
435
462
|
|
|
@@ -528,6 +555,14 @@ async function main(): Promise<number> {
|
|
|
528
555
|
});
|
|
529
556
|
}
|
|
530
557
|
|
|
558
|
+
case "update":
|
|
559
|
+
case "self-update": {
|
|
560
|
+
return updateCommand({
|
|
561
|
+
check: flags["check"] === true,
|
|
562
|
+
json: jsonFlag,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
531
566
|
case "sync": {
|
|
532
567
|
const specPath = positional[0];
|
|
533
568
|
if (!specPath) {
|
|
@@ -169,7 +169,7 @@ export interface DiagnoseResult {
|
|
|
169
169
|
grouped_failures?: FailureGroup[];
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string): DiagnoseResult {
|
|
172
|
+
export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string, maxExamples?: number): DiagnoseResult {
|
|
173
173
|
getDb(dbPath);
|
|
174
174
|
const diagRun = getRunById(runId);
|
|
175
175
|
if (!diagRun) throw new Error(`Run ${runId} not found`);
|
|
@@ -272,7 +272,7 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string):
|
|
|
272
272
|
|
|
273
273
|
const { grouped_failures, compactFailures } = verbose
|
|
274
274
|
? { grouped_failures: undefined, compactFailures: failures }
|
|
275
|
-
: groupFailures(failures);
|
|
275
|
+
: groupFailures(failures, maxExamples);
|
|
276
276
|
|
|
277
277
|
return {
|
|
278
278
|
run: {
|
|
@@ -301,7 +301,7 @@ export function diagnoseRun(runId: number, verbose?: boolean, dbPath?: string):
|
|
|
301
301
|
type FailureItem = { suite_name: string; test_name: string; failure_type: string; recommended_action: RecommendedAction; hint?: string; response_status: number | null };
|
|
302
302
|
|
|
303
303
|
/** Group similar failures for compact output. Exported for testing. */
|
|
304
|
-
export function groupFailures<T extends FailureItem>(failures: T[]): { grouped_failures?: FailureGroup[]; compactFailures: T[] } {
|
|
304
|
+
export function groupFailures<T extends FailureItem>(failures: T[], maxExamples = 2): { grouped_failures?: FailureGroup[]; compactFailures: T[] } {
|
|
305
305
|
if (failures.length <= 5) {
|
|
306
306
|
return { compactFailures: failures };
|
|
307
307
|
}
|
|
@@ -341,7 +341,7 @@ export function groupFailures<T extends FailureItem>(failures: T[]): { grouped_f
|
|
|
341
341
|
failure_type: group.failure_type,
|
|
342
342
|
recommended_action: group.items[0]!.recommended_action,
|
|
343
343
|
hint: group.hint,
|
|
344
|
-
examples: group.items.slice(0,
|
|
344
|
+
examples: (maxExamples === 0 ? group.items : group.items.slice(0, maxExamples)).map(f => `${f.suite_name}/${f.test_name}`),
|
|
345
345
|
response_status: group.response_status,
|
|
346
346
|
});
|
|
347
347
|
compactFailures.push(group.items[0]!);
|
|
@@ -7,7 +7,10 @@ import type { OpenAPIV3 } from "openapi-types";
|
|
|
7
7
|
export function generateFromSchema(
|
|
8
8
|
schema: OpenAPIV3.SchemaObject,
|
|
9
9
|
propertyName?: string,
|
|
10
|
+
_depth = 0,
|
|
10
11
|
): unknown {
|
|
12
|
+
if (_depth > 5) return {};
|
|
13
|
+
|
|
11
14
|
// allOf: merge all schemas
|
|
12
15
|
if (schema.allOf) {
|
|
13
16
|
const merged: OpenAPIV3.SchemaObject = { type: "object", properties: {} };
|
|
@@ -17,15 +20,15 @@ export function generateFromSchema(
|
|
|
17
20
|
merged.properties = { ...merged.properties, ...s.properties };
|
|
18
21
|
}
|
|
19
22
|
}
|
|
20
|
-
return generateFromSchema(merged, propertyName);
|
|
23
|
+
return generateFromSchema(merged, propertyName, _depth + 1);
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
// oneOf / anyOf: use first variant
|
|
24
27
|
if (schema.oneOf) {
|
|
25
|
-
return generateFromSchema(schema.oneOf[0] as OpenAPIV3.SchemaObject, propertyName);
|
|
28
|
+
return generateFromSchema(schema.oneOf[0] as OpenAPIV3.SchemaObject, propertyName, _depth + 1);
|
|
26
29
|
}
|
|
27
30
|
if (schema.anyOf) {
|
|
28
|
-
return generateFromSchema(schema.anyOf[0] as OpenAPIV3.SchemaObject, propertyName);
|
|
31
|
+
return generateFromSchema(schema.anyOf[0] as OpenAPIV3.SchemaObject, propertyName, _depth + 1);
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
// enum: first value
|
|
@@ -51,7 +54,7 @@ export function generateFromSchema(
|
|
|
51
54
|
|
|
52
55
|
case "array": {
|
|
53
56
|
if (schema.items) {
|
|
54
|
-
const item = generateFromSchema(schema.items as OpenAPIV3.SchemaObject);
|
|
57
|
+
const item = generateFromSchema(schema.items as OpenAPIV3.SchemaObject, undefined, _depth + 1);
|
|
55
58
|
return [item];
|
|
56
59
|
}
|
|
57
60
|
return [];
|
|
@@ -63,14 +66,14 @@ export function generateFromSchema(
|
|
|
63
66
|
if (schema.properties) {
|
|
64
67
|
const obj: Record<string, unknown> = {};
|
|
65
68
|
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
66
|
-
obj[key] = generateFromSchema(propSchema as OpenAPIV3.SchemaObject, key);
|
|
69
|
+
obj[key] = generateFromSchema(propSchema as OpenAPIV3.SchemaObject, key, _depth + 1);
|
|
67
70
|
}
|
|
68
71
|
return obj;
|
|
69
72
|
}
|
|
70
73
|
// Record type: additionalProperties defines value schema
|
|
71
74
|
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
|
|
72
75
|
const valSchema = schema.additionalProperties as OpenAPIV3.SchemaObject;
|
|
73
|
-
return { key1: generateFromSchema(valSchema, "key1"), key2: generateFromSchema(valSchema, "key2") };
|
|
76
|
+
return { key1: generateFromSchema(valSchema, "key1", _depth + 1), key2: generateFromSchema(valSchema, "key2", _depth + 1) };
|
|
74
77
|
}
|
|
75
78
|
if (schema.additionalProperties === true) {
|
|
76
79
|
return { key1: "value1", key2: "value2" };
|
|
@@ -248,3 +248,55 @@ export async function describeCompact(
|
|
|
248
248
|
|
|
249
249
|
return result;
|
|
250
250
|
}
|
|
251
|
+
|
|
252
|
+
export interface ParamInfo {
|
|
253
|
+
name: string;
|
|
254
|
+
in: string;
|
|
255
|
+
type?: string;
|
|
256
|
+
required: boolean;
|
|
257
|
+
usedBy: string[];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export async function describeAllParams(
|
|
261
|
+
specPath: string,
|
|
262
|
+
options?: { insecure?: boolean },
|
|
263
|
+
): Promise<ParamInfo[]> {
|
|
264
|
+
const doc = await readOpenApiSpec(specPath, options) as OpenAPIV3.Document;
|
|
265
|
+
const paths = doc.paths ?? {};
|
|
266
|
+
const paramMap = new Map<string, ParamInfo>();
|
|
267
|
+
|
|
268
|
+
for (const [path, pathItem] of Object.entries(paths)) {
|
|
269
|
+
const pathParams = ((pathItem as any)?.parameters ?? []) as OpenAPIV3.ParameterObject[];
|
|
270
|
+
|
|
271
|
+
for (const method of ["get", "post", "put", "patch", "delete"]) {
|
|
272
|
+
const op = (pathItem as any)?.[method] as OpenAPIV3.OperationObject | undefined;
|
|
273
|
+
if (!op) continue;
|
|
274
|
+
|
|
275
|
+
const allParams = [...pathParams, ...((op.parameters ?? []) as OpenAPIV3.ParameterObject[])];
|
|
276
|
+
const endpoint = `${method.toUpperCase()} ${path}`;
|
|
277
|
+
|
|
278
|
+
for (const p of allParams) {
|
|
279
|
+
const key = `${p.in}:${p.name}`;
|
|
280
|
+
const existing = paramMap.get(key);
|
|
281
|
+
const schema = p.schema as OpenAPIV3.SchemaObject | undefined;
|
|
282
|
+
if (existing) {
|
|
283
|
+
existing.usedBy.push(endpoint);
|
|
284
|
+
if (p.required) existing.required = true;
|
|
285
|
+
} else {
|
|
286
|
+
paramMap.set(key, {
|
|
287
|
+
name: p.name,
|
|
288
|
+
in: p.in,
|
|
289
|
+
type: schema?.type,
|
|
290
|
+
required: p.required ?? false,
|
|
291
|
+
usedBy: [endpoint],
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return [...paramMap.values()].sort((a, b) => {
|
|
299
|
+
if (a.in !== b.in) return a.in.localeCompare(b.in);
|
|
300
|
+
return a.name.localeCompare(b.name);
|
|
301
|
+
});
|
|
302
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { EndpointInfo } from "./types.ts";
|
|
2
2
|
|
|
3
|
-
export type WarningCode = "deprecated" | "no_response_schema" | "no_responses_defined" | "required_params_no_examples";
|
|
3
|
+
export type WarningCode = "deprecated" | "no_response_schema" | "no_responses_defined" | "required_params_no_examples" | "post_body_as_query";
|
|
4
4
|
|
|
5
5
|
export interface EndpointWarning {
|
|
6
6
|
method: string;
|
|
@@ -34,6 +34,15 @@ export function analyzeEndpoints(endpoints: EndpointInfo[]): EndpointWarning[] {
|
|
|
34
34
|
warnings.push(`required_params_no_examples: ${missingExamples.join(", ")}`);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// SpringDoc quirk: POST/PUT/PATCH with query param named "body" or single complex object query param
|
|
38
|
+
if (["POST", "PUT", "PATCH"].includes(ep.method)) {
|
|
39
|
+
const queryParams = ep.parameters.filter(p => p.in === "query");
|
|
40
|
+
const hasBodyQuery = queryParams.some(p => p.name.toLowerCase() === "body");
|
|
41
|
+
if (hasBodyQuery) {
|
|
42
|
+
warnings.push("post_body_as_query: query param 'body' on POST/PUT/PATCH likely means request body (SpringDoc quirk)");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
37
46
|
if (warnings.length > 0) {
|
|
38
47
|
result.push({ method: ep.method, path: ep.path, warnings });
|
|
39
48
|
}
|
|
@@ -15,8 +15,8 @@ function convertPath(path: string): string {
|
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Convert path params to seed values for smoke suites (no capture context).
|
|
18
|
-
* Uses the parameter's example/default from the spec, or falls back to
|
|
19
|
-
*
|
|
18
|
+
* Uses the parameter's example/default from the spec, or falls back to
|
|
19
|
+
* {{placeholder}} form so the user fills them in via .env.yaml.
|
|
20
20
|
*/
|
|
21
21
|
function convertPathWithSeeds(path: string, ep: EndpointInfo): string {
|
|
22
22
|
return path.replace(/\{([^}]+)\}/g, (_, name: string) => {
|
|
@@ -24,7 +24,6 @@ function convertPathWithSeeds(path: string, ep: EndpointInfo): string {
|
|
|
24
24
|
const schema = param?.schema as OpenAPIV3.SchemaObject | undefined;
|
|
25
25
|
const example = (param as any)?.example ?? schema?.example ?? schema?.default;
|
|
26
26
|
if (example !== undefined) return String(example);
|
|
27
|
-
if (schema?.type === "integer" || /^id$|_id$|Id$/i.test(name)) return "1";
|
|
28
27
|
return `{{${name}}}`;
|
|
29
28
|
});
|
|
30
29
|
}
|
|
@@ -81,6 +80,20 @@ function getBodyAssertions(ep: EndpointInfo): Record<string, Record<string, stri
|
|
|
81
80
|
return undefined;
|
|
82
81
|
}
|
|
83
82
|
|
|
83
|
+
/** Derive a variable name for a security scheme's token */
|
|
84
|
+
function schemeVarName(scheme: SecuritySchemeInfo, allSchemes: SecuritySchemeInfo[]): string {
|
|
85
|
+
// Count how many bearer-like schemes exist
|
|
86
|
+
const bearerSchemes = allSchemes.filter(s =>
|
|
87
|
+
(s.type === "http" && (s.scheme === "bearer" || !s.scheme)) ||
|
|
88
|
+
(s.type === "apiKey" && s.in === "header" && s.apiKeyName === "Authorization")
|
|
89
|
+
);
|
|
90
|
+
// If only one bearer scheme → keep generic auth_token for backward compat
|
|
91
|
+
if (bearerSchemes.length <= 1) return "auth_token";
|
|
92
|
+
// Multiple → derive from scheme name (e.g. "platformAuth" → "platform_auth_token")
|
|
93
|
+
const slug = scheme.name.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/_token$|_auth$/, "");
|
|
94
|
+
return `${slug}_token`;
|
|
95
|
+
}
|
|
96
|
+
|
|
84
97
|
function getAuthHeaders(
|
|
85
98
|
ep: EndpointInfo,
|
|
86
99
|
schemes: SecuritySchemeInfo[],
|
|
@@ -93,16 +106,15 @@ function getAuthHeaders(
|
|
|
93
106
|
|
|
94
107
|
if (scheme.type === "http") {
|
|
95
108
|
if (scheme.scheme === "bearer" || !scheme.scheme) {
|
|
96
|
-
return { Authorization:
|
|
109
|
+
return { Authorization: `Bearer {{${schemeVarName(scheme, schemes)}}}` };
|
|
97
110
|
}
|
|
98
111
|
if (scheme.scheme === "basic") {
|
|
99
|
-
return { Authorization:
|
|
112
|
+
return { Authorization: `Basic {{${schemeVarName(scheme, schemes)}}}` };
|
|
100
113
|
}
|
|
101
114
|
}
|
|
102
115
|
if (scheme.type === "apiKey" && scheme.in === "header" && scheme.apiKeyName) {
|
|
103
|
-
// When apiKey scheme uses Authorization header, it's typically a Bearer token
|
|
104
116
|
if (scheme.apiKeyName === "Authorization") {
|
|
105
|
-
return { Authorization:
|
|
117
|
+
return { Authorization: `Bearer {{${schemeVarName(scheme, schemes)}}}` };
|
|
106
118
|
}
|
|
107
119
|
return { [scheme.apiKeyName]: "{{api_key}}" };
|
|
108
120
|
}
|
|
@@ -111,14 +123,15 @@ function getAuthHeaders(
|
|
|
111
123
|
return undefined;
|
|
112
124
|
}
|
|
113
125
|
|
|
114
|
-
function getRequiredQueryParams(ep: EndpointInfo): Record<string,
|
|
126
|
+
function getRequiredQueryParams(ep: EndpointInfo): Record<string, string> | undefined {
|
|
115
127
|
const queryParams = ep.parameters.filter(p => p.in === "query" && p.required);
|
|
116
128
|
if (queryParams.length === 0) return undefined;
|
|
117
129
|
|
|
118
|
-
const query: Record<string,
|
|
130
|
+
const query: Record<string, string> = {};
|
|
119
131
|
for (const p of queryParams) {
|
|
120
132
|
if (p.schema) {
|
|
121
|
-
|
|
133
|
+
const val = generateFromSchema(p.schema as OpenAPIV3.SchemaObject, p.name);
|
|
134
|
+
query[p.name] = typeof val === "object" ? JSON.stringify(val) : String(val);
|
|
122
135
|
} else {
|
|
123
136
|
query[p.name] = "{{$randomString}}";
|
|
124
137
|
}
|
|
@@ -387,9 +400,10 @@ export function generateCrudSuite(
|
|
|
387
400
|
}
|
|
388
401
|
|
|
389
402
|
/** Find unresolved template variables in a suite (excluding known globals, captured vars, and env keys) */
|
|
390
|
-
export function findUnresolvedVars(suite: RawSuite, envKeys?: Set<string>): string[] {
|
|
403
|
+
export function findUnresolvedVars(suite: RawSuite, envKeys?: Set<string>, extraKnown?: Set<string>): string[] {
|
|
391
404
|
const KNOWN = new Set(["base_url", "auth_token", "api_key"]);
|
|
392
405
|
if (envKeys) for (const k of envKeys) KNOWN.add(k);
|
|
406
|
+
if (extraKnown) for (const k of extraKnown) KNOWN.add(k);
|
|
393
407
|
const captured = new Set<string>();
|
|
394
408
|
for (const step of suite.tests) {
|
|
395
409
|
if (step.expect?.body) {
|
|
@@ -518,8 +532,13 @@ function generateConsistentAuthSuite(
|
|
|
518
532
|
/token|access_token|accessToken|jwt/i.test(k)
|
|
519
533
|
);
|
|
520
534
|
if (tokenField) {
|
|
535
|
+
// Determine the capture variable name based on the login endpoint's security scheme
|
|
536
|
+
const loginScheme = loginEp.security.length > 0
|
|
537
|
+
? securitySchemes.find(s => s.name === loginEp.security[0])
|
|
538
|
+
: undefined;
|
|
539
|
+
const captureVar = loginScheme ? schemeVarName(loginScheme, securitySchemes) : "auth_token";
|
|
521
540
|
if (!loginStep.expect.body) loginStep.expect.body = {};
|
|
522
|
-
loginStep.expect.body[tokenField] = { capture:
|
|
541
|
+
loginStep.expect.body[tokenField] = { capture: captureVar };
|
|
523
542
|
}
|
|
524
543
|
}
|
|
525
544
|
tests.push(loginStep);
|
|
@@ -12,3 +12,29 @@ export function filterSuitesByTags(suites: TestSuite[], tags: string[]): TestSui
|
|
|
12
12
|
return suite.tags.some(t => normalizedTags.includes(t.toLowerCase()));
|
|
13
13
|
});
|
|
14
14
|
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Exclude suites whose tags intersect with the exclusion set (OR logic, case-insensitive).
|
|
18
|
+
* Suites without tags are kept.
|
|
19
|
+
*/
|
|
20
|
+
export function excludeSuitesByTags(suites: TestSuite[], excludeTags: string[]): TestSuite[] {
|
|
21
|
+
if (excludeTags.length === 0) return suites;
|
|
22
|
+
const normalizedTags = excludeTags.map(t => t.toLowerCase());
|
|
23
|
+
return suites.filter(suite => {
|
|
24
|
+
if (!suite.tags || suite.tags.length === 0) return true;
|
|
25
|
+
return !suite.tags.some(t => normalizedTags.includes(t.toLowerCase()));
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Filter test steps within suites by HTTP method (case-insensitive).
|
|
31
|
+
* Suites with no remaining tests are removed.
|
|
32
|
+
*/
|
|
33
|
+
export function filterSuitesByMethod(suites: TestSuite[], method: string): TestSuite[] {
|
|
34
|
+
const upperMethod = method.toUpperCase();
|
|
35
|
+
const filtered = suites.map(suite => ({
|
|
36
|
+
...suite,
|
|
37
|
+
tests: suite.tests.filter(t => (t.method ?? "GET").toUpperCase() === upperMethod),
|
|
38
|
+
}));
|
|
39
|
+
return filtered.filter(s => s.tests.length > 0);
|
|
40
|
+
}
|
package/src/db/queries.ts
CHANGED
|
@@ -292,6 +292,30 @@ export function getResultsByRunId(runId: number): StoredStepResult[] {
|
|
|
292
292
|
}));
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
+
export function getFilteredResults(runId: number, filters: { method?: string; status?: number }): StoredStepResult[] {
|
|
296
|
+
const db = getDb();
|
|
297
|
+
const conditions = ["run_id = ?"];
|
|
298
|
+
const params: (string | number)[] = [runId];
|
|
299
|
+
|
|
300
|
+
if (filters.method) {
|
|
301
|
+
conditions.push("request_method = ?");
|
|
302
|
+
params.push(filters.method.toUpperCase());
|
|
303
|
+
}
|
|
304
|
+
if (filters.status !== undefined) {
|
|
305
|
+
conditions.push("response_status = ?");
|
|
306
|
+
params.push(filters.status);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const rows = db.query(`SELECT * FROM results WHERE ${conditions.join(" AND ")} ORDER BY id`).all(...params) as Array<
|
|
310
|
+
Omit<StoredStepResult, "assertions" | "captures"> & { assertions: string | null; captures: string | null }
|
|
311
|
+
>;
|
|
312
|
+
return rows.map((row) => ({
|
|
313
|
+
...row,
|
|
314
|
+
assertions: row.assertions ? JSON.parse(row.assertions) : [],
|
|
315
|
+
captures: row.captures ? JSON.parse(row.captures) : {},
|
|
316
|
+
}));
|
|
317
|
+
}
|
|
318
|
+
|
|
295
319
|
// ──────────────────────────────────────────────
|
|
296
320
|
// Dashboard metrics
|
|
297
321
|
// ──────────────────────────────────────────────
|
package/src/web/server.ts
CHANGED
|
@@ -4,7 +4,7 @@ import dashboard from "./routes/dashboard.ts";
|
|
|
4
4
|
import runs from "./routes/runs.ts";
|
|
5
5
|
import api from "./routes/api.ts";
|
|
6
6
|
import styleCssPath from "./static/style.css" with { type: "file" };
|
|
7
|
-
import htmxJsPath from "./static/htmx.min.
|
|
7
|
+
import htmxJsPath from "./static/htmx.min.cjs" with { type: "file" };
|
|
8
8
|
|
|
9
9
|
export interface ServerOptions {
|
|
10
10
|
port?: number;
|
|
File without changes
|