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 +2 -0
- package/dist/THIRD-PARTY-NOTICES.txt +1 -1
- package/dist/cli.mjs +329 -71
- package/package.json +5 -1
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.
|
|
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(
|
|
50218
|
+
function latencyBin(ms3) {
|
|
50219
50219
|
for (let i = 0; i < LATENCY_BIN_EDGES_MS.length; i++) {
|
|
50220
|
-
if (
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
50760
|
-
function exportRunJunit(report, verdict,
|
|
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 <
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
50851
|
-
var seconds = (
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
51064
|
-
var
|
|
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
|
|
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:
|
|
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:
|
|
51210
|
+
return { text: ms2(s.latencyMs.p95), hint: `slowest 1% ${ms2(s.latencyMs.p99)}`, tone };
|
|
51084
51211
|
}
|
|
51085
51212
|
case "p50":
|
|
51086
|
-
return { text:
|
|
51213
|
+
return { text: ms2(s.latencyMs.median), hint: "half of requests were faster" };
|
|
51087
51214
|
case "avg":
|
|
51088
|
-
return { text:
|
|
51215
|
+
return { text: ms2(s.latencyMs.mean), hint: `typical spread \xB1${ms2(s.latencyMs.stdDev)}` };
|
|
51089
51216
|
case "p99":
|
|
51090
|
-
return { text:
|
|
51217
|
+
return { text: ms2(s.latencyMs.p99), hint: `slowest ${ms2(s.latencyMs.max)}` };
|
|
51091
51218
|
case "max":
|
|
51092
|
-
return { text:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
51226
|
-
if (
|
|
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(...
|
|
51229
|
-
const yt = niceMax(Math.max(...
|
|
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 =
|
|
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(
|
|
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) })),
|
|
51264
|
-
ref: o.showTarget !== false && slo?.p95Ms ? { value: slo.p95Ms, label: `goal ${
|
|
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) }],
|
|
51273
|
-
ref: o.showTarget !== false && slo ? { value: slo.errorRate, label: `goal ${
|
|
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
|
-
|
|
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) }],
|
|
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:
|
|
51311
|
-
{ label: PERCENTILE_LABEL.p50, value: l.median, color: C.pct.p50, text:
|
|
51312
|
-
{ label: PERCENTILE_LABEL.p90, value: l.p90, color: C.pct.p90, text:
|
|
51313
|
-
{ label: PERCENTILE_LABEL.p95, value: l.p95, color: C.pct.p95, text:
|
|
51314
|
-
{ label: PERCENTILE_LABEL.p99, value: l.p99, color: C.pct.p99, text:
|
|
51315
|
-
{ label: PERCENTILE_LABEL.max, value: l.max, color: C.ink, text:
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
51411
|
-
if (slo?.p95Ms) speed += p95 <= slo.p95Ms ? ` That is within your ${esc3(
|
|
51412
|
-
if (s.latencyMs.p99 > p95 * 3 && s.latencyMs.p99 > 100) speed += ` The slowest 1% (${esc3(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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}"
|
|
51517
|
-
}
|
|
51518
|
-
const
|
|
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:
|
|
51543
|
-
.
|
|
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:
|
|
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) { .
|
|
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(
|
|
52466
|
-
if (!Number.isFinite(
|
|
52467
|
-
const v = Math.max(
|
|
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 +=
|
|
52472
|
-
this.sumSq +=
|
|
52473
|
-
if (
|
|
52474
|
-
if (
|
|
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(
|
|
52786
|
-
if (
|
|
52787
|
-
return
|
|
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.
|
|
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
|
|
53677
|
-
const targets = values.p95 || values.p99 || values.errors || values.checks ? { p95Ms: num3(values.p95, "--p95"), p99Ms: num3(values.p99, "--p99"), errorRate:
|
|
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
|
-
${
|
|
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
|
-
|
|
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
|
|
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.
|
|
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": [
|