@fedify/cli 2.3.0-dev.1214 → 2.3.0-dev.1258

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 (61) hide show
  1. package/dist/bench/action.js +203 -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 +42 -0
  6. package/dist/bench/discovery/discover.js +67 -0
  7. package/dist/bench/discovery/probe.js +50 -0
  8. package/dist/bench/load/arrival.js +27 -0
  9. package/dist/bench/load/clock.js +15 -0
  10. package/dist/bench/load/generator.js +112 -0
  11. package/dist/bench/metrics/aggregate.js +64 -0
  12. package/dist/bench/metrics/histogram.js +141 -0
  13. package/dist/bench/metrics/stats-client.js +154 -0
  14. package/dist/bench/mod.js +4 -0
  15. package/dist/bench/render/format.js +46 -0
  16. package/dist/bench/render/index.js +20 -0
  17. package/dist/bench/render/json.js +12 -0
  18. package/dist/bench/render/markdown.js +62 -0
  19. package/dist/bench/render/text.js +74 -0
  20. package/dist/bench/result/build.js +129 -0
  21. package/dist/bench/result/expect/assert.js +74 -0
  22. package/dist/bench/result/expect/evaluate.js +128 -0
  23. package/dist/bench/result/expect/metrics.js +34 -0
  24. package/dist/bench/result/schema.js +15 -0
  25. package/dist/bench/safety/gate.js +54 -0
  26. package/dist/bench/safety/tiers.js +41 -0
  27. package/dist/bench/scenario/coerce.js +24 -0
  28. package/dist/bench/scenario/errors.js +36 -0
  29. package/dist/bench/scenario/load.js +69 -0
  30. package/dist/bench/scenario/normalize.js +126 -0
  31. package/dist/bench/scenario/schema.js +358 -0
  32. package/dist/bench/scenario/units.js +56 -0
  33. package/dist/bench/scenario/validate.js +29 -0
  34. package/dist/bench/scenarios/inbox.js +155 -0
  35. package/dist/bench/scenarios/registry.js +21 -0
  36. package/dist/bench/scenarios/runner.js +76 -0
  37. package/dist/bench/scenarios/webfinger.js +44 -0
  38. package/dist/bench/server/synthetic.js +118 -0
  39. package/dist/bench/signing/activity-id.js +18 -0
  40. package/dist/bench/signing/pipeline.js +134 -0
  41. package/dist/bench/signing/signer.js +39 -0
  42. package/dist/bench/template/generate.js +90 -0
  43. package/dist/bench/template/helpers.js +19 -0
  44. package/dist/bench/template/template.js +132 -0
  45. package/dist/cache.js +1 -1
  46. package/dist/config.js +14 -2
  47. package/dist/deno.js +1 -1
  48. package/dist/generate-vocab/action.js +3 -3
  49. package/dist/generate-vocab/command.js +1 -1
  50. package/dist/imagerenderer.js +1 -1
  51. package/dist/inbox.js +1 -1
  52. package/dist/lookup.js +34 -34
  53. package/dist/mod.js +3 -0
  54. package/dist/nodeinfo.js +6 -6
  55. package/dist/options.js +1 -1
  56. package/dist/runner.js +9 -8
  57. package/dist/tempserver.js +1 -1
  58. package/dist/tunnel.js +2 -2
  59. package/dist/utils.js +3 -2
  60. package/dist/webfinger/action.js +2 -2
  61. package/package.json +12 -10
@@ -0,0 +1,64 @@
1
+ import "@js-temporal/polyfill";
2
+ import { LogLinearHistogram } from "./histogram.js";
3
+ //#region src/bench/metrics/aggregate.ts
4
+ /**
5
+ * Aggregates samples into the client side of a scenario measurement (the
6
+ * `server` field is left `null` for the runner to fill from the stats endpoint).
7
+ * @param samples The raw samples from the load generator.
8
+ * @param options Aggregation options.
9
+ * @returns The client-side scenario measurement.
10
+ */
11
+ function aggregateSamples(samples, options) {
12
+ const measured = samples.filter((s) => !s.warmup);
13
+ const histogram = new LogLinearHistogram();
14
+ const errorCounts = /* @__PURE__ */ new Map();
15
+ let ok = 0;
16
+ for (const sample of measured) {
17
+ histogram.record(sample.latencyMs);
18
+ if (sample.outcome.ok) ok++;
19
+ else bucketError(errorCounts, sample);
20
+ }
21
+ const total = measured.length;
22
+ const requests = {
23
+ total,
24
+ ok,
25
+ failed: total - ok,
26
+ successRate: total === 0 ? 1 : ok / total
27
+ };
28
+ const windowSec = Math.max(options.measuredWindowMs, 1) / 1e3;
29
+ const client = { latencyMs: {
30
+ p50: histogram.percentile(50),
31
+ p95: histogram.percentile(95),
32
+ p99: histogram.percentile(99),
33
+ mean: histogram.mean,
34
+ max: histogram.max
35
+ } };
36
+ const errors = [...errorCounts.values()].sort((a, b) => b.count - a.count);
37
+ return {
38
+ requests,
39
+ throughputPerSec: total / windowSec,
40
+ client,
41
+ server: null,
42
+ errors,
43
+ ...options.includeHistogram ? { histogram: histogram.toJSON() } : {}
44
+ };
45
+ }
46
+ function bucketError(buckets, sample) {
47
+ const { status, errorKind, reason } = sample.outcome;
48
+ const kind = errorKind ?? (status != null ? "http" : "error");
49
+ const reasonText = reason ?? (status != null ? `status_${status}` : "error");
50
+ const key = `${kind}|${status ?? ""}|${reasonText}`;
51
+ const existing = buckets.get(key);
52
+ if (existing != null) buckets.set(key, {
53
+ ...existing,
54
+ count: existing.count + 1
55
+ });
56
+ else buckets.set(key, {
57
+ kind,
58
+ ...status != null ? { status } : {},
59
+ reason: reasonText,
60
+ count: 1
61
+ });
62
+ }
63
+ //#endregion
64
+ export { aggregateSamples };
@@ -0,0 +1,141 @@
1
+ import "@js-temporal/polyfill";
2
+ /**
3
+ * A sparse log-linear histogram.
4
+ * @since 2.3.0
5
+ */
6
+ var LogLinearHistogram = class LogLinearHistogram {
7
+ subBucketCount;
8
+ #buckets = /* @__PURE__ */ new Map();
9
+ #count = 0;
10
+ #zeroCount = 0;
11
+ #sum = 0;
12
+ #min = Number.POSITIVE_INFINITY;
13
+ #max = Number.NEGATIVE_INFINITY;
14
+ constructor(options = {}) {
15
+ const subBucketCount = options.subBucketCount ?? 128;
16
+ if (!Number.isInteger(subBucketCount) || subBucketCount < 1) throw new RangeError(`subBucketCount must be a positive integer; got ${subBucketCount}.`);
17
+ this.subBucketCount = subBucketCount;
18
+ }
19
+ /** The total number of recorded samples, including zeros. */
20
+ get count() {
21
+ return this.#count;
22
+ }
23
+ /** The smallest recorded value, or `0` when the histogram is empty. */
24
+ get min() {
25
+ return this.#count === 0 ? 0 : this.#min;
26
+ }
27
+ /** The largest recorded value, or `0` when the histogram is empty. */
28
+ get max() {
29
+ return this.#count === 0 ? 0 : this.#max;
30
+ }
31
+ /** The arithmetic mean of all recorded values, or `0` when empty. */
32
+ get mean() {
33
+ return this.#count === 0 ? 0 : this.#sum / this.#count;
34
+ }
35
+ /** The exact sum of all recorded values. */
36
+ get sum() {
37
+ return this.#sum;
38
+ }
39
+ /**
40
+ * Records a single sample.
41
+ * @param value The value to record. Non-finite values are ignored; any
42
+ * non-positive value (negatives, `0`, and `-0`) is normalized to
43
+ * `0` and recorded in the zero bucket, since latency samples are
44
+ * never negative.
45
+ */
46
+ record(value) {
47
+ if (!Number.isFinite(value)) return;
48
+ const v = value <= 0 ? 0 : value;
49
+ this.#count++;
50
+ this.#sum += v;
51
+ if (v < this.#min) this.#min = v;
52
+ if (v > this.#max) this.#max = v;
53
+ if (v === 0) {
54
+ this.#zeroCount++;
55
+ return;
56
+ }
57
+ const index = this.#indexOf(v);
58
+ this.#buckets.set(index, (this.#buckets.get(index) ?? 0) + 1);
59
+ }
60
+ /**
61
+ * Computes an estimated percentile.
62
+ * @param p The percentile to compute, between 0 and 100 inclusive.
63
+ * @returns The estimated value at the given percentile, or `0` when the
64
+ * histogram is empty.
65
+ */
66
+ percentile(p) {
67
+ if (this.#count === 0) return 0;
68
+ if (p <= 0) return this.#min;
69
+ if (p >= 100) return this.#max;
70
+ const target = Math.ceil(p / 100 * this.#count);
71
+ let accumulated = this.#zeroCount;
72
+ if (accumulated >= target) return 0;
73
+ const indices = [...this.#buckets.keys()].sort((a, b) => a - b);
74
+ for (const index of indices) {
75
+ accumulated += this.#buckets.get(index);
76
+ if (accumulated >= target) return this.#clamp(this.#representativeValue(index));
77
+ }
78
+ return this.#max;
79
+ }
80
+ /**
81
+ * Merges another histogram into this one. Both histograms must use the same
82
+ * {@link LogLinearHistogram.subBucketCount}.
83
+ * @param other The histogram to merge in.
84
+ */
85
+ merge(other) {
86
+ if (other.subBucketCount !== this.subBucketCount) throw new TypeError(`Cannot merge histograms with different subBucketCount (${this.subBucketCount} vs ${other.subBucketCount}).`);
87
+ if (other.#count === 0) return;
88
+ for (const [index, count] of other.#buckets) this.#buckets.set(index, (this.#buckets.get(index) ?? 0) + count);
89
+ this.#count += other.#count;
90
+ this.#zeroCount += other.#zeroCount;
91
+ this.#sum += other.#sum;
92
+ if (other.#min < this.#min) this.#min = other.#min;
93
+ if (other.#max > this.#max) this.#max = other.#max;
94
+ }
95
+ /** Serializes the histogram to a plain JSON-compatible object. */
96
+ toJSON() {
97
+ const indices = [...this.#buckets.keys()].sort((a, b) => a - b);
98
+ return {
99
+ version: 1,
100
+ subBucketCount: this.subBucketCount,
101
+ count: this.#count,
102
+ zeroCount: this.#zeroCount,
103
+ min: this.min,
104
+ max: this.max,
105
+ sum: this.#sum,
106
+ indices,
107
+ counts: indices.map((index) => this.#buckets.get(index))
108
+ };
109
+ }
110
+ /** Reconstructs a histogram from its serialized form. */
111
+ static fromJSON(json) {
112
+ if (json.indices.length !== json.counts.length) throw new TypeError("Serialized histogram indices and counts must have equal length.");
113
+ const histogram = new LogLinearHistogram({ subBucketCount: json.subBucketCount });
114
+ for (let i = 0; i < json.indices.length; i++) histogram.#buckets.set(json.indices[i], json.counts[i]);
115
+ histogram.#count = json.count;
116
+ histogram.#zeroCount = json.zeroCount;
117
+ histogram.#sum = json.sum;
118
+ histogram.#min = json.count === 0 ? Number.POSITIVE_INFINITY : json.min;
119
+ histogram.#max = json.count === 0 ? Number.NEGATIVE_INFINITY : json.max;
120
+ return histogram;
121
+ }
122
+ #indexOf(value) {
123
+ const octave = Math.floor(Math.log2(value));
124
+ let sub = Math.floor((value / 2 ** octave - 1) * this.subBucketCount);
125
+ if (sub < 0) sub = 0;
126
+ else if (sub >= this.subBucketCount) sub = this.subBucketCount - 1;
127
+ return octave * this.subBucketCount + sub;
128
+ }
129
+ #representativeValue(index) {
130
+ const octave = Math.floor(index / this.subBucketCount);
131
+ const sub = index - octave * this.subBucketCount;
132
+ return 2 ** octave * (1 + (sub + .5) / this.subBucketCount);
133
+ }
134
+ #clamp(value) {
135
+ if (value < this.#min) return this.#min;
136
+ if (value > this.#max) return this.#max;
137
+ return value;
138
+ }
139
+ };
140
+ //#endregion
141
+ export { LogLinearHistogram };
@@ -0,0 +1,154 @@
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
+ return {
40
+ signature,
41
+ queueDepthMax
42
+ };
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+ /**
48
+ * Subtracts a baseline snapshot from an end snapshot, yielding the instruments
49
+ * accumulated between the two (the measured window). Signature histogram
50
+ * counts are diffed bucket by bucket; the queue depth is a gauge, not a
51
+ * cumulative count, so the end value is kept as-is. Callers that cannot obtain
52
+ * both snapshots should not call this (and should report no server metrics)
53
+ * rather than passing a stand-in, since a missing baseline cannot be diffed.
54
+ * @param baseline The snapshot taken at the measured-window start.
55
+ * @param end The snapshot taken at the measured-window end.
56
+ * @returns The windowed snapshot.
57
+ */
58
+ function diffSnapshots(baseline, end) {
59
+ return {
60
+ signature: diffHistogram(baseline.signature, end.signature),
61
+ queueDepthMax: end.queueDepthMax
62
+ };
63
+ }
64
+ /**
65
+ * Projects a raw server snapshot into the report's server metrics, or `null`
66
+ * when it carries no usable measurement.
67
+ * @param snapshot The raw (optionally diffed) server snapshot.
68
+ * @returns The projected server metrics, or `null`.
69
+ */
70
+ function snapshotToMetrics(snapshot) {
71
+ if (snapshot == null) return null;
72
+ const result = {};
73
+ if (snapshot.signature != null) {
74
+ if (snapshot.signature.counts.reduce((sum, n) => sum + n, 0) > 0) result.signatureVerificationMs = { overall: {
75
+ p50: histogramPercentile(snapshot.signature, 50),
76
+ p95: histogramPercentile(snapshot.signature, 95),
77
+ p99: histogramPercentile(snapshot.signature, 99)
78
+ } };
79
+ }
80
+ if (snapshot.queueDepthMax != null) result.queue = { depthMax: snapshot.queueDepthMax };
81
+ return Object.keys(result).length > 0 ? result : null;
82
+ }
83
+ /**
84
+ * Fetches and parses the target's raw server snapshot.
85
+ * @param target The target base URL.
86
+ * @param fetchImpl The fetch implementation (overridable for tests).
87
+ * @returns The raw server snapshot, or `null` if unavailable.
88
+ */
89
+ async function fetchServerSnapshot(target, fetchImpl = fetch) {
90
+ try {
91
+ const response = await fetchImpl(new URL(STATS_PATH, target), { redirect: "manual" });
92
+ if (!response.ok) return null;
93
+ return parseServerSnapshot(await response.json());
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+ function isFiniteNumber(value) {
99
+ return typeof value === "number" && Number.isFinite(value);
100
+ }
101
+ function flattenMetrics(snapshot) {
102
+ return (Array.isArray(snapshot?.scopeMetrics) ? snapshot.scopeMetrics : []).flatMap((scope) => {
103
+ const metrics = scope?.metrics;
104
+ return Array.isArray(metrics) ? metrics.filter((m) => m != null) : [];
105
+ });
106
+ }
107
+ function mergeHistogram(dataPoints) {
108
+ if (!Array.isArray(dataPoints)) return null;
109
+ let boundaries = null;
110
+ let counts = null;
111
+ for (const point of dataPoints) {
112
+ const value = point?.value;
113
+ if (typeof value !== "object" || value == null) continue;
114
+ const b = value.buckets?.boundaries;
115
+ const c = value.buckets?.counts;
116
+ if (!Array.isArray(b) || !Array.isArray(c)) continue;
117
+ if (!b.every(isFiniteNumber) || !c.every(isFiniteNumber)) continue;
118
+ if (boundaries == null) {
119
+ boundaries = [...b];
120
+ counts = [...c];
121
+ } 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];
122
+ }
123
+ return boundaries != null && counts != null ? {
124
+ boundaries,
125
+ counts
126
+ } : null;
127
+ }
128
+ function diffHistogram(baseline, end) {
129
+ if (end == null) return null;
130
+ if (baseline == null) return end;
131
+ if (!histogramsCompatible(baseline, end)) return null;
132
+ const counts = end.counts.map((count, i) => Math.max(0, count - baseline.counts[i]));
133
+ return {
134
+ boundaries: end.boundaries,
135
+ counts
136
+ };
137
+ }
138
+ function histogramsCompatible(a, b) {
139
+ return a.boundaries.length === b.boundaries.length && a.counts.length === b.counts.length && a.boundaries.every((boundary, i) => boundary === b.boundaries[i]);
140
+ }
141
+ function histogramPercentile(histogram, p) {
142
+ const { boundaries, counts } = histogram;
143
+ const total = counts.reduce((sum, n) => sum + n, 0);
144
+ if (total === 0) return 0;
145
+ const target = Math.ceil(p / 100 * total);
146
+ let accumulated = 0;
147
+ for (let i = 0; i < counts.length; i++) {
148
+ accumulated += counts[i];
149
+ if (accumulated >= target) return i < boundaries.length ? boundaries[i] : boundaries[boundaries.length - 1] ?? 0;
150
+ }
151
+ return boundaries[boundaries.length - 1] ?? 0;
152
+ }
153
+ //#endregion
154
+ export { diffSnapshots, fetchServerSnapshot, snapshotToMetrics };
@@ -0,0 +1,4 @@
1
+ import "@js-temporal/polyfill";
2
+ import "./action.js";
3
+ import "./command.js";
4
+ export {};
@@ -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,62 @@
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
+ const l = scenario.client.latencyMs;
36
+ lines.push(`| Latency p50 | ${formatNumber(l.p50)}ms |`);
37
+ lines.push(`| Latency p95 | ${formatNumber(l.p95)}ms |`);
38
+ lines.push(`| Latency p99 | ${formatNumber(l.p99)}ms |`);
39
+ const sig = scenario.server?.signatureVerificationMs?.overall;
40
+ if (sig?.p95 != null) lines.push(`| Signature verification p95 (server) | ${formatNumber(sig.p95)}ms |`);
41
+ const queue = scenario.server?.queue;
42
+ if (queue?.drainMs?.p95 != null) lines.push(`| Queue drain p95 (server) | ${formatNumber(queue.drainMs.p95)}ms |`);
43
+ if (queue?.depthMax != null) lines.push(`| Queue depth max (server) | ${formatNumber(queue.depthMax)} |`);
44
+ if (scenario.errors.length > 0) {
45
+ lines.push("", "| Error | Count |", "| --- | --- |");
46
+ for (const error of scenario.errors) {
47
+ const code = error.status == null ? error.kind : String(error.status);
48
+ lines.push(`| ${code} ${error.reason} | ${formatNumber(error.count)} |`);
49
+ }
50
+ }
51
+ if (scenario.expectations.length > 0) {
52
+ lines.push("", "| Expectation | Actual | Result |", "| --- | --- | --- |");
53
+ for (const e of scenario.expectations) {
54
+ const tag = e.pass ? "✅" : e.severity === "warn" ? "⚠️" : "❌";
55
+ const unit = metricDisplayUnit(e.metric);
56
+ lines.push(`| \`${e.metric} ${opSymbol(e.op)} ${formatThreshold(e.threshold, e.unit ?? unit)}\` | ${formatActual(e.actual, unit)} | ${tag} |`);
57
+ }
58
+ }
59
+ return lines;
60
+ }
61
+ //#endregion
62
+ export { renderMarkdown };
@@ -0,0 +1,74 @@
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
+ const l = scenario.client.latencyMs;
32
+ 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)}`);
33
+ if (scenario.server?.signatureVerificationMs != null) lines.push(` Server signature verification (ms): ${describePartial(scenario.server.signatureVerificationMs.overall)}`);
34
+ const queue = scenario.server?.queue;
35
+ if (queue?.drainMs != null && hasPartial(queue.drainMs)) {
36
+ const depth = queue.depthMax;
37
+ const suffix = depth == null ? "" : ` (depth max ${formatNumber(depth)})`;
38
+ lines.push(` Server queue drain (ms): ${describePartial(queue.drainMs)}${suffix}`);
39
+ } else if (queue?.depthMax != null) lines.push(` Server queue depth max: ${formatNumber(queue.depthMax)}`);
40
+ if (scenario.errors.length > 0) {
41
+ lines.push(" Errors:");
42
+ for (const error of scenario.errors) {
43
+ const code = error.status == null ? error.kind : String(error.status);
44
+ lines.push(` ${code} ${error.reason}: ${formatNumber(error.count)}`);
45
+ }
46
+ }
47
+ if (scenario.expectations.length > 0) {
48
+ lines.push(" Expectations:");
49
+ for (const e of scenario.expectations) {
50
+ const tag = e.pass ? "PASS" : e.severity === "warn" ? "WARN" : "FAIL";
51
+ const unit = metricDisplayUnit(e.metric);
52
+ lines.push(` [${tag}] ${e.metric} ${opSymbol(e.op)} ${formatThreshold(e.threshold, e.unit ?? unit)} (actual ${formatActual(e.actual, unit)})`);
53
+ }
54
+ }
55
+ return lines;
56
+ }
57
+ function describeLoad(load) {
58
+ const tail = `duration ${formatNumber(load.durationMs)}ms, warmup ${formatNumber(load.warmupMs)}ms`;
59
+ if (load.model === "closed") return `closed, concurrency ${load.concurrency}, ${tail}`;
60
+ return `open, ${formatNumber(load.ratePerSec)}/s ${load.arrival}, ${tail}`;
61
+ }
62
+ function describePartial(latency) {
63
+ const parts = [];
64
+ if (latency.p50 != null) parts.push(`p50 ${formatNumber(latency.p50)}`);
65
+ if (latency.p95 != null) parts.push(`p95 ${formatNumber(latency.p95)}`);
66
+ if (latency.p99 != null) parts.push(`p99 ${formatNumber(latency.p99)}`);
67
+ return parts.join(" ");
68
+ }
69
+ /** Whether a partial latency carries at least one renderable percentile. */
70
+ function hasPartial(latency) {
71
+ return latency.p50 != null || latency.p95 != null || latency.p99 != null;
72
+ }
73
+ //#endregion
74
+ export { renderText };