apitestcenter 0.4.0 → 0.5.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/README.md CHANGED
@@ -10,6 +10,8 @@ npx apitestcenter suite "Orders API" --env UAT --junit results.xml
10
10
  npx apitestcenter run "Orders API/List orders" --env UAT --p95 500 --errors 1 --junit results.xml
11
11
  ```
12
12
 
13
+ With `npx`, use the full name `apitestcenter`. Installed (`npm install -g apitestcenter`), the short name `atc` works too: `atc suite "Orders API"`.
14
+
13
15
  Build and save your requests in the free [APITestCenter desktop app](https://apitestcenter.com/#download), then run them here. Exit code **0** means everything passed, **1** means something failed, **2** means it could not start.
14
16
 
15
17
  ## In a pipeline
@@ -1,4 +1,4 @@
1
- APITestCenter CLI 0.4.0 — third-party notices
1
+ APITestCenter CLI 0.5.0 — third-party notices
2
2
 
3
3
  This package bundles the open-source packages below. k6 (AGPL-3.0) is not bundled: if it is
4
4
  not installed, the CLI downloads the unmodified official release from github.com/grafana/k6.
package/dist/cli.mjs CHANGED
@@ -50215,9 +50215,9 @@ var LATENCY_BIN_EDGES_MS = [
50215
50215
  1e4,
50216
50216
  Infinity
50217
50217
  ];
50218
- function latencyBin(ms2) {
50218
+ function latencyBin(ms3) {
50219
50219
  for (let i = 0; i < LATENCY_BIN_EDGES_MS.length; i++) {
50220
- if (ms2 <= LATENCY_BIN_EDGES_MS[i]) return i;
50220
+ if (ms3 <= LATENCY_BIN_EDGES_MS[i]) return i;
50221
50221
  }
50222
50222
  return LATENCY_BIN_EDGES_MS.length - 1;
50223
50223
  }
@@ -50757,7 +50757,7 @@ function deltaVsBaseline(current, baseline) {
50757
50757
 
50758
50758
  // ../shared/src/junit.ts
50759
50759
  var esc2 = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
50760
- function exportRunJunit(report, verdict, checksRate = 0.99) {
50760
+ function exportRunJunit(report, verdict, checksRate2 = 0.99, extra = []) {
50761
50761
  const seconds2 = Math.max(0, (Date.parse(report.endedAt) - Date.parse(report.startedAt)) / 1e3);
50762
50762
  const cases = [];
50763
50763
  let failures = 0;
@@ -50773,9 +50773,10 @@ function exportRunJunit(report, verdict, checksRate = 0.99) {
50773
50773
  for (const c of report.summary.checks) {
50774
50774
  const total = c.passes + c.fails;
50775
50775
  const rate = total ? c.passes / total : 1;
50776
- add("checks", c.name, rate < checksRate ? `${c.fails} of ${total} failed (${(rate * 100).toFixed(2)}% passed)` : void 0);
50776
+ add("checks", c.name, rate < checksRate2 ? `${c.fails} of ${total} failed (${(rate * 100).toFixed(2)}% passed)` : void 0);
50777
50777
  }
50778
50778
  for (const a of verdict.aspects) add("verdict", a.title, a.level === "fail" ? a.summary : void 0);
50779
+ for (const e of extra) add(e.classname, e.name, e.failure);
50779
50780
  return `<?xml version="1.0" encoding="UTF-8"?>
50780
50781
  <testsuites name="APITestCenter" tests="${cases.length}" failures="${failures}" time="${seconds2.toFixed(1)}">
50781
50782
  <testsuite name="${esc2(report.testName)}" tests="${cases.length}" failures="${failures}" time="${seconds2.toFixed(1)}" timestamp="${esc2(report.startedAt)}">
@@ -50848,7 +50849,7 @@ function suiteHeadline(run) {
50848
50849
  return parts.join(" \xB7 ");
50849
50850
  }
50850
50851
  var xml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
50851
- var seconds = (ms2) => ((ms2 ?? 0) / 1e3).toFixed(3);
50852
+ var seconds = (ms3) => ((ms3 ?? 0) / 1e3).toFixed(3);
50852
50853
  function exportSuiteJunit(run) {
50853
50854
  const t = suiteTotals(run.steps);
50854
50855
  const name = run.collection || "Saved requests";
@@ -50961,6 +50962,132 @@ ${rows2}
50961
50962
  `;
50962
50963
  }
50963
50964
 
50965
+ // ../shared/src/before-after.ts
50966
+ var LABELS = {
50967
+ p50: ["Typical response time", "p50"],
50968
+ p95: ["Response time", "p95"],
50969
+ p99: ["Slowest 1%", "p99"],
50970
+ rps: ["Requests per second", "throughput"],
50971
+ errorRate: ["Failed requests", "error rate"],
50972
+ checksRate: ["Checks passed", "checks rate"]
50973
+ };
50974
+ function checksRate(r) {
50975
+ const passes = r.summary.checks.reduce((n, c) => n + c.passes, 0);
50976
+ const fails = r.summary.checks.reduce((n, c) => n + c.fails, 0);
50977
+ return passes + fails ? passes / (passes + fails) : null;
50978
+ }
50979
+ function plannedLoad(r) {
50980
+ const load = r.definition.load;
50981
+ const rate = load.executor.endsWith("arrival-rate");
50982
+ const peak = rate ? load.rate ?? Math.max(0, ...load.stages.map((s) => s.target)) : Math.max(0, ...load.stages.map((s) => s.target));
50983
+ const ms3 = load.stages.reduce((n, s) => n + durationToMs(s.duration), 0);
50984
+ return { rate, peak, ms: ms3 };
50985
+ }
50986
+ var pct = (v) => `${Math.round(Math.abs(v) * 100)}%`;
50987
+ var ms = (v) => v >= 1e3 ? `${(v / 1e3).toFixed(2)} s` : `${Math.round(v)} ms`;
50988
+ var pts = (v) => `${(v * 100).toFixed(v < 0.1 ? 1 : 0)}%`;
50989
+ function fairness(before, after) {
50990
+ const unfair = [];
50991
+ const caveats = [];
50992
+ const a = endpointKey(before.definition.method, before.definition.url);
50993
+ const b = endpointKey(after.definition.method, after.definition.url);
50994
+ if (a !== b) unfair.push(`they test different requests (${a} and ${b})`);
50995
+ const pa = plannedLoad(before);
50996
+ const pb = plannedLoad(after);
50997
+ if (pa.rate !== pb.rate) unfair.push("one held a request rate and the other used simulated users");
50998
+ else if (pa.peak !== pb.peak) unfair.push(`the load was different (${pa.peak} vs ${pb.peak} ${pa.rate ? "requests per second" : "users"} at the peak)`);
50999
+ if (pa.ms && pb.ms && Math.abs(pa.ms - pb.ms) / Math.max(pa.ms, pb.ms) > 0.25) caveats.push("the runs were planned for different lengths");
51000
+ for (const [r, which] of [[before, "before"], [after, "after"]]) {
51001
+ if (r.endReason === "stopped") caveats.push(`the ${which} run was stopped early`);
51002
+ if (r.endReason === "target-missed") caveats.push(`the ${which} run stopped early because a goal was missed`);
51003
+ }
51004
+ return { unfair, caveats };
51005
+ }
51006
+ function compareRuns(before, after, opts = {}) {
51007
+ const minRelative = opts.minRelative ?? 0.05;
51008
+ const minMs = opts.minMs ?? 10;
51009
+ const minRate = opts.minRate ?? 5e-3;
51010
+ const errorHeavy = before.summary.http.failed.rate >= 0.5 || after.summary.http.failed.rate >= 0.5;
51011
+ const metric = (key, b, a, higherIsBetter, kind) => {
51012
+ const [label, tech] = LABELS[key];
51013
+ const base = { key, label, tech, before: b, after: a };
51014
+ if (b == null || a == null) return { ...base, relative: null, absolute: null, direction: "n/a", note: "not measured in both runs" };
51015
+ const absolute = a - b;
51016
+ const relative2 = b > 0 ? a / b - 1 : a > 0 ? null : 0;
51017
+ if (kind === "ms" && errorHeavy) return { ...base, relative: relative2, absolute, direction: "n/a", note: "mostly error responses \u2014 not the API's speed" };
51018
+ const small = kind === "rate" ? Math.abs(absolute) < minRate : Math.abs(relative2 ?? 1) < minRelative || kind === "ms" && Math.abs(absolute) < minMs;
51019
+ const direction = small || absolute === 0 ? "same" : absolute > 0 === higherIsBetter ? "better" : "worse";
51020
+ return { ...base, relative: relative2, absolute, direction };
51021
+ };
51022
+ const s0 = before.summary;
51023
+ const s1 = after.summary;
51024
+ const metrics = [
51025
+ metric("p95", s0.latencyMs.p95, s1.latencyMs.p95, false, "ms"),
51026
+ metric("p50", s0.latencyMs.median, s1.latencyMs.median, false, "ms"),
51027
+ metric("p99", s0.latencyMs.p99, s1.latencyMs.p99, false, "ms"),
51028
+ metric("rps", s0.http.rps.avg, s1.http.rps.avg, true, "count"),
51029
+ metric("errorRate", s0.http.failed.rate, s1.http.failed.rate, false, "rate")
51030
+ ];
51031
+ const c0 = checksRate(before);
51032
+ const c1 = checksRate(after);
51033
+ if (c0 != null || c1 != null) metrics.push(metric("checksRate", c0, c1, true, "rate"));
51034
+ const { unfair, caveats } = fairness(before, after);
51035
+ const by = Object.fromEntries(metrics.map((m) => [m.key, m]));
51036
+ const p95 = by.p95;
51037
+ const errors = by.errorRate;
51038
+ const checks = by.checksRate;
51039
+ let level;
51040
+ let headline;
51041
+ if (unfair.length) {
51042
+ level = "unfair";
51043
+ headline = `Not a fair comparison: ${unfair.join("; ")}. The numbers are shown, but don't read them as better or worse.`;
51044
+ } else if (errors.direction === "worse") {
51045
+ level = "worse";
51046
+ headline = `After fails more often: ${pts(errors.before)} \u2192 ${pts(errors.after)} of requests failed.`;
51047
+ } else if (checks?.direction === "worse") {
51048
+ level = "worse";
51049
+ headline = `After gives more wrong answers: checks passed ${pts(checks.before)} \u2192 ${pts(checks.after)}.`;
51050
+ } else if (p95.direction === "worse") {
51051
+ level = "worse";
51052
+ headline = `After is ${pct(p95.relative ?? 0)} slower: 95% of requests within ${ms(p95.before)} \u2192 ${ms(p95.after)}. That is more than normal run-to-run variation.`;
51053
+ } else if (p95.direction === "better") {
51054
+ const otherWorse = metrics.some((m) => m.direction === "worse");
51055
+ level = otherWorse ? "mixed" : "better";
51056
+ headline = `After is ${pct(p95.relative ?? 0)} faster: 95% of requests within ${ms(p95.before)} \u2192 ${ms(p95.after)}.${otherWorse ? " Some other measures got worse \u2014 see below." : ""}`;
51057
+ } else if (errors.direction === "better") {
51058
+ level = "better";
51059
+ headline = `After fails less often: ${pts(errors.before)} \u2192 ${pts(errors.after)} of requests failed. Speed is about the same.`;
51060
+ } else if (p95.direction === "n/a") {
51061
+ level = "same";
51062
+ headline = "Speed can't be compared: most responses in at least one run were errors.";
51063
+ } else {
51064
+ const worse = metrics.filter((m) => m.direction === "worse");
51065
+ level = worse.length ? "mixed" : "same";
51066
+ headline = worse.length ? `Response time is about the same, but ${worse.map((m) => m.label.toLowerCase()).join(" and ")} got worse.` : "About the same \u2014 the differences are within normal run-to-run variation.";
51067
+ }
51068
+ if (!unfair.length && caveats.length) headline += ` Note: ${caveats.join("; ")}.`;
51069
+ return {
51070
+ before: { id: before.id, name: before.testName, startedAt: before.startedAt },
51071
+ after: { id: after.id, name: after.testName, startedAt: after.startedAt },
51072
+ metrics,
51073
+ fair: unfair.length === 0,
51074
+ reasons: [...unfair, ...caveats],
51075
+ level,
51076
+ headline
51077
+ };
51078
+ }
51079
+ function slowerThanAllowed(result, maxSlower) {
51080
+ const p95 = result.metrics.find((m) => m.key === "p95");
51081
+ const errors = result.metrics.find((m) => m.key === "errorRate");
51082
+ if (!result.fair) return { failed: false, reason: `not compared: ${result.reasons[0]}` };
51083
+ if (errors.direction === "worse") return { failed: true, reason: `more requests failed than in the baseline (${pts(errors.before)} \u2192 ${pts(errors.after)})` };
51084
+ if (p95.direction === "n/a" || p95.relative == null) return { failed: false, reason: "response times can't be compared" };
51085
+ if (p95.relative > maxSlower && p95.direction === "worse") {
51086
+ return { failed: true, reason: `${pct(p95.relative)} slower than the baseline (p95 ${ms(p95.before)} \u2192 ${ms(p95.after)}), more than the ${pct(maxSlower)} allowed` };
51087
+ }
51088
+ return { failed: false, reason: `p95 ${ms(p95.before)} \u2192 ${ms(p95.after)} (${p95.relative >= 0 ? "+" : "\u2212"}${pct(p95.relative)}), within the ${pct(maxSlower)} allowed` };
51089
+ }
51090
+
50964
51091
  // ../shared/src/export.ts
50965
51092
  function exportRunJson(report, exportedAt = (/* @__PURE__ */ new Date()).toISOString()) {
50966
51093
  return `${JSON.stringify(
@@ -51061,9 +51188,9 @@ var C = {
51061
51188
  heat: ["#eef4fc", "#c3d9f5", "#86b6ef", "#3987e5", "#256abf", "#104281"]
51062
51189
  };
51063
51190
  var esc3 = (v) => String(v).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
51064
- var ms = (n) => !Number.isFinite(n) ? "\u2014" : n < 10 ? `${n.toFixed(1)} ms` : n >= 1e4 ? `${(n / 1e3).toFixed(1)} s` : `${Math.round(n)} ms`;
51191
+ var ms2 = (n) => !Number.isFinite(n) ? "\u2014" : n < 10 ? `${n.toFixed(1)} ms` : n >= 1e4 ? `${(n / 1e3).toFixed(1)} s` : `${Math.round(n)} ms`;
51065
51192
  var rps = (n) => !Number.isFinite(n) ? "\u2014" : n >= 1e3 ? `${(n / 1e3).toFixed(2)}k` : n.toFixed(n >= 100 ? 0 : 1);
51066
- var pct = (n) => `${(n * 100).toFixed(n < 0.01 ? 2 : 1)}%`;
51193
+ var pct2 = (n) => `${(n * 100).toFixed(n < 0.01 ? 2 : 1)}%`;
51067
51194
  var num2 = (n) => new Intl.NumberFormat("en-US").format(Math.round(n));
51068
51195
  var bytes = (n) => n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(1)} KB` : `${(n / (1024 * 1024)).toFixed(2)} MB`;
51069
51196
  var secs = (n) => n >= 60 ? `${Math.floor(n / 60)}m ${n % 60 ? `${n % 60}s` : ""}`.trim() : `${n}s`;
@@ -51078,26 +51205,26 @@ function kpiFor(report, key, slo) {
51078
51205
  return { text: `${rps(s.http.rps.max)} rps`, hint: `average ${rps(s.http.rps.avg)}` };
51079
51206
  case "p95": {
51080
51207
  const bar = slo?.p95Ms ?? 500;
51081
- if (s.http.failed.rate >= 0.5) return { text: ms(s.latencyMs.p95), hint: "mostly error responses \u2014 not the API\u2019s speed" };
51208
+ if (s.http.failed.rate >= 0.5) return { text: ms2(s.latencyMs.p95), hint: "mostly error responses \u2014 not the API\u2019s speed" };
51082
51209
  const tone = s.latencyMs.p95 >= (slo ? bar : 2e3) ? "bad" : s.latencyMs.p95 >= bar * (slo ? 0.8 : 1) ? "warn" : "ok";
51083
- return { text: ms(s.latencyMs.p95), hint: `slowest 1% ${ms(s.latencyMs.p99)}`, tone };
51210
+ return { text: ms2(s.latencyMs.p95), hint: `slowest 1% ${ms2(s.latencyMs.p99)}`, tone };
51084
51211
  }
51085
51212
  case "p50":
51086
- return { text: ms(s.latencyMs.median), hint: "half of requests were faster" };
51213
+ return { text: ms2(s.latencyMs.median), hint: "half of requests were faster" };
51087
51214
  case "avg":
51088
- return { text: ms(s.latencyMs.mean), hint: `typical spread \xB1${ms(s.latencyMs.stdDev)}` };
51215
+ return { text: ms2(s.latencyMs.mean), hint: `typical spread \xB1${ms2(s.latencyMs.stdDev)}` };
51089
51216
  case "p99":
51090
- return { text: ms(s.latencyMs.p99), hint: `slowest ${ms(s.latencyMs.max)}` };
51217
+ return { text: ms2(s.latencyMs.p99), hint: `slowest ${ms2(s.latencyMs.max)}` };
51091
51218
  case "max":
51092
- return { text: ms(s.latencyMs.max), hint: `fastest ${ms(s.latencyMs.min)}` };
51219
+ return { text: ms2(s.latencyMs.max), hint: `fastest ${ms2(s.latencyMs.min)}` };
51093
51220
  case "ttfb": {
51094
51221
  const share = s.latencyMs.mean > 0 ? Math.min(1, s.phasesMs.ttfb.avg / s.latencyMs.mean) : 0;
51095
- return { text: ms(s.phasesMs.ttfb.avg), hint: `${Math.round(share * 100)}% of the response time` };
51222
+ return { text: ms2(s.phasesMs.ttfb.avg), hint: `${Math.round(share * 100)}% of the response time` };
51096
51223
  }
51097
51224
  case "errors": {
51098
51225
  const rate = s.http.failed.rate;
51099
51226
  const tone = rate >= (slo ? slo.errorRate : 0.05) ? "bad" : rate >= (slo ? slo.errorRate * 0.5 : 0.01) ? "warn" : "ok";
51100
- return { text: pct(rate), hint: `${num2(s.http.failed.count)} failed`, tone };
51227
+ return { text: pct2(rate), hint: `${num2(s.http.failed.count)} failed`, tone };
51101
51228
  }
51102
51229
  case "checks": {
51103
51230
  const passes = s.checks.reduce((n, c) => n + c.passes, 0);
@@ -51105,7 +51232,7 @@ function kpiFor(report, key, slo) {
51105
51232
  if (!passes && !fails) return { text: "\u2014", hint: "no checks set" };
51106
51233
  const rate = passes / (passes + fails);
51107
51234
  const goal = slo?.checksRate ?? 0.99;
51108
- return { text: pct(rate), hint: `${num2(fails)} failed`, tone: rate >= goal ? "ok" : rate >= goal - 0.05 ? "warn" : "bad" };
51235
+ return { text: pct2(rate), hint: `${num2(fails)} failed`, tone: rate >= goal ? "ok" : rate >= goal - 0.05 ? "warn" : "bad" };
51109
51236
  }
51110
51237
  case "vus":
51111
51238
  return { text: String(s.vus.max), hint: `average ${s.vus.avg.toFixed(1)}` };
@@ -51222,15 +51349,15 @@ function heatmap(report) {
51222
51349
  }
51223
51350
  function scatter(report) {
51224
51351
  const rate = report.definition.load.executor.endsWith("arrival-rate");
51225
- const pts = report.buckets.filter((b) => b.rps > 0).map((b) => ({ x: rate ? b.rps : b.vus, y: b.p95Ms, t: b.elapsedS }));
51226
- if (pts.length < 3) return `<p class="empty">Not enough data points.</p>`;
51352
+ const pts2 = report.buckets.filter((b) => b.rps > 0).map((b) => ({ x: rate ? b.rps : b.vus, y: b.p95Ms, t: b.elapsedS }));
51353
+ if (pts2.length < 3) return `<p class="empty">Not enough data points.</p>`;
51227
51354
  const H = 210;
51228
- const xt = niceMax(Math.max(...pts.map((p) => p.x)));
51229
- const yt = niceMax(Math.max(...pts.map((p) => p.y)));
51355
+ const xt = niceMax(Math.max(...pts2.map((p) => p.x)));
51356
+ const yt = niceMax(Math.max(...pts2.map((p) => p.y)));
51230
51357
  const sx = (v) => PAD.l + v / xt * (W - PAD.l - PAD.r);
51231
51358
  const sy = (v) => H - PAD.b - v / yt * (H - PAD.t - PAD.b);
51232
- const dots = pts.map((p) => `<circle cx="${sx(p.x).toFixed(1)}" cy="${sy(p.y).toFixed(1)}" r="4" fill="${C.series[0]}" fill-opacity=".75" stroke="#fff" stroke-width="1.5"><title>${esc3(secs(p.t))}: ${rate ? `${rps(p.x)} rps` : `${p.x} users`} \u2192 ${esc3(ms(p.y))}</title></circle>`).join("");
51233
- const axes = [0, 0.5, 1].map((k) => `<line x1="${PAD.l}" x2="${W - PAD.r}" y1="${sy(yt * k)}" y2="${sy(yt * k)}" class="g"/><text x="${PAD.l - 6}" y="${sy(yt * k) + 3.5}" class="t" text-anchor="end">${esc3(ms(yt * k))}</text><text x="${sx(xt * k)}" y="${H - 6}" class="t" text-anchor="middle">${esc3(rate ? rps(xt * k) : String(Math.round(xt * k)))}</text>`).join("");
51359
+ const dots = pts2.map((p) => `<circle cx="${sx(p.x).toFixed(1)}" cy="${sy(p.y).toFixed(1)}" r="4" fill="${C.series[0]}" fill-opacity=".75" stroke="#fff" stroke-width="1.5"><title>${esc3(secs(p.t))}: ${rate ? `${rps(p.x)} rps` : `${p.x} users`} \u2192 ${esc3(ms2(p.y))}</title></circle>`).join("");
51360
+ const axes = [0, 0.5, 1].map((k) => `<line x1="${PAD.l}" x2="${W - PAD.r}" y1="${sy(yt * k)}" y2="${sy(yt * k)}" class="g"/><text x="${PAD.l - 6}" y="${sy(yt * k) + 3.5}" class="t" text-anchor="end">${esc3(ms2(yt * k))}</text><text x="${sx(xt * k)}" y="${H - 6}" class="t" text-anchor="middle">${esc3(rate ? rps(xt * k) : String(Math.round(xt * k)))}</text>`).join("");
51234
51361
  return `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="Load against response time">${axes}${dots}</svg><p class="note">Each dot is one second: ${rate ? "requests per second" : "simulated users"} across, response time (95% of requests) up. Where the dots climb is where the API starts to struggle.</p>`;
51235
51362
  }
51236
51363
  function widgetBody(w, report, opts) {
@@ -51260,8 +51387,8 @@ function widgetBody(w, report, opts) {
51260
51387
  case "latency": {
51261
51388
  const keys = o.percentiles?.length ? o.percentiles : ["p50", "p95", "p99"];
51262
51389
  const pick2 = { p50: (k) => k.p50Ms, p90: (k) => k.p90Ms, p95: (k) => k.p95Ms, p99: (k) => k.p99Ms, p999: (k) => k.p999Ms, max: (k) => k.maxMs };
51263
- return timeChart(x, keys.map((k) => ({ label: PERCENTILE_LABEL[k], color: C.pct[k], values: b.map((k2) => pick2[k](k2) ?? null) })), ms, {
51264
- ref: o.showTarget !== false && slo?.p95Ms ? { value: slo.p95Ms, label: `goal ${ms(slo.p95Ms)}` } : void 0
51390
+ return timeChart(x, keys.map((k) => ({ label: PERCENTILE_LABEL[k], color: C.pct[k], values: b.map((k2) => pick2[k](k2) ?? null) })), ms2, {
51391
+ ref: o.showTarget !== false && slo?.p95Ms ? { value: slo.p95Ms, label: `goal ${ms2(slo.p95Ms)}` } : void 0
51265
51392
  });
51266
51393
  }
51267
51394
  case "throughput":
@@ -51269,8 +51396,8 @@ function widgetBody(w, report, opts) {
51269
51396
  case "vus":
51270
51397
  return timeChart(x, [{ label: "Simulated users", color: C.series[6], values: b.map((k) => k.vus) }], (n) => String(Math.round(n)), { area: true });
51271
51398
  case "errors":
51272
- return timeChart(x, [{ label: "Failed requests", color: C.bad, values: b.map((k) => k.failedRate) }], pct, {
51273
- ref: o.showTarget !== false && slo ? { value: slo.errorRate, label: `goal ${pct(slo.errorRate)}` } : void 0
51399
+ return timeChart(x, [{ label: "Failed requests", color: C.bad, values: b.map((k) => k.failedRate) }], pct2, {
51400
+ ref: o.showTarget !== false && slo ? { value: slo.errorRate, label: `goal ${pct2(slo.errorRate)}` } : void 0
51274
51401
  });
51275
51402
  case "status":
51276
51403
  return timeChart(
@@ -51294,10 +51421,10 @@ function widgetBody(w, report, opts) {
51294
51421
  { label: "TCP connect", color: C.series[1], values: b.map((k) => k.connectMs ?? null) },
51295
51422
  { label: "TLS handshake", color: C.series[2], values: b.map((k) => k.tlsMs ?? null) }
51296
51423
  ],
51297
- ms
51424
+ ms2
51298
51425
  );
51299
51426
  case "checksRate":
51300
- return timeChart(x, [{ label: "Checks passing", color: C.ok, values: b.map((k) => k.checksPassed != null && k.checksPassed + (k.checksFailed ?? 0) > 0 ? k.checksPassed / (k.checksPassed + (k.checksFailed ?? 0)) : null) }], pct);
51427
+ return timeChart(x, [{ label: "Checks passing", color: C.ok, values: b.map((k) => k.checksPassed != null && k.checksPassed + (k.checksFailed ?? 0) > 0 ? k.checksPassed / (k.checksPassed + (k.checksFailed ?? 0)) : null) }], pct2);
51301
51428
  case "dropped":
51302
51429
  return timeChart(x, [{ label: "Requests not sent", color: C.warn, values: b.map((k) => k.dropped ?? null) }], (n) => String(Math.round(n)));
51303
51430
  case "histogram":
@@ -51307,12 +51434,12 @@ function widgetBody(w, report, opts) {
51307
51434
  case "percentiles": {
51308
51435
  const l = report.summary.latencyMs;
51309
51436
  return barList([
51310
- { label: "Fastest", value: l.min, color: C.mute, text: ms(l.min) },
51311
- { label: PERCENTILE_LABEL.p50, value: l.median, color: C.pct.p50, text: ms(l.median) },
51312
- { label: PERCENTILE_LABEL.p90, value: l.p90, color: C.pct.p90, text: ms(l.p90) },
51313
- { label: PERCENTILE_LABEL.p95, value: l.p95, color: C.pct.p95, text: ms(l.p95) },
51314
- { label: PERCENTILE_LABEL.p99, value: l.p99, color: C.pct.p99, text: ms(l.p99) },
51315
- { label: PERCENTILE_LABEL.max, value: l.max, color: C.ink, text: ms(l.max) }
51437
+ { label: "Fastest", value: l.min, color: C.mute, text: ms2(l.min) },
51438
+ { label: PERCENTILE_LABEL.p50, value: l.median, color: C.pct.p50, text: ms2(l.median) },
51439
+ { label: PERCENTILE_LABEL.p90, value: l.p90, color: C.pct.p90, text: ms2(l.p90) },
51440
+ { label: PERCENTILE_LABEL.p95, value: l.p95, color: C.pct.p95, text: ms2(l.p95) },
51441
+ { label: PERCENTILE_LABEL.p99, value: l.p99, color: C.pct.p99, text: ms2(l.p99) },
51442
+ { label: PERCENTILE_LABEL.max, value: l.max, color: C.ink, text: ms2(l.max) }
51316
51443
  ]);
51317
51444
  }
51318
51445
  case "phases": {
@@ -51327,7 +51454,7 @@ function widgetBody(w, report, opts) {
51327
51454
  { label: "Download", value: p.download.avg, color: C.series[6] }
51328
51455
  ];
51329
51456
  const total = rows2.reduce((n, r) => n + r.value, 0) || 1;
51330
- return `<div class="stack">${rows2.filter((r) => r.value > 0).map((r) => `<span style="width:${(r.value / total * 100).toFixed(2)}%;background:${r.color}" title="${esc3(r.label)} ${esc3(ms(r.value))}"></span>`).join("")}</div>${barList(rows2.map((r) => ({ ...r, text: `${ms(r.value)} \xB7 ${Math.round(r.value / total * 100)}%` })))}<p class="note">\u201CServer time\u201D is your API working on the answer; high connection times point to the network or the testing computer.</p>`;
51457
+ return `<div class="stack">${rows2.filter((r) => r.value > 0).map((r) => `<span style="width:${(r.value / total * 100).toFixed(2)}%;background:${r.color}" title="${esc3(r.label)} ${esc3(ms2(r.value))}"></span>`).join("")}</div>${barList(rows2.map((r) => ({ ...r, text: `${ms2(r.value)} \xB7 ${Math.round(r.value / total * 100)}%` })))}<p class="note">\u201CServer time\u201D is your API working on the answer; high connection times point to the network or the testing computer.</p>`;
51331
51458
  }
51332
51459
  case "concurrency":
51333
51460
  return scatter(report);
@@ -51338,7 +51465,7 @@ function widgetBody(w, report, opts) {
51338
51465
  case "targets": {
51339
51466
  const t = report.summary.thresholds;
51340
51467
  const goals = t.length ? `<ul class="list">${t.map((g) => `<li><b class="${g.ok ? "pass" : "fail"}">${g.ok ? "\u2713" : "\u2715"}</b> ${esc3(g.name)}${g.actual ? ` <span class="note">measured ${esc3(g.actual)}</span>` : ""}</li>`).join("")}</ul>` : `<p class="note">No goals were set for this run.</p>`;
51341
- const checks = report.summary.checks.length ? `<h4>Checks on every response</h4><ul class="list">${report.summary.checks.map((c) => `<li><b class="${c.fails ? "fail" : "pass"}">${c.fails ? "\u2715" : "\u2713"}</b> <code>${esc3(c.name)}</code> <span class="note">${esc3(pct(c.passes / Math.max(1, c.passes + c.fails)))} \xB7 ${esc3(num2(c.fails))} failed of ${esc3(num2(c.passes + c.fails))}</span></li>`).join("")}</ul>` : "";
51468
+ const checks = report.summary.checks.length ? `<h4>Checks on every response</h4><ul class="list">${report.summary.checks.map((c) => `<li><b class="${c.fails ? "fail" : "pass"}">${c.fails ? "\u2715" : "\u2713"}</b> <code>${esc3(c.name)}</code> <span class="note">${esc3(pct2(c.passes / Math.max(1, c.passes + c.fails)))} \xB7 ${esc3(num2(c.fails))} failed of ${esc3(num2(c.passes + c.fails))}</span></li>`).join("")}</ul>` : "";
51342
51469
  return `<h4>Goals</h4>${goals}${checks}`;
51343
51470
  }
51344
51471
  case "statusTable": {
@@ -51347,7 +51474,7 @@ function widgetBody(w, report, opts) {
51347
51474
  return `<table class="tbl"><thead><tr><th>Code</th><th>Meaning</th><th>Count</th><th>Share</th></tr></thead><tbody>${entries.map(([code, n]) => {
51348
51475
  const c = Number(code);
51349
51476
  const meaning = code === "0" ? "No answer (timeout or connection error)" : c < 300 ? "Success" : c < 400 ? "Redirect" : c < 500 ? "Client error" : "Server error";
51350
- return `<tr><td><code>${esc3(code)}</code></td><td>${esc3(meaning)}</td><td>${esc3(num2(n))}</td><td>${esc3(pct(n / total))}</td></tr>`;
51477
+ return `<tr><td><code>${esc3(code)}</code></td><td>${esc3(meaning)}</td><td>${esc3(num2(n))}</td><td>${esc3(pct2(n / total))}</td></tr>`;
51351
51478
  }).join("")}</tbody></table>`;
51352
51479
  }
51353
51480
  case "config":
@@ -51404,21 +51531,21 @@ function runDebrief(report, opts = {}) {
51404
51531
  if (story.timingsUnreliable) {
51405
51532
  const ok = story.healthyP95Ms;
51406
51533
  findings.push(
51407
- `<b>Speed.</b> The response times of this run are not meaningful: ${esc3(pct(s.http.failed.rate))} of responses were errors, and errors come back almost instantly.${ok ? story.onset ? ` While requests were still succeeding (before ${esc3(secs(story.onset.elapsedS))}), 95% took about ${esc3(ms(ok))}.` : ` Successful requests alone: 95% took about ${esc3(ms(ok))}.` : ""}`
51534
+ `<b>Speed.</b> The response times of this run are not meaningful: ${esc3(pct2(s.http.failed.rate))} of responses were errors, and errors come back almost instantly.${ok ? story.onset ? ` While requests were still succeeding (before ${esc3(secs(story.onset.elapsedS))}), 95% took about ${esc3(ms2(ok))}.` : ` Successful requests alone: 95% took about ${esc3(ms2(ok))}.` : ""}`
51408
51535
  );
51409
51536
  } else {
51410
- let speed = `<b>Speed.</b> A typical request took ${esc3(ms(s.latencyMs.median))}; 95% finished within ${esc3(ms(p95))}, and the slowest took ${esc3(ms(s.latencyMs.max))}.`;
51411
- if (slo?.p95Ms) speed += p95 <= slo.p95Ms ? ` That is within your ${esc3(ms(slo.p95Ms))} goal.` : ` That is over your ${esc3(ms(slo.p95Ms))} goal by ${esc3(ms(p95 - slo.p95Ms))}.`;
51412
- if (s.latencyMs.p99 > p95 * 3 && s.latencyMs.p99 > 100) speed += ` The slowest 1% (${esc3(ms(s.latencyMs.p99))}) is much slower than the rest \u2014 a few requests hit something slow, like a cold cache, a lock, or garbage collection.`;
51537
+ let speed = `<b>Speed.</b> A typical request took ${esc3(ms2(s.latencyMs.median))}; 95% finished within ${esc3(ms2(p95))}, and the slowest took ${esc3(ms2(s.latencyMs.max))}.`;
51538
+ if (slo?.p95Ms) speed += p95 <= slo.p95Ms ? ` That is within your ${esc3(ms2(slo.p95Ms))} goal.` : ` That is over your ${esc3(ms2(slo.p95Ms))} goal by ${esc3(ms2(p95 - slo.p95Ms))}.`;
51539
+ if (s.latencyMs.p99 > p95 * 3 && s.latencyMs.p99 > 100) speed += ` The slowest 1% (${esc3(ms2(s.latencyMs.p99))}) is much slower than the rest \u2014 a few requests hit something slow, like a cold cache, a lock, or garbage collection.`;
51413
51540
  findings.push(speed);
51414
51541
  }
51415
51542
  if (b.length >= 6 && vi.earlyP95 > 0 && !story.onset && !story.timingsUnreliable) {
51416
51543
  const ratio = vi.lateP95 / vi.earlyP95;
51417
51544
  if (ratio > 1.5) {
51418
- findings.push(`<b>Over time.</b> It got slower as the test went on: 95% of requests took ${esc3(ms(vi.earlyP95))} early on and ${esc3(ms(vi.lateP95))} near the end (\xD7${ratio.toFixed(1)}). That often means work piling up \u2014 a queue, a connection pool, or memory that keeps growing.`);
51545
+ findings.push(`<b>Over time.</b> It got slower as the test went on: 95% of requests took ${esc3(ms2(vi.earlyP95))} early on and ${esc3(ms2(vi.lateP95))} near the end (\xD7${ratio.toFixed(1)}). That often means work piling up \u2014 a queue, a connection pool, or memory that keeps growing.`);
51419
51546
  next.push("Run it longer (the Soak preset) to see whether the slowdown keeps growing.");
51420
51547
  } else if (ratio < 0.67) {
51421
- findings.push(`<b>Over time.</b> It got faster after the start (${esc3(ms(vi.earlyP95))} \u2192 ${esc3(ms(vi.lateP95))}) \u2014 typical of warm-up: caches filling, connections opening, code getting compiled.`);
51548
+ findings.push(`<b>Over time.</b> It got faster after the start (${esc3(ms2(vi.earlyP95))} \u2192 ${esc3(ms2(vi.lateP95))}) \u2014 typical of warm-up: caches filling, connections opening, code getting compiled.`);
51422
51549
  } else {
51423
51550
  findings.push(`<b>Over time.</b> Response time stayed steady from start to finish.`);
51424
51551
  }
@@ -51430,7 +51557,7 @@ function runDebrief(report, opts = {}) {
51430
51557
  const codes = Object.entries(s.http.status).filter(([c]) => c === "0" || Number(c) >= 400).sort((a, z2) => z2[1] - a[1]).slice(0, 4).map(([c, n]) => `${c === "0" ? "no answer" : c} \xD7${num2(n)}`).join(", ");
51431
51558
  const firstBad = b.find((k) => k.failedRate > 0);
51432
51559
  const d = story.dominant;
51433
- let line = `<b>Errors.</b> ${esc3(num2(failed))} requests failed (${esc3(pct(s.http.failed.rate))})${codes ? `: ${esc3(codes)}` : ""}.`;
51560
+ let line = `<b>Errors.</b> ${esc3(num2(failed))} requests failed (${esc3(pct2(s.http.failed.rate))})${codes ? `: ${esc3(codes)}` : ""}.`;
51434
51561
  if (d) line += ` ${esc3(d.code === "0" ? "No answer" : d.code)} means ${esc3(d.meaning)}.`;
51435
51562
  if (story.onset) {
51436
51563
  line += ` From ${esc3(secs(story.onset.elapsedS))} on \u2014 at about ${esc3(num2(story.onset.vus))} simultaneous users and ${esc3(rps(story.onset.rps))} requests per second \u2014 most requests failed.`;
@@ -51479,10 +51606,10 @@ function runDebrief(report, opts = {}) {
51479
51606
  if (key(high[0]) > key(low[low.length - 1]) && lowP95 > 0) {
51480
51607
  const k = highP95 / lowP95;
51481
51608
  if (k > 2) {
51482
- findings.push(`<b>Capacity.</b> Under the heaviest load, 95% of requests took ${esc3(ms(highP95))}, against ${esc3(ms(lowP95))} at light load (\xD7${k.toFixed(1)}). The API is near its comfortable limit at about ${esc3(rate ? `${rps(avg(high, key))} requests per second` : `${Math.round(avg(high, key))} simultaneous users`)}.`);
51609
+ findings.push(`<b>Capacity.</b> Under the heaviest load, 95% of requests took ${esc3(ms2(highP95))}, against ${esc3(ms2(lowP95))} at light load (\xD7${k.toFixed(1)}). The API is near its comfortable limit at about ${esc3(rate ? `${rps(avg(high, key))} requests per second` : `${Math.round(avg(high, key))} simultaneous users`)}.`);
51483
51610
  next.push("Use the Load vs response time chart to find where the dots start climbing \u2014 that is the most this API can take comfortably.");
51484
51611
  } else {
51485
- findings.push(`<b>Capacity.</b> Response time barely changed between light and full load (${esc3(ms(lowP95))} \u2192 ${esc3(ms(highP95))}), so this load is comfortably within what the API can handle.`);
51612
+ findings.push(`<b>Capacity.</b> Response time barely changed between light and full load (${esc3(ms2(lowP95))} \u2192 ${esc3(ms2(highP95))}), so this load is comfortably within what the API can handle.`);
51486
51613
  next.push("Raise the load (more users, or the Stress preset) to find where it starts to slow down.");
51487
51614
  }
51488
51615
  }
@@ -51501,21 +51628,79 @@ function runDebrief(report, opts = {}) {
51501
51628
  if (!slo) next.splice(story.blocked || story.dominant?.code === "503" ? 1 : 0, 0, "Set pass/fail goals (Goals tab) so the result reflects what \u201Cfast enough\u201D means for this API.");
51502
51629
  return { findings, next: next.slice(0, 5) };
51503
51630
  }
51631
+ function layoutBands(widgets) {
51632
+ const bands = [];
51633
+ let bottom = -1;
51634
+ for (const w of readingOrder(widgets)) {
51635
+ if (!bands.length || w.y >= bottom) bands.push([]);
51636
+ bands[bands.length - 1].push(w);
51637
+ bottom = Math.max(bottom, w.y + w.h);
51638
+ }
51639
+ return bands.map((band) => {
51640
+ const lanes = [];
51641
+ for (const w of [...band].sort((a, b) => a.x - b.x || a.y - b.y)) {
51642
+ const lane = lanes.find((l) => w.x < l.end && w.x + w.w > l.x);
51643
+ if (lane) {
51644
+ lane.widgets.push(w);
51645
+ lane.x = Math.min(lane.x, w.x);
51646
+ lane.end = Math.max(lane.end, w.x + w.w);
51647
+ } else lanes.push({ x: w.x, end: w.x + w.w, widgets: [w] });
51648
+ }
51649
+ for (let merged = true; merged; ) {
51650
+ merged = false;
51651
+ lanes.sort((a, b) => a.x - b.x);
51652
+ for (let i = 1; i < lanes.length; i++) {
51653
+ if (lanes[i].x < lanes[i - 1].end) {
51654
+ lanes[i - 1].end = Math.max(lanes[i - 1].end, lanes[i].end);
51655
+ lanes[i - 1].widgets.push(...lanes[i].widgets);
51656
+ lanes.splice(i, 1);
51657
+ merged = true;
51658
+ break;
51659
+ }
51660
+ }
51661
+ }
51662
+ return lanes.map((l) => ({ x: l.x, w: l.end - l.x, widgets: l.widgets.sort((a, b) => a.y - b.y || a.x - b.x) }));
51663
+ });
51664
+ }
51665
+ function beforeAfterSection(report, before) {
51666
+ if (!before || before.id === report.id) return "";
51667
+ const r = compareRuns(before, report);
51668
+ const tone = r.level === "worse" ? "fail" : r.level === "better" ? "pass" : r.level === "same" ? "same" : "warn";
51669
+ const fmt = (key, v) => v == null ? "\u2014" : key === "rps" ? `${rps(v)}/s` : key === "errorRate" || key === "checksRate" ? pct2(v) : ms2(v);
51670
+ const change = (m) => {
51671
+ if (m.direction === "n/a") return `<span class="sub">${esc3(m.note ?? "\u2014")}</span>`;
51672
+ if (m.direction === "same") return `<span class="sub">= about the same</span>`;
51673
+ const up = (m.absolute ?? 0) > 0;
51674
+ const amount = m.key === "errorRate" || m.key === "checksRate" ? `${up ? "+" : "\u2212"}${(Math.abs(m.absolute ?? 0) * 100).toFixed(1)} points` : m.relative != null ? `${up ? "+" : "\u2212"}${Math.round(Math.abs(m.relative) * 100)}%` : "";
51675
+ return `<span style="color:${m.direction === "better" ? C.ok : C.bad};font-weight:600">${up ? "\u2191" : "\u2193"} ${esc3(amount)} \xB7 ${m.direction}</span>`;
51676
+ };
51677
+ const rows2 = r.metrics.map((m) => `<tr><th>${esc3(m.label)} <span class="sub">${esc3(m.tech)}</span></th><td>${esc3(fmt(m.key, m.before))}</td><td>${esc3(fmt(m.key, m.after))}</td><td>${change(m)}</td></tr>`).join("");
51678
+ return ` <section class="debrief">
51679
+ <h2>Before / after</h2>
51680
+ <p class="sub">This run compared with \u201C${esc3(before.testName)}\u201D (${esc3(new Date(before.startedAt).toLocaleString("en-GB"))}).</p>
51681
+ <div class="verdict ${tone}" style="margin:10px 0">${esc3(r.headline)}</div>
51682
+ <table class="kv-tbl"><tr><th>Measure</th><th>Before</th><th>After</th><th>Change</th></tr>${rows2}</table>
51683
+ <p class="note">Changes under 5% (or under 10 ms) count as about the same \u2014 two runs of an unchanged API never match exactly.</p>
51684
+ </section>`;
51685
+ }
51504
51686
  function exportRunHtml(report, opts = {}) {
51505
51687
  const generatedAt = opts.generatedAt ?? (/* @__PURE__ */ new Date()).toISOString();
51506
51688
  const view = opts.view ?? defaultViewFor("run");
51507
51689
  const verdict = buildVerdict(report, { slo: opts.slo ?? null });
51508
51690
  const debrief = runDebrief(report, opts);
51509
51691
  const lv = verdict.overall;
51510
- const widgets = readingOrder(view.widgets).map((w) => {
51692
+ const card = (w) => {
51511
51693
  const def = METRICS[w.metric];
51512
51694
  const title = widgetTitle(w);
51513
51695
  const tech = widgetTech(w);
51696
+ W = Math.max(320, Math.round(w.w / 12 * 1080));
51514
51697
  const body = widgetBody(w, report, opts);
51515
51698
  const cls = def.bare ? "w head" : "w";
51516
- return `<section class="${cls}" style="--x:${w.x + 1};--w:${w.w}">${def.bare ? `<h2>${esc3(title)}</h2>` : `<h3>${esc3(title)}${tech ? ` <small>${esc3(tech)}</small>` : ""}</h3>`}${body}</section>`;
51517
- }).join("\n");
51518
- const seriesRows = report.buckets.map((k) => `<tr><td>${esc3(secs(k.elapsedS))}</td><td>${k.vus}</td><td>${esc3(rps(k.rps))}</td><td>${esc3(ms(k.p50Ms))}</td><td>${esc3(ms(k.p95Ms))}</td><td>${esc3(ms(k.p99Ms))}</td><td>${esc3(pct(k.failedRate))}</td><td>${k.http2xx}</td><td>${k.http4xx}</td><td>${k.http5xx}</td><td>${k.http0}</td></tr>`).join("");
51699
+ return `<section class="${cls}">${def.bare ? `<h2>${esc3(title)}</h2>` : `<h3>${esc3(title)}${tech ? ` <small>${esc3(tech)}</small>` : ""}</h3>`}${body}</section>`;
51700
+ };
51701
+ const widgets = layoutBands(view.widgets).map((band) => `<div class="band">${band.map((lane) => `<div class="lane" style="--x:${lane.x + 1};--w:${lane.w}">${lane.widgets.map(card).join("")}</div>`).join("")}</div>`).join("\n");
51702
+ W = 640;
51703
+ const seriesRows = report.buckets.map((k) => `<tr><td>${esc3(secs(k.elapsedS))}</td><td>${k.vus}</td><td>${esc3(rps(k.rps))}</td><td>${esc3(ms2(k.p50Ms))}</td><td>${esc3(ms2(k.p95Ms))}</td><td>${esc3(ms2(k.p99Ms))}</td><td>${esc3(pct2(k.failedRate))}</td><td>${k.http2xx}</td><td>${k.http4xx}</td><td>${k.http5xx}</td><td>${k.http0}</td></tr>`).join("");
51519
51704
  return `<!DOCTYPE html>
51520
51705
  <html lang="en">
51521
51706
  <head>
@@ -51539,14 +51724,16 @@ function exportRunHtml(report, opts = {}) {
51539
51724
  .debrief { background: ${C.panel}; border: 1px solid ${C.line}; border-radius: 12px; padding: 16px 20px; margin: 16px 0; }
51540
51725
  .debrief h2 { margin: 0 0 8px; font-size: 17px; }
51541
51726
  .debrief p { margin: 6px 0; } .debrief ol { margin: 6px 0 0; padding-left: 20px; } .debrief li { margin: 3px 0; }
51542
- .grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 12px; align-items: start; }
51543
- .w { grid-column: var(--x) / span var(--w); background: ${C.panel}; border: 1px solid ${C.line}; border-radius: 12px; padding: 12px 14px; min-width: 0; break-inside: avoid; }
51727
+ .grid { display: flex; flex-direction: column; gap: 12px; }
51728
+ .band { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 12px; align-items: start; }
51729
+ .lane { grid-column: var(--x) / span var(--w); display: flex; flex-direction: column; gap: 12px; min-width: 0; }
51730
+ .w { background: ${C.panel}; border: 1px solid ${C.line}; border-radius: 12px; padding: 12px 14px; min-width: 0; break-inside: avoid; }
51544
51731
  .w.head { background: none; border: 0; padding: 14px 2px 0; }
51545
51732
  .w.head h2 { margin: 0; padding-bottom: 4px; border-bottom: 1px solid ${C.line}; font-size: 18px; }
51546
51733
  .w h3 { margin: 0 0 8px; font-size: 14px; } .w h3 small { color: ${C.mute}; font-weight: 400; font-size: 12px; margin-left: 4px; }
51547
51734
  .w h4 { margin: 10px 0 4px; font-size: 12px; text-transform: uppercase; letter-spacing: .05em; color: ${C.mute}; }
51548
51735
  svg { width: 100%; height: auto; display: block; }
51549
- svg .g { stroke: ${C.line}; stroke-width: 1; } svg .t { fill: ${C.mute}; font: 10px ui-monospace, Consolas, monospace; }
51736
+ svg .g { stroke: ${C.line}; stroke-width: 1; } svg .t { fill: ${C.mute}; font: 11px ui-monospace, Consolas, monospace; }
51550
51737
  .legend { display: flex; flex-wrap: wrap; gap: 12px; font-size: 12px; color: ${C.mute}; margin-top: 4px; }
51551
51738
  .legend i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 5px; vertical-align: -1px; }
51552
51739
  .kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; }
@@ -51570,7 +51757,7 @@ function exportRunHtml(report, opts = {}) {
51570
51757
  details.appendix summary { cursor: pointer; font-weight: 600; }
51571
51758
  .series-wrap { max-height: 420px; overflow: auto; margin-top: 10px; }
51572
51759
  footer { color: ${C.mute}; font-size: 12px; margin-top: 24px; }
51573
- @media (max-width: 900px) { .w { grid-column: 1 / -1; } }
51760
+ @media (max-width: 900px) { .lane { grid-column: 1 / -1; } }
51574
51761
  @media print { body { background: #fff; } .series-wrap { max-height: none; } details.appendix { display: none; } .w, .debrief { box-shadow: none; } }
51575
51762
  </style>
51576
51763
  </head>
@@ -51588,6 +51775,7 @@ function exportRunHtml(report, opts = {}) {
51588
51775
  ${debrief.next.length ? `<h2 style="margin-top:14px">Suggested next steps</h2><ol>${debrief.next.map((n) => `<li>${esc3(n)}</li>`).join("")}</ol>` : ""}
51589
51776
  <p class="note">Written from the measured numbers by fixed rules. ${esc3(verdict.disclaimer)}</p>
51590
51777
  </section>
51778
+ ${beforeAfterSection(report, opts.before ?? null)}
51591
51779
  <div class="grid">
51592
51780
  ${widgets}
51593
51781
  </div>
@@ -51613,6 +51801,11 @@ function isCompareExport(value) {
51613
51801
  const doc = value;
51614
51802
  return doc.kind === "compare" && Array.isArray(doc.apis) && doc.apis.length >= 1 && doc.apis.every((api) => api && typeof api === "object" && isRunReport(api.report));
51615
51803
  }
51804
+ function parseRunReportFile(raw) {
51805
+ const parsed = parseExportFile(raw);
51806
+ if (parsed.kind === "report") return parsed.report;
51807
+ throw new Error("That file is a compare export. Open it on Preview as a compare document.");
51808
+ }
51616
51809
  function parseExportFile(raw) {
51617
51810
  if (!raw || typeof raw !== "object") {
51618
51811
  throw new Error("That file is not JSON we can open.");
@@ -52462,16 +52655,16 @@ var LatencyHistogram = class {
52462
52655
  max = 0;
52463
52656
  sum = 0;
52464
52657
  sumSq = 0;
52465
- add(ms2) {
52466
- if (!Number.isFinite(ms2) || ms2 < 0) return;
52467
- const v = Math.max(ms2, FLOOR_MS);
52658
+ add(ms3) {
52659
+ if (!Number.isFinite(ms3) || ms3 < 0) return;
52660
+ const v = Math.max(ms3, FLOOR_MS);
52468
52661
  const bin = Math.floor(Math.log(v / FLOOR_MS) / GROWTH);
52469
52662
  this.bins.set(bin, (this.bins.get(bin) ?? 0) + 1);
52470
52663
  this.count += 1;
52471
- this.sum += ms2;
52472
- this.sumSq += ms2 * ms2;
52473
- if (ms2 < this.min) this.min = ms2;
52474
- if (ms2 > this.max) this.max = ms2;
52664
+ this.sum += ms3;
52665
+ this.sumSq += ms3 * ms3;
52666
+ if (ms3 < this.min) this.min = ms3;
52667
+ if (ms3 > this.max) this.max = ms3;
52475
52668
  }
52476
52669
  mean() {
52477
52670
  return this.count ? this.sum / this.count : 0;
@@ -52782,9 +52975,9 @@ function liveSummaryParts(run) {
52782
52975
  const checks = [...run.aggregator.checks].map(([name, [passes, fails]]) => ({ name, passes, fails }));
52783
52976
  return { checks, dropped: Math.round(run.aggregator.dropped) };
52784
52977
  }
52785
- function fmtMs(ms2) {
52786
- if (ms2 < 1) return `${ms2.toFixed(2)} ms`;
52787
- return ms2 < 10 ? `${ms2.toFixed(1)} ms` : `${Math.round(ms2)} ms`;
52978
+ function fmtMs(ms3) {
52979
+ if (ms3 < 1) return `${ms3.toFixed(2)} ms`;
52980
+ return ms3 < 10 ? `${ms3.toFixed(1)} ms` : `${Math.round(ms3)} ms`;
52788
52981
  }
52789
52982
  function fmtPct(rate) {
52790
52983
  return `${(rate * 100).toFixed(rate < 0.01 ? 2 : 1)}%`;
@@ -53419,6 +53612,33 @@ var WorkspaceStore = class {
53419
53612
  requests.sort((a, b) => a.collection.localeCompare(b.collection) || a.name.localeCompare(b.name));
53420
53613
  return { requests, environments: await this.environments(), secretsPersistent: this.secrets.persistent, invalid };
53421
53614
  }
53615
+ /**
53616
+ * A collection's files exactly as they are on disk — secrets are never in them — for running
53617
+ * in CI: its requests, any Run first requests they use from other collections, and
53618
+ * environments.json. Paths are relative to the data folder. `secrets` lists requests whose
53619
+ * saved secret will be missing, so people can switch them to {{variables}}.
53620
+ */
53621
+ async exportCollection(collection) {
53622
+ const all = [];
53623
+ for (const path of await this.files()) {
53624
+ const file2 = await this.readRequest(path);
53625
+ if (file2) all.push({ path, file: file2 });
53626
+ }
53627
+ const picked = new Map(all.filter((f) => f.file.collection === collection).map((f) => [f.file.id, f]));
53628
+ for (const f of [...picked.values()]) {
53629
+ const before = f.file.request.before;
53630
+ const ref = typeof before?.requestId === "string" ? all.find((a) => a.file.id === before.requestId) : void 0;
53631
+ if (ref) picked.set(ref.file.id, ref);
53632
+ }
53633
+ const files = [];
53634
+ for (const f of picked.values()) files.push({ path: relative(this.dir, f.path).split(sep).join("/"), text: await readFile5(f.path, "utf8") });
53635
+ try {
53636
+ files.push({ path: "environments.json", text: await readFile5(this.envFile, "utf8") });
53637
+ } catch {
53638
+ }
53639
+ const secrets = [...picked.values()].filter((f) => f.file.secretFields?.length).map((f) => `${f.file.name} (${f.file.secretFields.join(", ")})`);
53640
+ return { files, secrets };
53641
+ }
53422
53642
  async getRequest(id) {
53423
53643
  const found = await this.find(id);
53424
53644
  if (!found) return null;
@@ -53521,7 +53741,7 @@ var WorkspaceStore = class {
53521
53741
  // ../../apps/engine/src/cli.ts
53522
53742
  var repoRoot = resolve2(dirname2(fileURLToPath(import.meta.url)), "../../..");
53523
53743
  var EXIT = { passed: 0, failed: 1, error: 2 };
53524
- var VERSION = "0.4.0";
53744
+ var VERSION = "0.5.0";
53525
53745
  var HELP = `APITestCenter CLI ${VERSION} \u2014 https://apitestcenter.com/docs/cli-and-ci.html
53526
53746
 
53527
53747
  Usage (apitestcenter, or the short name atc)
@@ -53552,6 +53772,12 @@ Collection runs (atc suite)
53552
53772
  --stop-on-fail stop at the first request that fails
53553
53773
  --env, --var, --json, --html, --junit, --quiet work as for run
53554
53774
 
53775
+ Compare with a baseline (atc run)
53776
+ --baseline <file> a saved report (JSON export, or --save-baseline from an earlier run)
53777
+ --max-slower <percent> fail when p95 is slower than the baseline by more than this (default 10)
53778
+ or when more requests fail; changes under 5% / 10 ms count as the same
53779
+ --save-baseline <file> save this run as a baseline for later runs
53780
+
53555
53781
  Output
53556
53782
  --json <file> --html <file> --junit <file> --save also keep the run in History
53557
53783
  --no-check skip the single check request before load
@@ -53589,6 +53815,9 @@ async function main() {
53589
53815
  save: { type: "boolean" },
53590
53816
  "no-check": { type: "boolean" },
53591
53817
  quiet: { type: "boolean" },
53818
+ baseline: { type: "string" },
53819
+ "max-slower": { type: "string" },
53820
+ "save-baseline": { type: "string" },
53592
53821
  "stop-on-fail": { type: "boolean" },
53593
53822
  help: { type: "boolean", short: "h" },
53594
53823
  version: { type: "boolean", short: "v" },
@@ -53673,9 +53902,24 @@ Environments (${index.environments.length})`);
53673
53902
  if (Object.keys(overrides).length) load = { ...load, ...overrides, stages: load.stages && !values.duration ? load.stages : null, preset: "custom" };
53674
53903
  const problem = loadProblem(load);
53675
53904
  if (problem) fail(problem);
53676
- const pct2 = (v) => v == null ? void 0 : (num3(v, "percent") ?? 0) / 100;
53677
- const targets = values.p95 || values.p99 || values.errors || values.checks ? { p95Ms: num3(values.p95, "--p95"), p99Ms: num3(values.p99, "--p99"), errorRate: pct2(values.errors), checksRate: pct2(values.checks), abortOnFail: values.abort === true } : void 0;
53905
+ const pct3 = (v) => v == null ? void 0 : (num3(v, "percent") ?? 0) / 100;
53906
+ const targets = values.p95 || values.p99 || values.errors || values.checks ? { p95Ms: num3(values.p95, "--p95"), p99Ms: num3(values.p99, "--p99"), errorRate: pct3(values.errors), checksRate: pct3(values.checks), abortOnFail: values.abort === true } : void 0;
53678
53907
  const slo = targets?.p95Ms && targets.errorRate != null ? { p95Ms: targets.p95Ms, errorRate: targets.errorRate, checksRate: targets.checksRate } : null;
53908
+ let baseline = null;
53909
+ if (values.baseline) {
53910
+ try {
53911
+ baseline = parseRunReportFile(JSON.parse(await readFile6(values.baseline, "utf8")));
53912
+ } catch (err) {
53913
+ fail(`can't read the baseline "${values.baseline}": ${err instanceof Error ? err.message : String(err)}`);
53914
+ }
53915
+ }
53916
+ const maxSlower = (() => {
53917
+ const raw2 = values["max-slower"];
53918
+ if (raw2 == null) return 0.1;
53919
+ const n = Number(raw2.replace(/%$/, ""));
53920
+ if (!Number.isFinite(n) || n < 0) fail("--max-slower must be a percentage, like 10");
53921
+ return n / 100;
53922
+ })();
53679
53923
  const payload = buildPayload(draft, toLoadConfig(load), `${name} \xB7 CLI`, targets);
53680
53924
  const log = (line) => !values.quiet && console.log(line);
53681
53925
  if (draft.before) {
@@ -53731,18 +53975,32 @@ Environments (${index.environments.length})`);
53731
53975
  const verdict = buildVerdict(report, { slo });
53732
53976
  const missed = report.summary.thresholds.filter((t) => !t.ok);
53733
53977
  const s = report.summary;
53978
+ const compared = baseline ? compareRuns(baseline, report) : null;
53979
+ const gate = compared ? slowerThanAllowed(compared, maxSlower) : null;
53980
+ const failed = missed.length > 0 || verdict.overall === "fail" || gate?.failed === true;
53734
53981
  console.log(`
53735
- ${missed.length || verdict.overall === "fail" ? "FAIL" : "PASS"} (verdict: ${verdict.overall}) ${verdict.headline}`);
53982
+ ${failed ? "FAIL" : "PASS"} (verdict: ${verdict.overall}) ${verdict.headline}`);
53736
53983
  console.log(` requests ${s.http.requests} \xB7 ${s.http.rps.avg.toFixed(1)} req/s \xB7 p95 ${s.latencyMs.p95.toFixed(1)} ms \xB7 p99 ${s.latencyMs.p99.toFixed(1)} ms \xB7 errors ${(s.http.failed.rate * 100).toFixed(2)}%`);
53737
53984
  for (const t of s.thresholds) console.log(` ${t.ok ? "\u2713" : "\u2715"} ${t.name}${t.actual ? ` (measured ${t.actual})` : ""}`);
53738
53985
  for (const c of s.checks) console.log(` ${c.fails ? "\u2715" : "\u2713"} check ${c.name}: ${c.passes}/${c.passes + c.fails}`);
53739
53986
  if (report.endReason === "target-missed") console.log(" stopped early: a target was missed");
53987
+ if (compared && gate && baseline) {
53988
+ console.log(`
53989
+ compared with baseline \u201C${baseline.testName}\u201D (${baseline.startedAt.slice(0, 10)})`);
53990
+ console.log(` ${compared.headline}`);
53991
+ console.log(` ${gate.failed ? "\u2715" : "\u2713"} ${gate.reason}`);
53992
+ }
53740
53993
  if (values.json) await writeFile6(values.json, exportRunJson(report), "utf8");
53741
- if (values.html) await writeFile6(values.html, exportRunHtml(report, { slo }), "utf8");
53742
- if (values.junit) await writeFile6(values.junit, exportRunJunit(report, verdict, targets?.checksRate ?? 0.99), "utf8");
53994
+ if (values.html) await writeFile6(values.html, exportRunHtml(report, { slo, before: baseline }), "utf8");
53995
+ const extra = gate ? [{ classname: "baseline", name: `no slower than the baseline by more than ${Math.round(maxSlower * 100)}%`, failure: gate.failed ? gate.reason : void 0 }] : [];
53996
+ if (values.junit) await writeFile6(values.junit, exportRunJunit(report, verdict, targets?.checksRate ?? 0.99, extra), "utf8");
53997
+ if (values["save-baseline"]) {
53998
+ await writeFile6(values["save-baseline"], exportRunJson(report), "utf8");
53999
+ log(` saved this run as a baseline \u2192 ${values["save-baseline"]}`);
54000
+ }
53743
54001
  for (const [flag, file2] of [["json", values.json], ["html", values.html], ["junit", values.junit]]) if (file2) log(` wrote ${flag} \u2192 ${file2}`);
53744
54002
  if (history) log(` saved to History in ${dataDir}`);
53745
- return missed.length || verdict.overall === "fail" ? EXIT.failed : EXIT.passed;
54003
+ return failed ? EXIT.failed : EXIT.passed;
53746
54004
  }
53747
54005
  async function suite(collection, values, workspace, dataDir) {
53748
54006
  if (collection === void 0) fail('suite needs a collection name, or "" for requests outside any collection. Run "atc list".');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apitestcenter",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "APITestCenter on the command line: run API collections and load tests in CI, with JUnit reports and pass/fail exit codes.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,6 +16,10 @@
16
16
  "node": ">=20"
17
17
  },
18
18
  "homepage": "https://apitestcenter.com/docs/cli-and-ci.html",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/apitestcenter/app.git"
22
+ },
19
23
  "license": "SEE LICENSE IN LICENSE.txt",
20
24
  "author": "Ratul Roy",
21
25
  "keywords": [