@kirrosh/zond 0.22.0 → 0.26.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 +811 -0
- package/README.md +59 -6
- package/package.json +9 -7
- package/src/CLAUDE.md +112 -0
- package/src/cli/argv.ts +122 -0
- package/src/cli/commands/add-api.ts +146 -0
- package/src/cli/commands/api/annotate/idempotency.ts +59 -0
- package/src/cli/commands/api/annotate/index.ts +880 -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 +64 -0
- package/src/cli/commands/api/annotate/prompts.ts +220 -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 +786 -0
- package/src/cli/commands/catalog.ts +35 -0
- package/src/cli/commands/check.ts +361 -0
- package/src/cli/commands/checks.ts +1072 -0
- package/src/cli/commands/ci-init.ts +43 -0
- package/src/cli/commands/clean.ts +212 -0
- package/src/cli/commands/cleanup.ts +236 -0
- package/src/cli/commands/completions.ts +16 -0
- package/src/cli/commands/coverage.ts +823 -132
- package/src/cli/commands/db.ts +486 -12
- package/src/cli/commands/describe.ts +37 -2
- package/src/cli/commands/discover.ts +1356 -0
- package/src/cli/commands/doctor.ts +661 -0
- package/src/cli/commands/fixtures.ts +402 -0
- package/src/cli/commands/generate.ts +438 -47
- package/src/cli/commands/init/bootstrap.ts +34 -2
- package/src/cli/commands/{init.ts → init/index.ts} +99 -5
- package/src/cli/commands/init/skills.ts +99 -3
- package/src/cli/commands/init/templates/agents.md +77 -64
- package/src/cli/commands/init/templates/skills/warm-up-target.md +122 -0
- package/src/cli/commands/init/templates/skills/zond-checks.md +621 -0
- package/src/cli/commands/init/templates/skills/zond-seed.md +114 -0
- package/src/cli/commands/init/templates/skills/zond-triage.md +272 -0
- package/src/cli/commands/init/templates/skills/zond.md +802 -125
- package/src/cli/commands/init/templates/zond-config.yml +8 -9
- package/src/cli/commands/prepare-fixtures.ts +97 -0
- package/src/cli/commands/probe/_seed-bodies.ts +52 -0
- package/src/cli/commands/probe/mass-assignment.ts +594 -0
- package/src/cli/commands/probe/security.ts +537 -0
- package/src/cli/commands/probe/static.ts +255 -0
- package/src/cli/commands/probe/webhooks.ts +163 -0
- package/src/cli/commands/probe.ts +535 -0
- package/src/cli/commands/reference.ts +87 -0
- package/src/cli/commands/refresh-api.ts +227 -0
- package/src/cli/commands/remove-api.ts +150 -0
- package/src/cli/commands/report-bundle.ts +310 -0
- package/src/cli/commands/report.ts +241 -0
- package/src/cli/commands/request.ts +495 -4
- package/src/cli/commands/run.ts +870 -53
- package/src/cli/commands/schema-from-runs.ts +128 -0
- package/src/cli/commands/secrets.ts +133 -0
- package/src/cli/commands/session.ts +244 -0
- package/src/cli/commands/use.ts +18 -1
- package/src/cli/index.ts +20 -3
- package/src/cli/json-envelope.ts +92 -3
- package/src/cli/json-schemas.ts +314 -0
- package/src/cli/output.ts +17 -1
- package/src/cli/program.ts +199 -635
- package/src/cli/resolve.ts +105 -0
- package/src/cli/safe-live.ts +24 -0
- package/src/cli/status-filter.ts +114 -0
- package/src/cli/util/api-context.ts +85 -0
- package/src/cli/version.ts +5 -0
- package/src/core/audit/persist.ts +183 -0
- package/src/core/checks/budget.ts +59 -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 +147 -0
- package/src/core/checks/checks/cursor_boundary_fuzzing.ts +219 -0
- package/src/core/checks/checks/ensure_resource_availability.ts +62 -0
- package/src/core/checks/checks/idempotency_replay.ts +242 -0
- package/src/core/checks/checks/ignored_auth.ts +254 -0
- package/src/core/checks/checks/index.ts +68 -0
- package/src/core/checks/checks/lifecycle_transitions.ts +416 -0
- package/src/core/checks/checks/missing_required_header.ts +40 -0
- package/src/core/checks/checks/negative_data_rejection.ts +148 -0
- package/src/core/checks/checks/not_a_server_error.ts +35 -0
- package/src/core/checks/checks/open_cors_on_sensitive.ts +160 -0
- package/src/core/checks/checks/pagination_invariants.ts +419 -0
- package/src/core/checks/checks/positive_data_acceptance.ts +33 -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 +132 -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 +82 -0
- package/src/core/checks/recommended-action.ts +68 -0
- package/src/core/checks/registry.ts +78 -0
- package/src/core/checks/runner.ts +1461 -0
- package/src/core/checks/sarif.ts +230 -0
- package/src/core/checks/spec-findings.ts +308 -0
- package/src/core/checks/stateful.ts +121 -0
- package/src/core/checks/types.ts +305 -0
- package/src/core/checks/zond-extensions.ts +73 -0
- package/src/core/classifier/recommended-action.ts +251 -0
- package/src/core/context/current.ts +22 -6
- package/src/core/context/session.ts +78 -0
- package/src/core/coverage/loader.ts +216 -0
- package/src/core/coverage/reasons.ts +300 -0
- package/src/core/diagnostics/db-analysis.ts +293 -59
- package/src/core/diagnostics/failure-class.ts +140 -0
- package/src/core/diagnostics/failure-hints.ts +88 -89
- package/src/core/diagnostics/spec-pointer.ts +99 -0
- package/src/core/diagnostics/suggested-fixes.ts +155 -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 +38 -19
- package/src/core/generator/coverage-phase.ts +0 -0
- package/src/core/generator/data-factory.ts +586 -22
- package/src/core/generator/describe.ts +1 -1
- package/src/core/generator/fixtures-builder.ts +332 -0
- package/src/core/generator/index.ts +5 -5
- package/src/core/generator/openapi-reader.ts +135 -7
- package/src/core/generator/path-param-disambig.ts +140 -0
- package/src/core/generator/resources-builder.ts +898 -0
- package/src/core/generator/schema-utils.ts +33 -3
- package/src/core/generator/serializer.ts +103 -13
- package/src/core/generator/suite-generator.ts +583 -122
- package/src/core/generator/types.ts +14 -0
- package/src/core/identity/identity-file.ts +0 -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 +73 -0
- package/src/core/output/index.ts +13 -0
- package/src/core/output/run.ts +91 -0
- package/src/core/output/types.ts +122 -0
- package/src/core/parser/dynamic-values.ts +160 -0
- package/src/core/parser/env-interpolation.ts +104 -0
- package/src/core/parser/filter.ts +57 -0
- package/src/core/parser/schema.ts +129 -4
- package/src/core/parser/types.ts +19 -1
- package/src/core/parser/variables.ts +0 -0
- package/src/core/parser/yaml-parser.ts +58 -12
- package/src/core/probe/bootstrap.ts +34 -0
- package/src/core/probe/dry-run-envelope.ts +61 -0
- package/src/core/probe/mass-assignment/classify.ts +175 -0
- package/src/core/probe/mass-assignment/cleanup.ts +52 -0
- package/src/core/probe/mass-assignment/digest.ts +114 -0
- package/src/core/probe/mass-assignment/orchestrator.ts +459 -0
- package/src/core/probe/mass-assignment/regression.ts +141 -0
- package/src/core/probe/mass-assignment/suspects.ts +92 -0
- package/src/core/probe/mass-assignment/types.ts +135 -0
- package/src/core/probe/mass-assignment-probe-class.ts +198 -0
- package/src/core/probe/mass-assignment-probe.ts +27 -0
- package/src/core/probe/mass-assignment-template.ts +240 -0
- package/src/core/probe/method-probe.ts +43 -76
- package/src/core/probe/method-shared.ts +69 -0
- package/src/core/probe/negative-probe.ts +183 -149
- package/src/core/probe/orphan-tracker.ts +188 -0
- package/src/core/probe/path-discovery.ts +439 -0
- package/src/core/probe/probe-harness.ts +119 -0
- package/src/core/probe/registry.ts +89 -0
- package/src/core/probe/runner.ts +136 -0
- package/src/core/probe/security/baseline.ts +174 -0
- package/src/core/probe/security/classify.ts +341 -0
- package/src/core/probe/security/cleanup.ts +125 -0
- package/src/core/probe/security/detectors.ts +71 -0
- package/src/core/probe/security/digest.ts +104 -0
- package/src/core/probe/security/orchestrator.ts +398 -0
- package/src/core/probe/security/regression.ts +103 -0
- package/src/core/probe/security/types.ts +151 -0
- package/src/core/probe/security-probe-class.ts +207 -0
- package/src/core/probe/security-probe.ts +32 -0
- package/src/core/probe/shared.ts +531 -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 +282 -0
- package/src/core/reporter/console.ts +41 -2
- package/src/core/reporter/index.ts +2 -3
- package/src/core/reporter/json.ts +11 -1
- 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 +59 -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 +265 -36
- package/src/core/runner/form-encode.ts +41 -0
- package/src/core/runner/http-client.ts +112 -2
- package/src/core/runner/learn-drift.ts +293 -0
- package/src/core/runner/preflight-vars.ts +153 -0
- package/src/core/runner/progress-tracker.ts +73 -0
- package/src/core/runner/rate-limiter.ts +87 -33
- package/src/core/runner/run-kind.ts +45 -0
- package/src/core/runner/schema-validator.ts +308 -0
- package/src/core/runner/send-request.ts +158 -20
- package/src/core/runner/types.ts +44 -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 +457 -20
- package/src/core/severity/category.ts +94 -0
- package/src/core/severity/index.ts +58 -0
- package/src/core/spec/infer-schema.ts +102 -0
- package/src/core/spec/layers.ts +154 -0
- package/src/core/spec/merge-specs.ts +156 -0
- package/src/core/spec/schema-from-runs.ts +117 -0
- package/src/core/spec/schema-overlay.ts +130 -0
- package/src/core/util/ajv.ts +13 -0
- package/src/core/util/format-eta.ts +21 -0
- package/src/core/util/headers.ts +9 -0
- package/src/core/util/url.ts +24 -0
- package/src/core/utils.ts +5 -1
- package/src/core/workspace/config.ts +129 -0
- package/src/core/workspace/fixture-gap-report.ts +84 -0
- package/src/core/workspace/fixture-gaps.ts +71 -0
- package/src/core/workspace/manifest.ts +283 -0
- package/src/core/workspace/output-rotation.ts +62 -0
- package/src/core/workspace/root.ts +13 -11
- package/src/core/workspace/triage-path.ts +87 -0
- package/src/db/lint-runs.ts +47 -0
- package/src/db/migrate.ts +128 -0
- package/src/db/migrations/0001_run_kind.sql +25 -0
- package/src/db/migrations/0002_run_kind_request.sql +59 -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 +216 -0
- package/src/db/queries/runs.ts +289 -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 +75 -802
- package/src/db/schema.ts +178 -50
- package/src/cli/commands/export.ts +0 -144
- package/src/cli/commands/guide.ts +0 -127
- package/src/cli/commands/init/templates/skills/scenarios.md +0 -97
- package/src/cli/commands/probe-methods.ts +0 -108
- package/src/cli/commands/probe-validation.ts +0 -124
- package/src/cli/commands/serve.ts +0 -114
- package/src/cli/commands/sync.ts +0 -268
- package/src/cli/commands/update.ts +0 -189
- package/src/cli/commands/validate.ts +0 -34
- package/src/core/diagnostics/render-md.ts +0 -112
- package/src/core/exporter/postman.ts +0 -963
- package/src/core/generator/guide-builder.ts +0 -253
- package/src/core/meta/types.ts +0 -19
- 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
|
@@ -1,24 +1,106 @@
|
|
|
1
1
|
import { resolve, dirname, basename } from "node:path";
|
|
2
|
-
import type { TestSuite, TestStep, Environment } from "../parser/types.ts";
|
|
2
|
+
import type { TestSuite, TestStep, Environment, SourceMetadata, AssertionRule } from "../parser/types.ts";
|
|
3
3
|
import { substituteString, substituteStep, substituteDeep, extractVariableReferences } from "../parser/variables.ts";
|
|
4
|
-
import type { TestRunResult, StepResult, HttpRequest } from "./types.ts";
|
|
4
|
+
import type { TestRunResult, StepResult, HttpRequest, AssertionResult } from "./types.ts";
|
|
5
5
|
import { executeRequest, type FetchOptions } from "./http-client.ts";
|
|
6
6
|
import type { RateLimiter } from "./rate-limiter.ts";
|
|
7
|
-
import { checkAssertions, extractCaptures } from "./assertions.ts";
|
|
7
|
+
import { checkAssertions, extractCaptures, findMissedCaptures } from "./assertions.ts";
|
|
8
8
|
import { evaluateExpr } from "./expr-eval.ts";
|
|
9
9
|
import { applyTransform } from "./transforms.ts";
|
|
10
|
+
import type { SchemaValidator } from "./schema-validator.ts";
|
|
11
|
+
import { classifyFailure } from "../diagnostics/failure-class.ts";
|
|
12
|
+
import { buildUrl } from "../util/url.ts";
|
|
13
|
+
|
|
14
|
+
/** Shallow-merge suite-level и step-level provenance. Step перекрывает suite. */
|
|
15
|
+
function mergeProvenance(
|
|
16
|
+
suiteSrc?: SourceMetadata,
|
|
17
|
+
stepSrc?: SourceMetadata,
|
|
18
|
+
): SourceMetadata | null {
|
|
19
|
+
if (!suiteSrc && !stepSrc) return null;
|
|
20
|
+
return { ...(suiteSrc ?? {}), ...(stepSrc ?? {}) };
|
|
21
|
+
}
|
|
10
22
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
23
|
+
/** ARV-157: build the top-level `schema_validation` summary for a step that
|
|
24
|
+
* was run with `--validate-schema`. Mirrors the shape `zond request
|
|
25
|
+
* --validate-schema` already produces (see src/cli/commands/request.ts);
|
|
26
|
+
* consumers can `jq '.steps[] | .schema_validation'` instead of digging
|
|
27
|
+
* into `assertions[] | select(.kind=="schema")`.
|
|
28
|
+
*
|
|
29
|
+
* Returns undefined when the validator wasn't attached or the response had
|
|
30
|
+
* no parseable JSON body — same precondition as `assertions.push(...)` at
|
|
31
|
+
* the call site, so the summary is present iff schema actually ran. */
|
|
32
|
+
function buildSchemaValidationSummary(
|
|
33
|
+
validator: SchemaValidator,
|
|
34
|
+
method: string,
|
|
35
|
+
path: string,
|
|
36
|
+
status: number,
|
|
37
|
+
schemaAssertions: AssertionResult[],
|
|
38
|
+
): StepResult["schema_validation"] {
|
|
39
|
+
const ins = validator.inspect(method, path, status);
|
|
40
|
+
if (!ins.matchedEndpoint) {
|
|
41
|
+
return { result: "no-endpoint", matched_endpoint: null, matched_response_status: null, error_count: 0 };
|
|
42
|
+
}
|
|
43
|
+
if (!ins.hasJsonSchema) {
|
|
44
|
+
return {
|
|
45
|
+
result: "no-schema",
|
|
46
|
+
matched_endpoint: ins.matchedEndpoint,
|
|
47
|
+
matched_response_status: ins.matchedResponseStatus,
|
|
48
|
+
error_count: 0,
|
|
49
|
+
};
|
|
16
50
|
}
|
|
17
|
-
|
|
51
|
+
const failed = schemaAssertions.filter((a) => !a.passed).length;
|
|
52
|
+
return {
|
|
53
|
+
result: failed === 0 ? "PASS" : "FAIL",
|
|
54
|
+
matched_endpoint: ins.matchedEndpoint,
|
|
55
|
+
matched_response_status: ins.matchedResponseStatus,
|
|
56
|
+
error_count: failed,
|
|
57
|
+
};
|
|
18
58
|
}
|
|
19
59
|
|
|
20
|
-
|
|
21
|
-
|
|
60
|
+
/** TASK-256: turn each missed-capture (path didn't resolve in response)
|
|
61
|
+
* into an auxiliary failed assertion. The step then fails loudly with
|
|
62
|
+
* "capture <var>: path '<path>' not found in body" instead of producing
|
|
63
|
+
* silent `captures: {}` that the user only notices when the next step in a
|
|
64
|
+
* CRUD chain skips with `Depends on missing capture`. */
|
|
65
|
+
function buildMissedCaptureAssertions(
|
|
66
|
+
misses: ReturnType<typeof findMissedCaptures>,
|
|
67
|
+
): AssertionResult[] {
|
|
68
|
+
return misses.map((m) => ({
|
|
69
|
+
field: `capture ${m.var}`,
|
|
70
|
+
rule: m.source === "body" ? "body-path-exists" : "header-exists",
|
|
71
|
+
passed: false,
|
|
72
|
+
actual: undefined,
|
|
73
|
+
expected: `${m.source} '${m.path}' present`,
|
|
74
|
+
kind: "auxiliary",
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function collectChainCaptures(tests: TestStep[]): Set<string> {
|
|
79
|
+
const out = new Set<string>();
|
|
80
|
+
const visit = (rules: Record<string, AssertionRule> | undefined) => {
|
|
81
|
+
if (!rules) return;
|
|
82
|
+
for (const r of Object.values(rules)) {
|
|
83
|
+
if (r.capture) out.add(r.capture);
|
|
84
|
+
if (r.each) visit(r.each);
|
|
85
|
+
if (r.contains_item) visit(r.contains_item);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
for (const step of tests) visit(step.expect?.body);
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function emptyVarSkipReason(varName: string, chainCaptures: Set<string>): string {
|
|
93
|
+
return chainCaptures.has(varName)
|
|
94
|
+
? `chain capture {{${varName}}} unbound (upstream step did not run or did not capture it)`
|
|
95
|
+
: `required fixture {{${varName}}} is empty`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function makeSkippedResult(
|
|
99
|
+
stepName: string,
|
|
100
|
+
reason: string,
|
|
101
|
+
opts?: { cascade?: { missingCapture: string } },
|
|
102
|
+
): StepResult {
|
|
103
|
+
const result: StepResult = {
|
|
22
104
|
name: stepName,
|
|
23
105
|
status: "skip",
|
|
24
106
|
duration_ms: 0,
|
|
@@ -27,6 +109,11 @@ function makeSkippedResult(stepName: string, reason: string): StepResult {
|
|
|
27
109
|
captures: {},
|
|
28
110
|
error: reason,
|
|
29
111
|
};
|
|
112
|
+
if (opts?.cascade) {
|
|
113
|
+
result.failure_class = "cascade";
|
|
114
|
+
result.failure_class_reason = `Upstream capture not produced: ${opts.cascade.missingCapture}`;
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
30
117
|
}
|
|
31
118
|
|
|
32
119
|
/** Interpolate {{var}} placeholders inside a test/step name. Falls back to
|
|
@@ -67,8 +154,46 @@ export function expandParameterize(params?: Record<string, unknown[]>): Record<s
|
|
|
67
154
|
|
|
68
155
|
export interface RunSuiteOptions {
|
|
69
156
|
rateLimiter?: RateLimiter;
|
|
157
|
+
/** Optional OpenAPI response-schema validator. When provided, every step's
|
|
158
|
+
* parsed JSON body is validated against the matching schema; failures are
|
|
159
|
+
* appended to the step's `assertions`. */
|
|
160
|
+
schemaValidator?: SchemaValidator;
|
|
161
|
+
/** TASK-144: per-step network-retry budget used by http-client for
|
|
162
|
+
* ECONNRESET / EPIPE / `socket hang up` / `fetch failed` / abort cases.
|
|
163
|
+
* Set by `zond run --retry-on-network <N>`. HTTP statuses are not retried
|
|
164
|
+
* by this path. */
|
|
165
|
+
networkRetries?: number;
|
|
166
|
+
/** ARV-249: shared HTTP-request budget across all parallel suites. When
|
|
167
|
+
* `used >= limit`, remaining steps short-circuit to `skip` with reason
|
|
168
|
+
* `max-requests-cap-reached`. Each `retry_until` attempt counts as one
|
|
169
|
+
* request; dry-run and set-only steps do not consume the budget. */
|
|
170
|
+
requestBudget?: RequestBudget;
|
|
171
|
+
/** ARV-249: invoked after every step completes (pass/fail/skip/error) so
|
|
172
|
+
* the CLI can render a periodic progress line without each suite
|
|
173
|
+
* knowing how many siblings are running in parallel. */
|
|
174
|
+
onStepDone?: (step: StepResult) => void;
|
|
70
175
|
}
|
|
71
176
|
|
|
177
|
+
/** ARV-249: shared `--max-requests` budget. Mutated in-place by every
|
|
178
|
+
* parallel `runSuite` call — single-threaded JS makes the
|
|
179
|
+
* check-then-increment race-free. `limit === Infinity` means "uncapped"
|
|
180
|
+
* (the default). */
|
|
181
|
+
export interface RequestBudget {
|
|
182
|
+
limit: number;
|
|
183
|
+
used: number;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Try to reserve one HTTP slot from the shared budget. Returns true if
|
|
187
|
+
* the caller may proceed, false if the cap has been reached. */
|
|
188
|
+
export function reserveRequest(budget: RequestBudget | undefined): boolean {
|
|
189
|
+
if (!budget) return true;
|
|
190
|
+
if (budget.used >= budget.limit) return false;
|
|
191
|
+
budget.used += 1;
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export const MAX_REQUESTS_SKIP_REASON = "max-requests-cap-reached";
|
|
196
|
+
|
|
72
197
|
export async function runSuite(
|
|
73
198
|
suite: TestSuite,
|
|
74
199
|
env: Environment = {},
|
|
@@ -78,14 +203,37 @@ export async function runSuite(
|
|
|
78
203
|
const startedAt = new Date().toISOString();
|
|
79
204
|
const steps: StepResult[] = [];
|
|
80
205
|
|
|
206
|
+
/** Push a step result, attaching provenance + failure classification. */
|
|
207
|
+
const pushStep = (result: StepResult, currentStep?: TestStep): void => {
|
|
208
|
+
const merged = mergeProvenance(suite.source, currentStep?.source);
|
|
209
|
+
if (merged !== null) result.provenance = merged;
|
|
210
|
+
const classification = classifyFailure(result);
|
|
211
|
+
if (classification) {
|
|
212
|
+
result.failure_class = classification.failure_class;
|
|
213
|
+
result.failure_class_reason = classification.failure_class_reason;
|
|
214
|
+
}
|
|
215
|
+
steps.push(result);
|
|
216
|
+
if (options.onStepDone) options.onStepDone(result);
|
|
217
|
+
};
|
|
218
|
+
|
|
81
219
|
const fetchOptions: Partial<FetchOptions> = {
|
|
82
220
|
timeout: suite.config.timeout,
|
|
83
221
|
retries: suite.config.retries,
|
|
84
222
|
retry_delay: suite.config.retry_delay,
|
|
85
223
|
follow_redirects: suite.config.follow_redirects,
|
|
86
224
|
rate_limiter: options.rateLimiter,
|
|
225
|
+
...(options.networkRetries !== undefined ? { network_retries: options.networkRetries } : {}),
|
|
87
226
|
};
|
|
88
227
|
|
|
228
|
+
// Names of every variable a step in this suite tries to capture from a
|
|
229
|
+
// response (expect.body.<field>.capture: <name>). When a later step
|
|
230
|
+
// references one of these and the value is empty — under --dry-run, or
|
|
231
|
+
// because the capturing step was skipped — the missing var is a chain
|
|
232
|
+
// capture, NOT a fixture in .env.yaml. Distinguishing them in the skip
|
|
233
|
+
// message stops users from chasing fixture seeding for vars that
|
|
234
|
+
// shouldn't live in .env.yaml at all.
|
|
235
|
+
const chainCaptures = collectChainCaptures(suite.tests);
|
|
236
|
+
|
|
89
237
|
// parameterize cross-product → N iterations of the suite body.
|
|
90
238
|
// Captures and tainted/missing sets are reset per iteration so that
|
|
91
239
|
// values from one binding never leak into the next.
|
|
@@ -144,14 +292,14 @@ export async function runSuite(
|
|
|
144
292
|
const substituted = substituteDeep(rawDirective, variables);
|
|
145
293
|
variables[key] = applyTransform(substituted);
|
|
146
294
|
}
|
|
147
|
-
|
|
295
|
+
pushStep({
|
|
148
296
|
name: interpolateName(step.name, variables),
|
|
149
297
|
status: "pass",
|
|
150
298
|
duration_ms: 0,
|
|
151
299
|
request: { method: "", url: "", headers: {} },
|
|
152
300
|
assertions: [],
|
|
153
301
|
captures: {},
|
|
154
|
-
});
|
|
302
|
+
}, step);
|
|
155
303
|
continue;
|
|
156
304
|
}
|
|
157
305
|
|
|
@@ -160,13 +308,27 @@ export async function runSuite(
|
|
|
160
308
|
const referencedVars = extractVariableReferences(step);
|
|
161
309
|
const missing = referencedVars.find((v) => missingCaptures.has(v));
|
|
162
310
|
if (missing) {
|
|
163
|
-
|
|
311
|
+
pushStep(
|
|
312
|
+
makeSkippedResult(
|
|
313
|
+
interpolateName(step.name, variables),
|
|
314
|
+
`Depends on missing capture: ${missing}`,
|
|
315
|
+
{ cascade: { missingCapture: missing } },
|
|
316
|
+
),
|
|
317
|
+
step,
|
|
318
|
+
);
|
|
164
319
|
continue;
|
|
165
320
|
}
|
|
166
321
|
if (!step.always) {
|
|
167
322
|
const tainted = referencedVars.find((v) => taintedCaptures.has(v));
|
|
168
323
|
if (tainted) {
|
|
169
|
-
|
|
324
|
+
pushStep(
|
|
325
|
+
makeSkippedResult(
|
|
326
|
+
interpolateName(step.name, variables),
|
|
327
|
+
`Depends on tainted capture: ${tainted} (use always: true on cleanup steps)`,
|
|
328
|
+
{ cascade: { missingCapture: tainted } },
|
|
329
|
+
),
|
|
330
|
+
step,
|
|
331
|
+
);
|
|
170
332
|
continue;
|
|
171
333
|
}
|
|
172
334
|
}
|
|
@@ -175,7 +337,11 @@ export async function runSuite(
|
|
|
175
337
|
if (step.skip_if) {
|
|
176
338
|
const exprAfterSubst = String(substituteString(step.skip_if, variables));
|
|
177
339
|
if (evaluateExpr(exprAfterSubst)) {
|
|
178
|
-
|
|
340
|
+
const varMatch = step.skip_if.match(/\{\{([^{}]+)\}\}/);
|
|
341
|
+
const skipMsg = varMatch
|
|
342
|
+
? emptyVarSkipReason(varMatch[1]!.trim(), chainCaptures)
|
|
343
|
+
: step.skip_if;
|
|
344
|
+
pushStep(makeSkippedResult(interpolateName(step.name, variables), skipMsg), step);
|
|
179
345
|
continue;
|
|
180
346
|
}
|
|
181
347
|
}
|
|
@@ -197,7 +363,7 @@ export async function runSuite(
|
|
|
197
363
|
resolvedSuiteHeaders = suite.headers ? substituteDeep(suite.headers, variables) : undefined;
|
|
198
364
|
} catch (err) {
|
|
199
365
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
200
|
-
|
|
366
|
+
pushStep({
|
|
201
367
|
name: interpolateName(step.name, variables),
|
|
202
368
|
status: "error",
|
|
203
369
|
duration_ms: 0,
|
|
@@ -205,7 +371,7 @@ export async function runSuite(
|
|
|
205
371
|
assertions: [],
|
|
206
372
|
captures: {},
|
|
207
373
|
error: errorMsg,
|
|
208
|
-
});
|
|
374
|
+
}, step);
|
|
209
375
|
// Substitution never produced a request → capture truly missing.
|
|
210
376
|
if (step.expect.body) {
|
|
211
377
|
for (const rule of Object.values(step.expect.body)) {
|
|
@@ -214,6 +380,25 @@ export async function runSuite(
|
|
|
214
380
|
}
|
|
215
381
|
continue;
|
|
216
382
|
}
|
|
383
|
+
// Skip if any path-variable in the template resolved to empty — an empty
|
|
384
|
+
// path segment produces URLs like /repos//commits/ which always 404/500.
|
|
385
|
+
// The explicit skip_if guard only covers the first param (TASK-237);
|
|
386
|
+
// this catches all others.
|
|
387
|
+
{
|
|
388
|
+
let emptyVar: string | null = null;
|
|
389
|
+
for (const m of step.path.matchAll(/\{\{([^{}]+)\}\}/g)) {
|
|
390
|
+
const varName = m[1]!.trim();
|
|
391
|
+
const val = variables[varName];
|
|
392
|
+
if (val === "" || val === null || val === undefined) { emptyVar = varName; break; }
|
|
393
|
+
}
|
|
394
|
+
if (emptyVar) {
|
|
395
|
+
pushStep(makeSkippedResult(
|
|
396
|
+
interpolateName(step.name, variables),
|
|
397
|
+
emptyVarSkipReason(emptyVar, chainCaptures),
|
|
398
|
+
), step);
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
217
402
|
const url = buildUrl(resolvedBaseUrl, resolved.path, resolved.query);
|
|
218
403
|
const headers: Record<string, string> = { ...resolvedSuiteHeaders, ...resolved.headers };
|
|
219
404
|
let body: string | undefined;
|
|
@@ -249,7 +434,7 @@ export async function runSuite(
|
|
|
249
434
|
|
|
250
435
|
// Validate absolute URL before attempting fetch
|
|
251
436
|
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
|
252
|
-
|
|
437
|
+
pushStep({
|
|
253
438
|
name: interpolateName(step.name, variables),
|
|
254
439
|
status: "error",
|
|
255
440
|
duration_ms: 0,
|
|
@@ -257,7 +442,7 @@ export async function runSuite(
|
|
|
257
442
|
assertions: [],
|
|
258
443
|
captures: {},
|
|
259
444
|
error: `base_url is not configured — URL resolved to a relative path: "${url}". Set base_url in .env.yaml`,
|
|
260
|
-
});
|
|
445
|
+
}, step);
|
|
261
446
|
if (step.expect.body) {
|
|
262
447
|
for (const rule of Object.values(step.expect.body)) {
|
|
263
448
|
if (rule.capture) missingCaptures.add(rule.capture);
|
|
@@ -270,7 +455,7 @@ export async function runSuite(
|
|
|
270
455
|
const bodyPreview = formData
|
|
271
456
|
? ` [multipart: ${[...formData.keys()].length} field(s)]`
|
|
272
457
|
: body ? ` ${body.slice(0, 200)}` : "";
|
|
273
|
-
|
|
458
|
+
pushStep({
|
|
274
459
|
name: interpolateName(step.name, variables),
|
|
275
460
|
status: "pass",
|
|
276
461
|
duration_ms: 0,
|
|
@@ -278,7 +463,7 @@ export async function runSuite(
|
|
|
278
463
|
assertions: [],
|
|
279
464
|
captures: {},
|
|
280
465
|
error: `[DRY RUN] ${resolved.method} ${url}${bodyPreview}`,
|
|
281
|
-
});
|
|
466
|
+
}, step);
|
|
282
467
|
continue;
|
|
283
468
|
}
|
|
284
469
|
|
|
@@ -287,10 +472,31 @@ export async function runSuite(
|
|
|
287
472
|
const rt = step.retry_until;
|
|
288
473
|
let lastStepResult: StepResult | undefined;
|
|
289
474
|
for (let attempt = 0; attempt < rt.max_attempts; attempt++) {
|
|
475
|
+
if (!reserveRequest(options.requestBudget)) {
|
|
476
|
+
lastStepResult = makeSkippedResult(
|
|
477
|
+
interpolateName(step.name, variables),
|
|
478
|
+
MAX_REQUESTS_SKIP_REASON,
|
|
479
|
+
);
|
|
480
|
+
break;
|
|
481
|
+
}
|
|
290
482
|
try {
|
|
291
483
|
const response = await executeRequest(request, fetchOptions);
|
|
292
484
|
const captures = extractCaptures(resolved.expect.body, response.body_parsed, resolved.expect.headers, response.headers);
|
|
485
|
+
const missedCaps = findMissedCaptures(resolved.expect.body, response.body_parsed, resolved.expect.headers, response.headers);
|
|
293
486
|
const assertions = checkAssertions(resolved.expect, response);
|
|
487
|
+
assertions.push(...buildMissedCaptureAssertions(missedCaps));
|
|
488
|
+
let schemaValidationSummary: StepResult["schema_validation"] | undefined;
|
|
489
|
+
if (options.schemaValidator && response.body_parsed !== undefined) {
|
|
490
|
+
const schemaAssertions = options.schemaValidator.validate(resolved.method, resolved.path, response.status, response.body_parsed);
|
|
491
|
+
assertions.push(...schemaAssertions);
|
|
492
|
+
schemaValidationSummary = buildSchemaValidationSummary(
|
|
493
|
+
options.schemaValidator,
|
|
494
|
+
resolved.method,
|
|
495
|
+
resolved.path,
|
|
496
|
+
response.status,
|
|
497
|
+
schemaAssertions,
|
|
498
|
+
);
|
|
499
|
+
}
|
|
294
500
|
const allPassed = assertions.every((a) => a.passed);
|
|
295
501
|
|
|
296
502
|
lastStepResult = {
|
|
@@ -301,6 +507,10 @@ export async function runSuite(
|
|
|
301
507
|
response,
|
|
302
508
|
assertions,
|
|
303
509
|
captures,
|
|
510
|
+
...(response.network_retry_count && response.network_retry_count > 0
|
|
511
|
+
? { network_retry: response.network_retry_count }
|
|
512
|
+
: {}),
|
|
513
|
+
...(schemaValidationSummary ? { schema_validation: schemaValidationSummary } : {}),
|
|
304
514
|
};
|
|
305
515
|
|
|
306
516
|
// Evaluate condition with response context
|
|
@@ -331,7 +541,15 @@ export async function runSuite(
|
|
|
331
541
|
};
|
|
332
542
|
}
|
|
333
543
|
}
|
|
334
|
-
if (lastStepResult)
|
|
544
|
+
if (lastStepResult) pushStep(lastStepResult, step);
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (!reserveRequest(options.requestBudget)) {
|
|
549
|
+
pushStep(makeSkippedResult(
|
|
550
|
+
interpolateName(step.name, variables),
|
|
551
|
+
MAX_REQUESTS_SKIP_REASON,
|
|
552
|
+
), step);
|
|
335
553
|
continue;
|
|
336
554
|
}
|
|
337
555
|
|
|
@@ -352,10 +570,24 @@ export async function runSuite(
|
|
|
352
570
|
}
|
|
353
571
|
|
|
354
572
|
// Run assertions
|
|
573
|
+
const missedCaps = findMissedCaptures(resolved.expect.body, response.body_parsed, resolved.expect.headers, response.headers);
|
|
355
574
|
const assertions = checkAssertions(resolved.expect, response);
|
|
575
|
+
assertions.push(...buildMissedCaptureAssertions(missedCaps));
|
|
576
|
+
let schemaValidationSummary: StepResult["schema_validation"] | undefined;
|
|
577
|
+
if (options.schemaValidator && response.body_parsed !== undefined) {
|
|
578
|
+
const schemaAssertions = options.schemaValidator.validate(resolved.method, resolved.path, response.status, response.body_parsed);
|
|
579
|
+
assertions.push(...schemaAssertions);
|
|
580
|
+
schemaValidationSummary = buildSchemaValidationSummary(
|
|
581
|
+
options.schemaValidator,
|
|
582
|
+
resolved.method,
|
|
583
|
+
resolved.path,
|
|
584
|
+
response.status,
|
|
585
|
+
schemaAssertions,
|
|
586
|
+
);
|
|
587
|
+
}
|
|
356
588
|
const allPassed = assertions.every((a) => a.passed);
|
|
357
589
|
|
|
358
|
-
|
|
590
|
+
pushStep({
|
|
359
591
|
name: interpolateName(step.name, variables),
|
|
360
592
|
status: allPassed ? "pass" : "fail",
|
|
361
593
|
duration_ms: response.duration_ms,
|
|
@@ -363,7 +595,11 @@ export async function runSuite(
|
|
|
363
595
|
response,
|
|
364
596
|
assertions,
|
|
365
597
|
captures,
|
|
366
|
-
|
|
598
|
+
...(response.network_retry_count && response.network_retry_count > 0
|
|
599
|
+
? { network_retry: response.network_retry_count }
|
|
600
|
+
: {}),
|
|
601
|
+
...(schemaValidationSummary ? { schema_validation: schemaValidationSummary } : {}),
|
|
602
|
+
}, step);
|
|
367
603
|
|
|
368
604
|
// If step failed, captures that did extract are tainted (value is real
|
|
369
605
|
// but came from a step whose other assertions failed). Always-steps may
|
|
@@ -377,7 +613,7 @@ export async function runSuite(
|
|
|
377
613
|
}
|
|
378
614
|
} catch (err) {
|
|
379
615
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
380
|
-
|
|
616
|
+
pushStep({
|
|
381
617
|
name: interpolateName(step.name, variables),
|
|
382
618
|
status: "error",
|
|
383
619
|
duration_ms: 0,
|
|
@@ -385,7 +621,7 @@ export async function runSuite(
|
|
|
385
621
|
assertions: [],
|
|
386
622
|
captures: {},
|
|
387
623
|
error: errorMsg,
|
|
388
|
-
});
|
|
624
|
+
}, step);
|
|
389
625
|
|
|
390
626
|
// Network/runtime error → no response → capture truly missing.
|
|
391
627
|
if (step.expect.body) {
|
|
@@ -409,15 +645,8 @@ export async function runSuite(
|
|
|
409
645
|
passed: steps.filter((s) => s.status === "pass").length,
|
|
410
646
|
failed: steps.filter((s) => s.status === "fail").length,
|
|
411
647
|
skipped: steps.filter((s) => s.status === "skip").length,
|
|
648
|
+
// ARV-318: surface error steps so total = passed+failed+skipped+errored.
|
|
649
|
+
errored: steps.filter((s) => s.status === "error").length,
|
|
412
650
|
steps,
|
|
413
651
|
};
|
|
414
652
|
}
|
|
415
|
-
|
|
416
|
-
export async function runSuites(
|
|
417
|
-
suites: TestSuite[],
|
|
418
|
-
env: Environment = {},
|
|
419
|
-
dryRun = false,
|
|
420
|
-
options: RunSuiteOptions = {},
|
|
421
|
-
): Promise<TestRunResult[]> {
|
|
422
|
-
return Promise.all(suites.map((suite) => runSuite(suite, env, dryRun, options)));
|
|
423
|
-
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ARV-149 / ARV-150: encode a nested JS object as
|
|
3
|
+
* `application/x-www-form-urlencoded` using bracket notation — the
|
|
4
|
+
* canonical Stripe / Rails / PHP convention for nested fields:
|
|
5
|
+
*
|
|
6
|
+
* { address: { line1: "x", line2: "y" }, items: [{ id: 1 }, { id: 2 }] }
|
|
7
|
+
* → address[line1]=x&address[line2]=y&items[0][id]=1&items[1][id]=2
|
|
8
|
+
*
|
|
9
|
+
* Shared between `zond request --form`, the YAML runner's `form:` step,
|
|
10
|
+
* and the mass-assignment probe's form-bodied endpoints. The probe-side
|
|
11
|
+
* adoption (ARV-150) is what restores 265 SKIPPED Stripe endpoints.
|
|
12
|
+
*/
|
|
13
|
+
function appendFormParam(params: URLSearchParams, key: string, value: unknown): void {
|
|
14
|
+
if (value === null || value === undefined) return;
|
|
15
|
+
if (Array.isArray(value)) {
|
|
16
|
+
for (let i = 0; i < value.length; i++) appendFormParam(params, `${key}[${i}]`, value[i]);
|
|
17
|
+
} else if (typeof value === "object") {
|
|
18
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
19
|
+
appendFormParam(params, `${key}[${k}]`, v);
|
|
20
|
+
}
|
|
21
|
+
} else {
|
|
22
|
+
params.append(key, String(value));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function encodeFormBody(body: Record<string, unknown>): string {
|
|
27
|
+
const params = new URLSearchParams();
|
|
28
|
+
for (const [k, v] of Object.entries(body)) appendFormParam(params, k, v);
|
|
29
|
+
return params.toString();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Flatten a nested JS object to a `Record<string, string>` using the same
|
|
33
|
+
* bracket-notation walk as `encodeFormBody`, suitable for the YAML
|
|
34
|
+
* runner's `form:` step (which is typed as `Record<string, string>` and
|
|
35
|
+
* serialised via `URLSearchParams`). */
|
|
36
|
+
export function flattenToFormFields(body: unknown): Record<string, string> {
|
|
37
|
+
if (!body || typeof body !== "object") return {};
|
|
38
|
+
const params = new URLSearchParams();
|
|
39
|
+
for (const [k, v] of Object.entries(body as Record<string, unknown>)) appendFormParam(params, k, v);
|
|
40
|
+
return Object.fromEntries(params.entries());
|
|
41
|
+
}
|
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
import type { HttpRequest, HttpResponse } from "./types.ts";
|
|
2
2
|
import { type RateLimiter, parseRetryAfter, parseRateLimitHeaders } from "./rate-limiter.ts";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* ARV-265: module-level audit hook for commands that don't drive their
|
|
6
|
+
* HTTP through the suite runner. Each `executeRequest` call fires the
|
|
7
|
+
* registered recorder with the final outcome (or the swallowed network
|
|
8
|
+
* error if the call exhausted its retry budget). Recorders MUST NOT
|
|
9
|
+
* throw — the http client treats them as fire-and-forget telemetry.
|
|
10
|
+
*
|
|
11
|
+
* Convention: a command sets the recorder before the live work begins,
|
|
12
|
+
* unsets it in a `finally` block. There can be at most one recorder at
|
|
13
|
+
* a time; nesting is a bug (we surface it on stderr but keep the inner
|
|
14
|
+
* recorder so the live command's results take precedence).
|
|
15
|
+
*/
|
|
16
|
+
export interface AuditRecord {
|
|
17
|
+
request: HttpRequest;
|
|
18
|
+
response?: HttpResponse;
|
|
19
|
+
durationMs: number;
|
|
20
|
+
error?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let _auditRecorder: ((rec: AuditRecord) => void) | null = null;
|
|
24
|
+
|
|
25
|
+
export function setHttpAuditRecorder(recorder: ((rec: AuditRecord) => void) | null): void {
|
|
26
|
+
if (recorder && _auditRecorder) {
|
|
27
|
+
process.stderr.write("zond: nested HTTP audit recorder — overriding the outer scope.\n");
|
|
28
|
+
}
|
|
29
|
+
_auditRecorder = recorder;
|
|
30
|
+
}
|
|
31
|
+
|
|
4
32
|
export interface FetchOptions {
|
|
5
33
|
timeout: number;
|
|
6
34
|
retries: number;
|
|
@@ -9,17 +37,69 @@ export interface FetchOptions {
|
|
|
9
37
|
rate_limiter?: RateLimiter;
|
|
10
38
|
rate_limit_retries: number;
|
|
11
39
|
rate_limit_max_delay_ms: number;
|
|
40
|
+
/** TASK-144: number of network-level retries (ECONNRESET, EPIPE, socket hang
|
|
41
|
+
* up, fetch failed without HTTP response, timeout without response). HTTP
|
|
42
|
+
* status codes are NEVER retried by this path. Exponential backoff with
|
|
43
|
+
* jitter, base = `network_retry_base_ms`. Default 0 (CLI sets it to 1). */
|
|
44
|
+
network_retries: number;
|
|
45
|
+
network_retry_base_ms: number;
|
|
46
|
+
network_retry_max_delay_ms: number;
|
|
12
47
|
}
|
|
13
48
|
|
|
14
|
-
|
|
49
|
+
const DEFAULT_FETCH_OPTIONS: FetchOptions = {
|
|
15
50
|
timeout: 30000,
|
|
16
51
|
retries: 0,
|
|
17
52
|
retry_delay: 1000,
|
|
18
53
|
follow_redirects: true,
|
|
19
54
|
rate_limit_retries: 5,
|
|
20
55
|
rate_limit_max_delay_ms: 30000,
|
|
56
|
+
network_retries: 0,
|
|
57
|
+
network_retry_base_ms: 250,
|
|
58
|
+
network_retry_max_delay_ms: 8000,
|
|
21
59
|
};
|
|
22
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Recognise transient TCP/transport-level errors that warrant a retry. We
|
|
63
|
+
* deliberately do NOT include HTTP status codes — a 5xx is a real response
|
|
64
|
+
* the server chose to send, not a flaky socket. Patterns cover Node/Bun
|
|
65
|
+
* error codes (`ECONNRESET`, `EPIPE`, `ECONNREFUSED`, `ETIMEDOUT`,
|
|
66
|
+
* `EAI_AGAIN`), the WHATWG `fetch failed` wrapper Bun throws, classic
|
|
67
|
+
* `socket hang up`, and `AbortError` raised by our own timeout watchdog.
|
|
68
|
+
*/
|
|
69
|
+
export function isTransientNetworkError(err: unknown): boolean {
|
|
70
|
+
if (!err) return false;
|
|
71
|
+
const e = err as { code?: string; cause?: unknown; name?: string; message?: string };
|
|
72
|
+
const code = e.code ?? (e.cause as { code?: string } | undefined)?.code;
|
|
73
|
+
if (code) {
|
|
74
|
+
if (
|
|
75
|
+
code === "ECONNRESET" ||
|
|
76
|
+
code === "EPIPE" ||
|
|
77
|
+
code === "ECONNREFUSED" ||
|
|
78
|
+
code === "ETIMEDOUT" ||
|
|
79
|
+
code === "EAI_AGAIN" ||
|
|
80
|
+
code === "ENOTFOUND" ||
|
|
81
|
+
code === "ENETUNREACH"
|
|
82
|
+
) {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const msg = (e.message ?? String(err)).toLowerCase();
|
|
87
|
+
if (e.name === "AbortError" || msg.includes("aborted")) return true;
|
|
88
|
+
if (msg.includes("socket hang up")) return true;
|
|
89
|
+
if (msg.includes("fetch failed")) return true;
|
|
90
|
+
if (msg.includes("connection reset") || msg.includes("econnreset")) return true;
|
|
91
|
+
if (msg.includes("epipe")) return true;
|
|
92
|
+
if (msg.includes("network error")) return true;
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Exponential backoff with full jitter (AWS-style): pick uniformly in
|
|
97
|
+
* [0, min(cap, base * 2^attempt)). Returns ms. */
|
|
98
|
+
export function networkBackoffMs(attempt: number, baseMs: number, capMs: number): number {
|
|
99
|
+
const exp = Math.min(capMs, baseMs * 2 ** attempt);
|
|
100
|
+
return Math.floor(Math.random() * exp);
|
|
101
|
+
}
|
|
102
|
+
|
|
23
103
|
export async function executeRequest(
|
|
24
104
|
request: HttpRequest,
|
|
25
105
|
options?: Partial<FetchOptions>,
|
|
@@ -27,6 +107,7 @@ export async function executeRequest(
|
|
|
27
107
|
const opts = { ...DEFAULT_FETCH_OPTIONS, ...options };
|
|
28
108
|
let lastError: Error | undefined;
|
|
29
109
|
let networkAttempt = 0;
|
|
110
|
+
let networkRetryCount = 0;
|
|
30
111
|
let rate429Attempt = 0;
|
|
31
112
|
|
|
32
113
|
while (true) {
|
|
@@ -100,14 +181,43 @@ export async function executeRequest(
|
|
|
100
181
|
}
|
|
101
182
|
}
|
|
102
183
|
|
|
103
|
-
|
|
184
|
+
const httpResp: HttpResponse = {
|
|
185
|
+
status: response.status,
|
|
186
|
+
headers,
|
|
187
|
+
body: bodyText,
|
|
188
|
+
body_parsed,
|
|
189
|
+
duration_ms,
|
|
190
|
+
network_retry_count: networkRetryCount,
|
|
191
|
+
};
|
|
192
|
+
if (_auditRecorder) {
|
|
193
|
+
try { _auditRecorder({ request, response: httpResp, durationMs: duration_ms }); }
|
|
194
|
+
catch { /* recorder is fire-and-forget */ }
|
|
195
|
+
}
|
|
196
|
+
return httpResp;
|
|
104
197
|
} catch (err) {
|
|
105
198
|
lastError = err instanceof Error ? err : new Error(String(err));
|
|
199
|
+
const isNet = isTransientNetworkError(lastError);
|
|
200
|
+
// TASK-144 path: dedicated network-retry budget with exp+jitter backoff.
|
|
201
|
+
if (isNet && networkRetryCount < opts.network_retries) {
|
|
202
|
+
const wait = networkBackoffMs(
|
|
203
|
+
networkRetryCount,
|
|
204
|
+
opts.network_retry_base_ms,
|
|
205
|
+
opts.network_retry_max_delay_ms,
|
|
206
|
+
);
|
|
207
|
+
networkRetryCount++;
|
|
208
|
+
await Bun.sleep(wait);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
// Legacy linear path (yaml suite.config.retries).
|
|
106
212
|
if (networkAttempt < opts.retries) {
|
|
107
213
|
networkAttempt++;
|
|
108
214
|
await Bun.sleep(opts.retry_delay);
|
|
109
215
|
continue;
|
|
110
216
|
}
|
|
217
|
+
if (_auditRecorder) {
|
|
218
|
+
try { _auditRecorder({ request, durationMs: 0, error: lastError.message }); }
|
|
219
|
+
catch { /* recorder is fire-and-forget */ }
|
|
220
|
+
}
|
|
111
221
|
throw lastError;
|
|
112
222
|
}
|
|
113
223
|
}
|