@kirrosh/zond 0.21.0 → 0.22.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 +110 -3
- package/README.md +26 -15
- package/package.json +10 -6
- package/src/cli/commands/ci-init.ts +12 -6
- package/src/cli/commands/completions.ts +176 -0
- package/src/cli/commands/db.ts +2 -1
- package/src/cli/commands/generate.ts +0 -1
- package/src/cli/commands/init/agents-md.ts +61 -0
- package/src/cli/commands/init/bootstrap.ts +79 -0
- package/src/cli/commands/init/skills.ts +45 -0
- package/src/cli/commands/init/templates/agents.md +73 -0
- package/src/cli/commands/init/templates/markdown.d.ts +4 -0
- package/src/cli/commands/init/templates/skills/scenarios.md +97 -0
- package/src/cli/commands/init/templates/skills/zond.md +184 -0
- package/src/cli/commands/init/templates/zond-config.yml +15 -0
- package/src/cli/commands/init.ts +124 -31
- package/src/cli/commands/probe-methods.ts +108 -0
- package/src/cli/commands/probe-validation.ts +124 -0
- package/src/cli/commands/run.ts +99 -10
- package/src/cli/commands/serve.ts +52 -19
- package/src/cli/commands/sync.ts +0 -1
- package/src/cli/commands/update.ts +1 -1
- package/src/cli/commands/use.ts +57 -0
- package/src/cli/index.ts +21 -609
- package/src/cli/program.ts +655 -0
- package/src/cli/version.ts +3 -0
- package/src/core/context/current.ts +35 -0
- package/src/core/diagnostics/db-analysis.ts +11 -2
- package/src/core/diagnostics/render-md.ts +112 -0
- package/src/core/generator/chunker.ts +14 -2
- package/src/core/generator/data-factory.ts +50 -19
- package/src/core/generator/guide-builder.ts +1 -1
- package/src/core/generator/openapi-reader.ts +18 -0
- package/src/core/generator/serializer.ts +11 -2
- package/src/core/generator/suite-generator.ts +106 -7
- package/src/core/meta/types.ts +0 -2
- package/src/core/parser/schema.ts +3 -1
- package/src/core/parser/types.ts +10 -1
- package/src/core/parser/variables.ts +90 -2
- package/src/core/parser/yaml-parser.ts +50 -1
- package/src/core/probe/method-probe.ts +197 -0
- package/src/core/probe/negative-probe.ts +657 -0
- package/src/core/reporter/console.ts +29 -3
- package/src/core/reporter/index.ts +2 -2
- package/src/core/reporter/json.ts +5 -2
- package/src/core/runner/assertions.ts +4 -1
- package/src/core/runner/executor.ts +132 -37
- package/src/core/runner/http-client.ts +40 -5
- package/src/core/runner/rate-limiter.ts +131 -0
- package/src/core/setup-api.ts +4 -1
- package/src/core/workspace/root.ts +94 -0
- package/src/db/schema.ts +4 -1
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { mkdir } from "fs/promises";
|
|
3
|
+
import {
|
|
4
|
+
readOpenApiSpec,
|
|
5
|
+
extractEndpoints,
|
|
6
|
+
extractSecuritySchemes,
|
|
7
|
+
serializeSuite,
|
|
8
|
+
} from "../../core/generator/index.ts";
|
|
9
|
+
import { filterByTag } from "../../core/generator/chunker.ts";
|
|
10
|
+
import { generateMethodProbes } from "../../core/probe/method-probe.ts";
|
|
11
|
+
import { printError, printSuccess } from "../output.ts";
|
|
12
|
+
import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
|
|
13
|
+
|
|
14
|
+
export interface ProbeMethodsOptions {
|
|
15
|
+
specPath: string;
|
|
16
|
+
output: string;
|
|
17
|
+
tag?: string;
|
|
18
|
+
json?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function probeMethodsCommand(
|
|
22
|
+
options: ProbeMethodsOptions,
|
|
23
|
+
): Promise<number> {
|
|
24
|
+
try {
|
|
25
|
+
const doc = await readOpenApiSpec(options.specPath);
|
|
26
|
+
let endpoints = extractEndpoints(doc);
|
|
27
|
+
const securitySchemes = extractSecuritySchemes(doc);
|
|
28
|
+
|
|
29
|
+
if (options.tag) endpoints = filterByTag(endpoints, options.tag);
|
|
30
|
+
|
|
31
|
+
if (endpoints.length === 0) {
|
|
32
|
+
const message = "No endpoints to probe.";
|
|
33
|
+
if (options.json) {
|
|
34
|
+
printJson(jsonOk("probe-methods", { files: [], message }));
|
|
35
|
+
} else {
|
|
36
|
+
console.log(message);
|
|
37
|
+
}
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const result = generateMethodProbes({
|
|
42
|
+
endpoints,
|
|
43
|
+
securitySchemes,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
if (result.suites.length === 0) {
|
|
47
|
+
const message =
|
|
48
|
+
"Every path declares all of GET/POST/PUT/PATCH/DELETE — nothing to probe.";
|
|
49
|
+
if (options.json) {
|
|
50
|
+
printJson(
|
|
51
|
+
jsonOk("probe-methods", {
|
|
52
|
+
files: [],
|
|
53
|
+
probedPaths: 0,
|
|
54
|
+
skippedPaths: result.skippedPaths,
|
|
55
|
+
totalProbes: 0,
|
|
56
|
+
message,
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
} else {
|
|
60
|
+
console.log(message);
|
|
61
|
+
}
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
await mkdir(options.output, { recursive: true });
|
|
66
|
+
|
|
67
|
+
const created: Array<{ file: string; suite: string; tests: number }> = [];
|
|
68
|
+
for (const suite of result.suites) {
|
|
69
|
+
const fileName = `${suite.fileStem ?? suite.name}.yaml`;
|
|
70
|
+
const filePath = join(options.output, fileName);
|
|
71
|
+
await Bun.write(filePath, serializeSuite(suite));
|
|
72
|
+
created.push({ file: filePath, suite: suite.name, tests: suite.tests.length });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (options.json) {
|
|
76
|
+
printJson(
|
|
77
|
+
jsonOk("probe-methods", {
|
|
78
|
+
files: created,
|
|
79
|
+
probedPaths: result.probedPaths,
|
|
80
|
+
skippedPaths: result.skippedPaths,
|
|
81
|
+
totalProbes: result.totalProbes,
|
|
82
|
+
outputDir: options.output,
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
} else {
|
|
86
|
+
printSuccess(
|
|
87
|
+
`Generated ${result.suites.length} method-probe suite(s) with ${result.totalProbes} probe(s) in ${options.output}`,
|
|
88
|
+
);
|
|
89
|
+
console.log(
|
|
90
|
+
` ${result.probedPaths} path(s) probed, ${result.skippedPaths} skipped (full method coverage)`,
|
|
91
|
+
);
|
|
92
|
+
console.log("");
|
|
93
|
+
console.log("Next steps:");
|
|
94
|
+
console.log(` zond run ${options.output} --report json # any 5xx or 2xx → bug candidate`);
|
|
95
|
+
console.log(` zond db diagnose <run-id> # inspect failures`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return 0;
|
|
99
|
+
} catch (err) {
|
|
100
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
101
|
+
if (options.json) {
|
|
102
|
+
printJson(jsonError("probe-methods", [message]));
|
|
103
|
+
} else {
|
|
104
|
+
printError(message);
|
|
105
|
+
}
|
|
106
|
+
return 2;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { mkdir } from "fs/promises";
|
|
3
|
+
import {
|
|
4
|
+
readOpenApiSpec,
|
|
5
|
+
extractEndpoints,
|
|
6
|
+
extractSecuritySchemes,
|
|
7
|
+
serializeSuite,
|
|
8
|
+
} from "../../core/generator/index.ts";
|
|
9
|
+
import { filterByTag, collectTags } from "../../core/generator/chunker.ts";
|
|
10
|
+
import { generateNegativeProbes } from "../../core/probe/negative-probe.ts";
|
|
11
|
+
import { printError, printSuccess, printWarning } from "../output.ts";
|
|
12
|
+
import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
|
|
13
|
+
|
|
14
|
+
export interface ProbeValidationOptions {
|
|
15
|
+
specPath: string;
|
|
16
|
+
output: string;
|
|
17
|
+
tag?: string;
|
|
18
|
+
maxPerEndpoint?: number;
|
|
19
|
+
noCleanup?: boolean;
|
|
20
|
+
json?: boolean;
|
|
21
|
+
listTags?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function probeValidationCommand(
|
|
25
|
+
options: ProbeValidationOptions,
|
|
26
|
+
): Promise<number> {
|
|
27
|
+
try {
|
|
28
|
+
const doc = await readOpenApiSpec(options.specPath);
|
|
29
|
+
const allEndpoints = extractEndpoints(doc);
|
|
30
|
+
const securitySchemes = extractSecuritySchemes(doc);
|
|
31
|
+
|
|
32
|
+
if (options.listTags) {
|
|
33
|
+
const tags = collectTags(allEndpoints);
|
|
34
|
+
if (options.json) {
|
|
35
|
+
printJson(jsonOk("probe-validation", { tags }));
|
|
36
|
+
} else {
|
|
37
|
+
if (tags.length === 0) {
|
|
38
|
+
console.log("No tags found in spec.");
|
|
39
|
+
} else {
|
|
40
|
+
console.log("Available tags:");
|
|
41
|
+
for (const t of tags) console.log(` - ${t}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let endpoints = allEndpoints;
|
|
48
|
+
if (options.tag) {
|
|
49
|
+
endpoints = filterByTag(allEndpoints, options.tag);
|
|
50
|
+
if (endpoints.length === 0) {
|
|
51
|
+
const available = collectTags(allEndpoints);
|
|
52
|
+
const msg = `No endpoints tagged "${options.tag}". Available tags: ${available.length ? available.join(", ") : "(none)"}`;
|
|
53
|
+
if (options.json) {
|
|
54
|
+
printJson(jsonError("probe-validation", [msg]));
|
|
55
|
+
} else {
|
|
56
|
+
printWarning(msg);
|
|
57
|
+
}
|
|
58
|
+
return 2;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (endpoints.length === 0) {
|
|
63
|
+
const message = "No endpoints to probe.";
|
|
64
|
+
if (options.json) {
|
|
65
|
+
printJson(jsonOk("probe-validation", { files: [], message }));
|
|
66
|
+
} else {
|
|
67
|
+
console.log(message);
|
|
68
|
+
}
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const result = generateNegativeProbes({
|
|
73
|
+
endpoints,
|
|
74
|
+
securitySchemes,
|
|
75
|
+
maxProbesPerEndpoint: options.maxPerEndpoint,
|
|
76
|
+
noCleanup: options.noCleanup,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await mkdir(options.output, { recursive: true });
|
|
80
|
+
|
|
81
|
+
const created: Array<{ file: string; suite: string; tests: number }> = [];
|
|
82
|
+
for (const suite of result.suites) {
|
|
83
|
+
const fileName = `${suite.fileStem ?? suite.name}.yaml`;
|
|
84
|
+
const filePath = join(options.output, fileName);
|
|
85
|
+
await Bun.write(filePath, serializeSuite(suite));
|
|
86
|
+
created.push({ file: filePath, suite: suite.name, tests: suite.tests.length });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (options.json) {
|
|
90
|
+
printJson(
|
|
91
|
+
jsonOk("probe-validation", {
|
|
92
|
+
files: created,
|
|
93
|
+
probedEndpoints: result.probedEndpoints,
|
|
94
|
+
skippedEndpoints: result.skippedEndpoints,
|
|
95
|
+
totalProbes: result.totalProbes,
|
|
96
|
+
outputDir: options.output,
|
|
97
|
+
warnings: result.warnings,
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
} else {
|
|
101
|
+
printSuccess(
|
|
102
|
+
`Generated ${result.suites.length} probe suite(s) with ${result.totalProbes} probe(s) in ${options.output}`,
|
|
103
|
+
);
|
|
104
|
+
console.log(
|
|
105
|
+
` ${result.probedEndpoints} endpoint(s) probed, ${result.skippedEndpoints} skipped (no probable surface)`,
|
|
106
|
+
);
|
|
107
|
+
for (const w of result.warnings) printWarning(w);
|
|
108
|
+
console.log("");
|
|
109
|
+
console.log("Next steps:");
|
|
110
|
+
console.log(` zond run ${options.output} --report json # any 5xx → bug candidate`);
|
|
111
|
+
console.log(` zond db diagnose <run-id> # inspect failures`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return 0;
|
|
115
|
+
} catch (err) {
|
|
116
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
117
|
+
if (options.json) {
|
|
118
|
+
printJson(jsonError("probe-validation", [message]));
|
|
119
|
+
} else {
|
|
120
|
+
printError(message);
|
|
121
|
+
}
|
|
122
|
+
return 2;
|
|
123
|
+
}
|
|
124
|
+
}
|
package/src/cli/commands/run.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { dirname } from "path";
|
|
2
2
|
import { stat } from "node:fs/promises";
|
|
3
3
|
import { parse } from "../../core/parser/yaml-parser.ts";
|
|
4
|
-
import { loadEnvironment } from "../../core/parser/variables.ts";
|
|
4
|
+
import { loadEnvironment, loadEnvMeta, loadEnvFile } from "../../core/parser/variables.ts";
|
|
5
5
|
import { filterSuitesByTags, excludeSuitesByTags, filterSuitesByMethod } from "../../core/parser/filter.ts";
|
|
6
6
|
import { runSuite } from "../../core/runner/executor.ts";
|
|
7
|
-
import {
|
|
7
|
+
import { createRateLimiter, createAdaptiveRateLimiter } from "../../core/runner/rate-limiter.ts";
|
|
8
|
+
import { getReporter, generateJsonReport, generateJunitXml } from "../../core/reporter/index.ts";
|
|
8
9
|
import type { ReporterName } from "../../core/reporter/types.ts";
|
|
10
|
+
import { writeFile, mkdir } from "node:fs/promises";
|
|
11
|
+
import { dirname as pathDirname, isAbsolute, resolve as pathResolve } from "node:path";
|
|
9
12
|
import type { TestSuite } from "../../core/parser/types.ts";
|
|
10
13
|
import type { TestRunResult } from "../../core/runner/types.ts";
|
|
11
14
|
import { printError, printWarning } from "../output.ts";
|
|
@@ -19,7 +22,10 @@ export interface RunOptions {
|
|
|
19
22
|
env?: string;
|
|
20
23
|
report: ReporterName;
|
|
21
24
|
timeout?: number;
|
|
25
|
+
rateLimit?: number | "auto";
|
|
22
26
|
bail: boolean;
|
|
27
|
+
/** Run regular suites sequentially (one after another) instead of in parallel. */
|
|
28
|
+
sequential?: boolean;
|
|
23
29
|
noDb?: boolean;
|
|
24
30
|
dbPath?: string;
|
|
25
31
|
authToken?: string;
|
|
@@ -30,6 +36,8 @@ export interface RunOptions {
|
|
|
30
36
|
envVars?: string[];
|
|
31
37
|
dryRun?: boolean;
|
|
32
38
|
json?: boolean;
|
|
39
|
+
/** Write the report to a file instead of stdout. */
|
|
40
|
+
reportOut?: string;
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
export async function runCommand(options: RunOptions): Promise<number> {
|
|
@@ -110,6 +118,30 @@ export async function runCommand(options: RunOptions): Promise<number> {
|
|
|
110
118
|
return 2;
|
|
111
119
|
}
|
|
112
120
|
|
|
121
|
+
// Auto-load ./.env.yaml from cwd when --env not given and the searchDir env
|
|
122
|
+
// file produced nothing. Useful when running with absolute test paths from
|
|
123
|
+
// a collection cwd (e.g. APPLY agents in the auto-loop).
|
|
124
|
+
if (!options.env && Object.keys(env).length === 0) {
|
|
125
|
+
const cwd = process.cwd();
|
|
126
|
+
const cwdEnvPath = `${cwd}/.env.yaml`;
|
|
127
|
+
// Avoid double-load if cwd is already covered by searchDir or its parent
|
|
128
|
+
const alreadyCovered = cwd === searchDir || cwd === dirname(searchDir);
|
|
129
|
+
if (!alreadyCovered) {
|
|
130
|
+
try {
|
|
131
|
+
const cwdVars = await loadEnvFile(cwdEnvPath);
|
|
132
|
+
if (cwdVars && Object.keys(cwdVars).length > 0) {
|
|
133
|
+
env = { ...cwdVars };
|
|
134
|
+
if (!options.json) {
|
|
135
|
+
process.stderr.write(`zond: using ./.env.yaml (cwd fallback)\n`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} catch (err) {
|
|
139
|
+
printError(`Failed to load environment: ${(err as Error).message}`);
|
|
140
|
+
return 2;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
113
145
|
// Inject CLI auth token — overrides env file value
|
|
114
146
|
if (options.authToken) {
|
|
115
147
|
env.auth_token = options.authToken;
|
|
@@ -137,6 +169,19 @@ export async function runCommand(options: RunOptions): Promise<number> {
|
|
|
137
169
|
}
|
|
138
170
|
}
|
|
139
171
|
|
|
172
|
+
// 3b. Resolve rate limit: CLI flag > .env.yaml `rateLimit:` field
|
|
173
|
+
let rateLimit: number | "auto" | undefined = options.rateLimit;
|
|
174
|
+
if (rateLimit === undefined) {
|
|
175
|
+
try {
|
|
176
|
+
const envMeta = await loadEnvMeta(options.env, searchDir);
|
|
177
|
+
rateLimit = envMeta.rateLimit;
|
|
178
|
+
} catch { /* meta load failure is non-fatal */ }
|
|
179
|
+
}
|
|
180
|
+
const rateLimiter = rateLimit === "auto"
|
|
181
|
+
? createAdaptiveRateLimiter()
|
|
182
|
+
: createRateLimiter(rateLimit);
|
|
183
|
+
const runOpts = { rateLimiter };
|
|
184
|
+
|
|
140
185
|
// 4. Run suites — setup suites run first (sequentially), their captures flow into regular suites
|
|
141
186
|
const results: TestRunResult[] = [];
|
|
142
187
|
const dryRun = options.dryRun === true;
|
|
@@ -146,7 +191,7 @@ export async function runCommand(options: RunOptions): Promise<number> {
|
|
|
146
191
|
const setupCaptures: Record<string, string> = {};
|
|
147
192
|
|
|
148
193
|
for (const suite of setupSuites) {
|
|
149
|
-
const result = await runSuite(suite, env, dryRun);
|
|
194
|
+
const result = await runSuite(suite, env, dryRun, runOpts);
|
|
150
195
|
results.push(result);
|
|
151
196
|
for (const step of result.steps) {
|
|
152
197
|
for (const [k, v] of Object.entries(step.captures)) {
|
|
@@ -160,15 +205,21 @@ export async function runCommand(options: RunOptions): Promise<number> {
|
|
|
160
205
|
if (options.bail) {
|
|
161
206
|
// Sequential with bail at suite level
|
|
162
207
|
for (const suite of regularSuites) {
|
|
163
|
-
const result = await runSuite(suite, enrichedEnv, dryRun);
|
|
208
|
+
const result = await runSuite(suite, enrichedEnv, dryRun, runOpts);
|
|
164
209
|
results.push(result);
|
|
165
210
|
if (!dryRun && (result.failed > 0 || result.steps.some((s) => s.status === "error"))) {
|
|
166
211
|
break;
|
|
167
212
|
}
|
|
168
213
|
}
|
|
214
|
+
} else if (options.sequential) {
|
|
215
|
+
// Sequential without bail — run suites one by one
|
|
216
|
+
for (const suite of regularSuites) {
|
|
217
|
+
const result = await runSuite(suite, enrichedEnv, dryRun, runOpts);
|
|
218
|
+
results.push(result);
|
|
219
|
+
}
|
|
169
220
|
} else {
|
|
170
221
|
// Parallel
|
|
171
|
-
const all = await Promise.all(regularSuites.map((suite) => runSuite(suite, enrichedEnv, dryRun)));
|
|
222
|
+
const all = await Promise.all(regularSuites.map((suite) => runSuite(suite, enrichedEnv, dryRun, runOpts)));
|
|
172
223
|
results.push(...all);
|
|
173
224
|
}
|
|
174
225
|
|
|
@@ -182,10 +233,45 @@ export async function runCommand(options: RunOptions): Promise<number> {
|
|
|
182
233
|
|
|
183
234
|
// 5b. Report
|
|
184
235
|
if (!options.json) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
236
|
+
if (options.reportOut) {
|
|
237
|
+
// Write report to a file via fs (bypass stdout). Console reporter falls
|
|
238
|
+
// back to a single-line summary on stdout; json/junit produce no stdout.
|
|
239
|
+
const outPath = isAbsolute(options.reportOut)
|
|
240
|
+
? options.reportOut
|
|
241
|
+
: pathResolve(process.cwd(), options.reportOut);
|
|
242
|
+
let content: string;
|
|
243
|
+
let label: string;
|
|
244
|
+
switch (options.report) {
|
|
245
|
+
case "json":
|
|
246
|
+
content = generateJsonReport(results);
|
|
247
|
+
label = "JSON";
|
|
248
|
+
break;
|
|
249
|
+
case "junit":
|
|
250
|
+
content = generateJunitXml(results);
|
|
251
|
+
label = "JUnit XML";
|
|
252
|
+
break;
|
|
253
|
+
default: // "console" — fall back to JSON in the file (most useful)
|
|
254
|
+
content = generateJsonReport(results);
|
|
255
|
+
label = "JSON";
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
await mkdir(pathDirname(outPath), { recursive: true });
|
|
260
|
+
await writeFile(outPath, content, "utf-8");
|
|
261
|
+
process.stderr.write(`zond: ${label} report written to ${outPath}\n`);
|
|
262
|
+
} catch (err) {
|
|
263
|
+
printError(`Failed to write --report-out file ${outPath}: ${(err as Error).message}`);
|
|
264
|
+
return 2;
|
|
265
|
+
}
|
|
266
|
+
for (const w of warnings) {
|
|
267
|
+
printWarning(w);
|
|
268
|
+
}
|
|
269
|
+
} else {
|
|
270
|
+
const reporter = getReporter(options.report);
|
|
271
|
+
reporter.report(results);
|
|
272
|
+
for (const w of warnings) {
|
|
273
|
+
printWarning(w);
|
|
274
|
+
}
|
|
189
275
|
}
|
|
190
276
|
}
|
|
191
277
|
|
|
@@ -226,10 +312,13 @@ export async function runCommand(options: RunOptions): Promise<number> {
|
|
|
226
312
|
test: s.name,
|
|
227
313
|
...(r.suite_file ? { file: r.suite_file } : {}),
|
|
228
314
|
status: s.status,
|
|
315
|
+
...(typeof s.response?.status === "number" ? { http_status: s.response.status } : {}),
|
|
316
|
+
...(typeof s.response?.status === "number" && s.response.status >= 500 && s.response.status < 600 ? { is_5xx: true } : {}),
|
|
229
317
|
error: s.error,
|
|
230
318
|
}))
|
|
231
319
|
);
|
|
232
|
-
|
|
320
|
+
const fiveXx = failures.filter(f => f.is_5xx).length;
|
|
321
|
+
printJson(jsonOk("run", { summary: { total, passed, failed, fiveXx }, failures, warnings, runId: savedRunId }));
|
|
233
322
|
}
|
|
234
323
|
|
|
235
324
|
return hasFailures ? 1 : 0;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { startServer } from "../../web/server.ts";
|
|
2
|
+
import { printError } from "../output.ts";
|
|
2
3
|
|
|
3
4
|
export interface ServeOptions {
|
|
4
5
|
port?: number;
|
|
@@ -6,14 +7,18 @@ export interface ServeOptions {
|
|
|
6
7
|
dbPath?: string;
|
|
7
8
|
watch?: boolean;
|
|
8
9
|
open?: boolean;
|
|
10
|
+
/** When true, kill any existing process holding `port` before binding (DANGEROUS). */
|
|
11
|
+
killExisting?: boolean;
|
|
9
12
|
}
|
|
10
13
|
|
|
11
|
-
/**
|
|
14
|
+
/** Range scanned when auto-picking a free port (only when --port is not set). */
|
|
15
|
+
const PORT_SCAN_LENGTH = 11; // 8080..8090 inclusive
|
|
16
|
+
|
|
17
|
+
/** Kill any existing process listening on the given port (Windows + Unix). */
|
|
12
18
|
async function killPortHolder(port: number): Promise<void> {
|
|
13
19
|
const isWin = process.platform === "win32";
|
|
14
20
|
try {
|
|
15
21
|
if (isWin) {
|
|
16
|
-
// PowerShell: find PID on port, then kill it
|
|
17
22
|
const find = Bun.spawn(["powershell", "-NoProfile", "-Command",
|
|
18
23
|
`(Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue).OwningProcess`], {
|
|
19
24
|
stdout: "pipe", stderr: "ignore",
|
|
@@ -26,12 +31,8 @@ async function killPortHolder(port: number): Promise<void> {
|
|
|
26
31
|
stdout: "ignore", stderr: "ignore",
|
|
27
32
|
});
|
|
28
33
|
}
|
|
29
|
-
if (pids.length > 0)
|
|
30
|
-
// Give OS time to release the port
|
|
31
|
-
await Bun.sleep(500);
|
|
32
|
-
}
|
|
34
|
+
if (pids.length > 0) await Bun.sleep(500);
|
|
33
35
|
} else {
|
|
34
|
-
// Unix: lsof + kill
|
|
35
36
|
const find = Bun.spawn(["lsof", "-ti", `:${port}`], {
|
|
36
37
|
stdout: "pipe", stderr: "ignore",
|
|
37
38
|
});
|
|
@@ -40,20 +41,54 @@ async function killPortHolder(port: number): Promise<void> {
|
|
|
40
41
|
for (const pid of pids) {
|
|
41
42
|
Bun.spawn(["kill", "-9", pid], { stdout: "ignore", stderr: "ignore" });
|
|
42
43
|
}
|
|
43
|
-
if (pids.length > 0)
|
|
44
|
-
await Bun.sleep(300);
|
|
45
|
-
}
|
|
44
|
+
if (pids.length > 0) await Bun.sleep(300);
|
|
46
45
|
}
|
|
47
46
|
} catch {
|
|
48
|
-
// Best effort — if we can't kill,
|
|
47
|
+
// Best effort — if we can't kill, the bind below will fail with port-in-use
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Returns true if `port` is free on `host` (best-effort: tries to bind & immediately stops). */
|
|
52
|
+
async function isPortFree(port: number, host: string): Promise<boolean> {
|
|
53
|
+
try {
|
|
54
|
+
const srv = Bun.serve({ port, hostname: host, fetch: () => new Response() });
|
|
55
|
+
srv.stop(true);
|
|
56
|
+
return true;
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Scans `[start, start+count)` and returns the first free port, or null. */
|
|
63
|
+
async function pickAvailablePort(start: number, count: number, host: string): Promise<number | null> {
|
|
64
|
+
for (let p = start; p < start + count; p++) {
|
|
65
|
+
if (await isPortFree(p, host)) return p;
|
|
49
66
|
}
|
|
67
|
+
return null;
|
|
50
68
|
}
|
|
51
69
|
|
|
52
70
|
export async function serveCommand(options: ServeOptions): Promise<number> {
|
|
53
|
-
const
|
|
71
|
+
const requested = options.port ?? 8080;
|
|
72
|
+
const host = options.host ?? "0.0.0.0";
|
|
54
73
|
|
|
55
|
-
|
|
56
|
-
|
|
74
|
+
let port: number;
|
|
75
|
+
if (options.killExisting) {
|
|
76
|
+
await killPortHolder(requested);
|
|
77
|
+
port = requested;
|
|
78
|
+
} else {
|
|
79
|
+
const picked = await pickAvailablePort(requested, PORT_SCAN_LENGTH, host);
|
|
80
|
+
if (picked === null) {
|
|
81
|
+
printError(
|
|
82
|
+
`All ports ${requested}..${requested + PORT_SCAN_LENGTH - 1} are in use. ` +
|
|
83
|
+
`Use --port <n> to pick another, or --kill-existing to free :${requested}.`,
|
|
84
|
+
);
|
|
85
|
+
return 1;
|
|
86
|
+
}
|
|
87
|
+
if (picked !== requested) {
|
|
88
|
+
process.stderr.write(`[zond] port ${requested} busy, using ${picked}\n`);
|
|
89
|
+
}
|
|
90
|
+
port = picked;
|
|
91
|
+
}
|
|
57
92
|
|
|
58
93
|
await startServer({
|
|
59
94
|
port,
|
|
@@ -62,20 +97,18 @@ export async function serveCommand(options: ServeOptions): Promise<number> {
|
|
|
62
97
|
dev: options.watch,
|
|
63
98
|
});
|
|
64
99
|
|
|
65
|
-
// Open browser if requested
|
|
66
100
|
if (options.open) {
|
|
67
|
-
const
|
|
68
|
-
const url = `http://${
|
|
101
|
+
const openHost = host === "0.0.0.0" ? "localhost" : host;
|
|
102
|
+
const url = `http://${openHost}:${port}`;
|
|
69
103
|
try {
|
|
70
104
|
const cmd = process.platform === "win32" ? ["cmd", "/c", "start", url]
|
|
71
105
|
: process.platform === "darwin" ? ["open", url]
|
|
72
106
|
: ["xdg-open", url];
|
|
73
107
|
Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore" });
|
|
74
108
|
} catch {
|
|
75
|
-
// Best effort
|
|
109
|
+
// Best effort
|
|
76
110
|
}
|
|
77
111
|
}
|
|
78
112
|
|
|
79
|
-
// Keep running — Bun.serve keeps the process alive
|
|
80
113
|
return 0;
|
|
81
114
|
}
|
package/src/cli/commands/sync.ts
CHANGED
|
@@ -178,7 +178,6 @@ export async function syncCommand(options: SyncOptions): Promise<number> {
|
|
|
178
178
|
await writeMeta(options.testsDir, {
|
|
179
179
|
zondVersion: ZOND_VERSION,
|
|
180
180
|
lastSyncedAt: new Date().toISOString(),
|
|
181
|
-
specUrl: options.specPath,
|
|
182
181
|
specHash: currentHash,
|
|
183
182
|
files: { ...meta.files, ...updatedMetaFiles },
|
|
184
183
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { VERSION } from "../
|
|
1
|
+
import { VERSION } from "../version.ts";
|
|
2
2
|
import { isCompiledBinary } from "../runtime.ts";
|
|
3
3
|
import { printError, printSuccess, printWarning } from "../output.ts";
|
|
4
4
|
import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clearCurrentApi,
|
|
3
|
+
currentApiPath,
|
|
4
|
+
readCurrentApi,
|
|
5
|
+
writeCurrentApi,
|
|
6
|
+
} from "../../core/context/current.ts";
|
|
7
|
+
import { jsonError, jsonOk, printJson } from "../json-envelope.ts";
|
|
8
|
+
import { printError, printSuccess } from "../output.ts";
|
|
9
|
+
|
|
10
|
+
export interface UseOptions {
|
|
11
|
+
api?: string;
|
|
12
|
+
clear?: boolean;
|
|
13
|
+
json?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function useCommand(opts: UseOptions): Promise<number> {
|
|
17
|
+
const path = currentApiPath();
|
|
18
|
+
|
|
19
|
+
if (opts.clear) {
|
|
20
|
+
const removed = clearCurrentApi();
|
|
21
|
+
if (opts.json) {
|
|
22
|
+
printJson(jsonOk("use", { action: "cleared", path, removed }));
|
|
23
|
+
} else if (removed) {
|
|
24
|
+
printSuccess(`Cleared ${path}`);
|
|
25
|
+
} else {
|
|
26
|
+
process.stdout.write(`No .zond-current file in ${process.cwd()}\n`);
|
|
27
|
+
}
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (opts.api) {
|
|
32
|
+
try {
|
|
33
|
+
writeCurrentApi(opts.api);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
36
|
+
if (opts.json) printJson(jsonError("use", [message]));
|
|
37
|
+
else printError(message);
|
|
38
|
+
return 1;
|
|
39
|
+
}
|
|
40
|
+
if (opts.json) {
|
|
41
|
+
printJson(jsonOk("use", { action: "set", api: opts.api, path }));
|
|
42
|
+
} else {
|
|
43
|
+
printSuccess(`Set current API to '${opts.api}' (${path})`);
|
|
44
|
+
}
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const current = readCurrentApi();
|
|
49
|
+
if (opts.json) {
|
|
50
|
+
printJson(jsonOk("use", { action: "show", api: current, path }));
|
|
51
|
+
} else if (current) {
|
|
52
|
+
process.stdout.write(`${current}\n`);
|
|
53
|
+
} else {
|
|
54
|
+
process.stdout.write(`No current API set. Run 'zond use <api>'.\n`);
|
|
55
|
+
}
|
|
56
|
+
return 0;
|
|
57
|
+
}
|