@fedify/cli 2.3.0-dev.1299 → 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.
@@ -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 fetchImpl = withUserAgent(deps.fetch ?? fetch, command.userAgent);
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: command.target != null,
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: command.target != null,
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
- assertDestinationAllowed
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((s) => SIGNED_TYPES.has(s.type))) {
129
- log("Signed scenarios (inbox) need the benchmark's synthetic actor server to be reachable from the target. A loopback target reaches it automatically; for a non-loopback target, pass --advertise-host with an address the target can reach (the synthetic server then binds all interfaces), or use a read scenario such as webfinger.");
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
- if (suite.scenarios.some((s) => SIGNED_TYPES.has(s.type))) fleet = await spawnSyntheticServer(await buildFleet(suite.actors), { advertiseHost: command.advertiseHost });
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
- log(`Running scenario "${scenario.name}" (${scenario.type})…`);
141
- const measurement = await runners[i].run({
142
- scenario,
143
- target: suite.target,
144
- documentLoader,
145
- contextLoader,
146
- allowPrivateAddress,
147
- fleet: fleet ?? null,
148
- fetch: fetchImpl,
149
- assertDestinationAllowed: (url) => assertDestinationAllowed(url, scenario)
150
- });
151
- results.push(buildScenarioResult(scenario, measurement));
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
- return `${scenario.load.kind === "open" ? `open-loop ${scenario.load.ratePerSec}/s ${scenario.load.arrival}` : `closed-loop concurrency ${scenario.load.concurrency}`}, duration ${scenario.durationMs}ms, signing ${scenario.signing}`;
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 describeDestinationSafety(inbox, scenario, context) {
346
+ async function describeActorPlan(scenario, suite, context) {
273
347
  try {
274
- await context.assertDestinationAllowed(inbox, scenario);
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 };
@@ -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 benchCommand = command("bench", merge("Benchmark options", object({
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: optional(option("-t", "--target", string({ metavar: "URL" }), { description: message`Override the target URL declared in the suite.` })),
29
+ target: targetOption,
23
30
  format: formatOption,
24
- output: optional(option("-o", "--output", string({ metavar: "OUTPUT_PATH" }), { description: message`Write the report to a file instead of standard 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: optional(option("--advertise-host", string({ metavar: "HOST" }), { description: message`Host (name or IP) a non-loopback target can reach the \
28
- benchmark's synthetic actor server at. Required for signed scenarios against a \
29
- non-loopback target; binds the synthetic server on all interfaces and uses this \
30
- host in the actor and key URLs the target dereferences.` })),
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. Only the \`inbox\` \
38
- and \`webfinger\` scenario types are executed in this version; the format \
39
- itself can express every scenario type.`
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 };