@kirrosh/zond 0.21.0 → 0.23.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 +758 -3
- package/README.md +78 -15
- package/package.json +17 -10
- package/src/cli/argv.ts +122 -0
- package/src/cli/commands/add-api.ts +134 -0
- package/src/cli/commands/api/annotate/idempotency.ts +59 -0
- package/src/cli/commands/api/annotate/index.ts +525 -0
- package/src/cli/commands/api/annotate/lifecycle.ts +74 -0
- package/src/cli/commands/api/annotate/overlay.ts +206 -0
- package/src/cli/commands/api/annotate/pagination.ts +60 -0
- package/src/cli/commands/api/annotate/prompts.ts +183 -0
- package/src/cli/commands/api/annotate/readback.ts +58 -0
- package/src/cli/commands/api/annotate/resources.ts +91 -0
- package/src/cli/commands/api/annotate/seed-bodies.ts +61 -0
- package/src/cli/commands/audit.ts +480 -0
- package/src/cli/commands/bootstrap.ts +710 -0
- package/src/cli/commands/catalog.ts +35 -0
- package/src/cli/commands/check.ts +348 -0
- package/src/cli/commands/checks.ts +756 -0
- package/src/cli/commands/ci-init.ts +55 -6
- package/src/cli/commands/clean.ts +212 -0
- package/src/cli/commands/cleanup.ts +262 -0
- package/src/cli/commands/completions.ts +192 -0
- package/src/cli/commands/coverage.ts +605 -132
- package/src/cli/commands/db.ts +180 -8
- package/src/cli/commands/describe.ts +37 -2
- package/src/cli/commands/discover.ts +1236 -0
- package/src/cli/commands/doctor.ts +607 -0
- package/src/cli/commands/fixtures.ts +402 -0
- package/src/cli/commands/generate.ts +420 -47
- package/src/cli/commands/init/agents-md.ts +61 -0
- package/src/cli/commands/init/bootstrap.ts +108 -0
- package/src/cli/commands/init/index.ts +244 -0
- package/src/cli/commands/init/skills.ts +98 -0
- package/src/cli/commands/init/templates/agents.md +77 -0
- package/src/cli/commands/init/templates/markdown.d.ts +4 -0
- package/src/cli/commands/init/templates/skills/zond-checks.md +397 -0
- package/src/cli/commands/init/templates/skills/zond-triage.md +210 -0
- package/src/cli/commands/init/templates/skills/zond.md +651 -0
- package/src/cli/commands/init/templates/zond-config.yml +14 -0
- package/src/cli/commands/prepare-fixtures.ts +135 -0
- package/src/cli/commands/probe/mass-assignment.ts +503 -0
- package/src/cli/commands/probe/security.ts +454 -0
- package/src/cli/commands/probe/static.ts +255 -0
- package/src/cli/commands/probe/webhooks.ts +161 -0
- package/src/cli/commands/probe.ts +459 -0
- package/src/cli/commands/reference.ts +87 -0
- package/src/cli/commands/refresh-api.ts +169 -0
- package/src/cli/commands/remove-api.ts +150 -0
- package/src/cli/commands/report-bundle.ts +318 -0
- package/src/cli/commands/report.ts +241 -0
- package/src/cli/commands/request.ts +379 -4
- package/src/cli/commands/run.ts +911 -33
- package/src/cli/commands/session.ts +244 -0
- package/src/cli/commands/use.ts +74 -0
- package/src/cli/index.ts +36 -607
- package/src/cli/json-envelope.ts +112 -3
- package/src/cli/json-schemas.ts +263 -0
- package/src/cli/program.ts +218 -0
- package/src/cli/resolve.ts +105 -0
- package/src/cli/status-filter.ts +124 -0
- package/src/cli/util/api-context.ts +85 -0
- package/src/cli/version.ts +8 -0
- package/src/core/anti-fp/bootstrap.ts +34 -0
- package/src/core/anti-fp/index.ts +33 -0
- package/src/core/anti-fp/registry.ts +44 -0
- package/src/core/anti-fp/rules/baseline-echo.ts +74 -0
- package/src/core/anti-fp/rules/schemathesis/body_negation_becomes_valid.ts +52 -0
- package/src/core/anti-fp/rules/schemathesis/coverage_phase_boundary_positive.ts +38 -0
- package/src/core/anti-fp/rules/schemathesis/has_unverifiable_mutations.ts +35 -0
- package/src/core/anti-fp/rules/schemathesis/index.ts +24 -0
- package/src/core/anti-fp/rules/schemathesis/string_type_mutation_becomes_valid.ts +53 -0
- package/src/core/anti-fp/rules/subscription-gated/index.ts +11 -0
- package/src/core/anti-fp/rules/subscription-gated/paid-plan-403.ts +75 -0
- package/src/core/anti-fp/types.ts +68 -0
- package/src/core/checks/checks/_crud-helpers.ts +133 -0
- package/src/core/checks/checks/_negative_mutator.ts +133 -0
- package/src/core/checks/checks/_readback-helpers.ts +133 -0
- package/src/core/checks/checks/content_type_conformance.ts +39 -0
- package/src/core/checks/checks/cross_call_references.ts +134 -0
- package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
- package/src/core/checks/checks/idempotency_replay.ts +246 -0
- package/src/core/checks/checks/ignored_auth.ts +211 -0
- package/src/core/checks/checks/index.ts +65 -0
- package/src/core/checks/checks/lifecycle_transitions.ts +273 -0
- package/src/core/checks/checks/missing_required_header.ts +40 -0
- package/src/core/checks/checks/negative_data_rejection.ts +45 -0
- package/src/core/checks/checks/not_a_server_error.ts +27 -0
- package/src/core/checks/checks/open_cors_on_sensitive.ts +131 -0
- package/src/core/checks/checks/pagination_invariants.ts +238 -0
- package/src/core/checks/checks/positive_data_acceptance.ts +36 -0
- package/src/core/checks/checks/rate_limit_headers_absent.ts +77 -0
- package/src/core/checks/checks/response_headers_conformance.ts +74 -0
- package/src/core/checks/checks/response_schema_conformance.ts +30 -0
- package/src/core/checks/checks/status_code_conformance.ts +61 -0
- package/src/core/checks/checks/unsupported_method.ts +63 -0
- package/src/core/checks/checks/use_after_free.ts +78 -0
- package/src/core/checks/index.ts +30 -0
- package/src/core/checks/mode.ts +79 -0
- package/src/core/checks/recommended-action.ts +64 -0
- package/src/core/checks/registry.ts +78 -0
- package/src/core/checks/runner.ts +874 -0
- package/src/core/checks/sarif.ts +230 -0
- package/src/core/checks/stateful.ts +121 -0
- package/src/core/checks/types.ts +189 -0
- package/src/core/classifier/recommended-action.ts +222 -0
- package/src/core/context/current.ts +51 -0
- package/src/core/context/session.ts +78 -0
- package/src/core/coverage/loader.ts +185 -0
- package/src/core/coverage/reasons.ts +300 -0
- package/src/core/diagnostics/db-analysis.ts +161 -12
- package/src/core/diagnostics/failure-class.ts +120 -0
- package/src/core/diagnostics/failure-hints.ts +212 -9
- package/src/core/diagnostics/spec-pointer.ts +99 -0
- package/src/core/diagnostics/suggested-fixes.ts +156 -0
- package/src/core/exporter/case-study/index.ts +270 -0
- package/src/core/exporter/curl.ts +40 -0
- package/src/core/exporter/exporter.ts +48 -0
- package/src/core/exporter/html-report/escape.ts +24 -0
- package/src/core/exporter/html-report/index.ts +479 -0
- package/src/core/exporter/html-report/script.ts +100 -0
- package/src/core/exporter/html-report/styles.ts +408 -0
- package/src/core/generator/chunker.ts +53 -15
- package/src/core/generator/coverage-phase.ts +0 -0
- package/src/core/generator/create-body.ts +89 -0
- package/src/core/generator/data-factory.ts +490 -33
- package/src/core/generator/describe.ts +1 -1
- package/src/core/generator/fixtures-builder.ts +325 -0
- package/src/core/generator/index.ts +7 -5
- package/src/core/generator/openapi-reader.ts +55 -3
- package/src/core/generator/path-param-disambig.ts +114 -0
- package/src/core/generator/resources-builder.ts +648 -0
- package/src/core/generator/schema-utils.ts +11 -3
- package/src/core/generator/serializer.ts +114 -15
- package/src/core/generator/suite-generator.ts +484 -77
- package/src/core/generator/types.ts +8 -0
- package/src/core/identity/identity-file.ts +129 -0
- package/src/core/lint/affects.ts +28 -0
- package/src/core/lint/config.ts +96 -0
- package/src/core/lint/format.ts +42 -0
- package/src/core/lint/index.ts +94 -0
- package/src/core/lint/reporter.ts +128 -0
- package/src/core/lint/rules/consistency.ts +158 -0
- package/src/core/lint/rules/heuristics.ts +97 -0
- package/src/core/lint/rules/strictness.ts +109 -0
- package/src/core/lint/types.ts +96 -0
- package/src/core/lint/walker.ts +248 -0
- package/src/core/meta/meta-store.ts +6 -73
- package/src/core/output/README.md +91 -0
- package/src/core/output/index.ts +13 -0
- package/src/core/output/run.ts +126 -0
- package/src/core/output/types.ts +129 -0
- package/src/core/parser/env-interpolation.ts +104 -0
- package/src/core/parser/filter.ts +57 -0
- package/src/core/parser/schema.ts +132 -5
- package/src/core/parser/types.ts +29 -2
- package/src/core/parser/variables.ts +0 -0
- package/src/core/parser/yaml-parser.ts +108 -13
- package/src/core/probe/bootstrap.ts +34 -0
- package/src/core/probe/dry-run-envelope.ts +57 -0
- package/src/core/probe/mass-assignment-probe-class.ts +198 -0
- package/src/core/probe/mass-assignment-probe.ts +1122 -0
- package/src/core/probe/mass-assignment-template.ts +212 -0
- package/src/core/probe/method-probe.ts +164 -0
- package/src/core/probe/method-shared.ts +69 -0
- package/src/core/probe/negative-probe.ts +691 -0
- package/src/core/probe/orphan-tracker.ts +188 -0
- package/src/core/probe/path-discovery.ts +440 -0
- package/src/core/probe/probe-harness.ts +120 -0
- package/src/core/probe/registry.ts +89 -0
- package/src/core/probe/runner.ts +136 -0
- package/src/core/probe/security-probe-class.ts +201 -0
- package/src/core/probe/security-probe.ts +1453 -0
- package/src/core/probe/shared.ts +505 -0
- package/src/core/probe/static-probe-class.ts +125 -0
- package/src/core/probe/types.ts +165 -0
- package/src/core/probe/verdict-aggregator.ts +33 -0
- package/src/core/probe/webhooks-probe.ts +284 -0
- package/src/core/reporter/console.ts +69 -4
- package/src/core/reporter/index.ts +2 -3
- package/src/core/reporter/json.ts +15 -2
- package/src/core/reporter/junit.ts +27 -12
- package/src/core/reporter/ndjson.ts +37 -0
- package/src/core/reporter/types.ts +3 -0
- package/src/core/runner/assertions.ts +62 -2
- package/src/core/runner/async-pool.ts +108 -0
- package/src/core/runner/auth-path.ts +8 -0
- package/src/core/runner/ci-context.ts +72 -0
- package/src/core/runner/executor.ts +391 -52
- package/src/core/runner/form-encode.ts +51 -0
- package/src/core/runner/http-client.ts +115 -7
- package/src/core/runner/learn-drift.ts +293 -0
- package/src/core/runner/preflight-vars.ts +149 -0
- package/src/core/runner/progress-tracker.ts +73 -0
- package/src/core/runner/rate-limiter.ts +203 -0
- package/src/core/runner/run-kind.ts +39 -0
- package/src/core/runner/schema-validator.ts +312 -0
- package/src/core/runner/send-request.ts +153 -20
- package/src/core/runner/types.ts +38 -0
- package/src/core/secrets/registry.ts +164 -0
- package/src/core/secrets/secrets-file.ts +115 -0
- package/src/core/selectors/operation-filter.ts +144 -0
- package/src/core/setup-api.ts +419 -17
- package/src/core/severity/category.ts +94 -0
- package/src/core/severity/index.ts +121 -0
- package/src/core/spec/layers.ts +154 -0
- package/src/core/util/format-eta.ts +21 -0
- package/src/core/utils.ts +5 -1
- package/src/core/workspace/config.ts +129 -0
- package/src/core/workspace/manifest.ts +283 -0
- package/src/core/workspace/output-rotation.ts +62 -0
- package/src/core/workspace/root.ts +94 -0
- package/src/core/workspace/triage-path.ts +87 -0
- package/src/db/lint-runs.ts +47 -0
- package/src/db/migrate.ts +126 -0
- package/src/db/migrations/0001_run_kind.sql +25 -0
- package/src/db/migrations/sql.d.ts +4 -0
- package/src/db/queries/collections.ts +133 -0
- package/src/db/queries/coverage.ts +9 -0
- package/src/db/queries/dashboard.ts +59 -0
- package/src/db/queries/results.ts +128 -0
- package/src/db/queries/runs.ts +235 -0
- package/src/db/queries/sessions.ts +42 -0
- package/src/db/queries/settings.ts +28 -0
- package/src/db/queries/types.ts +172 -0
- package/src/db/queries.ts +72 -802
- package/src/db/schema.ts +179 -48
- package/src/cli/commands/export.ts +0 -144
- package/src/cli/commands/guide.ts +0 -127
- package/src/cli/commands/init.ts +0 -57
- package/src/cli/commands/serve.ts +0 -81
- package/src/cli/commands/sync.ts +0 -269
- package/src/cli/commands/update.ts +0 -189
- package/src/cli/commands/validate.ts +0 -34
- package/src/core/exporter/postman.ts +0 -963
- package/src/core/generator/guide-builder.ts +0 -253
- package/src/core/meta/types.ts +0 -21
- package/src/core/parser/index.ts +0 -21
- package/src/core/runner/execute-run.ts +0 -132
- package/src/core/runner/index.ts +0 -12
- package/src/core/sync/spec-differ.ts +0 -38
- package/src/web/data/collection-state.ts +0 -362
- package/src/web/routes/api.ts +0 -314
- package/src/web/routes/dashboard.ts +0 -350
- package/src/web/routes/runs.ts +0 -64
- package/src/web/schemas.ts +0 -121
- package/src/web/server.ts +0 -134
- package/src/web/static/htmx.min.cjs +0 -1
- package/src/web/static/style.css +0 -1148
- package/src/web/views/endpoints-tab.ts +0 -174
- package/src/web/views/explorer-tab.ts +0 -402
- package/src/web/views/health-strip.ts +0 -92
- package/src/web/views/layout.ts +0 -48
- package/src/web/views/results.ts +0 -210
- package/src/web/views/runs-tab.ts +0 -126
- package/src/web/views/suites-tab.ts +0 -181
package/src/cli/commands/run.ts
CHANGED
|
@@ -1,25 +1,75 @@
|
|
|
1
1
|
import { dirname } from "path";
|
|
2
2
|
import { stat } from "node:fs/promises";
|
|
3
|
-
import {
|
|
4
|
-
import { loadEnvironment } from "../../core/parser/variables.ts";
|
|
5
|
-
import { filterSuitesByTags, excludeSuitesByTags, filterSuitesByMethod } from "../../core/parser/filter.ts";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
3
|
+
import { parseSafe } from "../../core/parser/yaml-parser.ts";
|
|
4
|
+
import { loadEnvironment, loadEnvMeta, loadEnvFile } from "../../core/parser/variables.ts";
|
|
5
|
+
import { filterSuitesByTags, excludeSuitesByTags, filterSuitesByMethod, filterSuitesByOperationFilter } from "../../core/parser/filter.ts";
|
|
6
|
+
import { preflightCheckVars, formatMissingVarLine, summarizeMissingVars } from "../../core/runner/preflight-vars.ts";
|
|
7
|
+
import { runSuite, expandParameterize } from "../../core/runner/executor.ts";
|
|
8
|
+
import {
|
|
9
|
+
ProgressTracker,
|
|
10
|
+
formatProgressLine,
|
|
11
|
+
PROGRESS_INTERVAL_MS,
|
|
12
|
+
PROGRESS_QUIET_MS,
|
|
13
|
+
} from "../../core/runner/progress-tracker.ts";
|
|
14
|
+
import { createSchemaValidator } from "../../core/runner/schema-validator.ts";
|
|
15
|
+
import { readOpenApiSpec } from "../../core/generator/openapi-reader.ts";
|
|
16
|
+
import { createRateLimiter, createAdaptiveRateLimiter } from "../../core/runner/rate-limiter.ts";
|
|
17
|
+
import { getReporter, generateJsonReport, generateJunitXml } from "../../core/reporter/index.ts";
|
|
8
18
|
import type { ReporterName } from "../../core/reporter/types.ts";
|
|
19
|
+
import { resolveOutput, OutputSpecError, type OutputSpec } from "../../core/output/index.ts";
|
|
20
|
+
import { writeFile, mkdir } from "node:fs/promises";
|
|
21
|
+
import { dirname as pathDirname, resolve as pathResolve } from "node:path";
|
|
9
22
|
import type { TestSuite } from "../../core/parser/types.ts";
|
|
10
23
|
import type { TestRunResult } from "../../core/runner/types.ts";
|
|
11
24
|
import { printError, printWarning } from "../output.ts";
|
|
12
25
|
import { jsonOk, jsonError, printJson } from "../json-envelope.ts";
|
|
13
26
|
import { getDb } from "../../db/schema.ts";
|
|
14
27
|
import { createRun, finalizeRun, saveResults, findCollectionByTestPath } from "../../db/queries.ts";
|
|
15
|
-
import { AUTH_PATH_RE } from "../../core/runner/
|
|
28
|
+
import { AUTH_PATH_RE } from "../../core/runner/auth-path.ts";
|
|
29
|
+
import { resolveCollectionSpec } from "../../core/setup-api.ts";
|
|
30
|
+
import { findWorkspaceRoot } from "../../core/workspace/root.ts";
|
|
31
|
+
import { existsSync } from "node:fs";
|
|
32
|
+
import { buildSpecPointer } from "../../core/diagnostics/spec-pointer.ts";
|
|
33
|
+
import { detectStatusDrifts, formatDriftPlan, applyDriftsToTests, appendToleratedDrifts } from "../../core/runner/learn-drift.ts";
|
|
34
|
+
import { detectCiContext } from "../../core/runner/ci-context.ts";
|
|
35
|
+
import { detectRunKind } from "../../core/runner/run-kind.ts";
|
|
36
|
+
import { resolveRateLimit } from "../../core/workspace/config.ts";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* ARV-117 (m-19): OutputSpec for `zond run`. Three formats; console is
|
|
40
|
+
* the default (stdout, rich per-suite stream); `json` / `junit` default
|
|
41
|
+
* to stdout but accept `--output <path>` to redirect. None of the
|
|
42
|
+
* formats are envelope-wrapped — `--report json` is a test-run report
|
|
43
|
+
* (per-suite breakdown), NOT the `{ok, data, errors}` envelope that
|
|
44
|
+
* other commands emit for `--json`. That distinction is intentional
|
|
45
|
+
* (TASK-134) and remains enforced by the explicit absence of `--json`
|
|
46
|
+
* on this command.
|
|
47
|
+
*/
|
|
48
|
+
export const RUN_OUTPUT_SPEC: OutputSpec<unknown> = {
|
|
49
|
+
command: "run",
|
|
50
|
+
defaultFormat: "console",
|
|
51
|
+
formats: {
|
|
52
|
+
console: { defaultChannel: "stdout", description: "Rich per-suite stream (default)" },
|
|
53
|
+
json: { defaultChannel: "stdout", description: "Per-test JSON breakdown (`generateJsonReport`)" },
|
|
54
|
+
junit: { defaultChannel: "stdout", description: "JUnit XML — CI consumption" },
|
|
55
|
+
},
|
|
56
|
+
};
|
|
16
57
|
|
|
17
58
|
export interface RunOptions {
|
|
18
|
-
|
|
59
|
+
/**
|
|
60
|
+
* One or more paths to a YAML file or a directory of YAML files.
|
|
61
|
+
* Multi-path: shell-glob expansion (`zond run tests/*.yaml`) — suites from
|
|
62
|
+
* every path are merged into a single run. The first path is used as the
|
|
63
|
+
* anchor for env-file resolution and DB collection lookup.
|
|
64
|
+
*/
|
|
65
|
+
paths: string[];
|
|
19
66
|
env?: string;
|
|
20
67
|
report: ReporterName;
|
|
21
68
|
timeout?: number;
|
|
69
|
+
rateLimit?: number | "auto";
|
|
22
70
|
bail: boolean;
|
|
71
|
+
/** Run regular suites sequentially (one after another) instead of in parallel. */
|
|
72
|
+
sequential?: boolean;
|
|
23
73
|
noDb?: boolean;
|
|
24
74
|
dbPath?: string;
|
|
25
75
|
authToken?: string;
|
|
@@ -27,32 +77,159 @@ export interface RunOptions {
|
|
|
27
77
|
tag?: string[];
|
|
28
78
|
excludeTag?: string[];
|
|
29
79
|
method?: string;
|
|
80
|
+
/** ARV-25: parity with `zond generate`/`zond checks run` — selector
|
|
81
|
+
* grammar `<path|method|tag|operation-id>:<value>`, repeatable, OR. */
|
|
82
|
+
include?: string[];
|
|
83
|
+
/** ARV-25: same grammar as `include`; evaluated after includes. */
|
|
84
|
+
exclude?: string[];
|
|
30
85
|
envVars?: string[];
|
|
86
|
+
/** Hard-fail (exit 2) on undefined {{var}} references instead of warning. */
|
|
87
|
+
strictVars?: boolean;
|
|
31
88
|
dryRun?: boolean;
|
|
32
89
|
json?: boolean;
|
|
90
|
+
/** ARV-117: write the report to a file instead of stdout. Replaces
|
|
91
|
+
* the legacy `--report-out` flag (no alias — see m-19 lesson §E).
|
|
92
|
+
* Resolved through `core/output`'s OutputSpec policy. */
|
|
93
|
+
output?: string;
|
|
94
|
+
/** Validate every JSON response against the OpenAPI response schema. */
|
|
95
|
+
validateSchema?: boolean;
|
|
96
|
+
/** Explicit OpenAPI spec path/URL (overrides collection.openapi_spec). */
|
|
97
|
+
specPath?: string;
|
|
98
|
+
/** ARV-44: --api <name> passed alongside paths — used as the lookup-by-name
|
|
99
|
+
* fallback when test-path lookup in DB doesn't yield a spec. Parity with
|
|
100
|
+
* ARV-33 (probe mass-assignment / probe security). */
|
|
101
|
+
apiName?: string;
|
|
102
|
+
/** Group this run under a session id (multi-run campaigns). */
|
|
103
|
+
sessionId?: string;
|
|
104
|
+
/** TASK-144: per-step retry budget for transient network errors
|
|
105
|
+
* (ECONNRESET / EPIPE / socket hang up / fetch failed / abort).
|
|
106
|
+
* HTTP statuses are not retried by this path. Default 1, 0 disables. */
|
|
107
|
+
retryOnNetwork?: number;
|
|
108
|
+
/** TASK-282: detect "passing-test-but-wrong-status" drift and print a plan.
|
|
109
|
+
* Implies --validate-schema (requires a spec) so we only flag drift when
|
|
110
|
+
* the body matches the OpenAPI schema — the case where retrying with the
|
|
111
|
+
* observed status would produce a green test. */
|
|
112
|
+
learn?: boolean;
|
|
113
|
+
/** TASK-282: actually mutate files instead of printing the plan. Requires
|
|
114
|
+
* `learn: true` and a `learnTarget`. */
|
|
115
|
+
learnApply?: boolean;
|
|
116
|
+
/** TASK-282: where to record the drift — rewrite YAML (`test`) or append to
|
|
117
|
+
* apis/<name>/tolerated-drifts.yaml (`drifts`). */
|
|
118
|
+
learnTarget?: "test" | "drifts";
|
|
119
|
+
/** TASK-265: console reporter emits only the grand-total summary line. */
|
|
120
|
+
quiet?: boolean;
|
|
121
|
+
/** ARV-72 (feedback round-02 / F14): default true. Set to false (via
|
|
122
|
+
* --no-fail-on-failures) to keep exit code 0 even when steps failed —
|
|
123
|
+
* useful for advisory runs (audit pre-pass, surface discovery) where
|
|
124
|
+
* CI shouldn't break on a single test red. */
|
|
125
|
+
failOnFailures?: boolean;
|
|
126
|
+
/** ARV-249: hard cap on outgoing HTTP requests across the whole run.
|
|
127
|
+
* Once reached, remaining steps short-circuit to `skip` with reason
|
|
128
|
+
* `max-requests-cap-reached`. Useful for sampling huge probe-suite
|
|
129
|
+
* runs and for CI time-boxing. Each `retry_until` attempt counts as
|
|
130
|
+
* one request. */
|
|
131
|
+
maxRequests?: number;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** ARV-249: rough up-front estimate of the total step count for the
|
|
135
|
+
* progress reporter. Walks the parameterize cross-product so a 3×4 grid
|
|
136
|
+
* on a 50-step suite reports as 600. for_each is dynamic (depends on
|
|
137
|
+
* upstream captures) and not counted — the percentage will overshoot a
|
|
138
|
+
* little on for_each-heavy suites; acceptable for an ETA. */
|
|
139
|
+
function estimateTotalSteps(suites: TestSuite[]): number {
|
|
140
|
+
let total = 0;
|
|
141
|
+
for (const suite of suites) {
|
|
142
|
+
const iters = expandParameterize(suite.parameterize).length || 1;
|
|
143
|
+
total += suite.tests.length * iters;
|
|
144
|
+
}
|
|
145
|
+
return total;
|
|
33
146
|
}
|
|
34
147
|
|
|
35
148
|
export async function runCommand(options: RunOptions): Promise<number> {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
149
|
+
if (options.paths.length === 0) {
|
|
150
|
+
printError("No path given");
|
|
151
|
+
return 2;
|
|
152
|
+
}
|
|
153
|
+
const emptyPaths = options.paths.filter((p) => typeof p !== "string" || p.trim().length === 0);
|
|
154
|
+
if (emptyPaths.length > 0) {
|
|
155
|
+
printError(`Empty path argument (got ${emptyPaths.length} blank entr${emptyPaths.length === 1 ? "y" : "ies"}) — pass a non-empty file or directory path`);
|
|
42
156
|
return 2;
|
|
43
157
|
}
|
|
158
|
+
// ARV-39: strip leading/trailing whitespace from path args so a stray space
|
|
159
|
+
// from copy-paste / shell history doesn't produce an ENOENT that quotes
|
|
160
|
+
// the rogue character. Mutating options.paths so downstream lookups
|
|
161
|
+
// (collection by test_path, env-file search) see the cleaned form.
|
|
162
|
+
options.paths = options.paths.map((p) => p.trim());
|
|
163
|
+
const primaryPath = options.paths[0]!;
|
|
164
|
+
|
|
165
|
+
// 1. Parse test files from every input path (collect parse errors instead
|
|
166
|
+
// of silently skipping). Suites from all paths are merged into one run.
|
|
167
|
+
let suites: TestSuite[] = [];
|
|
168
|
+
const parseErrors: { file: string; error: string }[] = [];
|
|
169
|
+
for (const p of options.paths) {
|
|
170
|
+
try {
|
|
171
|
+
const parsed = await parseSafe(p);
|
|
172
|
+
suites.push(...parsed.suites);
|
|
173
|
+
parseErrors.push(...parsed.errors);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
176
|
+
printError(formatPathError(p, raw));
|
|
177
|
+
return 2;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
for (const pe of parseErrors) {
|
|
182
|
+
printWarning(`Skipped ${pe.file}: ${pe.error}`);
|
|
183
|
+
}
|
|
44
184
|
|
|
45
185
|
if (suites.length === 0) {
|
|
46
|
-
|
|
186
|
+
const pathList = options.paths.join(", ");
|
|
187
|
+
if (parseErrors.length > 0) {
|
|
188
|
+
printError(`All ${parseErrors.length} test file(s) in ${pathList} failed to parse`);
|
|
189
|
+
return 2;
|
|
190
|
+
}
|
|
191
|
+
printWarning(`No test files found in ${pathList}`);
|
|
47
192
|
return 0;
|
|
48
193
|
}
|
|
49
194
|
|
|
195
|
+
// ARV-37: when a selector matches zero suites (typo'd --tag, dead --include
|
|
196
|
+
// pattern, etc.), exit non-zero. Previous fail-open let CI builds go green
|
|
197
|
+
// for `--tag smok` instead of `smoke`. For --tag we also surface the tags
|
|
198
|
+
// actually available so the user can correct without re-reading help.
|
|
199
|
+
// 1b0. ARV-25: unified --include/--exclude filter (parity with generate/checks).
|
|
200
|
+
// Applied before tag/method filters so it can narrow the scope first.
|
|
201
|
+
if ((options.include && options.include.length > 0) || (options.exclude && options.exclude.length > 0)) {
|
|
202
|
+
const result = filterSuitesByOperationFilter(suites, options.include ?? [], options.exclude ?? []);
|
|
203
|
+
if (result.errors.length > 0) {
|
|
204
|
+
for (const err of result.errors) printError(err);
|
|
205
|
+
return 2;
|
|
206
|
+
}
|
|
207
|
+
suites = result.suites;
|
|
208
|
+
if (suites.length === 0) {
|
|
209
|
+
const parts: string[] = [];
|
|
210
|
+
if (options.include?.length) parts.push(`--include [${options.include.join(", ")}]`);
|
|
211
|
+
if (options.exclude?.length) parts.push(`--exclude [${options.exclude.join(", ")}]`);
|
|
212
|
+
printError(`No tests match ${parts.join(" / ")}`);
|
|
213
|
+
return 1;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
50
217
|
// 1b. Tag filter
|
|
51
218
|
if (options.tag && options.tag.length > 0) {
|
|
219
|
+
const availableTags = collectAvailableTags(suites);
|
|
52
220
|
suites = filterSuitesByTags(suites, options.tag);
|
|
53
221
|
if (suites.length === 0) {
|
|
54
|
-
|
|
55
|
-
|
|
222
|
+
const tagHint = availableTags.length > 0
|
|
223
|
+
? ` Available tags: ${availableTags.join(", ")}.`
|
|
224
|
+
: " (loaded suites declare no tags.)";
|
|
225
|
+
if (parseErrors.length > 0) {
|
|
226
|
+
printError(
|
|
227
|
+
`No suites match tags [${options.tag.join(", ")}] — but ${parseErrors.length} file(s) failed to parse (see warnings above). Fix parse errors and retry.${tagHint}`
|
|
228
|
+
);
|
|
229
|
+
return 1;
|
|
230
|
+
}
|
|
231
|
+
printError(`No suites match tags [${options.tag.join(", ")}].${tagHint}`);
|
|
232
|
+
return 1;
|
|
56
233
|
}
|
|
57
234
|
}
|
|
58
235
|
|
|
@@ -60,8 +237,8 @@ export async function runCommand(options: RunOptions): Promise<number> {
|
|
|
60
237
|
if (options.excludeTag && options.excludeTag.length > 0) {
|
|
61
238
|
suites = excludeSuitesByTags(suites, options.excludeTag);
|
|
62
239
|
if (suites.length === 0) {
|
|
63
|
-
|
|
64
|
-
return
|
|
240
|
+
printError(`All suites excluded by --exclude-tag [${options.excludeTag.join(", ")}]`);
|
|
241
|
+
return 1;
|
|
65
242
|
}
|
|
66
243
|
}
|
|
67
244
|
|
|
@@ -69,8 +246,8 @@ export async function runCommand(options: RunOptions): Promise<number> {
|
|
|
69
246
|
if (options.method) {
|
|
70
247
|
suites = filterSuitesByMethod(suites, options.method);
|
|
71
248
|
if (suites.length === 0) {
|
|
72
|
-
|
|
73
|
-
return
|
|
249
|
+
printError(`No tests found with method ${options.method.toUpperCase()}`);
|
|
250
|
+
return 1;
|
|
74
251
|
}
|
|
75
252
|
}
|
|
76
253
|
|
|
@@ -92,13 +269,13 @@ export async function runCommand(options: RunOptions): Promise<number> {
|
|
|
92
269
|
|
|
93
270
|
// 2. Load environment (resolve collection for scoped envs)
|
|
94
271
|
// Use path itself as searchDir if it's a directory; dirname() on a dir path gives the parent
|
|
95
|
-
const pathStat = await stat(
|
|
96
|
-
const searchDir = pathStat?.isDirectory() ?
|
|
272
|
+
const pathStat = await stat(primaryPath).catch(() => null);
|
|
273
|
+
const searchDir = pathStat?.isDirectory() ? primaryPath : dirname(primaryPath);
|
|
97
274
|
let collectionForEnv: { id: number } | null = null;
|
|
98
275
|
if (!options.noDb) {
|
|
99
276
|
try {
|
|
100
277
|
getDb(options.dbPath);
|
|
101
|
-
collectionForEnv = findCollectionByTestPath(
|
|
278
|
+
collectionForEnv = findCollectionByTestPath(primaryPath);
|
|
102
279
|
} catch { /* DB not available — OK */ }
|
|
103
280
|
}
|
|
104
281
|
|
|
@@ -110,6 +287,34 @@ export async function runCommand(options: RunOptions): Promise<number> {
|
|
|
110
287
|
return 2;
|
|
111
288
|
}
|
|
112
289
|
|
|
290
|
+
// Auto-load ./.env.yaml from cwd when --env not given and the searchDir env
|
|
291
|
+
// file produced nothing. Useful when running with absolute test paths from
|
|
292
|
+
// a collection cwd (e.g. APPLY agents in the auto-loop).
|
|
293
|
+
if (!options.env && Object.keys(env).length === 0) {
|
|
294
|
+
const cwd = process.cwd();
|
|
295
|
+
const cwdEnvPath = `${cwd}/.env.yaml`;
|
|
296
|
+
// Avoid double-load if cwd is already covered by searchDir or its parent
|
|
297
|
+
const alreadyCovered = cwd === searchDir || cwd === dirname(searchDir);
|
|
298
|
+
if (!alreadyCovered) {
|
|
299
|
+
try {
|
|
300
|
+
const cwdVars = await loadEnvFile(cwdEnvPath);
|
|
301
|
+
if (cwdVars && Object.keys(cwdVars).length > 0) {
|
|
302
|
+
env = { ...cwdVars };
|
|
303
|
+
if (!options.json) {
|
|
304
|
+
process.stderr.write(`zond: using ./.env.yaml (cwd fallback)\n`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
} catch (err) {
|
|
308
|
+
printError(`Failed to load environment: ${(err as Error).message}`);
|
|
309
|
+
return 2;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (options.sessionId && !options.json) {
|
|
315
|
+
process.stderr.write(`zond: session ${options.sessionId} (run will be grouped)\n`);
|
|
316
|
+
}
|
|
317
|
+
|
|
113
318
|
// Inject CLI auth token — overrides env file value
|
|
114
319
|
if (options.authToken) {
|
|
115
320
|
env.auth_token = options.authToken;
|
|
@@ -137,6 +342,167 @@ export async function runCommand(options: RunOptions): Promise<number> {
|
|
|
137
342
|
}
|
|
138
343
|
}
|
|
139
344
|
|
|
345
|
+
// 3b. Resolve rate limit: CLI flag > .env.yaml `rateLimit:` > workspace
|
|
346
|
+
// `defaults.rate_limit` (TASK-301) > undefined.
|
|
347
|
+
let envRateLimit: number | "auto" | undefined;
|
|
348
|
+
try {
|
|
349
|
+
envRateLimit = (await loadEnvMeta(options.env, searchDir)).rateLimit;
|
|
350
|
+
} catch { /* meta load failure is non-fatal */ }
|
|
351
|
+
const rateLimit = resolveRateLimit(options.rateLimit, envRateLimit);
|
|
352
|
+
// ARV-64 (feedback round-01 / F4): when no rate-limit was configured
|
|
353
|
+
// explicitly, default to an adaptive limiter. Adaptive is a no-op until
|
|
354
|
+
// a response carries RateLimit-* headers (RFC 9568) — in which case it
|
|
355
|
+
// learns the policy and throttles subsequent requests so a burst can't
|
|
356
|
+
// blow through small windows like small windows (e.g. 5 req/s). Without this default
|
|
357
|
+
// `zond run` ignored server-published rate-limit headers entirely and
|
|
358
|
+
// 22% of a typical sweep landed in 429.
|
|
359
|
+
let rateLimiter: ReturnType<typeof createAdaptiveRateLimiter> | undefined;
|
|
360
|
+
if (rateLimit === "auto") {
|
|
361
|
+
rateLimiter = createAdaptiveRateLimiter();
|
|
362
|
+
} else if (rateLimit !== undefined) {
|
|
363
|
+
rateLimiter = createRateLimiter(rateLimit);
|
|
364
|
+
} else {
|
|
365
|
+
rateLimiter = createAdaptiveRateLimiter();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// 3c. Resolve OpenAPI spec. Explicit --spec wins; otherwise fall back to the
|
|
369
|
+
// collection record. The doc is reused for --validate-schema (TASK-50) and
|
|
370
|
+
// for spec_pointer/spec_excerpt frozen evidence (TASK-102).
|
|
371
|
+
let schemaValidator: ReturnType<typeof createSchemaValidator> | undefined;
|
|
372
|
+
let openApiDoc: unknown | undefined;
|
|
373
|
+
{
|
|
374
|
+
let specPath = options.specPath;
|
|
375
|
+
if (!specPath) {
|
|
376
|
+
try {
|
|
377
|
+
const collection = findCollectionByTestPath(primaryPath);
|
|
378
|
+
if (collection?.openapi_spec) specPath = resolveCollectionSpec(collection.openapi_spec);
|
|
379
|
+
} catch { /* DB not available — fall through */ }
|
|
380
|
+
}
|
|
381
|
+
// ARV-44: parity with ARV-33 (probe mass-assignment / security). When the
|
|
382
|
+
// user passed --api <name> together with a path, the test-path lookup
|
|
383
|
+
// above may miss (path normalisation, alias, --all merge). Fall back to
|
|
384
|
+
// lookup-by-name, then to apis/<name>/spec.json on disk.
|
|
385
|
+
if (!specPath && options.apiName) {
|
|
386
|
+
try {
|
|
387
|
+
const byName = resolveApiCollection(options.apiName, options.dbPath);
|
|
388
|
+
if (!("error" in byName) && byName.spec) specPath = byName.spec;
|
|
389
|
+
} catch { /* fall through to disk probe */ }
|
|
390
|
+
if (!specPath) {
|
|
391
|
+
try {
|
|
392
|
+
const ws = findWorkspaceRoot();
|
|
393
|
+
const onDisk = pathResolve(ws.root, "apis", options.apiName, "spec.json");
|
|
394
|
+
if (existsSync(onDisk)) specPath = onDisk;
|
|
395
|
+
} catch { /* workspace not initialised — give up silently */ }
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
// ARV-209 (R12/F11): when --validate-schema is requested but no --api was
|
|
399
|
+
// passed (explicit suite path suppresses current-api fallback at the
|
|
400
|
+
// argv-parse level), still try to derive the spec from the test path
|
|
401
|
+
// pattern `apis/<name>/tests/...`. SKILL.md DEPTH-PASS examples use that
|
|
402
|
+
// exact shape and expect the spec to resolve automatically.
|
|
403
|
+
if (!specPath && primaryPath) {
|
|
404
|
+
const m = primaryPath.replace(/\\/g, "/").match(/(?:^|\/)apis\/([^\/]+)\/tests(?:\/|$)/);
|
|
405
|
+
if (m) {
|
|
406
|
+
const apiName = m[1]!;
|
|
407
|
+
try {
|
|
408
|
+
const byName = resolveApiCollection(apiName, options.dbPath);
|
|
409
|
+
if (!("error" in byName) && byName.spec) specPath = byName.spec;
|
|
410
|
+
} catch { /* fall through to disk probe */ }
|
|
411
|
+
if (!specPath) {
|
|
412
|
+
try {
|
|
413
|
+
const ws = findWorkspaceRoot();
|
|
414
|
+
const onDisk = pathResolve(ws.root, "apis", apiName, "spec.json");
|
|
415
|
+
if (existsSync(onDisk)) specPath = onDisk;
|
|
416
|
+
} catch { /* give up silently */ }
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (specPath) {
|
|
421
|
+
try {
|
|
422
|
+
openApiDoc = await readOpenApiSpec(specPath);
|
|
423
|
+
} catch (err) {
|
|
424
|
+
if (options.validateSchema) {
|
|
425
|
+
printError(`Failed to load OpenAPI spec '${specPath}': ${(err as Error).message}`);
|
|
426
|
+
return 2;
|
|
427
|
+
}
|
|
428
|
+
// spec_pointer is best-effort — non-fatal when spec can't be loaded.
|
|
429
|
+
printWarning(`Failed to load OpenAPI spec '${specPath}' for spec_pointer evidence: ${(err as Error).message}`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
// TASK-282: --learn requires schema validation evidence (status mismatch
|
|
433
|
+
// is only a "drift" if the body actually matches the OpenAPI contract —
|
|
434
|
+
// otherwise we'd silently encourage masking a real schema bug).
|
|
435
|
+
const needsSchema = options.validateSchema || options.learn;
|
|
436
|
+
if (needsSchema) {
|
|
437
|
+
if (!openApiDoc) {
|
|
438
|
+
const flag = options.learn ? "--learn" : "--validate-schema";
|
|
439
|
+
printError(
|
|
440
|
+
`${flag} requires --spec <path|url> or a collection with openapi_spec set. ` +
|
|
441
|
+
`Pass \`--api <name>\` (resolves apis/<name>/spec.json) or add \`--spec apis/<name>/spec.json\` explicitly.`,
|
|
442
|
+
);
|
|
443
|
+
return 2;
|
|
444
|
+
}
|
|
445
|
+
schemaValidator = createSchemaValidator(openApiDoc as Parameters<typeof createSchemaValidator>[0]);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// TASK-282: validate --learn flag combinations early (before run).
|
|
450
|
+
if (options.learnApply && !options.learn) {
|
|
451
|
+
printError("--learn-apply requires --learn");
|
|
452
|
+
return 2;
|
|
453
|
+
}
|
|
454
|
+
if (options.learnApply && !options.learnTarget) {
|
|
455
|
+
printError("--learn-apply requires --learn-target=test or --learn-target=drifts");
|
|
456
|
+
return 2;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ARV-249: shared HTTP-request budget. `Infinity` means uncapped.
|
|
460
|
+
const requestBudget = options.maxRequests !== undefined && options.maxRequests > 0
|
|
461
|
+
? { limit: options.maxRequests, used: 0 }
|
|
462
|
+
: undefined;
|
|
463
|
+
|
|
464
|
+
// ARV-249: progress reporter. Enabled when stderr is a TTY and the user
|
|
465
|
+
// hasn't opted out via --quiet. Suppressed for non-interactive output
|
|
466
|
+
// (CI logs already track timestamps; an extra line every 5s is noise).
|
|
467
|
+
const progressEnabled = !options.quiet
|
|
468
|
+
&& !options.dryRun
|
|
469
|
+
&& Boolean((process.stderr as { isTTY?: boolean }).isTTY);
|
|
470
|
+
const totalStepsForProgress = progressEnabled
|
|
471
|
+
? estimateTotalSteps(suites)
|
|
472
|
+
: 0;
|
|
473
|
+
const tracker = progressEnabled
|
|
474
|
+
? new ProgressTracker(totalStepsForProgress)
|
|
475
|
+
: undefined;
|
|
476
|
+
let lastProgressLineLen = 0;
|
|
477
|
+
const writeProgressLine = (final: boolean): void => {
|
|
478
|
+
if (!tracker) return;
|
|
479
|
+
const snap = tracker.snapshot();
|
|
480
|
+
// Hold off on the first emit until the run has actually been running
|
|
481
|
+
// a while — short test suites should stay silent.
|
|
482
|
+
if (!final && snap.elapsedMs < PROGRESS_QUIET_MS) return;
|
|
483
|
+
const line = formatProgressLine(snap);
|
|
484
|
+
// Overwrite previous line in place (single TTY row, no scroll).
|
|
485
|
+
const pad = " ".repeat(Math.max(0, lastProgressLineLen - line.length));
|
|
486
|
+
process.stderr.write(`\r${line}${pad}${final ? "\n" : ""}`);
|
|
487
|
+
lastProgressLineLen = line.length;
|
|
488
|
+
};
|
|
489
|
+
let progressInterval: ReturnType<typeof setInterval> | undefined;
|
|
490
|
+
if (tracker) {
|
|
491
|
+
progressInterval = setInterval(() => writeProgressLine(false), PROGRESS_INTERVAL_MS);
|
|
492
|
+
// Don't let the timer pin the event loop alive past run completion.
|
|
493
|
+
(progressInterval as { unref?: () => void }).unref?.();
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const runOpts = {
|
|
497
|
+
rateLimiter,
|
|
498
|
+
schemaValidator,
|
|
499
|
+
networkRetries: options.retryOnNetwork,
|
|
500
|
+
requestBudget,
|
|
501
|
+
onStepDone: tracker
|
|
502
|
+
? (step: import("../../core/runner/types.ts").StepResult) => tracker.recordStep(step)
|
|
503
|
+
: undefined,
|
|
504
|
+
};
|
|
505
|
+
|
|
140
506
|
// 4. Run suites — setup suites run first (sequentially), their captures flow into regular suites
|
|
141
507
|
const results: TestRunResult[] = [];
|
|
142
508
|
const dryRun = options.dryRun === true;
|
|
@@ -145,8 +511,19 @@ export async function runCommand(options: RunOptions): Promise<number> {
|
|
|
145
511
|
const regularSuites = suites.filter(s => !s.setup);
|
|
146
512
|
const setupCaptures: Record<string, string> = {};
|
|
147
513
|
|
|
514
|
+
// 3d. Pre-flight variable check on setup suites — only `env` is known
|
|
515
|
+
// (their captures don't exist yet).
|
|
516
|
+
{
|
|
517
|
+
const setupHits = preflightCheckVars(setupSuites, env);
|
|
518
|
+
emitMissingVarWarnings(setupHits);
|
|
519
|
+
if (options.strictVars && setupHits.length > 0) {
|
|
520
|
+
printError(`--strict-vars: ${setupHits.length} undefined variable reference(s) in setup suites`);
|
|
521
|
+
return 2;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
148
525
|
for (const suite of setupSuites) {
|
|
149
|
-
const result = await runSuite(suite, env, dryRun);
|
|
526
|
+
const result = await runSuite(suite, env, dryRun, runOpts);
|
|
150
527
|
results.push(result);
|
|
151
528
|
for (const step of result.steps) {
|
|
152
529
|
for (const [k, v] of Object.entries(step.captures)) {
|
|
@@ -157,21 +534,49 @@ export async function runCommand(options: RunOptions): Promise<number> {
|
|
|
157
534
|
|
|
158
535
|
const enrichedEnv = { ...env, ...setupCaptures };
|
|
159
536
|
|
|
537
|
+
// 3e. Pre-flight variable check on regular suites — env + setup captures
|
|
538
|
+
// are known producers; per-suite captures/sets/parameterize handled inside.
|
|
539
|
+
{
|
|
540
|
+
const hits = preflightCheckVars(regularSuites, enrichedEnv);
|
|
541
|
+
emitMissingVarWarnings(hits);
|
|
542
|
+
if (options.strictVars && hits.length > 0) {
|
|
543
|
+
printError(`--strict-vars: ${hits.length} undefined variable reference(s)`);
|
|
544
|
+
return 2;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
160
548
|
if (options.bail) {
|
|
161
549
|
// Sequential with bail at suite level
|
|
162
550
|
for (const suite of regularSuites) {
|
|
163
|
-
const result = await runSuite(suite, enrichedEnv, dryRun);
|
|
551
|
+
const result = await runSuite(suite, enrichedEnv, dryRun, runOpts);
|
|
164
552
|
results.push(result);
|
|
165
553
|
if (!dryRun && (result.failed > 0 || result.steps.some((s) => s.status === "error"))) {
|
|
166
554
|
break;
|
|
167
555
|
}
|
|
168
556
|
}
|
|
557
|
+
} else if (options.sequential) {
|
|
558
|
+
// Sequential without bail — run suites one by one
|
|
559
|
+
for (const suite of regularSuites) {
|
|
560
|
+
const result = await runSuite(suite, enrichedEnv, dryRun, runOpts);
|
|
561
|
+
results.push(result);
|
|
562
|
+
}
|
|
169
563
|
} else {
|
|
170
564
|
// Parallel
|
|
171
|
-
const all = await Promise.all(regularSuites.map((suite) => runSuite(suite, enrichedEnv, dryRun)));
|
|
565
|
+
const all = await Promise.all(regularSuites.map((suite) => runSuite(suite, enrichedEnv, dryRun, runOpts)));
|
|
172
566
|
results.push(...all);
|
|
173
567
|
}
|
|
174
568
|
|
|
569
|
+
// ARV-249: stop the progress tracker before any reporter output starts
|
|
570
|
+
// — overlapping carriage returns and report lines garble the terminal.
|
|
571
|
+
if (progressInterval !== undefined) {
|
|
572
|
+
clearInterval(progressInterval);
|
|
573
|
+
// Clear the in-place progress line so it doesn't linger above the report.
|
|
574
|
+
if (lastProgressLineLen > 0) {
|
|
575
|
+
process.stderr.write(`\r${" ".repeat(lastProgressLineLen)}\r`);
|
|
576
|
+
lastProgressLineLen = 0;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
175
580
|
// 5. Collect warnings
|
|
176
581
|
const warnings: string[] = [];
|
|
177
582
|
const rateLimited = results.flatMap(r => r.steps)
|
|
@@ -179,13 +584,119 @@ export async function runCommand(options: RunOptions): Promise<number> {
|
|
|
179
584
|
if (rateLimited.length > 0) {
|
|
180
585
|
warnings.push(`${rateLimited.length} request(s) hit rate limit (429). Consider: consolidating login steps, adding --bail, or using retry_until with delay.`);
|
|
181
586
|
}
|
|
587
|
+
// ARV-249: surface --max-requests cap when it actually fired.
|
|
588
|
+
if (requestBudget && requestBudget.used >= requestBudget.limit) {
|
|
589
|
+
const cappedSteps = results.flatMap(r => r.steps)
|
|
590
|
+
.filter(s => s.status === "skip" && s.error === "max-requests-cap-reached").length;
|
|
591
|
+
if (cappedSteps > 0) {
|
|
592
|
+
warnings.push(`--max-requests ${requestBudget.limit} cap reached; ${cappedSteps} subsequent step(s) skipped. Raise the cap or narrow the suite to cover them.`);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
182
595
|
|
|
183
|
-
// 5b. Report
|
|
596
|
+
// 5b. Report — ARV-117: route through core/output's OutputSpec so
|
|
597
|
+
// `--report <format>` + `--output <path>` follow the same policy as
|
|
598
|
+
// every other command. `--output` (was `--report-out`) is honoured for
|
|
599
|
+
// any format; with `console` format it falls back to JSON in the file
|
|
600
|
+
// (most useful), matching prior behaviour.
|
|
601
|
+
let resolvedOutput;
|
|
602
|
+
try {
|
|
603
|
+
resolvedOutput = resolveOutput(RUN_OUTPUT_SPEC, {
|
|
604
|
+
report: options.report,
|
|
605
|
+
output: options.output,
|
|
606
|
+
});
|
|
607
|
+
} catch (err) {
|
|
608
|
+
if (err instanceof OutputSpecError) {
|
|
609
|
+
printError(err.message);
|
|
610
|
+
return 2;
|
|
611
|
+
}
|
|
612
|
+
throw err;
|
|
613
|
+
}
|
|
184
614
|
if (!options.json) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
615
|
+
if (resolvedOutput.channel === "file") {
|
|
616
|
+
const outPath = resolvedOutput.path!;
|
|
617
|
+
let content: string;
|
|
618
|
+
let label: string;
|
|
619
|
+
switch (resolvedOutput.format) {
|
|
620
|
+
case "junit":
|
|
621
|
+
content = generateJunitXml(results);
|
|
622
|
+
label = "JUnit XML";
|
|
623
|
+
break;
|
|
624
|
+
case "json":
|
|
625
|
+
default: // "console" — fall back to JSON in the file (most useful)
|
|
626
|
+
content = generateJsonReport(results);
|
|
627
|
+
label = "JSON";
|
|
628
|
+
break;
|
|
629
|
+
}
|
|
630
|
+
try {
|
|
631
|
+
await mkdir(pathDirname(outPath), { recursive: true });
|
|
632
|
+
await writeFile(outPath, content, "utf-8");
|
|
633
|
+
process.stderr.write(`zond: ${label} report written to ${outPath}\n`);
|
|
634
|
+
} catch (err) {
|
|
635
|
+
printError(`Failed to write --output file ${outPath}: ${(err as Error).message}`);
|
|
636
|
+
return 2;
|
|
637
|
+
}
|
|
638
|
+
for (const w of warnings) {
|
|
639
|
+
printWarning(w);
|
|
640
|
+
}
|
|
641
|
+
} else {
|
|
642
|
+
const reporter = getReporter(options.report);
|
|
643
|
+
reporter.report(results, options.quiet ? { quiet: true } : undefined);
|
|
644
|
+
// TASK-265: --quiet drops the warnings tail too — they are non-essential
|
|
645
|
+
// run hints (deprecation, timing). Errors still reach stderr via the
|
|
646
|
+
// dedicated path; --strict-vars still aborts on undefined refs before
|
|
647
|
+
// we get here.
|
|
648
|
+
if (!options.quiet) {
|
|
649
|
+
for (const w of warnings) {
|
|
650
|
+
printWarning(w);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// 5b. Resolve spec_pointer + spec_excerpt for steps with provenance.
|
|
657
|
+
// Frozen evidence: pointer and excerpt are computed once, against the spec
|
|
658
|
+
// doc loaded at run time, and saved into the DB so later spec edits don't
|
|
659
|
+
// rewrite history.
|
|
660
|
+
if (openApiDoc) {
|
|
661
|
+
for (const r of results) {
|
|
662
|
+
for (const s of r.steps) {
|
|
663
|
+
if (!s.provenance) continue;
|
|
664
|
+
const ptr = buildSpecPointer(s.provenance, openApiDoc);
|
|
665
|
+
if (ptr) {
|
|
666
|
+
s.spec_pointer = ptr.pointer;
|
|
667
|
+
s.spec_excerpt = ptr.excerpt;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// 5c. TASK-282: --learn — surface or apply status-code drift.
|
|
674
|
+
if (options.learn) {
|
|
675
|
+
const drifts = detectStatusDrifts(results, { schemaValidatorAttached: schemaValidator !== undefined });
|
|
676
|
+
if (!options.learnApply) {
|
|
677
|
+
process.stderr.write(formatDriftPlan(drifts));
|
|
678
|
+
} else if (drifts.length === 0) {
|
|
679
|
+
process.stderr.write("zond: --learn-apply: no drift to apply\n");
|
|
680
|
+
} else if (options.learnTarget === "test") {
|
|
681
|
+
const applied = await applyDriftsToTests(drifts);
|
|
682
|
+
process.stderr.write(`zond: --learn-apply --learn-target=test: rewrote ${applied.updated} step(s)\n`);
|
|
683
|
+
for (const e of applied.errors) {
|
|
684
|
+
printWarning(`learn-apply: ${e.suite_file} step "${e.step_name}": ${e.reason}`);
|
|
685
|
+
}
|
|
686
|
+
} else if (options.learnTarget === "drifts") {
|
|
687
|
+
// Resolve apis/<name>/ from the primary path's collection record.
|
|
688
|
+
let apiDir: string | undefined;
|
|
689
|
+
try {
|
|
690
|
+
const collection = findCollectionByTestPath(primaryPath);
|
|
691
|
+
if (collection?.base_dir) apiDir = collection.base_dir;
|
|
692
|
+
else if (collection?.test_path) apiDir = dirname(collection.test_path);
|
|
693
|
+
} catch { /* DB unavailable */ }
|
|
694
|
+
if (!apiDir) {
|
|
695
|
+
printError("--learn-target=drifts: cannot resolve apis/<name>/ — collection not registered (run `zond add api <name>`)");
|
|
696
|
+
return 2;
|
|
697
|
+
}
|
|
698
|
+
const written = await appendToleratedDrifts(apiDir, drifts);
|
|
699
|
+
process.stderr.write(`zond: --learn-apply --learn-target=drifts: wrote ${written.written} entry(ies) to ${written.file}\n`);
|
|
189
700
|
}
|
|
190
701
|
}
|
|
191
702
|
|
|
@@ -194,11 +705,35 @@ export async function runCommand(options: RunOptions): Promise<number> {
|
|
|
194
705
|
if (!options.noDb) {
|
|
195
706
|
try {
|
|
196
707
|
getDb(options.dbPath);
|
|
197
|
-
const collection = findCollectionByTestPath(
|
|
708
|
+
const collection = findCollectionByTestPath(primaryPath);
|
|
709
|
+
// TASK-274: capture suite-level tags executed in this run (plus any
|
|
710
|
+
// explicit --tag filter). Stored on the run row so
|
|
711
|
+
// `coverage --union tag:<x>` can later select runs by tag.
|
|
712
|
+
const tagSet = new Set<string>();
|
|
713
|
+
for (const s of suites) {
|
|
714
|
+
for (const t of s.tags ?? []) tagSet.add(t);
|
|
715
|
+
}
|
|
716
|
+
for (const t of options.tag ?? []) tagSet.add(t);
|
|
717
|
+
const tags = [...tagSet].sort();
|
|
718
|
+
// TASK-116: stamp CI context on the run row when running under a
|
|
719
|
+
// detected CI environment (or when the user passed an explicit
|
|
720
|
+
// `--trigger ci`). Manual runs default to trigger=manual with no
|
|
721
|
+
// commit/branch — preserving prior behaviour.
|
|
722
|
+
const ci = detectCiContext();
|
|
723
|
+
// ARV-55: classify the run by what kinds of suite files it executed
|
|
724
|
+
// *before* INSERT, so coverage's default query becomes a simple
|
|
725
|
+
// run_kind='regular' compare instead of a per-result regex scan.
|
|
726
|
+
const runKind = detectRunKind(suites.map((s) => s.filePath ?? null));
|
|
198
727
|
savedRunId = createRun({
|
|
199
728
|
started_at: results[0]?.started_at ?? new Date().toISOString(),
|
|
200
729
|
environment: options.env,
|
|
201
730
|
collection_id: collection?.id,
|
|
731
|
+
session_id: options.sessionId,
|
|
732
|
+
trigger: ci.trigger,
|
|
733
|
+
commit_sha: ci.commit_sha ?? undefined,
|
|
734
|
+
branch: ci.branch ?? undefined,
|
|
735
|
+
...(tags.length > 0 ? { tags } : {}),
|
|
736
|
+
run_kind: runKind,
|
|
202
737
|
});
|
|
203
738
|
finalizeRun(savedRunId, results);
|
|
204
739
|
saveResults(savedRunId, results);
|
|
@@ -216,6 +751,23 @@ export async function runCommand(options: RunOptions): Promise<number> {
|
|
|
216
751
|
}
|
|
217
752
|
const hasFailures = results.some((r) => r.failed > 0 || r.steps.some((s) => s.status === "error"));
|
|
218
753
|
|
|
754
|
+
// ARV-105 (F10): a suite where every step was skipped (e.g. probe-emitted
|
|
755
|
+
// regression suite that needs an unfilled capture-chain var) reports
|
|
756
|
+
// total>0, passed=0, failed=0, skipped=total. Without surfacing this,
|
|
757
|
+
// CI sees "0 failed" and the run looks green even though nothing was
|
|
758
|
+
// actually tested. Compute the list once and expose it in both the
|
|
759
|
+
// JSON envelope and the stderr tail.
|
|
760
|
+
const allSkippedSuites = results
|
|
761
|
+
.filter(r => r.total > 0 && r.passed === 0 && r.failed === 0 && r.skipped === r.total)
|
|
762
|
+
.map(r => ({
|
|
763
|
+
suite: r.suite_name,
|
|
764
|
+
...(r.suite_file ? { file: r.suite_file } : {}),
|
|
765
|
+
total: r.total,
|
|
766
|
+
// Sample skip reason from the first step — usually "missing variable
|
|
767
|
+
// {{X}}" or similar. Helps the operator route to fixtures vs spec.
|
|
768
|
+
first_skip_reason: r.steps[0]?.error ?? null,
|
|
769
|
+
}));
|
|
770
|
+
|
|
219
771
|
if (options.json) {
|
|
220
772
|
const total = results.reduce((s, r) => s + r.total, 0);
|
|
221
773
|
const passed = results.reduce((s, r) => s + r.passed, 0);
|
|
@@ -226,11 +778,337 @@ export async function runCommand(options: RunOptions): Promise<number> {
|
|
|
226
778
|
test: s.name,
|
|
227
779
|
...(r.suite_file ? { file: r.suite_file } : {}),
|
|
228
780
|
status: s.status,
|
|
781
|
+
...(typeof s.response?.status === "number" ? { http_status: s.response.status } : {}),
|
|
782
|
+
...(typeof s.response?.status === "number" && s.response.status >= 500 && s.response.status < 600 ? { is_5xx: true } : {}),
|
|
229
783
|
error: s.error,
|
|
784
|
+
...(s.failure_class ? { failure_class: s.failure_class, failure_class_reason: s.failure_class_reason } : {}),
|
|
785
|
+
...(s.provenance ? { provenance: s.provenance } : {}),
|
|
786
|
+
...(s.spec_pointer ? { spec_pointer: s.spec_pointer, spec_excerpt: s.spec_excerpt } : {}),
|
|
787
|
+
...(s.network_retry ? { network_retry: s.network_retry } : {}),
|
|
230
788
|
}))
|
|
231
789
|
);
|
|
232
|
-
|
|
790
|
+
const fiveXx = failures.filter(f => f.is_5xx).length;
|
|
791
|
+
printJson(jsonOk("run", {
|
|
792
|
+
summary: { total, passed, failed, fiveXx, allSkippedSuites: allSkippedSuites.length },
|
|
793
|
+
failures,
|
|
794
|
+
...(allSkippedSuites.length > 0 ? { all_skipped_suites: allSkippedSuites } : {}),
|
|
795
|
+
warnings,
|
|
796
|
+
runId: savedRunId,
|
|
797
|
+
}));
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// ARV-105 (F10): non-json — name the all-skipped suites on stderr so a
|
|
801
|
+
// tail-eyeballing operator notices the visibility-pitfall. Doesn't gate
|
|
802
|
+
// exit code (skipping isn't a failure) but is loud enough that a green
|
|
803
|
+
// "0 failed" can no longer hide a regression suite that ran zero steps.
|
|
804
|
+
if (allSkippedSuites.length > 0 && !options.json) {
|
|
805
|
+
process.stderr.write(`zond: ${allSkippedSuites.length} suite(s) ran with every step skipped (no test executed — likely missing fixtures or capture-chain ids).\n`);
|
|
806
|
+
for (const s of allSkippedSuites) {
|
|
807
|
+
const reason = s.first_skip_reason ? ` — ${s.first_skip_reason}` : "";
|
|
808
|
+
process.stderr.write(` - ${s.suite}${s.file ? ` (${s.file})` : ""}: ${s.total} step(s) skipped${reason}\n`);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// ARV-162 (round-08 F19): when suites failed parse-time validation
|
|
813
|
+
// (`zond check tests` reject — e.g. form value emitted unquoted, parsed
|
|
814
|
+
// as int), per-file warnings already went to stderr at the top of the
|
|
815
|
+
// run, but they easily get lost in long output. Add a loud trailing
|
|
816
|
+
// summary so "47/68 ran" doesn't silently hide 21 invalid files.
|
|
817
|
+
if (parseErrors.length > 0 && !options.json) {
|
|
818
|
+
process.stderr.write(
|
|
819
|
+
`zond: ${parseErrors.length} test file(s) skipped due to validation errors — ` +
|
|
820
|
+
`run \`zond check tests <path>\` to see why. Coverage numbers below exclude these files.\n`,
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// ARV-72 (feedback round-02 / F14): make the exit-code → failures
|
|
825
|
+
// mapping visible. Tester reported "545 failed, exit_code=0" which is
|
|
826
|
+
// not what this function returns — but the symptom is real: the reader
|
|
827
|
+
// can't tell whether the surrounding shell or a wrapper script swallowed
|
|
828
|
+
// the non-zero exit. Print a one-line tail to stderr that names the
|
|
829
|
+
// exit code so wrapper scripts that hide it become obvious. Skipped in
|
|
830
|
+
// --json so the JSON envelope stays alone on stdout (this is stderr
|
|
831
|
+
// anyway, but skip avoids confusing parsers that capture both streams).
|
|
832
|
+
if (hasFailures && !options.json) {
|
|
833
|
+
const total = results.reduce((s, r) => s + r.failed, 0);
|
|
834
|
+
process.stderr.write(`zond: ${total} test step(s) failed — exiting with code 1 (pass --no-fail-on-failures to suppress, e.g. for advisory runs).\n`);
|
|
233
835
|
}
|
|
234
836
|
|
|
837
|
+
if (hasFailures && options.failOnFailures === false) {
|
|
838
|
+
return 0;
|
|
839
|
+
}
|
|
235
840
|
return hasFailures ? 1 : 0;
|
|
236
841
|
}
|
|
842
|
+
|
|
843
|
+
import type { Command } from "commander";
|
|
844
|
+
import { Option } from "commander";
|
|
845
|
+
import { resolveApiCollection } from "../resolve.ts";
|
|
846
|
+
import { collect, flatSplit, parseNonNegativeInt, parsePositiveInt, parseRateLimit, parseReporter } from "../argv.ts";
|
|
847
|
+
import { resolveSessionId } from "../../core/context/session.ts";
|
|
848
|
+
import { getApi } from "../util/api-context.ts";
|
|
849
|
+
import { readdirSync, statSync } from "node:fs";
|
|
850
|
+
import { join } from "node:path";
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* TASK-248: when 4+ hits collapse them into one summary line per unique
|
|
854
|
+
* variable name. Below threshold the per-(suite,step) form still helps
|
|
855
|
+
* users locate the missing reference.
|
|
856
|
+
*/
|
|
857
|
+
function emitMissingVarWarnings(hits: import("../../core/runner/preflight-vars.ts").MissingVarHit[]): void {
|
|
858
|
+
if (hits.length === 0) return;
|
|
859
|
+
if (hits.length < 4) {
|
|
860
|
+
for (const hit of hits) printWarning(formatMissingVarLine(hit));
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
for (const line of summarizeMissingVars(hits)) printWarning(line);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* ARV-39: rewrite an ENOENT bubbling up from `parseSafe` so the user sees
|
|
868
|
+
* a clean path quote and an actionable hint. Bun's raw glob/open error
|
|
869
|
+
* quotes the path with a trailing space ("'foo.yaml '"), which makes users
|
|
870
|
+
* think the path itself has a typo. We strip the noise and, when the file's
|
|
871
|
+
* parent directory exists, suggest either the dir form (`apis/<api>/tests`)
|
|
872
|
+
* or list its YAML siblings so the right invocation is discoverable.
|
|
873
|
+
*/
|
|
874
|
+
function formatPathError(path: string, raw: string): string {
|
|
875
|
+
const isEnoent = /ENOENT/i.test(raw) || /no such file or directory/i.test(raw);
|
|
876
|
+
if (!isEnoent) return raw;
|
|
877
|
+
const cleanPath = path.trim();
|
|
878
|
+
const parent = pathDirname(cleanPath);
|
|
879
|
+
const lines: string[] = [`Path not found: ${cleanPath}`];
|
|
880
|
+
// Parent directory exists → list known YAML suites to make the right
|
|
881
|
+
// invocation obvious. Parent missing → just say so; nothing useful to add.
|
|
882
|
+
try {
|
|
883
|
+
if (parent && existsSync(parent) && statSync(parent).isDirectory()) {
|
|
884
|
+
const siblings = readdirSync(parent)
|
|
885
|
+
.filter((f) => /\.ya?ml$/i.test(f))
|
|
886
|
+
.sort();
|
|
887
|
+
if (siblings.length === 0) {
|
|
888
|
+
lines.push(`(${parent}/ has no YAML files — did you forget to run \`zond generate\`?)`);
|
|
889
|
+
} else {
|
|
890
|
+
// Prefer suggesting the directory itself — that's the parity command
|
|
891
|
+
// most users actually want ("run all suites under apis/<api>/tests").
|
|
892
|
+
lines.push(`Did you mean \`zond run ${parent}\` (runs ${siblings.length} suite${siblings.length === 1 ? "" : "s"})?`);
|
|
893
|
+
const preview = siblings.slice(0, 6).map((s) => ` - ${parent}/${s}`).join("\n");
|
|
894
|
+
const more = siblings.length > 6 ? `\n … ${siblings.length - 6} more` : "";
|
|
895
|
+
lines.push(`Known suites in ${parent}/:\n${preview}${more}`);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
} catch { /* readdir / stat failed — fall through with the basic message */ }
|
|
899
|
+
return lines.join("\n");
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* ARV-37: list distinct tags across loaded suites so a fail-loud `--tag`
|
|
904
|
+
* mismatch can suggest the user-facing values without forcing them into
|
|
905
|
+
* `--help`. Order is alphabetical for stable output across runs.
|
|
906
|
+
*/
|
|
907
|
+
function collectAvailableTags(suites: { tags?: string[] }[]): string[] {
|
|
908
|
+
const seen = new Set<string>();
|
|
909
|
+
for (const s of suites) {
|
|
910
|
+
if (!s.tags) continue;
|
|
911
|
+
for (const t of s.tags) {
|
|
912
|
+
const trimmed = t.trim();
|
|
913
|
+
if (trimmed) seen.add(trimmed);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
return [...seen].sort();
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* TASK-116: discover every `apis/<name>/tests/` directory in the workspace
|
|
921
|
+
* for `zond run --all`. Skips entries without a tests/ subdir (some APIs
|
|
922
|
+
* may have probes only). Returns absolute paths so the run command resolves
|
|
923
|
+
* env files correctly even when invoked from a subdirectory.
|
|
924
|
+
*/
|
|
925
|
+
function discoverWorkspaceTestPaths(): { paths: string[] } | { error: string } {
|
|
926
|
+
let root: string;
|
|
927
|
+
try {
|
|
928
|
+
const ws = findWorkspaceRoot();
|
|
929
|
+
if (ws.fromFallback) {
|
|
930
|
+
return { error: "--all requires a workspace marker (zond.config.yml). Run `zond init` first." };
|
|
931
|
+
}
|
|
932
|
+
root = ws.root;
|
|
933
|
+
} catch (err) {
|
|
934
|
+
return { error: `Failed to locate workspace root: ${(err as Error).message}` };
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
const apisDir = join(root, "apis");
|
|
938
|
+
if (!existsSync(apisDir)) return { paths: [] };
|
|
939
|
+
|
|
940
|
+
const out: string[] = [];
|
|
941
|
+
for (const entry of readdirSync(apisDir, { withFileTypes: true })) {
|
|
942
|
+
if (!entry.isDirectory()) continue;
|
|
943
|
+
const testsDir = join(apisDir, entry.name, "tests");
|
|
944
|
+
if (!existsSync(testsDir)) continue;
|
|
945
|
+
try {
|
|
946
|
+
if (statSync(testsDir).isDirectory()) out.push(testsDir);
|
|
947
|
+
} catch { /* skip unreadable */ }
|
|
948
|
+
}
|
|
949
|
+
return { paths: out.sort() };
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
export function registerRun(program: Command): void {
|
|
953
|
+
program
|
|
954
|
+
.command("run [paths...]")
|
|
955
|
+
.description(
|
|
956
|
+
"Run API tests. Accepts one or more file/dir paths (shell-glob friendly: zond run tests/*.yaml). " +
|
|
957
|
+
"TASK-134: this command does NOT accept `--json` (which would collide with `--report json`). " +
|
|
958
|
+
"For JSON-envelope-style output use `--report json` (per-test breakdown) or `--report junit`. " +
|
|
959
|
+
"Other commands (`request`, `coverage`, `db diagnose`, etc.) DO accept `--json` — there it returns " +
|
|
960
|
+
"a small `{ok, data, errors}` envelope. The two flags are intentionally distinct: " +
|
|
961
|
+
"`run --report json` is a test-run report, `<cmd> --json` is a query-result envelope.",
|
|
962
|
+
)
|
|
963
|
+
.option("--env <name>", "Use environment file (.env.<name>.yaml)")
|
|
964
|
+
.option("--api <name>", "Use API collection (resolves test path automatically)")
|
|
965
|
+
.addOption(
|
|
966
|
+
new Option("--report <format>", "Output format")
|
|
967
|
+
.choices(["console", "json", "junit"])
|
|
968
|
+
.default("console")
|
|
969
|
+
.argParser(parseReporter),
|
|
970
|
+
)
|
|
971
|
+
.option("--timeout <ms>", "Override request timeout", parsePositiveInt("--timeout"))
|
|
972
|
+
.option("--rate-limit <N|auto>", "Throttle requests to at most N per second, or `auto` to adapt from ratelimit-* response headers. Default: adaptive (ARV-64) — no-op until the server publishes RateLimit-* headers, then paces requests automatically. Pass a number for hard caps; `off` is not supported (set --workers 1 + no flag for sequential).", parseRateLimit)
|
|
973
|
+
.option("--bail", "Stop on first suite failure")
|
|
974
|
+
.option("--sequential", "Run regular suites one after another instead of in parallel (opt-out of Promise.all)")
|
|
975
|
+
.option("--all", "TASK-116: discover every apis/<name>/tests/ directory in the workspace and merge them into a single run row (one runs.id per CI invocation, even with multiple registered APIs). Implies CI-style aggregation; pairs with auto-detected commit_sha / branch / trigger=ci.")
|
|
976
|
+
.option("--no-db", "Do not save results to .zond/zond.db")
|
|
977
|
+
.option("--db <path>", "Path to SQLite database file (default: .zond/zond.db)")
|
|
978
|
+
.option("--auth-token <token>", "Auth token injected as {{auth_token}} variable")
|
|
979
|
+
.option("--safe", "Run only GET tests (read-only, safe mode)")
|
|
980
|
+
.option("--tag <tag>", "Filter suites by tag (repeatable, comma-separated)", collect, [])
|
|
981
|
+
.option("--exclude-tag <tag>", "Exclude suites by tag (repeatable, comma-separated)", collect, [])
|
|
982
|
+
.option("--method <method>", "Filter tests by HTTP method (e.g. GET, POST)")
|
|
983
|
+
.option(
|
|
984
|
+
"--include <spec...>",
|
|
985
|
+
"ARV-25: keep only steps matching <selector>:<value> (path|method|tag|operation-id). Same grammar as `zond generate` / `zond checks run`. Repeatable, combines with OR.",
|
|
986
|
+
)
|
|
987
|
+
.option(
|
|
988
|
+
"--exclude <spec...>",
|
|
989
|
+
"ARV-25: drop steps matching <selector>:<value>. Same grammar as --include. Excludes evaluated after includes.",
|
|
990
|
+
)
|
|
991
|
+
.option("--env-var <KEY=VALUE>", "Inject env variable (repeatable, overrides env file)", collect, [])
|
|
992
|
+
.option("--strict-vars", "Hard-fail (exit 2) when a {{var}} reference has no producer (default: warn and continue)")
|
|
993
|
+
.option("--dry-run", "Show requests without sending them (exit code always 0)")
|
|
994
|
+
.option(
|
|
995
|
+
"--quiet",
|
|
996
|
+
"TASK-265: emit only the grand-total summary line — drops per-suite/per-test detail and warning footers. Exit code (0/1) still differentiates pass/fail. For CI logs and watcher loops where step-level output is noise.",
|
|
997
|
+
)
|
|
998
|
+
.option("--output <file>", "ARV-117: write the report to a file instead of stdout. Replaces the legacy --report-out flag. With --report console, falls back to JSON in the file (machine-parseable). Path is resolved relative to cwd; parent directories are auto-created.")
|
|
999
|
+
.option("--validate-schema", "Validate JSON responses against the OpenAPI schema (recommended for CRUD runs — catches contract drift like date-format and enum mismatches; requires --spec or a collection with openapi_spec set)")
|
|
1000
|
+
.option("--spec <path>", "Path or URL to OpenAPI spec used for --validate-schema (overrides the collection's openapi_spec)")
|
|
1001
|
+
.option("--session-id <id>", "Group this run under a session. Resolution order: --session-id flag > ZOND_SESSION_ID env > .zond/current-session file (set by 'zond session start')")
|
|
1002
|
+
.option("--learn", "TASK-282: detect status-code drift (passing-test-but-wrong-status). Implies --validate-schema. Without --learn-apply prints the plan; combine with --learn-apply --learn-target=test|drifts to mutate.")
|
|
1003
|
+
.option("--learn-apply", "Apply the drift plan instead of printing it. Requires --learn and --learn-target.")
|
|
1004
|
+
.addOption(
|
|
1005
|
+
new Option("--learn-target <where>", "What to mutate when --learn-apply is set: rewrite expect.status in YAML (test) or append to apis/<name>/tolerated-drifts.yaml (drifts)")
|
|
1006
|
+
.choices(["test", "drifts"]),
|
|
1007
|
+
)
|
|
1008
|
+
.option(
|
|
1009
|
+
"--retry-on-network <N>",
|
|
1010
|
+
"Auto-retry on transient network errors (ECONNRESET, EPIPE, socket hang up, fetch failed, timeout) — HTTP status codes (incl. 5xx) are NOT retried. Exponential backoff with jitter, base 250ms. Default 1, 0 disables.",
|
|
1011
|
+
parseNonNegativeInt("--retry-on-network"),
|
|
1012
|
+
1,
|
|
1013
|
+
)
|
|
1014
|
+
.option(
|
|
1015
|
+
"--no-fail-on-failures",
|
|
1016
|
+
"ARV-72: keep exit code 0 even when steps failed (advisory runs). Default: exit 1 on any failure. The stderr tail still names the count for visibility.",
|
|
1017
|
+
)
|
|
1018
|
+
.option(
|
|
1019
|
+
"--max-requests <N>",
|
|
1020
|
+
"ARV-249: hard cap on outgoing HTTP requests across the whole run. Once reached, remaining steps short-circuit to `skip` with reason `max-requests-cap-reached`. Each retry_until attempt counts as one request. Useful for sampling huge probe-suite runs and for CI time-boxing.",
|
|
1021
|
+
parsePositiveInt("--max-requests"),
|
|
1022
|
+
)
|
|
1023
|
+
.action(async (pathArgs: string[] | undefined, opts, cmd: Command) => {
|
|
1024
|
+
let paths = pathArgs ?? [];
|
|
1025
|
+
// ARV-53: explicit paths or --all suppress the current-API fallback —
|
|
1026
|
+
// `run path/to/test.yaml` should never silently pick up `.zond/current-api`.
|
|
1027
|
+
// Otherwise resolve via cli/util/api-context.ts.
|
|
1028
|
+
const apiFlag = (paths.length > 0 || opts.all === true)
|
|
1029
|
+
? (opts.api as string | undefined)
|
|
1030
|
+
: getApi(cmd, opts);
|
|
1031
|
+
const dbPath = typeof opts.db === "string" ? opts.db : undefined;
|
|
1032
|
+
|
|
1033
|
+
// TASK-116: --all expands to every apis/<name>/tests/ directory in the
|
|
1034
|
+
// workspace, merging them into a single run row.
|
|
1035
|
+
if (opts.all === true) {
|
|
1036
|
+
const discovered = discoverWorkspaceTestPaths();
|
|
1037
|
+
if ("error" in discovered) {
|
|
1038
|
+
printError(discovered.error);
|
|
1039
|
+
process.exitCode = 2;
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
if (discovered.paths.length === 0) {
|
|
1043
|
+
printError("--all found no apis/<name>/tests/ directories in the workspace. Run `zond add api <name>` and `zond generate` first.");
|
|
1044
|
+
process.exitCode = 1;
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
paths = discovered.paths;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
if (paths.length === 0 && apiFlag) {
|
|
1051
|
+
const resolved = resolveApiCollection(apiFlag, dbPath);
|
|
1052
|
+
if ("error" in resolved) {
|
|
1053
|
+
printError(resolved.error);
|
|
1054
|
+
process.exitCode = resolved.error.startsWith("Failed") ? 2 : 1;
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
if (!resolved.testPath) {
|
|
1058
|
+
printError(`API '${apiFlag}' has no test_path`);
|
|
1059
|
+
process.exitCode = 1;
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
paths = [resolved.testPath];
|
|
1063
|
+
}
|
|
1064
|
+
if (paths.length === 0) {
|
|
1065
|
+
printError("No path given and no current API set; run `zond use <api>`, set ZOND_API, pass --api <name>, or pass path explicitly");
|
|
1066
|
+
process.exitCode = 2;
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
const tags = flatSplit(opts.tag);
|
|
1071
|
+
const excludeTags = flatSplit(opts.excludeTag);
|
|
1072
|
+
const envVars = (opts.envVar as string[] | undefined)?.length ? (opts.envVar as string[]) : undefined;
|
|
1073
|
+
const includeSpecs = (opts.include as string[] | undefined)?.length ? (opts.include as string[]) : undefined;
|
|
1074
|
+
const excludeSpecs = (opts.exclude as string[] | undefined)?.length ? (opts.exclude as string[]) : undefined;
|
|
1075
|
+
|
|
1076
|
+
process.exitCode = await runCommand({
|
|
1077
|
+
paths,
|
|
1078
|
+
env: opts.env,
|
|
1079
|
+
report: opts.report as ReporterName,
|
|
1080
|
+
timeout: opts.timeout,
|
|
1081
|
+
rateLimit: opts.rateLimit,
|
|
1082
|
+
bail: opts.bail === true,
|
|
1083
|
+
sequential: opts.sequential === true,
|
|
1084
|
+
noDb: opts.db === false,
|
|
1085
|
+
dbPath: typeof opts.db === "string" ? opts.db : undefined,
|
|
1086
|
+
authToken: opts.authToken,
|
|
1087
|
+
safe: opts.safe === true,
|
|
1088
|
+
tag: tags,
|
|
1089
|
+
excludeTag: excludeTags,
|
|
1090
|
+
method: opts.method,
|
|
1091
|
+
include: includeSpecs,
|
|
1092
|
+
exclude: excludeSpecs,
|
|
1093
|
+
envVars,
|
|
1094
|
+
strictVars: opts.strictVars === true,
|
|
1095
|
+
dryRun: opts.dryRun === true,
|
|
1096
|
+
quiet: opts.quiet === true,
|
|
1097
|
+
failOnFailures: opts.failOnFailures !== false,
|
|
1098
|
+
output: typeof opts.output === "string" ? opts.output : undefined,
|
|
1099
|
+
validateSchema: opts.validateSchema === true,
|
|
1100
|
+
specPath: typeof opts.spec === "string" ? opts.spec : undefined,
|
|
1101
|
+
apiName: apiFlag,
|
|
1102
|
+
sessionId: resolveSessionId({
|
|
1103
|
+
flag: typeof opts.sessionId === "string" ? opts.sessionId : null,
|
|
1104
|
+
env: process.env.ZOND_SESSION_ID ?? null,
|
|
1105
|
+
}) ?? undefined,
|
|
1106
|
+
json: false,
|
|
1107
|
+
retryOnNetwork: typeof opts.retryOnNetwork === "number" ? opts.retryOnNetwork : 1,
|
|
1108
|
+
learn: opts.learn === true,
|
|
1109
|
+
learnApply: opts.learnApply === true,
|
|
1110
|
+
learnTarget: opts.learnTarget as "test" | "drifts" | undefined,
|
|
1111
|
+
maxRequests: typeof opts.maxRequests === "number" ? opts.maxRequests : undefined,
|
|
1112
|
+
});
|
|
1113
|
+
});
|
|
1114
|
+
}
|