@fedify/cli 2.3.0-dev.1281 → 2.3.0-dev.1336
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 +187 -27
- package/dist/bench/command.js +39 -11
- package/dist/bench/compare/schema.js +16 -0
- package/dist/bench/compare.js +667 -0
- package/dist/bench/load/clock.js +20 -2
- package/dist/bench/load/generator.js +42 -9
- package/dist/bench/metrics/stats-client.js +65 -3
- package/dist/bench/mod.js +9 -2
- package/dist/bench/render/markdown.js +1 -0
- package/dist/bench/render/text.js +1 -0
- package/dist/bench/result/build.js +132 -9
- package/dist/bench/result/expect/evaluate.js +1 -1
- package/dist/bench/result/schema.js +353 -3
- package/dist/bench/safety/gate.js +4 -2
- package/dist/bench/scenario/normalize.js +1 -2
- package/dist/bench/scenario/schema.js +50 -9
- package/dist/bench/scenario/validate.js +2 -2
- 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 +4 -12
- 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 +19 -1
- package/dist/bench/scenarios/runner.js +21 -1
- package/dist/bench/scenarios/webfinger.js +1 -1
- package/dist/deno.js +1 -1
- package/dist/log.js +1 -1
- package/dist/mod.js +1 -2
- package/dist/runner.js +1 -1
- package/dist/utils.js +1 -0
- package/package.json +9 -9
package/dist/bench/action.js
CHANGED
|
@@ -4,6 +4,7 @@ import { describeError } from "../utils.js";
|
|
|
4
4
|
import { buildFleet } from "./actor/fleet.js";
|
|
5
5
|
import { convertUrlIfHandle } from "../webfinger/lib.js";
|
|
6
6
|
import { discoverInbox, selectInbox } from "./discovery/discover.js";
|
|
7
|
+
import { actorUrlsFromRecipients, objectUrlsFromSource } from "./scenarios/object-discovery.js";
|
|
7
8
|
import { validateExpectBlock } from "./result/expect/evaluate.js";
|
|
8
9
|
import { buildReport, buildScenarioResult, configHash, detectEnvironment } from "./result/build.js";
|
|
9
10
|
import { probeBenchmarkMode } from "./discovery/probe.js";
|
|
@@ -13,13 +14,11 @@ import { normalizeSuite } from "./scenario/normalize.js";
|
|
|
13
14
|
import { validateSuite } from "./scenario/validate.js";
|
|
14
15
|
import { UnsafeTargetError, assertInboxDestinationAllowed, assertTargetAllowed, assertUnsafeOverrideAllowed } from "./safety/gate.js";
|
|
15
16
|
import { classifyResolvedTarget } from "./safety/tiers.js";
|
|
16
|
-
import { runnerFor } from "./scenarios/registry.js";
|
|
17
17
|
import { resolveAdvertiseHost, spawnSyntheticServer } from "./server/synthetic.js";
|
|
18
|
+
import { runnerFor } from "./scenarios/registry.js";
|
|
18
19
|
import { writeFile } from "node:fs/promises";
|
|
19
20
|
import process from "node:process";
|
|
20
21
|
//#region src/bench/action.ts
|
|
21
|
-
/** The scenario types that need the synthetic actor/key server. */
|
|
22
|
-
const SIGNED_TYPES = new Set(["inbox"]);
|
|
23
22
|
/**
|
|
24
23
|
* Runs the `fedify bench` command: load and validate the suite, gate the
|
|
25
24
|
* target, run each scenario, and render the report. The process exits 0 when
|
|
@@ -34,7 +33,10 @@ async function runBench(command, deps = {}) {
|
|
|
34
33
|
});
|
|
35
34
|
const writeOutput = deps.writeOutput ?? defaultWriteOutput;
|
|
36
35
|
const log = deps.log ?? ((message) => process.stderr.write(`${message}\n`));
|
|
37
|
-
const
|
|
36
|
+
const signal = deps.signal;
|
|
37
|
+
const fetchImpl = withUserAgent(withAbortSignal(deps.fetch ?? fetch, signal), command.userAgent);
|
|
38
|
+
const explicitCliTarget = command.explicitCliTarget ?? command.target != null;
|
|
39
|
+
throwIfAborted(signal);
|
|
38
40
|
let validated;
|
|
39
41
|
let suite;
|
|
40
42
|
try {
|
|
@@ -45,11 +47,12 @@ async function runBench(command, deps = {}) {
|
|
|
45
47
|
exit(2);
|
|
46
48
|
return;
|
|
47
49
|
}
|
|
50
|
+
throwIfAborted(signal);
|
|
48
51
|
let runners;
|
|
49
52
|
try {
|
|
50
53
|
runners = suite.scenarios.map((scenario) => {
|
|
51
54
|
const runner = runnerFor(scenario.type);
|
|
52
|
-
runner.validate?.(scenario);
|
|
55
|
+
runner.validate?.(scenario, { scenarios: suite.scenarios });
|
|
53
56
|
validateExpectBlock(scenario.expect);
|
|
54
57
|
return runner;
|
|
55
58
|
});
|
|
@@ -59,14 +62,17 @@ async function runBench(command, deps = {}) {
|
|
|
59
62
|
exit(2);
|
|
60
63
|
return;
|
|
61
64
|
}
|
|
65
|
+
throwIfAborted(signal);
|
|
62
66
|
const tier = await classifyResolvedTarget(suite.target, deps.resolveTargetAddresses);
|
|
67
|
+
throwIfAborted(signal);
|
|
63
68
|
const probe = await probeBenchmarkMode(suite.target, fetchImpl);
|
|
69
|
+
throwIfAborted(signal);
|
|
64
70
|
try {
|
|
65
71
|
if (!command.dryRun) assertUnsafeOverrideAllowed({
|
|
66
72
|
tier,
|
|
67
73
|
benchmarkMode: probe.benchmarkMode,
|
|
68
74
|
allowUnsafe: command.allowUnsafeTarget,
|
|
69
|
-
explicitCliTarget
|
|
75
|
+
explicitCliTarget,
|
|
70
76
|
scenarios: unsafeOverrideScenarios(validated)
|
|
71
77
|
});
|
|
72
78
|
assertTargetAllowed({
|
|
@@ -106,17 +112,35 @@ async function runBench(command, deps = {}) {
|
|
|
106
112
|
targetOrigin: suite.target.origin,
|
|
107
113
|
targetBenchmarkMode: probe.benchmarkMode,
|
|
108
114
|
allowUnsafe: command.allowUnsafeTarget,
|
|
109
|
-
explicitCliTarget
|
|
115
|
+
explicitCliTarget,
|
|
116
|
+
destinationTier,
|
|
117
|
+
defaults: validated.defaults
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
const assertDestinationWithoutSyntheticServerAllowed = async (url, scenario, loadDescription) => {
|
|
121
|
+
const sameOrigin = url.origin === suite.target.origin;
|
|
122
|
+
const destinationTier = sameOrigin ? tier : await classifyResolvedTarget(url, deps.resolveTargetAddresses);
|
|
123
|
+
const inheritsTargetGate = sameOrigin && probe.benchmarkMode;
|
|
124
|
+
if (destinationTier === "public" && !inheritsTargetGate && !command.allowUnsafeTarget) throw new UnsafeTargetError(`Refusing to send ${loadDescription} to ${url.href}: it is public and not part of the benchmarked target. Pass --allow-unsafe-target to override.`);
|
|
125
|
+
assertPublicDestinationOverrideAllowed(url, scenario, {
|
|
126
|
+
targetOrigin: suite.target.origin,
|
|
127
|
+
targetBenchmarkMode: probe.benchmarkMode,
|
|
128
|
+
allowUnsafe: command.allowUnsafeTarget,
|
|
129
|
+
explicitCliTarget,
|
|
110
130
|
destinationTier,
|
|
111
131
|
defaults: validated.defaults
|
|
112
132
|
});
|
|
113
133
|
};
|
|
134
|
+
const assertReadDestinationAllowed = (url, scenario) => assertDestinationWithoutSyntheticServerAllowed(url, scenario, "benchmark read load");
|
|
135
|
+
const assertActorlessDestinationAllowed = (url, scenario) => assertDestinationWithoutSyntheticServerAllowed(url, scenario, "benchmark load");
|
|
114
136
|
if (command.dryRun) try {
|
|
115
137
|
await writeOutput(await renderPlan(suite, {
|
|
116
138
|
documentLoader,
|
|
117
139
|
contextLoader,
|
|
118
140
|
allowPrivateAddress,
|
|
119
|
-
|
|
141
|
+
fetch: fetchImpl,
|
|
142
|
+
assertDestinationAllowed,
|
|
143
|
+
assertReadDestinationAllowed
|
|
120
144
|
}), command.output);
|
|
121
145
|
exit(0);
|
|
122
146
|
return;
|
|
@@ -125,30 +149,42 @@ async function runBench(command, deps = {}) {
|
|
|
125
149
|
exit(2);
|
|
126
150
|
return;
|
|
127
151
|
}
|
|
128
|
-
if (tier !== "loopback" && command.advertiseHost == null && suite.scenarios.some((
|
|
129
|
-
log("
|
|
152
|
+
if (tier !== "loopback" && command.advertiseHost == null && suite.scenarios.some((scenario) => scenarioNeedsReachableLocalServer(scenario, suite.scenarios))) {
|
|
153
|
+
log("Some scenarios need benchmark-owned local servers to be reachable from the target. A loopback target reaches them automatically; for a non-loopback target, pass --advertise-host with an address the target can reach, or use a scenario that does not need local benchmark servers such as webfinger.");
|
|
130
154
|
exit(2);
|
|
131
155
|
return;
|
|
132
156
|
}
|
|
133
157
|
let fleet;
|
|
134
158
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
135
159
|
try {
|
|
136
|
-
|
|
160
|
+
throwIfAborted(signal);
|
|
161
|
+
if (suite.scenarios.some((scenario) => scenarioNeedsSyntheticServer(scenario, suite.scenarios))) fleet = await spawnSyntheticServer(await buildFleet(suite.actors), { advertiseHost: command.advertiseHost });
|
|
137
162
|
const results = [];
|
|
138
163
|
for (let i = 0; i < suite.scenarios.length; i++) {
|
|
139
164
|
const scenario = suite.scenarios[i];
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
165
|
+
const measurements = [];
|
|
166
|
+
for (let run = 1; run <= scenario.runs; run++) {
|
|
167
|
+
throwIfAborted(signal);
|
|
168
|
+
const suffix = scenario.runs === 1 ? "" : ` run ${run}/${scenario.runs}`;
|
|
169
|
+
log(`Running scenario "${scenario.name}" (${scenario.type})${suffix}…`);
|
|
170
|
+
measurements.push(await runners[i].run({
|
|
171
|
+
scenario,
|
|
172
|
+
scenarios: suite.scenarios,
|
|
173
|
+
target: suite.target,
|
|
174
|
+
documentLoader,
|
|
175
|
+
contextLoader,
|
|
176
|
+
allowPrivateAddress,
|
|
177
|
+
fleet: fleet ?? null,
|
|
178
|
+
advertiseHost: command.advertiseHost,
|
|
179
|
+
fetch: fetchImpl,
|
|
180
|
+
assertDestinationAllowed: (url, gateScenario) => assertDestinationAllowed(url, gateScenario ?? scenario),
|
|
181
|
+
assertReadDestinationAllowed: (url, gateScenario) => assertReadDestinationAllowed(url, gateScenario ?? scenario),
|
|
182
|
+
assertActorlessDestinationAllowed: (url, gateScenario) => assertActorlessDestinationAllowed(url, gateScenario ?? scenario),
|
|
183
|
+
signal
|
|
184
|
+
}));
|
|
185
|
+
throwIfAborted(signal);
|
|
186
|
+
}
|
|
187
|
+
results.push(buildScenarioResult(scenario, measurements));
|
|
152
188
|
}
|
|
153
189
|
const report = buildReport({
|
|
154
190
|
scenarios: results,
|
|
@@ -210,6 +246,22 @@ function withUserAgent(fetchImpl, userAgent) {
|
|
|
210
246
|
});
|
|
211
247
|
});
|
|
212
248
|
}
|
|
249
|
+
function withAbortSignal(fetchImpl, signal) {
|
|
250
|
+
if (signal == null) return fetchImpl;
|
|
251
|
+
return ((input, init) => {
|
|
252
|
+
if (signal.aborted) return Promise.reject(abortReason(signal));
|
|
253
|
+
return fetchImpl(input, {
|
|
254
|
+
...init,
|
|
255
|
+
signal
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
function throwIfAborted(signal) {
|
|
260
|
+
if (signal?.aborted) throw abortReason(signal);
|
|
261
|
+
}
|
|
262
|
+
function abortReason(signal) {
|
|
263
|
+
return signal.reason ?? /* @__PURE__ */ new Error("Benchmark run aborted.");
|
|
264
|
+
}
|
|
213
265
|
async function defaultWriteOutput(content, outputPath) {
|
|
214
266
|
if (outputPath == null) {
|
|
215
267
|
process.stdout.write(content.endsWith("\n") ? content : `${content}\n`);
|
|
@@ -232,12 +284,34 @@ async function renderPlan(suite, context) {
|
|
|
232
284
|
return `${lines.join("\n")}\n`;
|
|
233
285
|
}
|
|
234
286
|
function describePlan(scenario) {
|
|
235
|
-
|
|
287
|
+
const load = scenario.load.kind === "open" ? `open-loop ${scenario.load.ratePerSec}/s ${scenario.load.arrival}` : `closed-loop concurrency ${scenario.load.concurrency}`;
|
|
288
|
+
const totalDurationMs = scenario.durationMs * scenario.runs;
|
|
289
|
+
const volume = describePlannedRequestVolume(scenario);
|
|
290
|
+
return [
|
|
291
|
+
load,
|
|
292
|
+
`duration ${scenario.durationMs}ms`,
|
|
293
|
+
`runs ${scenario.runs}`,
|
|
294
|
+
`total duration ${totalDurationMs}ms`,
|
|
295
|
+
...volume == null ? [] : [volume],
|
|
296
|
+
`signing ${scenario.signing}`
|
|
297
|
+
].join(", ");
|
|
298
|
+
}
|
|
299
|
+
function describePlannedRequestVolume(scenario) {
|
|
300
|
+
if (scenario.load.kind !== "open") return null;
|
|
301
|
+
return `estimated scheduled requests ${formatPlanNumber(scenario.load.ratePerSec * (scenario.durationMs / 1e3) * scenario.runs)}`;
|
|
302
|
+
}
|
|
303
|
+
function formatPlanNumber(value) {
|
|
304
|
+
if (Number.isInteger(value)) return String(value);
|
|
305
|
+
const formatted = value.toFixed(2).replace(/\.?0+$/, "");
|
|
306
|
+
return formatted === "" ? "0" : formatted;
|
|
236
307
|
}
|
|
237
308
|
async function describeDiscoveryPlan(scenario, suite, context) {
|
|
238
309
|
switch (scenario.type) {
|
|
239
310
|
case "inbox": return await describeInboxDiscoveryPlan(scenario, context);
|
|
240
311
|
case "webfinger": return describeWebFingerPlan(scenario, suite.target);
|
|
312
|
+
case "actor": return await describeActorPlan(scenario, suite, context);
|
|
313
|
+
case "object": return await describeObjectPlan(scenario, suite, context);
|
|
314
|
+
case "mixed": return describeMixedPlan(scenario);
|
|
241
315
|
default: return [" discovery: not available for this scenario type"];
|
|
242
316
|
}
|
|
243
317
|
}
|
|
@@ -269,15 +343,59 @@ function describeWebFingerPlan(scenario, target) {
|
|
|
269
343
|
return ` webfinger ${resource}: GET ${url.href}`;
|
|
270
344
|
});
|
|
271
345
|
}
|
|
272
|
-
async function
|
|
346
|
+
async function describeActorPlan(scenario, suite, context) {
|
|
273
347
|
try {
|
|
274
|
-
await
|
|
348
|
+
const urls = await actorUrlsFromRecipients(scenario.recipients, {
|
|
349
|
+
target: suite.target,
|
|
350
|
+
fetch: context.fetch
|
|
351
|
+
});
|
|
352
|
+
const lines = [];
|
|
353
|
+
for (const url of urls) {
|
|
354
|
+
lines.push(` actor: GET ${url.href}`);
|
|
355
|
+
lines.push(` destination safety: ${await describeDestinationSafety(url, scenario, context)}`);
|
|
356
|
+
}
|
|
357
|
+
return lines;
|
|
358
|
+
} catch (error) {
|
|
359
|
+
return [` actor discovery failed (${describeError(error)})`];
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
async function describeObjectPlan(scenario, suite, context) {
|
|
363
|
+
try {
|
|
364
|
+
const urls = await objectUrlsFromSource({
|
|
365
|
+
source: scenario.source,
|
|
366
|
+
target: suite.target,
|
|
367
|
+
fetch: context.fetch,
|
|
368
|
+
assertReadDestinationAllowed: (url) => context.assertReadDestinationAllowed(url, scenario)
|
|
369
|
+
});
|
|
370
|
+
const lines = [` objects: ${urls.length} URL(s) resolved`];
|
|
371
|
+
for (const url of urls.slice(0, 10)) {
|
|
372
|
+
lines.push(` object: GET ${url.href}`);
|
|
373
|
+
lines.push(` destination safety: ${await describeDestinationSafety(url, scenario, context)}`);
|
|
374
|
+
}
|
|
375
|
+
if (urls.length > 10) lines.push(` ... ${urls.length - 10} more`);
|
|
376
|
+
return lines;
|
|
377
|
+
} catch (error) {
|
|
378
|
+
return [` object discovery failed (${describeError(error)})`];
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
function describeMixedPlan(scenario) {
|
|
382
|
+
const entries = scenario.raw.mix ?? [];
|
|
383
|
+
if (entries.length < 1) return [" mix: no child scenarios"];
|
|
384
|
+
return entries.map((entry) => ` mix: ${entry.scenario} weight ${entry.weight}`);
|
|
385
|
+
}
|
|
386
|
+
async function describeDestinationSafety(url, scenario, context) {
|
|
387
|
+
try {
|
|
388
|
+
if (usesReadDestinationGate(scenario)) await context.assertReadDestinationAllowed(url, scenario);
|
|
389
|
+
else await context.assertDestinationAllowed(url, scenario);
|
|
275
390
|
return "allowed";
|
|
276
391
|
} catch (error) {
|
|
277
392
|
if (error instanceof UnsafeTargetError) return `would be refused: ${error.message}`;
|
|
278
393
|
throw error;
|
|
279
394
|
}
|
|
280
395
|
}
|
|
396
|
+
function usesReadDestinationGate(scenario) {
|
|
397
|
+
return (scenario.type === "actor" || scenario.type === "object") && !scenario.authenticated;
|
|
398
|
+
}
|
|
281
399
|
function assertPublicDestinationOverrideAllowed(url, scenario, context) {
|
|
282
400
|
const inheritsTargetGate = url.origin === context.targetOrigin && context.targetBenchmarkMode;
|
|
283
401
|
if (context.destinationTier !== "public" || inheritsTargetGate || !context.allowUnsafe) return;
|
|
@@ -295,15 +413,57 @@ function unsafeOverrideScenarios(suite) {
|
|
|
295
413
|
function unsafeOverrideScenario(scenario, defaults) {
|
|
296
414
|
const defaultDuration = defaults?.duration != null;
|
|
297
415
|
const defaultLoad = hasExplicitLoad(defaults?.load);
|
|
416
|
+
const defaultRuns = defaults?.runs != null;
|
|
298
417
|
const raw = "raw" in scenario ? scenario.raw : scenario;
|
|
299
418
|
return {
|
|
300
419
|
name: scenario.name,
|
|
301
420
|
explicitDuration: raw.duration != null || defaultDuration,
|
|
302
|
-
explicitLoad: hasExplicitLoad(raw.load) || defaultLoad
|
|
421
|
+
explicitLoad: hasExplicitLoad(raw.load) || defaultLoad,
|
|
422
|
+
explicitRuns: raw.runs != null || defaultRuns
|
|
303
423
|
};
|
|
304
424
|
}
|
|
305
425
|
function hasExplicitLoad(load) {
|
|
306
426
|
return load != null && typeof load === "object" && ("rate" in load && load.rate != null || "concurrency" in load && load.concurrency != null);
|
|
307
427
|
}
|
|
428
|
+
function scenarioNeedsSyntheticServer(scenario, scenarios, seen = /* @__PURE__ */ new Set()) {
|
|
429
|
+
if (seen.has(scenario.name)) return false;
|
|
430
|
+
const nextSeen = new Set(seen).add(scenario.name);
|
|
431
|
+
switch (scenario.type) {
|
|
432
|
+
case "inbox": return true;
|
|
433
|
+
case "actor":
|
|
434
|
+
case "object": return scenario.authenticated;
|
|
435
|
+
case "failure": return failureFaultsOf(scenario).some(isInboundFailureFault);
|
|
436
|
+
case "mixed": return mixedChildrenOf(scenario, scenarios).some((child) => scenarioNeedsSyntheticServer(child, scenarios, nextSeen));
|
|
437
|
+
default: return false;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
function scenarioNeedsReachableLocalServer(scenario, scenarios, seen = /* @__PURE__ */ new Set()) {
|
|
441
|
+
if (scenario.type === "fanout") return scenario.raw.sinkBase == null;
|
|
442
|
+
if (scenario.type === "failure") {
|
|
443
|
+
const faults = failureFaultsOf(scenario);
|
|
444
|
+
return faults.includes("invalid-signature") || scenario.raw.sinkBase == null && faults.some(isRemoteFailureFault);
|
|
445
|
+
}
|
|
446
|
+
if (scenario.type === "mixed") {
|
|
447
|
+
if (seen.has(scenario.name)) return false;
|
|
448
|
+
const nextSeen = new Set(seen).add(scenario.name);
|
|
449
|
+
return mixedChildrenOf(scenario, scenarios).some((child) => scenarioNeedsReachableLocalServer(child, scenarios, nextSeen));
|
|
450
|
+
}
|
|
451
|
+
return scenarioNeedsSyntheticServer(scenario, scenarios, seen);
|
|
452
|
+
}
|
|
453
|
+
function failureFaultsOf(scenario) {
|
|
454
|
+
return scenario.faults.length < 1 ? ["remote-404"] : scenario.faults;
|
|
455
|
+
}
|
|
456
|
+
function mixedChildrenOf(scenario, scenarios) {
|
|
457
|
+
return (scenario.raw.mix ?? []).flatMap((entry) => {
|
|
458
|
+
const child = scenarios.find((candidate) => candidate.name === entry.scenario);
|
|
459
|
+
return child == null ? [] : [child];
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
function isInboundFailureFault(fault) {
|
|
463
|
+
return fault === "invalid-signature" || fault === "missing-actor";
|
|
464
|
+
}
|
|
465
|
+
function isRemoteFailureFault(fault) {
|
|
466
|
+
return fault === "remote-404" || fault === "remote-410" || fault === "slow-inbox" || fault === "network-error";
|
|
467
|
+
}
|
|
308
468
|
//#endregion
|
|
309
469
|
export { runBench as default };
|
package/dist/bench/command.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import "@js-temporal/polyfill";
|
|
2
2
|
import { configContext } from "../config.js";
|
|
3
3
|
import { userAgentOption } from "../options.js";
|
|
4
|
-
import { argument, choice, command, constant, flag, group, merge, message, object, option, optional, string, withDefault } from "@optique/core";
|
|
4
|
+
import { argument, choice, command, constant, flag, group, merge, message, object, option, optional, or, string, withDefault } from "@optique/core";
|
|
5
5
|
import { bindConfig } from "@optique/config";
|
|
6
6
|
//#region src/bench/command.ts
|
|
7
7
|
const formatOption = bindConfig(option("-f", "--format", choice([
|
|
@@ -16,27 +16,55 @@ const formatOption = bindConfig(option("-f", "--format", choice([
|
|
|
16
16
|
const allowUnsafeTarget = withDefault(flag("--allow-unsafe-target", { description: message`Allow benchmarking a public target that does not advertise \
|
|
17
17
|
benchmark mode. Must be given on the command line for each run; it cannot be \
|
|
18
18
|
set in a configuration file.` }), false);
|
|
19
|
-
const
|
|
19
|
+
const outputOption = optional(option("-o", "--output", string({ metavar: "OUTPUT_PATH" }), { description: message`Write the report to a file instead of standard output.` }));
|
|
20
|
+
const targetOption = optional(option("-t", "--target", string({ metavar: "URL" }), { description: message`Override the target URL declared in the suite.` }));
|
|
21
|
+
const advertiseHostOption = optional(option("--advertise-host", string({ metavar: "HOST" }), { description: message`Host (name or IP) a non-loopback target can reach the \
|
|
22
|
+
benchmark's synthetic actor server at. Required for signed scenarios against a \
|
|
23
|
+
non-loopback target; binds the synthetic server on all interfaces and uses this \
|
|
24
|
+
host in the actor and key URLs the target dereferences.` }));
|
|
25
|
+
const runParser = merge("Benchmark options", object({
|
|
20
26
|
command: constant("bench"),
|
|
27
|
+
mode: constant("run"),
|
|
21
28
|
scenario: group("Arguments", argument(string({ metavar: "SCENARIO_FILE" }), { description: message`Path to the benchmark suite file (YAML or JSON).` })),
|
|
22
|
-
target:
|
|
29
|
+
target: targetOption,
|
|
23
30
|
format: formatOption,
|
|
24
|
-
output:
|
|
31
|
+
output: outputOption,
|
|
25
32
|
dryRun: withDefault(flag("--dry-run", { description: message`Resolve discovery and print the benchmark plan without \
|
|
26
33
|
sending load.` }), false),
|
|
27
|
-
advertiseHost:
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
34
|
+
advertiseHost: advertiseHostOption,
|
|
35
|
+
allowUnsafeTarget
|
|
36
|
+
}), userAgentOption);
|
|
37
|
+
const benchCommand = command("bench", or(command("compare", merge("Compare options", object({
|
|
38
|
+
command: constant("bench"),
|
|
39
|
+
mode: constant("compare"),
|
|
40
|
+
base: option("--base", string({ metavar: "REF" }), { description: message`The base git ref to benchmark.` }),
|
|
41
|
+
head: option("--head", string({ metavar: "REF" }), { description: message`The head git ref to benchmark.` }),
|
|
42
|
+
file: option("--file", string({ metavar: "SCENARIO_FILE" }), { description: message`Path to the benchmark suite file (YAML or JSON).` }),
|
|
43
|
+
startCommand: option("--start-command", string({ metavar: "COMMAND" }), { description: message`Shell command that starts the target application in each \
|
|
44
|
+
checked-out worktree.` }),
|
|
45
|
+
readyUrl: option("--ready-url", string({ metavar: "URL" }), { description: message`URL that returns success when the started target is ready.` }),
|
|
46
|
+
readyTimeout: withDefault(option("--ready-timeout", string({ metavar: "DURATION" }), { description: message`How long to wait for --ready-url.` }), "30s"),
|
|
47
|
+
maxRegression: option("--max-regression", string({ metavar: "PERCENT" }), { description: message`Maximum regression tolerated after the measured noise band.` }),
|
|
48
|
+
target: targetOption,
|
|
49
|
+
format: formatOption,
|
|
50
|
+
output: outputOption,
|
|
51
|
+
dryRun: constant(false),
|
|
52
|
+
advertiseHost: advertiseHostOption,
|
|
31
53
|
allowUnsafeTarget
|
|
32
54
|
}), userAgentOption), {
|
|
55
|
+
brief: message`Compare base and head benchmark runs.`,
|
|
56
|
+
description: message`Run the same benchmark suite against two git revisions on the \
|
|
57
|
+
same runner, then fail when the head revision regresses beyond the configured \
|
|
58
|
+
tolerance and measured noise band.`
|
|
59
|
+
}), runParser), {
|
|
33
60
|
brief: message`Benchmark a Fedify federation workload.`,
|
|
34
61
|
description: message`Run an ActivityPub-specific load benchmark against a \
|
|
35
62
|
cooperative Fedify target running in benchmark mode.
|
|
36
63
|
|
|
37
|
-
The suite file declares the target, actors, and scenarios.
|
|
38
|
-
|
|
39
|
-
|
|
64
|
+
The suite file declares the target, actors, and scenarios. This version \
|
|
65
|
+
executes the \`inbox\`, \`webfinger\`, \`actor\`, \`object\`, \`fanout\`, \
|
|
66
|
+
\`failure\`, and \`mixed\` scenario types; \`collection\` remains reserved by \
|
|
67
|
+
the suite format.`
|
|
40
68
|
});
|
|
41
69
|
//#endregion
|
|
42
70
|
export { benchCommand };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import "@js-temporal/polyfill";
|
|
2
|
+
//#region src/bench/compare/schema.ts
|
|
3
|
+
/**
|
|
4
|
+
* The embedded JSON Schema (draft 2020-12) for benchmark comparison output.
|
|
5
|
+
*
|
|
6
|
+
* The comparison report embeds the two benchmark reports it compares; this
|
|
7
|
+
* schema validates the comparison envelope and checks that the embedded reports
|
|
8
|
+
* look like current benchmark reports without duplicating the complete report
|
|
9
|
+
* schema in two published files.
|
|
10
|
+
* @since 2.3.0
|
|
11
|
+
* @module
|
|
12
|
+
*/
|
|
13
|
+
/** The hosted URL that serves the comparison report schema. */
|
|
14
|
+
const COMPARE_REPORT_SCHEMA_ID = "https://json-schema.fedify.dev/bench/compare-report-v1.json";
|
|
15
|
+
//#endregion
|
|
16
|
+
export { COMPARE_REPORT_SCHEMA_ID };
|