@fedify/cli 2.3.0-dev.994 → 2.3.1

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.
Files changed (83) hide show
  1. package/dist/bench/action.js +469 -0
  2. package/dist/bench/actor/documents.js +39 -0
  3. package/dist/bench/actor/fleet.js +39 -0
  4. package/dist/bench/actor/keys.js +35 -0
  5. package/dist/bench/command.js +72 -0
  6. package/dist/bench/compare/schema.js +16 -0
  7. package/dist/bench/compare.js +667 -0
  8. package/dist/bench/discovery/discover.js +67 -0
  9. package/dist/bench/discovery/probe.js +50 -0
  10. package/dist/bench/load/arrival.js +27 -0
  11. package/dist/bench/load/clock.js +33 -0
  12. package/dist/bench/load/generator.js +145 -0
  13. package/dist/bench/metrics/aggregate.js +64 -0
  14. package/dist/bench/metrics/histogram.js +141 -0
  15. package/dist/bench/metrics/stats-client.js +216 -0
  16. package/dist/bench/mod.js +11 -0
  17. package/dist/bench/render/format.js +46 -0
  18. package/dist/bench/render/index.js +20 -0
  19. package/dist/bench/render/json.js +12 -0
  20. package/dist/bench/render/markdown.js +63 -0
  21. package/dist/bench/render/text.js +75 -0
  22. package/dist/bench/result/build.js +252 -0
  23. package/dist/bench/result/expect/assert.js +74 -0
  24. package/dist/bench/result/expect/evaluate.js +128 -0
  25. package/dist/bench/result/expect/metrics.js +34 -0
  26. package/dist/bench/result/schema.js +365 -0
  27. package/dist/bench/safety/gate.js +62 -0
  28. package/dist/bench/safety/tiers.js +97 -0
  29. package/dist/bench/scenario/coerce.js +24 -0
  30. package/dist/bench/scenario/errors.js +36 -0
  31. package/dist/bench/scenario/load.js +69 -0
  32. package/dist/bench/scenario/normalize.js +125 -0
  33. package/dist/bench/scenario/schema.js +399 -0
  34. package/dist/bench/scenario/units.js +56 -0
  35. package/dist/bench/scenario/validate.js +29 -0
  36. package/dist/bench/scenarios/actor.js +38 -0
  37. package/dist/bench/scenarios/failure.js +363 -0
  38. package/dist/bench/scenarios/fanout.js +261 -0
  39. package/dist/bench/scenarios/inbox.js +147 -0
  40. package/dist/bench/scenarios/mixed.js +244 -0
  41. package/dist/bench/scenarios/object-discovery.js +211 -0
  42. package/dist/bench/scenarios/object.js +54 -0
  43. package/dist/bench/scenarios/read.js +108 -0
  44. package/dist/bench/scenarios/registry.js +39 -0
  45. package/dist/bench/scenarios/runner.js +96 -0
  46. package/dist/bench/scenarios/webfinger.js +44 -0
  47. package/dist/bench/server/synthetic.js +118 -0
  48. package/dist/bench/signing/activity-id.js +18 -0
  49. package/dist/bench/signing/pipeline.js +134 -0
  50. package/dist/bench/signing/signer.js +39 -0
  51. package/dist/bench/template/generate.js +90 -0
  52. package/dist/bench/template/helpers.js +19 -0
  53. package/dist/bench/template/template.js +132 -0
  54. package/dist/cache.js +2 -2
  55. package/dist/commands.js +110 -0
  56. package/dist/config.js +15 -3
  57. package/dist/deno.js +1 -1
  58. package/dist/docloader.js +1 -1
  59. package/dist/generate-vocab/action.js +3 -3
  60. package/dist/generate-vocab/command.js +6 -4
  61. package/dist/imagerenderer.js +3 -3
  62. package/dist/inbox/command.js +6 -4
  63. package/dist/inbox/view.js +1 -1
  64. package/dist/inbox.js +4 -4
  65. package/dist/log.js +2 -2
  66. package/dist/lookup/command.js +121 -0
  67. package/dist/lookup.js +27 -138
  68. package/dist/mod.js +2 -20
  69. package/dist/nodeinfo.js +53 -12
  70. package/dist/options.js +1 -1
  71. package/dist/relay/command.js +6 -4
  72. package/dist/relay.js +3 -3
  73. package/dist/runner.js +70 -45
  74. package/dist/tunnel.js +8 -6
  75. package/dist/utils.js +9 -4
  76. package/dist/webfinger/action.js +1 -1
  77. package/dist/webfinger/command.js +6 -4
  78. package/dist/webfinger/error.js +2 -0
  79. package/dist/webfinger/lib.js +1 -1
  80. package/package.json +28 -23
  81. package/dist/generate-vocab/mod.js +0 -4
  82. package/dist/init/mod.js +0 -3
  83. package/dist/webfinger/mod.js +0 -4
@@ -0,0 +1,216 @@
1
+ import "@js-temporal/polyfill";
2
+ import { STATS_PATH } from "../discovery/probe.js";
3
+ //#region src/bench/metrics/stats-client.ts
4
+ /**
5
+ * Reading server-side metrics from the cooperative `stats` endpoint.
6
+ *
7
+ * The endpoint returns a JSON projection of the target's OpenTelemetry meters
8
+ * (see *@fedify/fedify*'s benchmark module). This module projects the relevant
9
+ * instruments — signature verification latency and queue depth — into the
10
+ * report's `server` section, marked distinct from client-measured numbers.
11
+ *
12
+ * The server reader is cumulative and has no reset, so a single snapshot covers
13
+ * the target's whole lifetime. To scope server numbers to one scenario's
14
+ * measured window, callers take a {@link ServerSnapshot} baseline at the window
15
+ * start and another at the end, {@link diffSnapshots} the two, and project the
16
+ * difference with {@link snapshotToMetrics}.
17
+ * @since 2.3.0
18
+ * @module
19
+ */
20
+ /**
21
+ * Parses a `stats` snapshot into raw server instruments. A successful parse
22
+ * always yields a snapshot, even when it carries no relevant instruments (both
23
+ * fields `null`); `null` is reserved for an unparseable snapshot, so callers can
24
+ * tell "available but empty" apart from "unavailable".
25
+ * @param snapshot The parsed `stats` JSON.
26
+ * @returns The raw server snapshot, or `null` if it could not be parsed.
27
+ */
28
+ function parseServerSnapshot(snapshot) {
29
+ try {
30
+ const metrics = flattenMetrics(snapshot);
31
+ const sig = metrics.find((m) => m.dataPointType === "histogram" && (m.name ?? "").includes("signature.verification"));
32
+ const signature = sig == null ? null : mergeHistogram(sig.dataPoints);
33
+ let queueDepthMax = null;
34
+ const depth = metrics.find((m) => m.name === "fedify.queue.depth");
35
+ if (depth != null && Array.isArray(depth.dataPoints)) {
36
+ const values = depth.dataPoints.map((p) => p.value).filter(isFiniteNumber);
37
+ if (values.length > 0) queueDepthMax = Math.max(...values);
38
+ }
39
+ const queueTasks = parseQueueTasks(metrics);
40
+ const deliveryPermanentFailures = sumMetric(metrics, "activitypub.delivery.permanent_failure");
41
+ return {
42
+ signature,
43
+ queueDepthMax,
44
+ ...queueTasks == null ? {} : { queueTasks },
45
+ ...deliveryPermanentFailures == null ? {} : { deliveryPermanentFailures }
46
+ };
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+ /**
52
+ * Subtracts a baseline snapshot from an end snapshot, yielding the instruments
53
+ * accumulated between the two (the measured window). Signature histogram
54
+ * counts are diffed bucket by bucket; the queue depth is a gauge, not a
55
+ * cumulative count, so the end value is kept as-is. Callers that cannot obtain
56
+ * both snapshots should not call this (and should report no server metrics)
57
+ * rather than passing a stand-in, since a missing baseline cannot be diffed.
58
+ * @param baseline The snapshot taken at the measured-window start.
59
+ * @param end The snapshot taken at the measured-window end.
60
+ * @returns The windowed snapshot.
61
+ */
62
+ function diffSnapshots(baseline, end) {
63
+ const queueTasks = diffQueueTasks(baseline.queueTasks ?? null, end.queueTasks ?? null);
64
+ const deliveryPermanentFailures = diffCounter(baseline.deliveryPermanentFailures ?? null, end.deliveryPermanentFailures ?? null);
65
+ return {
66
+ signature: diffHistogram(baseline.signature, end.signature),
67
+ queueDepthMax: end.queueDepthMax,
68
+ ...queueTasks == null ? {} : { queueTasks },
69
+ ...deliveryPermanentFailures == null ? {} : { deliveryPermanentFailures }
70
+ };
71
+ }
72
+ /**
73
+ * Projects a raw server snapshot into the report's server metrics, or `null`
74
+ * when it carries no usable measurement.
75
+ * @param snapshot The raw (optionally diffed) server snapshot.
76
+ * @returns The projected server metrics, or `null`.
77
+ */
78
+ function snapshotToMetrics(snapshot) {
79
+ if (snapshot == null) return null;
80
+ const result = {};
81
+ if (snapshot.signature != null) {
82
+ if (snapshot.signature.counts.reduce((sum, n) => sum + n, 0) > 0) result.signatureVerificationMs = { overall: {
83
+ p50: histogramPercentile(snapshot.signature, 50),
84
+ p95: histogramPercentile(snapshot.signature, 95),
85
+ p99: histogramPercentile(snapshot.signature, 99)
86
+ } };
87
+ }
88
+ if (snapshot.queueDepthMax != null) result.queue = { depthMax: snapshot.queueDepthMax };
89
+ return Object.keys(result).length > 0 ? result : null;
90
+ }
91
+ /**
92
+ * Fetches and parses the target's raw server snapshot.
93
+ * @param target The target base URL.
94
+ * @param fetchImpl The fetch implementation (overridable for tests).
95
+ * @returns The raw server snapshot, or `null` if unavailable.
96
+ */
97
+ async function fetchServerSnapshot(target, fetchImpl = fetch) {
98
+ try {
99
+ const response = await fetchImpl(new URL(STATS_PATH, target), { redirect: "manual" });
100
+ if (!response.ok) return null;
101
+ return parseServerSnapshot(await response.json());
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+ /**
107
+ * Returns the remaining queue task backlog represented by a diffed snapshot.
108
+ * @param snapshot The server snapshot to inspect, usually already diffed
109
+ * against a baseline.
110
+ * @param baselineRemaining Queue tasks that were already outstanding when the
111
+ * diff baseline was taken. These must drain before diffed completions can be
112
+ * attributed to newly enqueued tasks.
113
+ * @returns `Math.max(0, baselineRemaining + enqueued - completed - failed)`,
114
+ * or `null` when the snapshot has no queue task counters.
115
+ */
116
+ function queueTaskRemaining(snapshot, baselineRemaining = 0) {
117
+ if (snapshot?.queueTasks == null) return null;
118
+ const { enqueued, completed, failed } = snapshot.queueTasks;
119
+ return Math.max(0, baselineRemaining + enqueued - completed - failed);
120
+ }
121
+ function isFiniteNumber(value) {
122
+ return typeof value === "number" && Number.isFinite(value);
123
+ }
124
+ function flattenMetrics(snapshot) {
125
+ return (Array.isArray(snapshot?.scopeMetrics) ? snapshot.scopeMetrics : []).flatMap((scope) => {
126
+ const metrics = scope?.metrics;
127
+ return Array.isArray(metrics) ? metrics.filter((m) => m != null) : [];
128
+ });
129
+ }
130
+ function mergeHistogram(dataPoints) {
131
+ if (!Array.isArray(dataPoints)) return null;
132
+ let boundaries = null;
133
+ let counts = null;
134
+ for (const point of dataPoints) {
135
+ const value = point?.value;
136
+ if (typeof value !== "object" || value == null) continue;
137
+ const b = value.buckets?.boundaries;
138
+ const c = value.buckets?.counts;
139
+ if (!Array.isArray(b) || !Array.isArray(c)) continue;
140
+ if (!b.every(isFiniteNumber) || !c.every(isFiniteNumber)) continue;
141
+ if (boundaries == null) {
142
+ boundaries = [...b];
143
+ counts = [...c];
144
+ } else if (counts != null && counts.length === c.length && boundaries.length === b.length && boundaries.every((v, i) => v === b[i])) for (let i = 0; i < c.length; i++) counts[i] += c[i];
145
+ }
146
+ return boundaries != null && counts != null ? {
147
+ boundaries,
148
+ counts
149
+ } : null;
150
+ }
151
+ function parseQueueTasks(metrics) {
152
+ const enqueued = sumMetric(metrics, "fedify.queue.task.enqueued");
153
+ const completed = sumMetric(metrics, "fedify.queue.task.completed");
154
+ const failed = sumMetric(metrics, "fedify.queue.task.failed");
155
+ return enqueued == null && completed == null && failed == null ? null : {
156
+ enqueued: enqueued ?? 0,
157
+ completed: completed ?? 0,
158
+ failed: failed ?? 0
159
+ };
160
+ }
161
+ function sumMetric(metrics, name) {
162
+ let total = 0;
163
+ let found = false;
164
+ for (const metric of metrics) {
165
+ if (metric.name !== name || !Array.isArray(metric.dataPoints)) continue;
166
+ for (const point of metric.dataPoints) if (isRecord(point) && isFiniteNumber(point["value"])) {
167
+ total += point["value"];
168
+ found = true;
169
+ }
170
+ }
171
+ return found ? total : null;
172
+ }
173
+ function diffHistogram(baseline, end) {
174
+ if (end == null) return null;
175
+ if (baseline == null) return end;
176
+ if (!histogramsCompatible(baseline, end)) return null;
177
+ const counts = end.counts.map((count, i) => Math.max(0, count - baseline.counts[i]));
178
+ return {
179
+ boundaries: end.boundaries,
180
+ counts
181
+ };
182
+ }
183
+ function diffQueueTasks(baseline, end) {
184
+ if (end == null) return null;
185
+ if (baseline == null) return end;
186
+ return {
187
+ enqueued: Math.max(0, end.enqueued - baseline.enqueued),
188
+ completed: Math.max(0, end.completed - baseline.completed),
189
+ failed: Math.max(0, end.failed - baseline.failed)
190
+ };
191
+ }
192
+ function diffCounter(baseline, end) {
193
+ if (end == null) return null;
194
+ if (baseline == null) return end;
195
+ return Math.max(0, end - baseline);
196
+ }
197
+ function histogramsCompatible(a, b) {
198
+ return a.boundaries.length === b.boundaries.length && a.counts.length === b.counts.length && a.boundaries.every((boundary, i) => boundary === b.boundaries[i]);
199
+ }
200
+ function isRecord(value) {
201
+ return value != null && typeof value === "object" && !Array.isArray(value);
202
+ }
203
+ function histogramPercentile(histogram, p) {
204
+ const { boundaries, counts } = histogram;
205
+ const total = counts.reduce((sum, n) => sum + n, 0);
206
+ if (total === 0) return 0;
207
+ const target = Math.ceil(p / 100 * total);
208
+ let accumulated = 0;
209
+ for (let i = 0; i < counts.length; i++) {
210
+ accumulated += counts[i];
211
+ if (accumulated >= target) return i < boundaries.length ? boundaries[i] : boundaries[boundaries.length - 1] ?? 0;
212
+ }
213
+ return boundaries[boundaries.length - 1] ?? 0;
214
+ }
215
+ //#endregion
216
+ export { diffSnapshots, fetchServerSnapshot, queueTaskRemaining, snapshotToMetrics };
@@ -0,0 +1,11 @@
1
+ import "@js-temporal/polyfill";
2
+ import "./command.js";
3
+ import runBench$1 from "./action.js";
4
+ import { runBenchCompare } from "./compare.js";
5
+ //#region src/bench/mod.ts
6
+ /** Runs a parsed benchmark command. */
7
+ function runBench(command) {
8
+ return command.mode === "compare" ? runBenchCompare(command) : runBench$1(command);
9
+ }
10
+ //#endregion
11
+ export { runBench };
@@ -0,0 +1,46 @@
1
+ import "@js-temporal/polyfill";
2
+ //#region src/bench/render/format.ts
3
+ const OP_SYMBOLS = {
4
+ lt: "<",
5
+ lte: "<=",
6
+ gt: ">",
7
+ gte: ">=",
8
+ eq: "=="
9
+ };
10
+ /** Returns the symbolic form of a comparison operator. */
11
+ function opSymbol(op) {
12
+ return OP_SYMBOLS[op];
13
+ }
14
+ /** Formats a number with grouping and at most three fractional digits. */
15
+ function formatNumber(value) {
16
+ if (!Number.isFinite(value)) return String(value);
17
+ return (Math.round(value * 1e3) / 1e3).toLocaleString("en-US", { maximumFractionDigits: 3 });
18
+ }
19
+ /** Formats a ratio (0..1) as a percentage with at most two fractional digits. */
20
+ function formatPercent(ratio) {
21
+ return `${(Math.round(ratio * 1e6) / 1e4).toLocaleString("en-US", { maximumFractionDigits: 2 })}%`;
22
+ }
23
+ /**
24
+ * Formats a normalized threshold back into its human-friendly unit.
25
+ * @param threshold The normalized numeric threshold.
26
+ * @param unit The threshold's unit (`"ms"`, `"%"`, `"/s"`, or `null`).
27
+ */
28
+ function formatThreshold(threshold, unit) {
29
+ switch (unit) {
30
+ case "%": return formatPercent(threshold);
31
+ case "ms": return `${formatNumber(threshold)}ms`;
32
+ case "/s": return `${formatNumber(threshold)}/s`;
33
+ default: return formatNumber(threshold);
34
+ }
35
+ }
36
+ /**
37
+ * Formats a measured value using the unit of the assertion it is compared to.
38
+ * @param actual The measured value, or `null` if unmeasured.
39
+ * @param unit The assertion's unit.
40
+ */
41
+ function formatActual(actual, unit) {
42
+ if (actual == null) return "n/a";
43
+ return formatThreshold(actual, unit);
44
+ }
45
+ //#endregion
46
+ export { formatActual, formatNumber, formatPercent, formatThreshold, opSymbol };
@@ -0,0 +1,20 @@
1
+ import "@js-temporal/polyfill";
2
+ import { renderJson } from "./json.js";
3
+ import { renderMarkdown } from "./markdown.js";
4
+ import { renderText } from "./text.js";
5
+ //#region src/bench/render/index.ts
6
+ /**
7
+ * Renders a report in the requested format.
8
+ * @param report The report to render.
9
+ * @param format The output format.
10
+ * @returns The rendered text.
11
+ */
12
+ function renderReport(report, format) {
13
+ switch (format) {
14
+ case "json": return renderJson(report);
15
+ case "markdown": return renderMarkdown(report);
16
+ case "text": return renderText(report);
17
+ }
18
+ }
19
+ //#endregion
20
+ export { renderReport };
@@ -0,0 +1,12 @@
1
+ import "@js-temporal/polyfill";
2
+ //#region src/bench/render/json.ts
3
+ /**
4
+ * Renders a report as pretty-printed canonical JSON.
5
+ * @param report The report to render.
6
+ * @returns The JSON text, with a trailing newline.
7
+ */
8
+ function renderJson(report) {
9
+ return `${JSON.stringify(report, null, 2)}\n`;
10
+ }
11
+ //#endregion
12
+ export { renderJson };
@@ -0,0 +1,63 @@
1
+ import "@js-temporal/polyfill";
2
+ import { metricDisplayUnit } from "../result/expect/metrics.js";
3
+ import { formatActual, formatNumber, formatPercent, formatThreshold, opSymbol } from "./format.js";
4
+ //#region src/bench/render/markdown.ts
5
+ /**
6
+ * The Markdown renderer, suited to a GitHub Actions job summary or a PR
7
+ * comment. It is derived from the same report model as the text and JSON
8
+ * forms.
9
+ * @since 2.3.0
10
+ * @module
11
+ */
12
+ /**
13
+ * Renders a report as Markdown.
14
+ * @param report The report to render.
15
+ * @returns The Markdown text.
16
+ */
17
+ function renderMarkdown(report) {
18
+ const lines = [];
19
+ lines.push("# Fedify benchmark report", "");
20
+ lines.push(`**Result:** ${report.passed ? "✅ PASS" : "❌ FAIL"}`, "");
21
+ lines.push(`- **Target:** \`${report.target.url}\` (${report.target.statsAvailable ? "stats available" : "no stats"})`);
22
+ lines.push(`- **Environment:** ${report.environment.runtime} ${report.environment.runtimeVersion}, ${report.environment.os}, ${report.environment.cpuCount} CPUs`);
23
+ lines.push(`- **Config:** \`${report.suite.configHash}\``, "");
24
+ for (const scenario of report.scenarios) lines.push(...renderScenario(scenario), "");
25
+ return lines.join("\n");
26
+ }
27
+ function renderScenario(scenario) {
28
+ const lines = [];
29
+ lines.push(`## ${scenario.name} (${scenario.type}) ${scenario.passed ? "✅" : "❌"}`, "");
30
+ lines.push("| Metric | Value |", "| --- | --- |");
31
+ const r = scenario.requests;
32
+ lines.push(`| Requests | ${formatNumber(r.total)} |`);
33
+ lines.push(`| Success rate | ${formatPercent(r.successRate)} |`);
34
+ lines.push(`| Throughput | ${formatNumber(scenario.throughputPerSec)}/s |`);
35
+ if (scenario.deliveryThroughputPerSec != null) lines.push(`| Delivery throughput | ${formatNumber(scenario.deliveryThroughputPerSec)}/s |`);
36
+ const l = scenario.client.latencyMs;
37
+ lines.push(`| Latency p50 | ${formatNumber(l.p50)}ms |`);
38
+ lines.push(`| Latency p95 | ${formatNumber(l.p95)}ms |`);
39
+ lines.push(`| Latency p99 | ${formatNumber(l.p99)}ms |`);
40
+ const sig = scenario.server?.signatureVerificationMs?.overall;
41
+ if (sig?.p95 != null) lines.push(`| Signature verification p95 (server) | ${formatNumber(sig.p95)}ms |`);
42
+ const queue = scenario.server?.queue;
43
+ if (queue?.drainMs?.p95 != null) lines.push(`| Queue drain p95 (server) | ${formatNumber(queue.drainMs.p95)}ms |`);
44
+ if (queue?.depthMax != null) lines.push(`| Queue depth max (server) | ${formatNumber(queue.depthMax)} |`);
45
+ if (scenario.errors.length > 0) {
46
+ lines.push("", "| Error | Count |", "| --- | --- |");
47
+ for (const error of scenario.errors) {
48
+ const code = error.status == null ? error.kind : String(error.status);
49
+ lines.push(`| ${code} ${error.reason} | ${formatNumber(error.count)} |`);
50
+ }
51
+ }
52
+ if (scenario.expectations.length > 0) {
53
+ lines.push("", "| Expectation | Actual | Result |", "| --- | --- | --- |");
54
+ for (const e of scenario.expectations) {
55
+ const tag = e.pass ? "✅" : e.severity === "warn" ? "⚠️" : "❌";
56
+ const unit = metricDisplayUnit(e.metric);
57
+ lines.push(`| \`${e.metric} ${opSymbol(e.op)} ${formatThreshold(e.threshold, e.unit ?? unit)}\` | ${formatActual(e.actual, unit)} | ${tag} |`);
58
+ }
59
+ }
60
+ return lines;
61
+ }
62
+ //#endregion
63
+ export { renderMarkdown };
@@ -0,0 +1,75 @@
1
+ import "@js-temporal/polyfill";
2
+ import { metricDisplayUnit } from "../result/expect/metrics.js";
3
+ import { formatActual, formatNumber, formatPercent, formatThreshold, opSymbol } from "./format.js";
4
+ //#region src/bench/render/text.ts
5
+ /**
6
+ * Renders a report as a plain-text terminal summary.
7
+ * @param report The report to render.
8
+ * @returns The summary text.
9
+ */
10
+ function renderText(report) {
11
+ const lines = [];
12
+ lines.push("Fedify benchmark report", "");
13
+ const fedify = report.target.fedifyVersion == null ? "Fedify version unknown" : `Fedify ${report.target.fedifyVersion}`;
14
+ const stats = report.target.statsAvailable ? "stats available" : "stats unavailable";
15
+ lines.push(`Target: ${report.target.url} (${fedify}, ${stats})`);
16
+ const env = report.environment;
17
+ lines.push(`Environment: ${env.runtime} ${env.runtimeVersion}, ${env.os}, ${env.cpuCount} CPUs`);
18
+ lines.push(`Started: ${report.startedAt} Finished: ${report.finishedAt}`);
19
+ lines.push(`Config: ${report.suite.configHash}`, "");
20
+ for (const scenario of report.scenarios) lines.push(...renderScenario(scenario), "");
21
+ lines.push(`Overall: ${report.passed ? "PASS" : "FAIL"}`);
22
+ return lines.join("\n");
23
+ }
24
+ function renderScenario(scenario) {
25
+ const lines = [];
26
+ lines.push(`Scenario: ${scenario.name} (${scenario.type}) [${scenario.passed ? "PASS" : "FAIL"}]`);
27
+ lines.push(` Load: ${describeLoad(scenario.load)}`);
28
+ const r = scenario.requests;
29
+ lines.push(` Requests: ${formatNumber(r.total)} (ok ${formatNumber(r.ok)}, failed ${formatNumber(r.failed)}, success ${formatPercent(r.successRate)})`);
30
+ lines.push(` Throughput: ${formatNumber(scenario.throughputPerSec)} req/s`);
31
+ if (scenario.deliveryThroughputPerSec != null) lines.push(` Delivery throughput: ${formatNumber(scenario.deliveryThroughputPerSec)} deliveries/s`);
32
+ const l = scenario.client.latencyMs;
33
+ lines.push(` Client latency (ms): p50 ${formatNumber(l.p50)} p95 ${formatNumber(l.p95)} p99 ${formatNumber(l.p99)} mean ${formatNumber(l.mean)} max ${formatNumber(l.max)}`);
34
+ if (scenario.server?.signatureVerificationMs != null) lines.push(` Server signature verification (ms): ${describePartial(scenario.server.signatureVerificationMs.overall)}`);
35
+ const queue = scenario.server?.queue;
36
+ if (queue?.drainMs != null && hasPartial(queue.drainMs)) {
37
+ const depth = queue.depthMax;
38
+ const suffix = depth == null ? "" : ` (depth max ${formatNumber(depth)})`;
39
+ lines.push(` Server queue drain (ms): ${describePartial(queue.drainMs)}${suffix}`);
40
+ } else if (queue?.depthMax != null) lines.push(` Server queue depth max: ${formatNumber(queue.depthMax)}`);
41
+ if (scenario.errors.length > 0) {
42
+ lines.push(" Errors:");
43
+ for (const error of scenario.errors) {
44
+ const code = error.status == null ? error.kind : String(error.status);
45
+ lines.push(` ${code} ${error.reason}: ${formatNumber(error.count)}`);
46
+ }
47
+ }
48
+ if (scenario.expectations.length > 0) {
49
+ lines.push(" Expectations:");
50
+ for (const e of scenario.expectations) {
51
+ const tag = e.pass ? "PASS" : e.severity === "warn" ? "WARN" : "FAIL";
52
+ const unit = metricDisplayUnit(e.metric);
53
+ lines.push(` [${tag}] ${e.metric} ${opSymbol(e.op)} ${formatThreshold(e.threshold, e.unit ?? unit)} (actual ${formatActual(e.actual, unit)})`);
54
+ }
55
+ }
56
+ return lines;
57
+ }
58
+ function describeLoad(load) {
59
+ const tail = `duration ${formatNumber(load.durationMs)}ms, warmup ${formatNumber(load.warmupMs)}ms`;
60
+ if (load.model === "closed") return `closed, concurrency ${load.concurrency}, ${tail}`;
61
+ return `open, ${formatNumber(load.ratePerSec)}/s ${load.arrival}, ${tail}`;
62
+ }
63
+ function describePartial(latency) {
64
+ const parts = [];
65
+ if (latency.p50 != null) parts.push(`p50 ${formatNumber(latency.p50)}`);
66
+ if (latency.p95 != null) parts.push(`p95 ${formatNumber(latency.p95)}`);
67
+ if (latency.p99 != null) parts.push(`p99 ${formatNumber(latency.p99)}`);
68
+ return parts.join(" ");
69
+ }
70
+ /** Whether a partial latency carries at least one renderable percentile. */
71
+ function hasPartial(latency) {
72
+ return latency.p50 != null || latency.p95 != null || latency.p99 != null;
73
+ }
74
+ //#endregion
75
+ export { renderText };