@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
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
import type { RunRecord, StoredStepResult } from "../../../db/queries.ts";
|
|
2
|
+
import type { FailureClass } from "../../diagnostics/failure-class.ts";
|
|
3
|
+
import type { CoverageMatrix, ReasonCode, StatusClass } from "../../coverage/reasons.ts";
|
|
4
|
+
import { REPO_URL } from "../../../cli/version.ts";
|
|
5
|
+
import { escapeHtml, tryPrettyJson } from "./escape.ts";
|
|
6
|
+
import { buildCurl } from "../curl.ts";
|
|
7
|
+
import { STYLES } from "./styles.ts";
|
|
8
|
+
import { SCRIPT } from "./script.ts";
|
|
9
|
+
|
|
10
|
+
export interface RenderOptions {
|
|
11
|
+
run: RunRecord;
|
|
12
|
+
results: StoredStepResult[];
|
|
13
|
+
zondVersion: string;
|
|
14
|
+
generatedAt: Date;
|
|
15
|
+
/** Optional collection name for the title. */
|
|
16
|
+
collectionName?: string | null;
|
|
17
|
+
/** Optional resolved base_url (currently best-effort from results). */
|
|
18
|
+
baseUrl?: string | null;
|
|
19
|
+
/** Optional spec-aware coverage matrix with reason codes (TASK-109).
|
|
20
|
+
* When supplied, replaces the URL-only coverage map. */
|
|
21
|
+
coverageMatrix?: CoverageMatrix;
|
|
22
|
+
/** TASK-164 (m-9 P8): truncate request/response bodies to N bytes
|
|
23
|
+
* before rendering. Set to 0 (or omit) to keep full bodies. The
|
|
24
|
+
* default in the CLI wrapper is 8 KB. */
|
|
25
|
+
bodyCapBytes?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const REASON_LABEL: Record<ReasonCode, string> = {
|
|
29
|
+
"covered": "covered",
|
|
30
|
+
"partial-failed": "partial",
|
|
31
|
+
"not-generated": "not generated",
|
|
32
|
+
"no-spec": "not in spec",
|
|
33
|
+
"deprecated": "deprecated",
|
|
34
|
+
"no-fixtures": "missing fixtures",
|
|
35
|
+
"ephemeral-only": "ephemeral-only",
|
|
36
|
+
"auth-scope-mismatch": "no auth token",
|
|
37
|
+
"tag-filtered": "filtered",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const FAILURE_CLASS_META: Record<FailureClass, { label: string; cls: string; emoji: string }> = {
|
|
41
|
+
definitely_bug: { label: "Definitely bug", cls: "fail", emoji: "🐞" },
|
|
42
|
+
likely_bug: { label: "Likely bug", cls: "warn", emoji: "⚠️" },
|
|
43
|
+
quirk: { label: "Quirk", cls: "info", emoji: "·" },
|
|
44
|
+
env_issue: { label: "Env issue", cls: "info", emoji: "🌐" },
|
|
45
|
+
cascade: { label: "Cascade", cls: "info", emoji: "↳" },
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const STATUS_LABEL: Record<string, { label: string; cls: string }> = {
|
|
49
|
+
pass: { label: "PASS", cls: "solid-pass" },
|
|
50
|
+
fail: { label: "FAIL", cls: "solid-fail" },
|
|
51
|
+
error: { label: "ERROR", cls: "solid-fail" },
|
|
52
|
+
skip: { label: "SKIP", cls: "" },
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function formatDate(iso: string | null | undefined): string {
|
|
56
|
+
if (!iso) return "—";
|
|
57
|
+
const d = new Date(iso);
|
|
58
|
+
if (Number.isNaN(d.getTime())) return iso;
|
|
59
|
+
return d.toLocaleString();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function formatDuration(ms: number | null | undefined): string {
|
|
63
|
+
if (ms == null) return "—";
|
|
64
|
+
if (ms < 1000) return `${ms} ms`;
|
|
65
|
+
return `${(ms / 1000).toFixed(2)} s`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function pickBaseUrl(results: StoredStepResult[]): string | null {
|
|
69
|
+
for (const r of results) {
|
|
70
|
+
if (!r.request_url) continue;
|
|
71
|
+
try {
|
|
72
|
+
const u = new URL(r.request_url);
|
|
73
|
+
return `${u.protocol}//${u.host}`;
|
|
74
|
+
} catch {
|
|
75
|
+
// not absolute
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface CoverageRow {
|
|
82
|
+
endpoint: string;
|
|
83
|
+
buckets: Record<string, "ok" | "4xx" | "5xx" | "err" | undefined>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
|
|
87
|
+
|
|
88
|
+
function buildCoverage(results: StoredStepResult[]): CoverageRow[] {
|
|
89
|
+
// Group by request_url path × method, pick worst observed status per cell.
|
|
90
|
+
const rows = new Map<string, CoverageRow>();
|
|
91
|
+
for (const r of results) {
|
|
92
|
+
if (!r.request_url || !r.request_method) continue;
|
|
93
|
+
let path: string;
|
|
94
|
+
try {
|
|
95
|
+
path = new URL(r.request_url).pathname;
|
|
96
|
+
} catch {
|
|
97
|
+
path = r.request_url;
|
|
98
|
+
}
|
|
99
|
+
const method = r.request_method.toUpperCase();
|
|
100
|
+
if (!METHODS.includes(method as typeof METHODS[number])) continue;
|
|
101
|
+
let row = rows.get(path);
|
|
102
|
+
if (!row) {
|
|
103
|
+
row = { endpoint: path, buckets: {} };
|
|
104
|
+
rows.set(path, row);
|
|
105
|
+
}
|
|
106
|
+
const status = r.response_status;
|
|
107
|
+
let cell: "ok" | "4xx" | "5xx" | "err";
|
|
108
|
+
if (r.status === "error" || status == null) cell = "err";
|
|
109
|
+
else if (status >= 500) cell = "5xx";
|
|
110
|
+
else if (status >= 400) cell = "4xx";
|
|
111
|
+
else cell = "ok";
|
|
112
|
+
const existing = row.buckets[method];
|
|
113
|
+
// Worst-of-cell precedence: err/5xx > 4xx > ok
|
|
114
|
+
const rank = { ok: 0, "4xx": 1, "5xx": 2, err: 3 } as const;
|
|
115
|
+
if (!existing || rank[cell] > rank[existing]) {
|
|
116
|
+
row.buckets[method] = cell;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return [...rows.values()].sort((a, b) => a.endpoint.localeCompare(b.endpoint));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function badgeForStatus(status: number | null): string {
|
|
123
|
+
if (status == null) return `<span class="badge fail">no resp</span>`;
|
|
124
|
+
const cls = status >= 500 ? "fail" : status >= 400 ? "warn" : "pass";
|
|
125
|
+
return `<span class="badge ${cls}">${status}</span>`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function failureClassBadge(fc: FailureClass | null, reason: string | null): string {
|
|
129
|
+
if (!fc) return "";
|
|
130
|
+
const meta = FAILURE_CLASS_META[fc];
|
|
131
|
+
const title = reason ? ` title="${escapeHtml(reason)}"` : "";
|
|
132
|
+
return `<span class="badge ${meta.cls}"${title}>${meta.emoji} ${meta.label}</span>`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function renderProvenance(prov: StoredStepResult["provenance"]): string {
|
|
136
|
+
if (!prov) return "";
|
|
137
|
+
const parts: string[] = [];
|
|
138
|
+
if (prov.type) parts.push(`<span class="badge">${escapeHtml(prov.type)}</span>`);
|
|
139
|
+
if (prov.generator) parts.push(`<span class="mono" style="font-size:11px;color:var(--fg-muted)">${escapeHtml(prov.generator)}</span>`);
|
|
140
|
+
if (prov.endpoint) parts.push(`<span class="mono" style="font-size:11px">${escapeHtml(prov.endpoint)}</span>`);
|
|
141
|
+
if (prov.response_branch) parts.push(`<span class="badge info">→ ${escapeHtml(prov.response_branch)}</span>`);
|
|
142
|
+
return parts.length > 0 ? `<div style="display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin-bottom:8px">${parts.join("")}</div>` : "";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function renderSpecSnippet(pointer: string | null, excerpt: string | null): string {
|
|
146
|
+
if (!pointer && !excerpt) return "";
|
|
147
|
+
const ptrBlock = pointer
|
|
148
|
+
? `<div class="code-label">Spec pointer</div><pre class="code">${escapeHtml(pointer)}</pre>`
|
|
149
|
+
: "";
|
|
150
|
+
const exBlock = excerpt
|
|
151
|
+
? `<div class="code-label">Spec excerpt</div><pre class="code" data-lang="json">${escapeHtml(tryPrettyJson(excerpt))}</pre>`
|
|
152
|
+
: "";
|
|
153
|
+
return ptrBlock + exBlock;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function renderAssertions(asserts: StoredStepResult["assertions"]): string {
|
|
157
|
+
if (asserts.length === 0) {
|
|
158
|
+
return `<div class="empty" style="padding:16px">No assertions recorded.</div>`;
|
|
159
|
+
}
|
|
160
|
+
return `<ul class="asserts">${asserts.map((a) => {
|
|
161
|
+
const cls = a.passed ? "passed" : "failed";
|
|
162
|
+
const expected = a.expected !== undefined ? escapeHtml(JSON.stringify(a.expected)) : "";
|
|
163
|
+
const actual = a.actual !== undefined ? escapeHtml(JSON.stringify(a.actual)) : "";
|
|
164
|
+
const diff = !a.passed && (a.expected !== undefined || a.actual !== undefined)
|
|
165
|
+
? `<div class="a-diff">
|
|
166
|
+
<div><span class="lbl">expected:</span> ${expected}</div>
|
|
167
|
+
<div><span class="lbl">actual:</span> ${actual}</div>
|
|
168
|
+
</div>`
|
|
169
|
+
: "";
|
|
170
|
+
return `<li class="${cls}">
|
|
171
|
+
<div class="a-head">
|
|
172
|
+
<span class="badge ${a.passed ? "pass" : "fail"} dot">${escapeHtml(a.rule || "assertion")}</span>
|
|
173
|
+
${a.field ? `<span class="mono" style="font-size:11px;color:var(--fg-muted)">${escapeHtml(a.field)}</span>` : ""}
|
|
174
|
+
</div>
|
|
175
|
+
${diff}
|
|
176
|
+
</li>`;
|
|
177
|
+
}).join("")}</ul>`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function renderHeaders(rawJson: string | null): string {
|
|
181
|
+
if (!rawJson) return `<div class="empty" style="padding:16px">No headers.</div>`;
|
|
182
|
+
return `<pre class="code" data-lang="json">${escapeHtml(tryPrettyJson(rawJson))}</pre>`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* TASK-164 (m-9 P8): truncate body to N bytes when a positive cap is
|
|
187
|
+
* supplied. Returns the original string when cap ≤ 0 or content fits.
|
|
188
|
+
* Marker mirrors the existing DB-truncation marker so users see one
|
|
189
|
+
* consistent format.
|
|
190
|
+
*/
|
|
191
|
+
export function capBody(content: string | null, capBytes: number | undefined): string | null {
|
|
192
|
+
if (!content) return content;
|
|
193
|
+
if (!capBytes || capBytes <= 0 || content.length <= capBytes) return content;
|
|
194
|
+
const head = content.slice(0, capBytes);
|
|
195
|
+
const dropped = content.length - capBytes;
|
|
196
|
+
return `${head}\n[truncated ${dropped} bytes; first ${capBytes} shown; full body in run DB]`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function renderBody(label: string, content: string | null, capBytes?: number): string {
|
|
200
|
+
if (!content) return `<div class="code-label">${label}</div><div class="empty" style="padding:12px;font-size:11px">empty</div>`;
|
|
201
|
+
const capped = capBody(content, capBytes) ?? content;
|
|
202
|
+
const isJson = (() => { try { JSON.parse(capped); return true; } catch { return false; } })();
|
|
203
|
+
const lang = isJson ? "json" : "text";
|
|
204
|
+
const display = isJson ? tryPrettyJson(capped) : capped;
|
|
205
|
+
return `<div class="code-label">${label}</div><pre class="code" data-lang="${lang}">${escapeHtml(display)}</pre>`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function buildIssueMarkdown(step: StoredStepResult, run: RunRecord): string {
|
|
209
|
+
const fc = step.failure_class ? FAILURE_CLASS_META[step.failure_class].label : "Unclassified";
|
|
210
|
+
const lines: string[] = [];
|
|
211
|
+
lines.push(`## ${step.test_name}`);
|
|
212
|
+
lines.push("");
|
|
213
|
+
lines.push(`**Endpoint:** \`${step.request_method ?? "?"} ${step.request_url ?? "?"}\` `);
|
|
214
|
+
lines.push(`**Status:** ${step.response_status ?? "—"} · **Result:** ${step.status} · **Class:** ${fc}`);
|
|
215
|
+
if (step.failure_class_reason) {
|
|
216
|
+
lines.push(`**Reason:** ${step.failure_class_reason}`);
|
|
217
|
+
}
|
|
218
|
+
lines.push(`**Run:** zond run #${run.id} (${run.started_at})`);
|
|
219
|
+
lines.push("");
|
|
220
|
+
lines.push("### Reproduce");
|
|
221
|
+
lines.push("```sh");
|
|
222
|
+
lines.push(buildCurl(step));
|
|
223
|
+
lines.push("```");
|
|
224
|
+
if (step.response_body) {
|
|
225
|
+
lines.push("");
|
|
226
|
+
lines.push("### Response body");
|
|
227
|
+
lines.push("```json");
|
|
228
|
+
lines.push(tryPrettyJson(step.response_body));
|
|
229
|
+
lines.push("```");
|
|
230
|
+
}
|
|
231
|
+
if (step.spec_pointer) {
|
|
232
|
+
lines.push("");
|
|
233
|
+
lines.push(`**OpenAPI pointer:** \`${step.spec_pointer}\``);
|
|
234
|
+
}
|
|
235
|
+
const failedA = step.assertions.filter((a) => !a.passed);
|
|
236
|
+
if (failedA.length > 0) {
|
|
237
|
+
lines.push("");
|
|
238
|
+
lines.push("### Failed assertions");
|
|
239
|
+
for (const a of failedA) {
|
|
240
|
+
lines.push(`- \`${a.rule}\` at \`${a.field}\`: expected ${JSON.stringify(a.expected)}, got ${JSON.stringify(a.actual)}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
lines.push("");
|
|
244
|
+
lines.push("---");
|
|
245
|
+
lines.push(`_Generated by [zond](${REPO_URL})._`);
|
|
246
|
+
return lines.join("\n");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function renderFailureCard(step: StoredStepResult, run: RunRecord, capBytes?: number): string {
|
|
250
|
+
const method = (step.request_method ?? "—").toUpperCase();
|
|
251
|
+
const fcKey = step.failure_class ?? "unclassified";
|
|
252
|
+
const curl = buildCurl(step);
|
|
253
|
+
const issueMd = buildIssueMarkdown(step, run);
|
|
254
|
+
|
|
255
|
+
return `<li class="card" data-fclass="${escapeHtml(fcKey)}">
|
|
256
|
+
<button type="button" class="head">
|
|
257
|
+
<svg class="chev" viewBox="0 0 16 16" fill="currentColor"><path d="M5.22 4.22a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06L6.28 11.78a.75.75 0 0 1-1.06-1.06L7.94 8 5.22 5.28a.75.75 0 0 1 0-1.06Z"/></svg>
|
|
258
|
+
<span class="method ${method}">${escapeHtml(method)}</span>
|
|
259
|
+
<span class="name" title="${escapeHtml(step.test_name)}">${escapeHtml(step.test_name)}</span>
|
|
260
|
+
<span class="badges">
|
|
261
|
+
${failureClassBadge(step.failure_class, step.failure_class_reason)}
|
|
262
|
+
${badgeForStatus(step.response_status)}
|
|
263
|
+
<span class="badge ${STATUS_LABEL[step.status]?.cls ?? ""}">${STATUS_LABEL[step.status]?.label ?? step.status}</span>
|
|
264
|
+
</span>
|
|
265
|
+
</button>
|
|
266
|
+
<div class="body">
|
|
267
|
+
<div class="actions">
|
|
268
|
+
<button class="btn" data-copy="curl">📋 Copy curl</button>
|
|
269
|
+
<button class="btn" data-copy="issue">🐙 Copy as GitHub issue</button>
|
|
270
|
+
</div>
|
|
271
|
+
<div class="tabs">
|
|
272
|
+
<button class="active" data-tab="response">Response</button>
|
|
273
|
+
<button data-tab="request">Request</button>
|
|
274
|
+
<button data-tab="assertions">Assertions (${step.assertions.length})</button>
|
|
275
|
+
<button data-tab="source">Source</button>
|
|
276
|
+
</div>
|
|
277
|
+
<div class="panel active" data-tab="response">
|
|
278
|
+
<dl class="kv">
|
|
279
|
+
<dt>Status</dt><dd>${step.response_status ?? "—"}</dd>
|
|
280
|
+
<dt>Duration</dt><dd>${formatDuration(step.duration_ms)}</dd>
|
|
281
|
+
${step.error_message ? `<dt>Error</dt><dd style="color:var(--fail)">${escapeHtml(step.error_message)}</dd>` : ""}
|
|
282
|
+
</dl>
|
|
283
|
+
<div class="code-label">Headers</div>${renderHeaders(step.response_headers)}
|
|
284
|
+
${renderBody("Body", step.response_body, capBytes)}
|
|
285
|
+
</div>
|
|
286
|
+
<div class="panel" data-tab="request">
|
|
287
|
+
<dl class="kv">
|
|
288
|
+
<dt>Method</dt><dd class="mono">${escapeHtml(method)}</dd>
|
|
289
|
+
<dt>URL</dt><dd class="mono">${escapeHtml(step.request_url ?? "—")}</dd>
|
|
290
|
+
</dl>
|
|
291
|
+
${renderBody("Body", step.request_body, capBytes)}
|
|
292
|
+
</div>
|
|
293
|
+
<div class="panel" data-tab="assertions">${renderAssertions(step.assertions)}</div>
|
|
294
|
+
<div class="panel" data-tab="source">
|
|
295
|
+
${renderProvenance(step.provenance)}
|
|
296
|
+
${renderSpecSnippet(step.spec_pointer, step.spec_excerpt)}
|
|
297
|
+
${!step.provenance && !step.spec_pointer && !step.spec_excerpt ? `<div class="empty" style="padding:16px">No source metadata recorded.</div>` : ""}
|
|
298
|
+
</div>
|
|
299
|
+
<pre data-payload="curl" hidden>${escapeHtml(curl)}</pre>
|
|
300
|
+
<pre data-payload="issue" hidden>${escapeHtml(issueMd)}</pre>
|
|
301
|
+
</div>
|
|
302
|
+
</li>`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function renderRing(passRate: number, totalLabel: string): string {
|
|
306
|
+
const r = 56;
|
|
307
|
+
const C = 2 * Math.PI * r;
|
|
308
|
+
const offset = C * (1 - passRate / 100);
|
|
309
|
+
const color = passRate >= 90 ? "var(--pass)" : passRate >= 60 ? "var(--warn)" : "var(--fail)";
|
|
310
|
+
return `<div class="ring">
|
|
311
|
+
<svg viewBox="0 0 140 140">
|
|
312
|
+
<circle class="track" cx="70" cy="70" r="${r}" fill="none" stroke-width="12"/>
|
|
313
|
+
<circle class="fill" cx="70" cy="70" r="${r}" fill="none" stroke-width="12"
|
|
314
|
+
stroke="${color}" stroke-linecap="round"
|
|
315
|
+
stroke-dasharray="${C.toFixed(2)}" stroke-dashoffset="${offset.toFixed(2)}"/>
|
|
316
|
+
</svg>
|
|
317
|
+
<div class="label"><div class="pct">${passRate.toFixed(0)}%</div><div class="lbl">${totalLabel}</div></div>
|
|
318
|
+
</div>`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function renderCoverage(rows: CoverageRow[]): string {
|
|
322
|
+
if (rows.length === 0) return "";
|
|
323
|
+
const cellCls: Record<NonNullable<CoverageRow["buckets"][string]>, string> = {
|
|
324
|
+
ok: "s2",
|
|
325
|
+
"4xx": "s4",
|
|
326
|
+
"5xx": "s5",
|
|
327
|
+
err: "serr",
|
|
328
|
+
};
|
|
329
|
+
const cellLabel: Record<NonNullable<CoverageRow["buckets"][string]>, string> = {
|
|
330
|
+
ok: "2xx",
|
|
331
|
+
"4xx": "4xx",
|
|
332
|
+
"5xx": "5xx",
|
|
333
|
+
err: "ERR",
|
|
334
|
+
};
|
|
335
|
+
const header = `<div class="cov-row">
|
|
336
|
+
<div class="cov-cell head path">Endpoint</div>
|
|
337
|
+
${METHODS.map((m) => `<div class="cov-cell head">${m}</div>`).join("")}
|
|
338
|
+
</div>`;
|
|
339
|
+
const body = rows.map((row) => {
|
|
340
|
+
const cells = METHODS.map((m) => {
|
|
341
|
+
const v = row.buckets[m];
|
|
342
|
+
return v
|
|
343
|
+
? `<div class="cov-cell ${cellCls[v]}">${cellLabel[v]}</div>`
|
|
344
|
+
: `<div class="cov-cell empty">·</div>`;
|
|
345
|
+
}).join("");
|
|
346
|
+
return `<div class="cov-row"><div class="cov-cell path mono" title="${escapeHtml(row.endpoint)}">${escapeHtml(row.endpoint)}</div>${cells}</div>`;
|
|
347
|
+
}).join("");
|
|
348
|
+
return `<section>
|
|
349
|
+
<h2>Coverage map <span class="count">${rows.length} endpoint${rows.length === 1 ? "" : "s"} touched</span></h2>
|
|
350
|
+
<div class="cov-grid">${header}${body}</div>
|
|
351
|
+
</section>`;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function renderCoverageWithReasons(matrix: CoverageMatrix): string {
|
|
355
|
+
if (matrix.rows.length === 0) return "";
|
|
356
|
+
const classes: StatusClass[] = ["2xx", "4xx", "5xx"];
|
|
357
|
+
const cellCls = (s: "covered" | "partial" | "uncovered") =>
|
|
358
|
+
s === "covered" ? "s2" : s === "partial" ? "s4" : "su";
|
|
359
|
+
const header = `<div class="cov-row reasons">
|
|
360
|
+
<div class="cov-cell head path">Endpoint</div>
|
|
361
|
+
${classes.map((c) => `<div class="cov-cell head">${c}</div>`).join("")}
|
|
362
|
+
</div>`;
|
|
363
|
+
const body = matrix.rows.map((row) => {
|
|
364
|
+
const cells = classes.map((c) => {
|
|
365
|
+
const cell = row.cells[c];
|
|
366
|
+
const reasonChips = cell.reasons.map((r) =>
|
|
367
|
+
`<span class="rchip" title="${escapeHtml(r)}">${escapeHtml(REASON_LABEL[r])}</span>`,
|
|
368
|
+
).join("");
|
|
369
|
+
return `<div class="cov-cell ${cellCls(cell.status)} reasons">${reasonChips}</div>`;
|
|
370
|
+
}).join("");
|
|
371
|
+
const tagBadges = row.tags.length > 0
|
|
372
|
+
? row.tags.map((t) => `<span class="badge muted">${escapeHtml(t)}</span>`).join("")
|
|
373
|
+
: "";
|
|
374
|
+
const deprecated = row.deprecated ? `<span class="badge warn">deprecated</span>` : "";
|
|
375
|
+
return `<div class="cov-row reasons">
|
|
376
|
+
<div class="cov-cell path mono"><span class="method-mini">${escapeHtml(row.method)}</span> ${escapeHtml(row.path)} ${deprecated}${tagBadges}</div>
|
|
377
|
+
${cells}
|
|
378
|
+
</div>`;
|
|
379
|
+
}).join("");
|
|
380
|
+
const t = matrix.totals;
|
|
381
|
+
const pct = t.cells === 0 ? 0 : Math.round((t.covered / t.cells) * 1000) / 10;
|
|
382
|
+
return `<section>
|
|
383
|
+
<h2>Coverage map <span class="count">${pct}% covered · ${t.endpoints} endpoint${t.endpoints === 1 ? "" : "s"} × 3 classes</span></h2>
|
|
384
|
+
<div class="cov-grid wide">${header}${body}</div>
|
|
385
|
+
</section>`;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export function renderHtmlReport(opts: RenderOptions): string {
|
|
389
|
+
const { run, results, zondVersion, generatedAt, collectionName } = opts;
|
|
390
|
+
const failures = results.filter((r) => r.status !== "pass" && r.status !== "skip");
|
|
391
|
+
const passed = results.filter((r) => r.status === "pass").length;
|
|
392
|
+
const total = results.length;
|
|
393
|
+
const passRate = total > 0 ? (passed / total) * 100 : 0;
|
|
394
|
+
const baseUrl = opts.baseUrl ?? pickBaseUrl(results);
|
|
395
|
+
const errored = results.filter((r) => r.status === "error").length;
|
|
396
|
+
const coverage = buildCoverage(results);
|
|
397
|
+
|
|
398
|
+
// Failure-class breakdown
|
|
399
|
+
const fcCounts: Record<string, number> = {};
|
|
400
|
+
for (const f of failures) {
|
|
401
|
+
const k = f.failure_class ?? "unclassified";
|
|
402
|
+
fcCounts[k] = (fcCounts[k] ?? 0) + 1;
|
|
403
|
+
}
|
|
404
|
+
const fcKeys = Object.keys(fcCounts);
|
|
405
|
+
|
|
406
|
+
const title = collectionName
|
|
407
|
+
? `${collectionName} · Run #${run.id}`
|
|
408
|
+
: `zond Run #${run.id}`;
|
|
409
|
+
|
|
410
|
+
const filterButtons = `<div class="filters">
|
|
411
|
+
<button class="active" data-filter="all">All (${failures.length})</button>
|
|
412
|
+
${fcKeys.map((k) => {
|
|
413
|
+
const meta = k === "unclassified"
|
|
414
|
+
? { label: "Unclassified", emoji: "?" }
|
|
415
|
+
: { label: FAILURE_CLASS_META[k as FailureClass].label, emoji: FAILURE_CLASS_META[k as FailureClass].emoji };
|
|
416
|
+
return `<button data-filter="${escapeHtml(k)}">${meta.emoji} ${meta.label} (${fcCounts[k]})</button>`;
|
|
417
|
+
}).join("")}
|
|
418
|
+
</div>`;
|
|
419
|
+
|
|
420
|
+
const failuresSection = failures.length === 0
|
|
421
|
+
? `<section>
|
|
422
|
+
<h2>Failures</h2>
|
|
423
|
+
<div class="empty">🎉 All ${total} step${total === 1 ? "" : "s"} passed — nothing to investigate.</div>
|
|
424
|
+
</section>`
|
|
425
|
+
: `<section>
|
|
426
|
+
<h2>Failures <span class="count">${failures.length} of ${total} step${total === 1 ? "" : "s"}</span></h2>
|
|
427
|
+
${fcKeys.length > 0 ? filterButtons : ""}
|
|
428
|
+
<ul class="cards">
|
|
429
|
+
${failures.map((f) => renderFailureCard(f, run, opts.bodyCapBytes)).join("")}
|
|
430
|
+
</ul>
|
|
431
|
+
</section>`;
|
|
432
|
+
|
|
433
|
+
return `<!doctype html>
|
|
434
|
+
<html lang="en">
|
|
435
|
+
<head>
|
|
436
|
+
<meta charset="utf-8">
|
|
437
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
438
|
+
<title>${escapeHtml(title)}</title>
|
|
439
|
+
<meta name="generator" content="zond ${escapeHtml(zondVersion)}">
|
|
440
|
+
<style>${STYLES}</style>
|
|
441
|
+
</head>
|
|
442
|
+
<body>
|
|
443
|
+
<div class="container">
|
|
444
|
+
<header class="hero">
|
|
445
|
+
<div>
|
|
446
|
+
<h1>${escapeHtml(title)}</h1>
|
|
447
|
+
<div class="sub">${escapeHtml(run.environment ?? "no environment")} ${baseUrl ? `· <span class="mono">${escapeHtml(baseUrl)}</span>` : ""}</div>
|
|
448
|
+
<dl class="meta">
|
|
449
|
+
<div><dt>Started</dt><dd>${formatDate(run.started_at)}</dd></div>
|
|
450
|
+
<div><dt>Finished</dt><dd>${formatDate(run.finished_at)}</dd></div>
|
|
451
|
+
<div><dt>Duration</dt><dd>${formatDuration(run.duration_ms)}</dd></div>
|
|
452
|
+
<div><dt>Trigger</dt><dd>${escapeHtml(run.trigger ?? "—")}</dd></div>
|
|
453
|
+
<div><dt>Branch</dt><dd>${escapeHtml(run.branch ?? "—")}</dd></div>
|
|
454
|
+
<div><dt>Commit</dt><dd class="mono">${escapeHtml(run.commit_sha?.slice(0, 8) ?? "—")}</dd></div>
|
|
455
|
+
</dl>
|
|
456
|
+
</div>
|
|
457
|
+
${renderRing(passRate, total === 0 ? "no tests" : `${passed}/${total} pass`)}
|
|
458
|
+
</header>
|
|
459
|
+
|
|
460
|
+
<div class="kpis">
|
|
461
|
+
<div class="kpi"><div class="n">${total}</div><div class="l">Total</div></div>
|
|
462
|
+
<div class="kpi pass"><div class="n">${passed}</div><div class="l">Passed</div></div>
|
|
463
|
+
<div class="kpi fail"><div class="n">${run.failed}</div><div class="l">Failed</div></div>
|
|
464
|
+
${errored > 0 ? `<div class="kpi warn"><div class="n">${errored}</div><div class="l">Errored</div></div>` : ""}
|
|
465
|
+
${run.skipped > 0 ? `<div class="kpi"><div class="n">${run.skipped}</div><div class="l">Skipped</div></div>` : ""}
|
|
466
|
+
</div>
|
|
467
|
+
|
|
468
|
+
${failuresSection}
|
|
469
|
+
${opts.coverageMatrix ? renderCoverageWithReasons(opts.coverageMatrix) : renderCoverage(coverage)}
|
|
470
|
+
|
|
471
|
+
<footer>
|
|
472
|
+
<span>zond <span class="mono">${escapeHtml(zondVersion)}</span> · generated ${escapeHtml(generatedAt.toISOString())}</span>
|
|
473
|
+
<span><a href="${escapeHtml(REPO_URL)}">${escapeHtml(REPO_URL.replace(/^https?:\/\//, ""))}</a></span>
|
|
474
|
+
</footer>
|
|
475
|
+
</div>
|
|
476
|
+
<script>${SCRIPT}</script>
|
|
477
|
+
</body>
|
|
478
|
+
</html>`;
|
|
479
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Inline JS for the report. No external deps. Uses RegExp() with string args
|
|
2
|
+
// to dodge backslash-escaping headaches inside the TS template literal.
|
|
3
|
+
|
|
4
|
+
export const SCRIPT = `
|
|
5
|
+
(() => {
|
|
6
|
+
// JSON highlighter: build regex from a string source.
|
|
7
|
+
var src = '("(?:\\\\\\\\.|[^"\\\\\\\\])*")(\\\\s*:)?|\\\\b(true|false|null)\\\\b|-?\\\\d+(?:\\\\.\\\\d+)?(?:[eE][+-]?\\\\d+)?';
|
|
8
|
+
var reJson = new RegExp(src, 'g');
|
|
9
|
+
function highlight(str) {
|
|
10
|
+
return str.replace(reJson, function (m, quoted, colon, kw) {
|
|
11
|
+
if (quoted) {
|
|
12
|
+
return colon
|
|
13
|
+
? '<span class="j-key">' + quoted + '</span>' + colon
|
|
14
|
+
: '<span class="j-str">' + quoted + '</span>';
|
|
15
|
+
}
|
|
16
|
+
if (kw === 'true' || kw === 'false') return '<span class="j-bool">' + kw + '</span>';
|
|
17
|
+
if (kw === 'null') return '<span class="j-null">null</span>';
|
|
18
|
+
return '<span class="j-num">' + m + '</span>';
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
document.querySelectorAll('pre.code[data-lang="json"]').forEach(function (el) {
|
|
22
|
+
if (el.dataset.hl === '1') return;
|
|
23
|
+
el.dataset.hl = '1';
|
|
24
|
+
el.innerHTML = highlight(el.textContent || '');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Card expand/collapse.
|
|
28
|
+
document.querySelectorAll('.card .head').forEach(function (btn) {
|
|
29
|
+
btn.addEventListener('click', function () {
|
|
30
|
+
btn.closest('.card').classList.toggle('open');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Tabs (per card).
|
|
35
|
+
document.querySelectorAll('.tabs').forEach(function (tabs) {
|
|
36
|
+
tabs.querySelectorAll('button').forEach(function (btn) {
|
|
37
|
+
btn.addEventListener('click', function () {
|
|
38
|
+
var target = btn.dataset.tab;
|
|
39
|
+
var card = btn.closest('.card');
|
|
40
|
+
card.querySelectorAll('.tabs button').forEach(function (b) {
|
|
41
|
+
b.classList.toggle('active', b === btn);
|
|
42
|
+
});
|
|
43
|
+
card.querySelectorAll('.panel').forEach(function (p) {
|
|
44
|
+
p.classList.toggle('active', p.dataset.tab === target);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
function flash(btn, ok) {
|
|
51
|
+
var orig = btn.dataset.label || btn.textContent;
|
|
52
|
+
btn.dataset.label = orig;
|
|
53
|
+
btn.textContent = ok ? '✓ Copied' : '✗ Failed';
|
|
54
|
+
btn.classList.add('copied');
|
|
55
|
+
setTimeout(function () {
|
|
56
|
+
btn.textContent = orig;
|
|
57
|
+
btn.classList.remove('copied');
|
|
58
|
+
}, 1500);
|
|
59
|
+
}
|
|
60
|
+
function copyText(text, btn) {
|
|
61
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
62
|
+
navigator.clipboard.writeText(text).then(
|
|
63
|
+
function () { flash(btn, true); },
|
|
64
|
+
function () { fallbackCopy(text, btn); },
|
|
65
|
+
);
|
|
66
|
+
} else {
|
|
67
|
+
fallbackCopy(text, btn);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function fallbackCopy(text, btn) {
|
|
71
|
+
var ta = document.createElement('textarea');
|
|
72
|
+
ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
|
|
73
|
+
document.body.appendChild(ta); ta.select();
|
|
74
|
+
try { document.execCommand('copy'); flash(btn, true); }
|
|
75
|
+
catch (e) { flash(btn, false); }
|
|
76
|
+
finally { document.body.removeChild(ta); }
|
|
77
|
+
}
|
|
78
|
+
document.querySelectorAll('[data-copy]').forEach(function (btn) {
|
|
79
|
+
btn.addEventListener('click', function () {
|
|
80
|
+
var sel = btn.dataset.copy;
|
|
81
|
+
var src = btn.closest('.card').querySelector('[data-payload="' + sel + '"]');
|
|
82
|
+
var text = src ? (src.textContent || '') : '';
|
|
83
|
+
copyText(text, btn);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Failure-class filter.
|
|
88
|
+
var filterBtns = document.querySelectorAll('.filters [data-filter]');
|
|
89
|
+
filterBtns.forEach(function (btn) {
|
|
90
|
+
btn.addEventListener('click', function () {
|
|
91
|
+
var f = btn.dataset.filter;
|
|
92
|
+
filterBtns.forEach(function (b) { b.classList.toggle('active', b === btn); });
|
|
93
|
+
document.querySelectorAll('.cards .card').forEach(function (c) {
|
|
94
|
+
var fc = c.dataset.fclass || 'unclassified';
|
|
95
|
+
c.classList.toggle('hidden', f !== 'all' && fc !== f);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
})();
|
|
100
|
+
`;
|