@fedify/cli 2.3.0-dev.994 → 2.3.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/dist/bench/action.js +469 -0
- package/dist/bench/actor/documents.js +39 -0
- package/dist/bench/actor/fleet.js +39 -0
- package/dist/bench/actor/keys.js +35 -0
- package/dist/bench/command.js +72 -0
- package/dist/bench/compare/schema.js +16 -0
- package/dist/bench/compare.js +667 -0
- package/dist/bench/discovery/discover.js +67 -0
- package/dist/bench/discovery/probe.js +50 -0
- package/dist/bench/load/arrival.js +27 -0
- package/dist/bench/load/clock.js +33 -0
- package/dist/bench/load/generator.js +145 -0
- package/dist/bench/metrics/aggregate.js +64 -0
- package/dist/bench/metrics/histogram.js +141 -0
- package/dist/bench/metrics/stats-client.js +216 -0
- package/dist/bench/mod.js +11 -0
- package/dist/bench/render/format.js +46 -0
- package/dist/bench/render/index.js +20 -0
- package/dist/bench/render/json.js +12 -0
- package/dist/bench/render/markdown.js +63 -0
- package/dist/bench/render/text.js +75 -0
- package/dist/bench/result/build.js +252 -0
- package/dist/bench/result/expect/assert.js +74 -0
- package/dist/bench/result/expect/evaluate.js +128 -0
- package/dist/bench/result/expect/metrics.js +34 -0
- package/dist/bench/result/schema.js +365 -0
- package/dist/bench/safety/gate.js +62 -0
- package/dist/bench/safety/tiers.js +97 -0
- package/dist/bench/scenario/coerce.js +24 -0
- package/dist/bench/scenario/errors.js +36 -0
- package/dist/bench/scenario/load.js +69 -0
- package/dist/bench/scenario/normalize.js +125 -0
- package/dist/bench/scenario/schema.js +399 -0
- package/dist/bench/scenario/units.js +56 -0
- package/dist/bench/scenario/validate.js +29 -0
- package/dist/bench/scenarios/actor.js +38 -0
- package/dist/bench/scenarios/failure.js +363 -0
- package/dist/bench/scenarios/fanout.js +261 -0
- package/dist/bench/scenarios/inbox.js +147 -0
- package/dist/bench/scenarios/mixed.js +244 -0
- package/dist/bench/scenarios/object-discovery.js +211 -0
- package/dist/bench/scenarios/object.js +54 -0
- package/dist/bench/scenarios/read.js +108 -0
- package/dist/bench/scenarios/registry.js +39 -0
- package/dist/bench/scenarios/runner.js +96 -0
- package/dist/bench/scenarios/webfinger.js +44 -0
- package/dist/bench/server/synthetic.js +118 -0
- package/dist/bench/signing/activity-id.js +18 -0
- package/dist/bench/signing/pipeline.js +134 -0
- package/dist/bench/signing/signer.js +39 -0
- package/dist/bench/template/generate.js +90 -0
- package/dist/bench/template/helpers.js +19 -0
- package/dist/bench/template/template.js +132 -0
- package/dist/cache.js +2 -2
- package/dist/commands.js +110 -0
- package/dist/config.js +15 -3
- package/dist/deno.js +1 -1
- package/dist/docloader.js +1 -1
- package/dist/generate-vocab/action.js +3 -3
- package/dist/generate-vocab/command.js +6 -4
- package/dist/imagerenderer.js +3 -3
- package/dist/inbox/command.js +6 -4
- package/dist/inbox/view.js +1 -1
- package/dist/inbox.js +4 -4
- package/dist/log.js +2 -2
- package/dist/lookup/command.js +121 -0
- package/dist/lookup.js +27 -138
- package/dist/mod.js +2 -20
- package/dist/nodeinfo.js +53 -12
- package/dist/options.js +1 -1
- package/dist/relay/command.js +6 -4
- package/dist/relay.js +3 -3
- package/dist/runner.js +70 -45
- package/dist/tunnel.js +8 -6
- package/dist/utils.js +9 -4
- package/dist/webfinger/action.js +1 -1
- package/dist/webfinger/command.js +6 -4
- package/dist/webfinger/error.js +2 -0
- package/dist/webfinger/lib.js +1 -1
- package/package.json +28 -23
- package/dist/generate-vocab/mod.js +0 -4
- package/dist/init/mod.js +0 -3
- package/dist/webfinger/mod.js +0 -4
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import "@js-temporal/polyfill";
|
|
2
|
+
import { version } from "../../deno.js";
|
|
3
|
+
import { evaluateExpect } from "./expect/evaluate.js";
|
|
4
|
+
import { REPORT_SCHEMA_ID } from "./schema.js";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
import { cpus } from "node:os";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
//#region src/bench/result/build.ts
|
|
9
|
+
/**
|
|
10
|
+
* Assembly of the canonical benchmark report from measured scenario data.
|
|
11
|
+
*
|
|
12
|
+
* The runners produce per-scenario measurements; this module turns each into a
|
|
13
|
+
* {@link ScenarioResult} (evaluating its `expect` block) and assembles the
|
|
14
|
+
* top-level {@link BenchReport} with reproducibility metadata.
|
|
15
|
+
* @since 2.3.0
|
|
16
|
+
* @module
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Builds a scenario result from its resolved definition and measurement,
|
|
20
|
+
* evaluating the `expect` block in the process.
|
|
21
|
+
* @param scenario The resolved scenario.
|
|
22
|
+
* @param measurement The measured client and server metrics.
|
|
23
|
+
* @returns The assembled scenario result.
|
|
24
|
+
*/
|
|
25
|
+
function buildScenarioResult(scenario, measurement) {
|
|
26
|
+
const measurements = Array.isArray(measurement) ? measurement : [measurement];
|
|
27
|
+
if (measurements.length < 1) throw new RangeError("At least one scenario measurement is required.");
|
|
28
|
+
const aggregate = measurements.length === 1 ? measurements[0] : aggregateMeasurements(measurements);
|
|
29
|
+
const { results, passed } = evaluateExpect(scenario.expect, aggregate);
|
|
30
|
+
return {
|
|
31
|
+
name: scenario.name,
|
|
32
|
+
type: scenario.type,
|
|
33
|
+
load: loadSummary(scenario),
|
|
34
|
+
requests: aggregate.requests,
|
|
35
|
+
throughputPerSec: aggregate.throughputPerSec,
|
|
36
|
+
...aggregate.deliveryThroughputPerSec == null ? {} : { deliveryThroughputPerSec: aggregate.deliveryThroughputPerSec },
|
|
37
|
+
client: aggregate.client,
|
|
38
|
+
server: aggregate.server,
|
|
39
|
+
errors: aggregate.errors,
|
|
40
|
+
expectations: results,
|
|
41
|
+
passed: passed && measurements.every((m) => m.requests.total > 0),
|
|
42
|
+
runCount: measurements.length,
|
|
43
|
+
...measurements.length > 1 ? { runs: measurements.map((m, index) => runResult(index + 1, m)) } : {},
|
|
44
|
+
...aggregate.histogram ? { histogram: aggregate.histogram } : {}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Assembles the top-level report. The gate passes only when every scenario
|
|
49
|
+
* passes.
|
|
50
|
+
* @param input The report inputs.
|
|
51
|
+
* @returns The complete report.
|
|
52
|
+
*/
|
|
53
|
+
function buildReport(input) {
|
|
54
|
+
return {
|
|
55
|
+
$schema: REPORT_SCHEMA_ID,
|
|
56
|
+
schemaVersion: 3,
|
|
57
|
+
tool: {
|
|
58
|
+
name: "@fedify/cli",
|
|
59
|
+
version
|
|
60
|
+
},
|
|
61
|
+
environment: input.environment,
|
|
62
|
+
target: input.target,
|
|
63
|
+
startedAt: input.startedAt,
|
|
64
|
+
finishedAt: input.finishedAt,
|
|
65
|
+
suite: input.suite,
|
|
66
|
+
passed: input.scenarios.every((s) => s.passed),
|
|
67
|
+
scenarios: input.scenarios
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function aggregateMeasurements(measurements) {
|
|
71
|
+
const errors = sumErrorBuckets(measurements.flatMap((m) => m.errors));
|
|
72
|
+
const total = sum(measurements.map((m) => m.requests.total));
|
|
73
|
+
const ok = sum(measurements.map((m) => m.requests.ok));
|
|
74
|
+
const failed = sum(measurements.map((m) => m.requests.failed));
|
|
75
|
+
const delivery = medianPresent(measurements.map((m) => m.deliveryThroughputPerSec));
|
|
76
|
+
return {
|
|
77
|
+
requests: {
|
|
78
|
+
total,
|
|
79
|
+
ok,
|
|
80
|
+
failed,
|
|
81
|
+
successRate: Math.min(...measurements.map((m) => m.requests.successRate))
|
|
82
|
+
},
|
|
83
|
+
throughputPerSec: median(measurements.map((m) => m.throughputPerSec)),
|
|
84
|
+
...delivery == null ? {} : { deliveryThroughputPerSec: delivery },
|
|
85
|
+
client: { latencyMs: {
|
|
86
|
+
p50: median(measurements.map((m) => m.client.latencyMs.p50)),
|
|
87
|
+
p95: median(measurements.map((m) => m.client.latencyMs.p95)),
|
|
88
|
+
p99: median(measurements.map((m) => m.client.latencyMs.p99)),
|
|
89
|
+
mean: median(measurements.map((m) => m.client.latencyMs.mean)),
|
|
90
|
+
max: median(measurements.map((m) => m.client.latencyMs.max))
|
|
91
|
+
} },
|
|
92
|
+
server: aggregateServer(measurements.map((m) => m.server)),
|
|
93
|
+
errors
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function runResult(run, measurement) {
|
|
97
|
+
return {
|
|
98
|
+
run,
|
|
99
|
+
requests: measurement.requests,
|
|
100
|
+
throughputPerSec: measurement.throughputPerSec,
|
|
101
|
+
...measurement.deliveryThroughputPerSec == null ? {} : { deliveryThroughputPerSec: measurement.deliveryThroughputPerSec },
|
|
102
|
+
client: measurement.client,
|
|
103
|
+
server: measurement.server,
|
|
104
|
+
errors: measurement.errors,
|
|
105
|
+
...measurement.histogram ? { histogram: measurement.histogram } : {}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function aggregateServer(servers) {
|
|
109
|
+
const present = servers.filter((s) => s != null);
|
|
110
|
+
if (present.length !== servers.length) return null;
|
|
111
|
+
const signature = aggregateSignatureVerification(present);
|
|
112
|
+
const queue = aggregateQueue(present);
|
|
113
|
+
return {
|
|
114
|
+
...signature == null ? {} : { signatureVerificationMs: signature },
|
|
115
|
+
...queue == null ? {} : { queue }
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function aggregateSignatureVerification(servers) {
|
|
119
|
+
const values = servers.map((s) => s.signatureVerificationMs).filter((s) => s != null);
|
|
120
|
+
if (values.length !== servers.length) return null;
|
|
121
|
+
const standards = /* @__PURE__ */ new Set();
|
|
122
|
+
for (const value of values) for (const key of Object.keys(value.byStandard ?? {})) standards.add(key);
|
|
123
|
+
const byStandard = {};
|
|
124
|
+
for (const standard of standards) byStandard[standard] = aggregatePartial(values.map((v) => v.byStandard?.[standard]), "present");
|
|
125
|
+
return {
|
|
126
|
+
overall: aggregatePartial(values.map((v) => v.overall)),
|
|
127
|
+
...Object.keys(byStandard).length < 1 ? {} : { byStandard }
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function aggregateQueue(servers) {
|
|
131
|
+
const values = servers.map((s) => s.queue).filter((q) => q != null);
|
|
132
|
+
if (values.length !== servers.length) return null;
|
|
133
|
+
const drainMs = aggregatePartial(values.map((v) => v.drainMs));
|
|
134
|
+
const depths = values.map((v) => v.depthMax);
|
|
135
|
+
return {
|
|
136
|
+
...hasPartial(drainMs) ? { drainMs } : {},
|
|
137
|
+
...depths.every(isNumber) ? { depthMax: Math.max(...depths) } : {}
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function aggregatePartial(values, mode = "complete") {
|
|
141
|
+
return {
|
|
142
|
+
...partialField(values, "p50", mode),
|
|
143
|
+
...partialField(values, "p95", mode),
|
|
144
|
+
...partialField(values, "p99", mode)
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function partialField(values, key, mode) {
|
|
148
|
+
const fieldValues = values.map((v) => v?.[key]);
|
|
149
|
+
if (mode === "present") {
|
|
150
|
+
const present = fieldValues.filter(isNumber);
|
|
151
|
+
return present.length < 1 ? {} : { [key]: median(present) };
|
|
152
|
+
}
|
|
153
|
+
return fieldValues.every(isNumber) ? { [key]: median(fieldValues) } : {};
|
|
154
|
+
}
|
|
155
|
+
function hasPartial(value) {
|
|
156
|
+
return value.p50 != null || value.p95 != null || value.p99 != null;
|
|
157
|
+
}
|
|
158
|
+
function sumErrorBuckets(errors) {
|
|
159
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
160
|
+
for (const error of errors) {
|
|
161
|
+
const key = `${error.kind}|${error.status ?? ""}|${error.reason}`;
|
|
162
|
+
const previous = buckets.get(key);
|
|
163
|
+
buckets.set(key, {
|
|
164
|
+
...error,
|
|
165
|
+
count: (previous?.count ?? 0) + error.count
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return [...buckets.values()].sort((a, b) => b.count - a.count);
|
|
169
|
+
}
|
|
170
|
+
function medianPresent(values) {
|
|
171
|
+
const present = values.filter(isNumber);
|
|
172
|
+
return present.length < 1 ? null : median(present);
|
|
173
|
+
}
|
|
174
|
+
function median(values) {
|
|
175
|
+
if (values.length < 1) return 0;
|
|
176
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
177
|
+
const middle = Math.floor(sorted.length / 2);
|
|
178
|
+
if (sorted.length % 2 === 1) return sorted[middle];
|
|
179
|
+
return (sorted[middle - 1] + sorted[middle]) / 2;
|
|
180
|
+
}
|
|
181
|
+
function sum(values) {
|
|
182
|
+
return values.reduce((a, b) => a + b, 0);
|
|
183
|
+
}
|
|
184
|
+
function isNumber(value) {
|
|
185
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
186
|
+
}
|
|
187
|
+
/** Detects the current runtime environment for reproducibility metadata. */
|
|
188
|
+
function detectEnvironment() {
|
|
189
|
+
const g = globalThis;
|
|
190
|
+
let runtime = "node";
|
|
191
|
+
let runtimeVersion = process.versions?.node ?? "unknown";
|
|
192
|
+
if (g.Deno?.version?.deno != null) {
|
|
193
|
+
runtime = "deno";
|
|
194
|
+
runtimeVersion = g.Deno.version.deno;
|
|
195
|
+
} else if (g.Bun?.version != null) {
|
|
196
|
+
runtime = "bun";
|
|
197
|
+
runtimeVersion = g.Bun.version;
|
|
198
|
+
}
|
|
199
|
+
let cpuCount = 0;
|
|
200
|
+
try {
|
|
201
|
+
cpuCount = cpus().length;
|
|
202
|
+
} catch {
|
|
203
|
+
cpuCount = 0;
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
runtime,
|
|
207
|
+
runtimeVersion,
|
|
208
|
+
os: process.platform,
|
|
209
|
+
cpuCount
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Computes a stable `sha256:` hash of a resolved configuration, so CI only
|
|
214
|
+
* compares runs from the same configuration.
|
|
215
|
+
* @param config The configuration object to hash.
|
|
216
|
+
* @returns A `sha256:`-prefixed hex digest.
|
|
217
|
+
*/
|
|
218
|
+
function configHash(config) {
|
|
219
|
+
return `sha256:${createHash("sha256").update(canonicalJson(config)).digest("hex")}`;
|
|
220
|
+
}
|
|
221
|
+
/** A guard against unbounded recursion on pathologically nested input. */
|
|
222
|
+
const MAX_HASH_DEPTH = 100;
|
|
223
|
+
function canonicalJson(value, depth = 0) {
|
|
224
|
+
if (depth > MAX_HASH_DEPTH) throw new RangeError("Maximum depth exceeded while hashing the config.");
|
|
225
|
+
if (value === void 0) return "null";
|
|
226
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
227
|
+
const toJson = value.toJSON;
|
|
228
|
+
if (typeof toJson === "function") return canonicalJson(toJson.call(value), depth + 1);
|
|
229
|
+
if (Array.isArray(value)) return `[${value.map((v) => canonicalJson(v, depth + 1)).join(",")}]`;
|
|
230
|
+
return `{${Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v, depth + 1)}`).join(",")}}`;
|
|
231
|
+
}
|
|
232
|
+
function loadSummary(scenario) {
|
|
233
|
+
const { load, durationMs, warmupMs } = scenario;
|
|
234
|
+
const maxInFlight = load.maxInFlight == null ? {} : { maxInFlight: load.maxInFlight };
|
|
235
|
+
if (load.kind === "closed") return {
|
|
236
|
+
model: "closed",
|
|
237
|
+
concurrency: load.concurrency,
|
|
238
|
+
durationMs,
|
|
239
|
+
warmupMs,
|
|
240
|
+
...maxInFlight
|
|
241
|
+
};
|
|
242
|
+
return {
|
|
243
|
+
model: "open",
|
|
244
|
+
ratePerSec: load.ratePerSec,
|
|
245
|
+
arrival: load.arrival,
|
|
246
|
+
durationMs,
|
|
247
|
+
warmupMs,
|
|
248
|
+
...maxInFlight
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
//#endregion
|
|
252
|
+
export { buildReport, buildScenarioResult, configHash, detectEnvironment };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import "@js-temporal/polyfill";
|
|
2
|
+
//#region src/bench/result/expect/assert.ts
|
|
3
|
+
const ASSERT_RE = /^\s*(<=|>=|==|=|<|>)\s*(\d+(?:\.\d+)?)\s*(%|ms|s|\/s)?\s*$/;
|
|
4
|
+
const OP_MAP = {
|
|
5
|
+
"<": "lt",
|
|
6
|
+
"<=": "lte",
|
|
7
|
+
">": "gt",
|
|
8
|
+
">=": "gte",
|
|
9
|
+
"==": "eq",
|
|
10
|
+
"=": "eq"
|
|
11
|
+
};
|
|
12
|
+
/** An error raised when an `expect` assertion cannot be parsed. */
|
|
13
|
+
var AssertionParseError = class extends Error {};
|
|
14
|
+
/**
|
|
15
|
+
* Parses an `expect` assertion string.
|
|
16
|
+
* @param text The assertion, e.g. `">= 99%"`.
|
|
17
|
+
* @returns The parsed operator, normalized threshold, and unit.
|
|
18
|
+
* @throws {AssertionParseError} If the assertion cannot be parsed.
|
|
19
|
+
*/
|
|
20
|
+
function parseAssertion(text) {
|
|
21
|
+
const match = text.match(ASSERT_RE);
|
|
22
|
+
if (match == null) throw new AssertionParseError(`Invalid expect assertion: ${JSON.stringify(text)}.`);
|
|
23
|
+
const op = OP_MAP[match[1]];
|
|
24
|
+
const value = Number.parseFloat(match[2]);
|
|
25
|
+
switch (match[3]) {
|
|
26
|
+
case "%": return {
|
|
27
|
+
op,
|
|
28
|
+
threshold: value / 100,
|
|
29
|
+
unit: "%"
|
|
30
|
+
};
|
|
31
|
+
case "ms": return {
|
|
32
|
+
op,
|
|
33
|
+
threshold: value,
|
|
34
|
+
unit: "ms"
|
|
35
|
+
};
|
|
36
|
+
case "s": return {
|
|
37
|
+
op,
|
|
38
|
+
threshold: value * 1e3,
|
|
39
|
+
unit: "ms"
|
|
40
|
+
};
|
|
41
|
+
case "/s": return {
|
|
42
|
+
op,
|
|
43
|
+
threshold: value,
|
|
44
|
+
unit: "/s"
|
|
45
|
+
};
|
|
46
|
+
default: return {
|
|
47
|
+
op,
|
|
48
|
+
threshold: value,
|
|
49
|
+
unit: null
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Compares a measured value against a threshold using a comparison operator.
|
|
55
|
+
* @param actual The measured value.
|
|
56
|
+
* @param op The comparison operator.
|
|
57
|
+
* @param threshold The threshold.
|
|
58
|
+
* @param tolerant Whether `eq` allows a small floating-point tolerance. Pass
|
|
59
|
+
* `false` for exact (count) metrics; defaults to `true` so
|
|
60
|
+
* float-normalized thresholds (e.g. `"99.4%"` ->
|
|
61
|
+
* `0.9940000000000001`) still match a measured `0.994`.
|
|
62
|
+
* @returns Whether the comparison holds.
|
|
63
|
+
*/
|
|
64
|
+
function compare(actual, op, threshold, tolerant = true) {
|
|
65
|
+
switch (op) {
|
|
66
|
+
case "lt": return actual < threshold;
|
|
67
|
+
case "lte": return actual <= threshold;
|
|
68
|
+
case "gt": return actual > threshold;
|
|
69
|
+
case "gte": return actual >= threshold;
|
|
70
|
+
case "eq": return tolerant ? Math.abs(actual - threshold) <= 1e-9 + 1e-9 * Math.abs(threshold) : actual === threshold;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
//#endregion
|
|
74
|
+
export { AssertionParseError, compare, parseAssertion };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import "@js-temporal/polyfill";
|
|
2
|
+
import { AssertionParseError, compare, parseAssertion } from "./assert.js";
|
|
3
|
+
import { metricUnit } from "./metrics.js";
|
|
4
|
+
//#region src/bench/result/expect/evaluate.ts
|
|
5
|
+
/**
|
|
6
|
+
* Parses every assertion in an `expect` block, throwing on the first malformed
|
|
7
|
+
* one. Run during preflight so that a typo in a CI gate is reported as a
|
|
8
|
+
* configuration error before any load is sent, instead of crashing the run with
|
|
9
|
+
* an uncaught {@link AssertionParseError} after the traffic has already gone out.
|
|
10
|
+
* @param expect The scenario's `expect` block.
|
|
11
|
+
* @throws {AssertionParseError} If an entry has no assertion string or its
|
|
12
|
+
* assertion cannot be parsed.
|
|
13
|
+
*/
|
|
14
|
+
function validateExpectBlock(expect) {
|
|
15
|
+
for (const [metric, value] of Object.entries(expect)) {
|
|
16
|
+
const assertion = typeof value === "string" ? value : value.assert;
|
|
17
|
+
if (typeof assertion !== "string") throw new AssertionParseError(`The \`expect\` entry for "${metric}" has no assertion string.`);
|
|
18
|
+
try {
|
|
19
|
+
parseAssertion(assertion);
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (!(error instanceof AssertionParseError)) throw error;
|
|
22
|
+
throw new AssertionParseError(`Invalid \`expect\` assertion for "${metric}": ${JSON.stringify(assertion)}.`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Evaluates an `expect` block against measured metrics.
|
|
28
|
+
* @param expect The scenario's `expect` block.
|
|
29
|
+
* @param metrics The measured metrics to evaluate against.
|
|
30
|
+
* @returns The evaluated assertions and whether the gate passed.
|
|
31
|
+
*/
|
|
32
|
+
function evaluateExpect(expect, metrics) {
|
|
33
|
+
const results = [];
|
|
34
|
+
for (const [metric, value] of Object.entries(expect)) {
|
|
35
|
+
const assertion = typeof value === "string" ? value : value.assert;
|
|
36
|
+
const severity = typeof value === "string" ? "fail" : value.severity ?? "fail";
|
|
37
|
+
const { op, threshold, unit } = parseAssertion(assertion);
|
|
38
|
+
const lookup = lookupMetric(metrics, metric);
|
|
39
|
+
const actual = lookup?.value ?? null;
|
|
40
|
+
const pass = lookup != null && actual != null && unitCompatible(unit, lookup.unit) && compare(actual, op, threshold, lookup.unit !== "count");
|
|
41
|
+
results.push({
|
|
42
|
+
metric,
|
|
43
|
+
op,
|
|
44
|
+
threshold,
|
|
45
|
+
unit,
|
|
46
|
+
actual,
|
|
47
|
+
severity,
|
|
48
|
+
pass
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
results,
|
|
53
|
+
passed: results.every((r) => r.severity === "warn" || r.pass)
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Whether an assertion's (normalized) unit is compatible with a metric's
|
|
58
|
+
* natural unit. A unitless assertion is always compatible.
|
|
59
|
+
*/
|
|
60
|
+
function unitCompatible(assertionUnit, unit) {
|
|
61
|
+
if (assertionUnit == null) return true;
|
|
62
|
+
switch (unit) {
|
|
63
|
+
case "ratio": return assertionUnit === "%";
|
|
64
|
+
case "ms": return assertionUnit === "ms";
|
|
65
|
+
case "rate": return assertionUnit === "/s";
|
|
66
|
+
case "count": return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function lookupMetric(metrics, metric) {
|
|
70
|
+
const unit = metricUnit(metric);
|
|
71
|
+
if (unit == null) return null;
|
|
72
|
+
return {
|
|
73
|
+
value: lookupValue(metrics, metric),
|
|
74
|
+
unit
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function lookupValue(metrics, metric) {
|
|
78
|
+
switch (metric) {
|
|
79
|
+
case "successRate": return metrics.requests.successRate;
|
|
80
|
+
case "throughputPerSec": return metrics.throughputPerSec;
|
|
81
|
+
case "deliveryThroughput": return metrics.deliveryThroughputPerSec ?? null;
|
|
82
|
+
case "errors.total": return sumErrors(metrics.errors);
|
|
83
|
+
case "errors.4xx": return sumErrors(metrics.errors, {
|
|
84
|
+
min: 400,
|
|
85
|
+
max: 500
|
|
86
|
+
});
|
|
87
|
+
case "errors.5xx": return sumErrors(metrics.errors, {
|
|
88
|
+
min: 500,
|
|
89
|
+
max: 600
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
if (metric.startsWith("latency.")) return latencyField(metrics.client.latencyMs, metric.slice(8));
|
|
93
|
+
if (metric.startsWith("signatureVerification.")) return partialField(metrics.server?.signatureVerificationMs?.overall, metric.slice(22));
|
|
94
|
+
if (metric.startsWith("queueDrain.")) return partialField(metrics.server?.queue?.drainMs, metric.slice(11));
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
function latencyField(latency, key) {
|
|
98
|
+
switch (key) {
|
|
99
|
+
case "p50": return latency.p50;
|
|
100
|
+
case "p95": return latency.p95;
|
|
101
|
+
case "p99": return latency.p99;
|
|
102
|
+
case "mean": return latency.mean;
|
|
103
|
+
case "max": return latency.max;
|
|
104
|
+
default: return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function partialField(source, key) {
|
|
108
|
+
if (source == null) return null;
|
|
109
|
+
switch (key) {
|
|
110
|
+
case "p50": return source.p50 ?? null;
|
|
111
|
+
case "p95": return source.p95 ?? null;
|
|
112
|
+
case "p99": return source.p99 ?? null;
|
|
113
|
+
default: return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Sums error counts, optionally restricted to a half-open HTTP status range.
|
|
118
|
+
* The bounds are a single coupled argument so a caller cannot pass one without
|
|
119
|
+
* the other.
|
|
120
|
+
*/
|
|
121
|
+
function sumErrors(errors, range) {
|
|
122
|
+
let total = 0;
|
|
123
|
+
for (const error of errors) if (range == null) total += error.count;
|
|
124
|
+
else if (error.status != null && error.status >= range.min && error.status < range.max) total += error.count;
|
|
125
|
+
return total;
|
|
126
|
+
}
|
|
127
|
+
//#endregion
|
|
128
|
+
export { evaluateExpect, validateExpectBlock };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import "@js-temporal/polyfill";
|
|
2
|
+
//#region src/bench/result/expect/metrics.ts
|
|
3
|
+
/**
|
|
4
|
+
* Returns the natural unit class of a metric, or `null` if the metric name is
|
|
5
|
+
* not recognized.
|
|
6
|
+
* @param metric The metric name, e.g. `"latency.p95"`.
|
|
7
|
+
*/
|
|
8
|
+
function metricUnit(metric) {
|
|
9
|
+
switch (metric) {
|
|
10
|
+
case "successRate": return "ratio";
|
|
11
|
+
case "throughputPerSec":
|
|
12
|
+
case "deliveryThroughput": return "rate";
|
|
13
|
+
case "errors.total":
|
|
14
|
+
case "errors.4xx":
|
|
15
|
+
case "errors.5xx": return "count";
|
|
16
|
+
}
|
|
17
|
+
if (metric.startsWith("latency.") || metric.startsWith("signatureVerification.") || metric.startsWith("queueDrain.")) return "ms";
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Returns the human display unit for a metric (`"%"`, `"ms"`, `"/s"`), or
|
|
22
|
+
* `null` for counts and unknown metrics.
|
|
23
|
+
* @param metric The metric name.
|
|
24
|
+
*/
|
|
25
|
+
function metricDisplayUnit(metric) {
|
|
26
|
+
switch (metricUnit(metric)) {
|
|
27
|
+
case "ratio": return "%";
|
|
28
|
+
case "ms": return "ms";
|
|
29
|
+
case "rate": return "/s";
|
|
30
|
+
default: return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
//#endregion
|
|
34
|
+
export { metricDisplayUnit, metricUnit };
|