@testsmith/api-spector 0.7.7 → 0.7.8
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/bin/cli.js +4 -0
- package/out/main/chunks/{broker-client-Df4PpJqB.js → broker-client-BYpWeOWT.js} +4 -1
- package/out/main/chunks/{flow-engine-BVzjz4lJ.js → flow-engine-BxwjEy37.js} +66 -1
- package/out/main/chunks/{handle-Bz0C56Eq.js → handle-Crv1i9hb.js} +60 -0
- package/out/main/chunks/{import-BhGBiW7j.js → import-DTcZMRNu.js} +2 -2
- package/out/main/chunks/perf-report-ChaO3K0w.js +124 -0
- package/out/main/chunks/perf-run-uZBqnG3S.js +578 -0
- package/out/main/chunks/{request-collection-KGPuvN7o.js → request-collection-gkOYlWml.js} +68 -11
- package/out/main/chunks/{request-exec-B-F0K-pN.js → request-exec-Cv2jkrO1.js} +439 -12
- package/out/main/chunks/run-concurrent-468tH0AX.js +26 -0
- package/out/main/chunks/{soap-handler-DqIPcIQL.js → soap-handler-Brr7BPWO.js} +2 -2
- package/out/main/contract.js +6 -3
- package/out/main/flow.js +8 -5
- package/out/main/index.js +1338 -179
- package/out/main/lib.js +6 -3
- package/out/main/perf-worker.js +45 -0
- package/out/main/perf.js +253 -0
- package/out/main/runner.js +181 -7
- package/out/main/socketio.js +172 -0
- package/out/main/wsdl.js +3 -3
- package/out/preload/index.js +123 -0
- package/out/renderer/assets/{GraphQLEditor-BXpdmpn6.js → GraphQLEditor-Dv2K4wdd.js} +59 -14
- package/out/renderer/assets/{SoapEditor-De6WlP6E.js → SoapEditor-Cnz3M4Dz.js} +3 -3
- package/out/renderer/assets/ajv-CJPwFjiP.js +6718 -0
- package/out/renderer/assets/{codemirror-F6m3Hm1M.js → codemirror-CdIFBPs2.js} +1214 -1207
- package/out/renderer/assets/{index-Gcd_t6pX.js → index-CtCdFiRx.js} +15213 -12165
- package/out/renderer/assets/index-DkhdKzQJ.css +627 -0
- package/out/renderer/index.html +4 -4
- package/package.json +12 -6
- package/out/renderer/assets/ajv-2MAg6kGF.js +0 -6153
- package/out/renderer/assets/index-CYArpqm5.css +0 -627
package/bin/cli.js
CHANGED
|
@@ -26,7 +26,9 @@ const COMMANDS = {
|
|
|
26
26
|
coverage: { entrypoint: 'coverage.js',runner: 'node' },
|
|
27
27
|
'generate-tests': { entrypoint: 'generate-tests.js', runner: 'node' },
|
|
28
28
|
compare: { entrypoint: 'compare.js', runner: 'node' },
|
|
29
|
+
perf: { entrypoint: 'perf.js', runner: 'node' },
|
|
29
30
|
wsdl: { entrypoint: 'wsdl.js', runner: 'node' },
|
|
31
|
+
socketio: { entrypoint: 'socketio.js',runner: 'node' },
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
function printHelp() {
|
|
@@ -43,7 +45,9 @@ function printHelp() {
|
|
|
43
45
|
console.log(' api-spector coverage --spec <file> Measure OpenAPI test coverage')
|
|
44
46
|
console.log(' api-spector generate-tests --spec <file> Generate tests from an OpenAPI spec')
|
|
45
47
|
console.log(' api-spector compare <old> <new> Diff specs; find breaking changes + impact')
|
|
48
|
+
console.log(' api-spector perf list|run <plan> Load test a plan from CLI (CI-friendly)')
|
|
46
49
|
console.log(' api-spector wsdl describe|import-* Inspect a WSDL or import as collection/mock')
|
|
50
|
+
console.log(' api-spector socketio --url <url> Connect to Socket.IO, emit events, observe')
|
|
47
51
|
console.log('')
|
|
48
52
|
console.log(' Options:')
|
|
49
53
|
console.log(' api-spector agents init <name> Initialize AI agent files')
|
|
@@ -3,7 +3,7 @@ const promises = require("node:fs/promises");
|
|
|
3
3
|
const path = require("node:path");
|
|
4
4
|
const crypto = require("node:crypto");
|
|
5
5
|
const undici = require("undici");
|
|
6
|
-
const requestExec = require("./request-exec-
|
|
6
|
+
const requestExec = require("./request-exec-Cv2jkrO1.js");
|
|
7
7
|
const Ajv = require("ajv");
|
|
8
8
|
const jsYaml = require("js-yaml");
|
|
9
9
|
require("node:http");
|
|
@@ -425,6 +425,7 @@ function scalarFromName(name) {
|
|
|
425
425
|
return { kind: "scalar", type: "boolean" };
|
|
426
426
|
case "null":
|
|
427
427
|
return { kind: "scalar", type: "null" };
|
|
428
|
+
// string / str / text / any unknown type name → string
|
|
428
429
|
default:
|
|
429
430
|
return { kind: "scalar", type: "string" };
|
|
430
431
|
}
|
|
@@ -523,6 +524,8 @@ function compileShape(shape) {
|
|
|
523
524
|
case "null":
|
|
524
525
|
rules[path2] = { matchers: [{ match: "null" }] };
|
|
525
526
|
return null;
|
|
527
|
+
// string uses the generic `type` matcher: ruleToMatcher has no `string`
|
|
528
|
+
// case, so `match: "string"` would fall through to an exact match.
|
|
526
529
|
default:
|
|
527
530
|
rules[path2] = { matchers: [{ match: "type" }] };
|
|
528
531
|
return "string";
|
|
@@ -107,6 +107,15 @@ const BLOCK_SPECS = {
|
|
|
107
107
|
inputs: IN,
|
|
108
108
|
outputs: OUT
|
|
109
109
|
},
|
|
110
|
+
parallel: {
|
|
111
|
+
type: "parallel",
|
|
112
|
+
category: "logic",
|
|
113
|
+
label: "Parallel",
|
|
114
|
+
description: "Send the connected requests at the same time; continues on done once all finish. Set copies to hit one request several times at once.",
|
|
115
|
+
inputs: IN,
|
|
116
|
+
outputs: () => [{ id: "branch", label: "branch" }, { id: "done", label: "done" }],
|
|
117
|
+
defaultConfig: { copies: 1 }
|
|
118
|
+
},
|
|
110
119
|
// ── Looping ─────────────────────────────────────────────────────────────
|
|
111
120
|
forEach: {
|
|
112
121
|
type: "forEach",
|
|
@@ -157,7 +166,7 @@ const BLOCK_SPECS = {
|
|
|
157
166
|
};
|
|
158
167
|
[
|
|
159
168
|
{ category: "action", blocks: [BLOCK_SPECS.request, BLOCK_SPECS.subflow] },
|
|
160
|
-
{ category: "logic", blocks: [BLOCK_SPECS.if, BLOCK_SPECS.condition, BLOCK_SPECS.validate, BLOCK_SPECS.evaluate, BLOCK_SPECS.setVar, BLOCK_SPECS.delay, BLOCK_SPECS.merge] },
|
|
169
|
+
{ category: "logic", blocks: [BLOCK_SPECS.if, BLOCK_SPECS.condition, BLOCK_SPECS.validate, BLOCK_SPECS.evaluate, BLOCK_SPECS.setVar, BLOCK_SPECS.delay, BLOCK_SPECS.merge, BLOCK_SPECS.parallel] },
|
|
161
170
|
{ category: "looping", blocks: [BLOCK_SPECS.forEach, BLOCK_SPECS.repeat, BLOCK_SPECS.collect] },
|
|
162
171
|
{ category: "visualize", blocks: [BLOCK_SPECS.display, BLOCK_SPECS.log] }
|
|
163
172
|
];
|
|
@@ -425,6 +434,62 @@ async function runFlow(flow, deps, options = {}) {
|
|
|
425
434
|
});
|
|
426
435
|
return [ok ? "success" : "fail"];
|
|
427
436
|
}
|
|
437
|
+
case "parallel": {
|
|
438
|
+
const branchEdges = outEdges(block, "branch");
|
|
439
|
+
const targets = branchEdges.map((e) => nodeById.get(e.target)).filter((n) => !!n && n.type === "request" && !!n.config?.ref);
|
|
440
|
+
emitEdges(block.id, block.type, branchEdges.map((e) => e.id));
|
|
441
|
+
deps.onEvent?.({ kind: "block-start", blockId: block.id, blockType: block.type, status: "running", iteration });
|
|
442
|
+
const copies = Math.max(1, Math.min(50, Math.floor(cfg.copies ?? 1)));
|
|
443
|
+
const input = scriptInput();
|
|
444
|
+
const dispatches = [];
|
|
445
|
+
for (const node of targets) {
|
|
446
|
+
for (let i = 0; i < copies; i++) dispatches.push({ node, ref: node.config.ref });
|
|
447
|
+
}
|
|
448
|
+
for (const node of targets) {
|
|
449
|
+
deps.onEvent?.({ kind: "block-start", blockId: node.id, blockType: "request", status: "running", iteration });
|
|
450
|
+
}
|
|
451
|
+
const results = await Promise.all(dispatches.map(async ({ node, ref }) => {
|
|
452
|
+
const r = await deps.runRequest(ref, input);
|
|
453
|
+
if (!r) {
|
|
454
|
+
record(node, "error", { iteration, error: "Linked request not found" });
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
const reqStatus = r.status === "skipped" ? "done" : r.status === "pending" ? "running" : r.status;
|
|
458
|
+
const reqBody = clipBody(r.sentRequest?.body);
|
|
459
|
+
const resBody = clipBody(r.response?.body);
|
|
460
|
+
record(node, reqStatus, {
|
|
461
|
+
iteration,
|
|
462
|
+
httpStatus: r.httpStatus,
|
|
463
|
+
durationMs: r.durationMs,
|
|
464
|
+
error: r.error,
|
|
465
|
+
testResults: r.testResults,
|
|
466
|
+
method: r.method,
|
|
467
|
+
resolvedUrl: r.resolvedUrl,
|
|
468
|
+
http: {
|
|
469
|
+
reqHeaders: r.sentRequest?.headers,
|
|
470
|
+
reqBody: reqBody.text,
|
|
471
|
+
statusText: r.response?.statusText,
|
|
472
|
+
resHeaders: r.response?.headers,
|
|
473
|
+
resBody: resBody.text,
|
|
474
|
+
bodySize: r.response?.bodySize,
|
|
475
|
+
truncated: reqBody.clipped || resBody.clipped
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
return r;
|
|
479
|
+
}));
|
|
480
|
+
for (const r of results) {
|
|
481
|
+
if (!r) continue;
|
|
482
|
+
ctx.envVars = { ...ctx.envVars, ...r.envVars };
|
|
483
|
+
ctx.collectionVars = { ...ctx.collectionVars, ...r.collectionVars };
|
|
484
|
+
ctx.globals = { ...ctx.globals, ...r.globals };
|
|
485
|
+
ctx.localVars = { ...ctx.localVars, ...r.localVars };
|
|
486
|
+
}
|
|
487
|
+
const last = [...results].reverse().find((r) => r?.response);
|
|
488
|
+
if (last?.response) ctx.response = last.response;
|
|
489
|
+
const anyFailed = results.some((r) => r && r.status !== "passed" && r.status !== "skipped");
|
|
490
|
+
record(block, anyFailed ? "failed" : "done", { iteration });
|
|
491
|
+
return ["done"];
|
|
492
|
+
}
|
|
428
493
|
case "subflow": {
|
|
429
494
|
const sub = cfg.flowId ? deps.resolveFlow(cfg.flowId) : null;
|
|
430
495
|
if (!sub) {
|
|
@@ -80,6 +80,8 @@ const IPC = {
|
|
|
80
80
|
insomnia: "import:insomnia",
|
|
81
81
|
bruno: "import:bruno",
|
|
82
82
|
http: "import:http",
|
|
83
|
+
har: "import:har",
|
|
84
|
+
arazzo: "import:arazzo",
|
|
83
85
|
spector: "import:spector",
|
|
84
86
|
openapiSchemas: "import:openapi-schemas",
|
|
85
87
|
openapiSchemasUrl: "import:openapi-schemas-url"
|
|
@@ -114,11 +116,69 @@ const IPC = {
|
|
|
114
116
|
connect: "ws:connect",
|
|
115
117
|
send: "ws:send",
|
|
116
118
|
disconnect: "ws:disconnect",
|
|
119
|
+
/** Run a WebSocket request as a test: connect, optionally send, collect for
|
|
120
|
+
* a window, then assert over the received frames. */
|
|
121
|
+
test: "ws:test",
|
|
117
122
|
/** Event: inbound/outbound WS message. */
|
|
118
123
|
message: "ws:message",
|
|
119
124
|
/** Event: connection status change. */
|
|
120
125
|
status: "ws:status"
|
|
121
126
|
},
|
|
127
|
+
// ─── jq (JSON response filtering) ───────────────────────────────────────────
|
|
128
|
+
jq: {
|
|
129
|
+
filter: "jq:filter"
|
|
130
|
+
},
|
|
131
|
+
// ─── GraphQL subscriptions (graphql-ws) ─────────────────────────────────────
|
|
132
|
+
graphqlws: {
|
|
133
|
+
subscribe: "graphqlws:subscribe",
|
|
134
|
+
stop: "graphqlws:stop",
|
|
135
|
+
/** Run a GraphQL subscription as a test: subscribe, collect the emitted
|
|
136
|
+
* payloads for a window, then assert over them. */
|
|
137
|
+
test: "graphqlws:test",
|
|
138
|
+
/** Event: a next / error / complete / system message. */
|
|
139
|
+
message: "graphqlws:message",
|
|
140
|
+
/** Event: connection status change. */
|
|
141
|
+
status: "graphqlws:status"
|
|
142
|
+
},
|
|
143
|
+
// ─── Socket.IO ─────────────────────────────────────────────────────────────
|
|
144
|
+
socketio: {
|
|
145
|
+
connect: "socketio:connect",
|
|
146
|
+
emit: "socketio:emit",
|
|
147
|
+
disconnect: "socketio:disconnect",
|
|
148
|
+
/** Run a Socket.IO request as a test: connect, optionally emit, collect the
|
|
149
|
+
* received events for a window, then assert over them. */
|
|
150
|
+
test: "socketio:test",
|
|
151
|
+
/** Event: inbound/outbound Socket.IO event, ack, or system note. */
|
|
152
|
+
message: "socketio:message",
|
|
153
|
+
/** Event: connection status change. */
|
|
154
|
+
status: "socketio:status"
|
|
155
|
+
},
|
|
156
|
+
// ─── MQTT ──────────────────────────────────────────────────────────────────
|
|
157
|
+
mqtt: {
|
|
158
|
+
connect: "mqtt:connect",
|
|
159
|
+
publish: "mqtt:publish",
|
|
160
|
+
subscribe: "mqtt:subscribe",
|
|
161
|
+
unsubscribe: "mqtt:unsubscribe",
|
|
162
|
+
disconnect: "mqtt:disconnect",
|
|
163
|
+
/** Run an MQTT request as a test: connect, subscribe, optionally publish,
|
|
164
|
+
* collect for a window, then assert over the messages. */
|
|
165
|
+
test: "mqtt:test",
|
|
166
|
+
/** Event: a published / received message, or a system note. */
|
|
167
|
+
message: "mqtt:message",
|
|
168
|
+
/** Event: connection status change. */
|
|
169
|
+
status: "mqtt:status"
|
|
170
|
+
},
|
|
171
|
+
// ─── Performance (load) testing ─────────────────────────────────────────────
|
|
172
|
+
perf: {
|
|
173
|
+
/** Run a load test for a request; resolves with the aggregate result. */
|
|
174
|
+
run: "perf:run",
|
|
175
|
+
/** Abort the active run early. */
|
|
176
|
+
stop: "perf:stop",
|
|
177
|
+
/** Export a finished run as a self-contained HTML report (save dialog). */
|
|
178
|
+
exportHtml: "perf:exportHtml",
|
|
179
|
+
/** Event: live progress while a run is in flight. */
|
|
180
|
+
progress: "perf:progress"
|
|
181
|
+
},
|
|
122
182
|
// ─── SOAP / WSDL ───────────────────────────────────────────────────────────
|
|
123
183
|
wsdl: {
|
|
124
184
|
fetch: "wsdl:fetch",
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
3
|
const uuid = require("uuid");
|
|
4
|
-
const soapHandler = require("./soap-handler-
|
|
5
|
-
require("./handle-
|
|
4
|
+
const soapHandler = require("./soap-handler-Brr7BPWO.js");
|
|
5
|
+
require("./handle-Crv1i9hb.js");
|
|
6
6
|
require("node:https");
|
|
7
7
|
require("node:http");
|
|
8
8
|
require("@xmldom/xmldom");
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
3
|
+
const ms = (v) => v < 1 ? "<1 ms" : v < 1e3 ? `${Math.round(v)} ms` : `${(v / 1e3).toFixed(2)} s`;
|
|
4
|
+
const bytes = (v) => v < 1024 ? `${v} B` : v < 1024 * 1024 ? `${(v / 1024).toFixed(1)} KB` : `${(v / (1024 * 1024)).toFixed(2)} MB`;
|
|
5
|
+
const pct = (v) => `${v.toFixed(1)}%`;
|
|
6
|
+
const OP = { lt: "<", lte: "≤", gt: ">", gte: "≥" };
|
|
7
|
+
const METRIC = { iteration_p95: "Iteration p95", iteration_p99: "Iteration p99", req_p95: "Request p95", error_rate: "Error rate", throughput: "Throughput" };
|
|
8
|
+
function seriesSvg(series) {
|
|
9
|
+
if (series.length === 0) return "";
|
|
10
|
+
const W = 1e3, H = 200, pad = 28;
|
|
11
|
+
const maxRps = Math.max(1, ...series.map((s) => s.rps));
|
|
12
|
+
const maxP95 = Math.max(1, ...series.map((s) => s.p95));
|
|
13
|
+
const bw = (W - pad * 2) / series.length;
|
|
14
|
+
const bars = series.map((s, i) => {
|
|
15
|
+
const h = s.rps / maxRps * (H - pad * 2);
|
|
16
|
+
return `<rect x="${(pad + i * bw + bw * 0.1).toFixed(1)}" y="${(H - pad - h).toFixed(1)}" width="${(bw * 0.8).toFixed(1)}" height="${h.toFixed(1)}" fill="#f59e0b" opacity="0.55"/>`;
|
|
17
|
+
}).join("");
|
|
18
|
+
const line = series.map((s, i) => `${(pad + i * bw + bw * 0.5).toFixed(1)},${(H - pad - s.p95 / maxP95 * (H - pad * 2)).toFixed(1)}`).join(" ");
|
|
19
|
+
return `<svg viewBox="0 0 ${W} ${H}" width="100%" preserveAspectRatio="none" style="height:220px">
|
|
20
|
+
<line x1="${pad}" y1="${H - pad}" x2="${W - pad}" y2="${H - pad}" stroke="#334" stroke-width="1"/>
|
|
21
|
+
${bars}
|
|
22
|
+
<polyline points="${line}" fill="none" stroke="#e879f9" stroke-width="2"/>
|
|
23
|
+
</svg>
|
|
24
|
+
<div class="muted small">bars: iterations/sec (peak ${Math.round(maxRps)}) · line: p95 latency (peak ${ms(maxP95)}) · ${series.length}s</div>`;
|
|
25
|
+
}
|
|
26
|
+
function perfReportToHtml(result, meta = {}) {
|
|
27
|
+
const errorRate = result.iterations > 0 ? result.iterationsFailed / result.iterations * 100 : 0;
|
|
28
|
+
const pass = result.passed;
|
|
29
|
+
const kpi = (label, value, sub = "") => `<div class="kpi"><div class="kpi-l">${esc(label)}</div><div class="kpi-v">${value}</div>${sub ? `<div class="kpi-s">${sub}</div>` : ""}</div>`;
|
|
30
|
+
const thresholdsHtml = result.thresholds.length ? `
|
|
31
|
+
<h2>Thresholds</h2>
|
|
32
|
+
<table><thead><tr><th>Metric</th><th>Condition</th><th>Actual</th><th>Result</th></tr></thead><tbody>
|
|
33
|
+
${result.thresholds.map((t) => `<tr>
|
|
34
|
+
<td>${esc(METRIC[t.metric] ?? t.metric)}</td>
|
|
35
|
+
<td>${OP[t.op] ?? t.op} ${t.value}</td>
|
|
36
|
+
<td class="num">${t.metric === "error_rate" ? pct(t.actual) : t.metric === "throughput" ? t.actual.toFixed(1) : ms(t.actual)}</td>
|
|
37
|
+
<td><span class="badge ${t.passed ? "ok" : "bad"}">${t.passed ? "PASS" : "FAIL"}</span></td>
|
|
38
|
+
</tr>`).join("")}
|
|
39
|
+
</tbody></table>` : "";
|
|
40
|
+
const stepsHtml = `
|
|
41
|
+
<h2>${result.mode === "flow" ? "Steps (per request in the flow)" : result.mode === "sequence" ? "Steps (per request in the sequence)" : "Request"}</h2>
|
|
42
|
+
<table><thead><tr><th>Step</th><th class="num">Count</th><th class="num">p95</th><th class="num">Mean</th><th class="num">Max</th><th class="num">Fail</th></tr></thead><tbody>
|
|
43
|
+
${result.steps.map((s, i) => `<tr>
|
|
44
|
+
<td>${result.mode !== "request" ? `${i + 1}. ` : ""}${esc(s.name)}</td>
|
|
45
|
+
<td class="num">${s.count}</td><td class="num">${ms(s.latency.p95)}</td><td class="num">${ms(s.latency.mean)}</td><td class="num">${ms(s.latency.max)}</td>
|
|
46
|
+
<td class="num ${s.failed > 0 ? "red" : ""}">${s.failed}</td>
|
|
47
|
+
</tr>`).join("")}
|
|
48
|
+
</tbody></table>`;
|
|
49
|
+
const latRows = [["min", result.requestLatency.min], ["mean", result.requestLatency.mean], ["p50", result.requestLatency.p50], ["p90", result.requestLatency.p90], ["p95", result.requestLatency.p95], ["p99", result.requestLatency.p99], ["max", result.requestLatency.max]].map(([k, v]) => `<tr><td>${k}</td><td class="num">${ms(v)}</td></tr>`).join("");
|
|
50
|
+
const statusHtml = Object.entries(result.statusCounts).sort((a, b) => b[1] - a[1]).map(([c, n]) => `<span class="chip">${c === "error" ? "error" : esc(c)} ×${n}</span>`).join(" ");
|
|
51
|
+
const errorsHtml = Object.keys(result.errors).length ? `
|
|
52
|
+
<h2>Errors</h2><ul class="errs">${Object.entries(result.errors).sort((a, b) => b[1] - a[1]).map(([m, n]) => `<li><code>${esc(m)}</code> ×${n}</li>`).join("")}</ul>` : "";
|
|
53
|
+
return `<!doctype html>
|
|
54
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
55
|
+
<title>Performance report — ${esc(meta.planName ?? "API Spector")}</title>
|
|
56
|
+
<style>
|
|
57
|
+
:root { color-scheme: dark; }
|
|
58
|
+
* { box-sizing: border-box; }
|
|
59
|
+
body { margin: 0; background: #0b1622; color: #dfe7ef; font: 14px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
|
|
60
|
+
.wrap { max-width: 1040px; margin: 0 auto; padding: 32px 20px 64px; }
|
|
61
|
+
header { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; border-bottom: 1px solid #1e2d3e; padding-bottom: 16px; margin-bottom: 24px; }
|
|
62
|
+
h1 { font-size: 20px; margin: 0; }
|
|
63
|
+
h2 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.08em; color: #7f93ab; margin: 28px 0 10px; }
|
|
64
|
+
.sub { color: #7f93ab; font-size: 12px; }
|
|
65
|
+
.badge { font-weight: 700; font-size: 12px; padding: 3px 10px; border-radius: 6px; }
|
|
66
|
+
.badge.ok { background: #0f3d2a; color: #34d399; } .badge.bad { background: #44181b; color: #f87171; }
|
|
67
|
+
.kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
|
|
68
|
+
.kpi { background: #111f2e; border: 1px solid #1e2d3e; border-radius: 8px; padding: 12px; }
|
|
69
|
+
.kpi-l { font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; color: #7f93ab; }
|
|
70
|
+
.kpi-v { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
71
|
+
.kpi-s { font-size: 11px; color: #7f93ab; }
|
|
72
|
+
.accent .kpi-v { color: #fbbf24; }
|
|
73
|
+
table { width: 100%; border-collapse: collapse; background: #111f2e; border: 1px solid #1e2d3e; border-radius: 8px; overflow: hidden; }
|
|
74
|
+
th, td { text-align: left; padding: 7px 12px; border-bottom: 1px solid #1a2838; font-variant-numeric: tabular-nums; }
|
|
75
|
+
th { font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; color: #7f93ab; }
|
|
76
|
+
td.num, th.num { text-align: right; } td.red { color: #f87171; }
|
|
77
|
+
.chart { background: #0d1a28; border: 1px solid #1e2d3e; border-radius: 8px; padding: 12px; }
|
|
78
|
+
.chip { display: inline-block; border: 1px solid #2a3b4f; border-radius: 6px; padding: 2px 8px; font-size: 12px; margin: 0 4px 4px 0; font-variant-numeric: tabular-nums; }
|
|
79
|
+
.muted { color: #7f93ab; } .small { font-size: 11px; margin-top: 8px; }
|
|
80
|
+
code { background: #0d1a28; padding: 1px 5px; border-radius: 4px; color: #f9a8a8; font-size: 12px; }
|
|
81
|
+
.errs { margin: 0; padding-left: 18px; } .errs li { margin: 3px 0; }
|
|
82
|
+
footer { margin-top: 40px; color: #55697f; font-size: 11px; }
|
|
83
|
+
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; align-items: start; }
|
|
84
|
+
@media (max-width: 720px) { .kpis { grid-template-columns: repeat(2, 1fr); } .cols { grid-template-columns: 1fr; } }
|
|
85
|
+
</style></head>
|
|
86
|
+
<body><div class="wrap">
|
|
87
|
+
<header>
|
|
88
|
+
<h1>Performance report</h1>
|
|
89
|
+
${result.thresholds.length ? `<span class="badge ${pass ? "ok" : "bad"}">${pass ? "PASSED" : "FAILED"}</span>` : ""}
|
|
90
|
+
<span class="sub">${esc(meta.planName ?? "")}${meta.target ? ` · ${esc(meta.target)}` : ""} · ${esc(result.pattern)} / ${esc(result.executor)}${meta.generatedAt ? ` · ${esc(meta.generatedAt)}` : ""}${result.stopped ? " · stopped early" : ""}</span>
|
|
91
|
+
</header>
|
|
92
|
+
|
|
93
|
+
<div class="kpis">
|
|
94
|
+
${kpi("Throughput", `${result.iterationsPerSec.toFixed(1)}`, `iter/s · ${result.requestsPerSec.toFixed(1)} req/s`)}
|
|
95
|
+
${kpi("Iteration p95", ms(result.iterationLatency.p95), `p99 ${ms(result.iterationLatency.p99)}`)}
|
|
96
|
+
${kpi("Error rate", pct(errorRate), `${result.iterationsFailed} / ${result.iterations} iterations`)}
|
|
97
|
+
${kpi("Peak users", String(result.peakVus), result.droppedArrivals > 0 ? `${result.droppedArrivals} dropped` : ms(result.durationMs))}
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
${thresholdsHtml}
|
|
101
|
+
|
|
102
|
+
<h2>Over time (per second)</h2>
|
|
103
|
+
<div class="chart">${seriesSvg(result.series)}</div>
|
|
104
|
+
|
|
105
|
+
${stepsHtml}
|
|
106
|
+
|
|
107
|
+
<div class="cols">
|
|
108
|
+
<div>
|
|
109
|
+
<h2>Request latency (all steps)</h2>
|
|
110
|
+
<table><tbody>${latRows}
|
|
111
|
+
<tr><td>data read</td><td class="num">${bytes(result.bytes)} (${bytes(result.bytesPerSec)}/s)</td></tr>
|
|
112
|
+
</tbody></table>
|
|
113
|
+
</div>
|
|
114
|
+
<div>
|
|
115
|
+
<h2>Status codes</h2>
|
|
116
|
+
<div>${statusHtml || '<span class="muted">none</span>'}</div>
|
|
117
|
+
${errorsHtml}
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<footer>Generated by API Spector · ${result.iterations} iterations over ${ms(result.durationMs)}</footer>
|
|
122
|
+
</div></body></html>`;
|
|
123
|
+
}
|
|
124
|
+
exports.perfReportToHtml = perfReportToHtml;
|