@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.
Files changed (31) hide show
  1. package/bin/cli.js +4 -0
  2. package/out/main/chunks/{broker-client-Df4PpJqB.js → broker-client-BYpWeOWT.js} +4 -1
  3. package/out/main/chunks/{flow-engine-BVzjz4lJ.js → flow-engine-BxwjEy37.js} +66 -1
  4. package/out/main/chunks/{handle-Bz0C56Eq.js → handle-Crv1i9hb.js} +60 -0
  5. package/out/main/chunks/{import-BhGBiW7j.js → import-DTcZMRNu.js} +2 -2
  6. package/out/main/chunks/perf-report-ChaO3K0w.js +124 -0
  7. package/out/main/chunks/perf-run-uZBqnG3S.js +578 -0
  8. package/out/main/chunks/{request-collection-KGPuvN7o.js → request-collection-gkOYlWml.js} +68 -11
  9. package/out/main/chunks/{request-exec-B-F0K-pN.js → request-exec-Cv2jkrO1.js} +439 -12
  10. package/out/main/chunks/run-concurrent-468tH0AX.js +26 -0
  11. package/out/main/chunks/{soap-handler-DqIPcIQL.js → soap-handler-Brr7BPWO.js} +2 -2
  12. package/out/main/contract.js +6 -3
  13. package/out/main/flow.js +8 -5
  14. package/out/main/index.js +1338 -179
  15. package/out/main/lib.js +6 -3
  16. package/out/main/perf-worker.js +45 -0
  17. package/out/main/perf.js +253 -0
  18. package/out/main/runner.js +181 -7
  19. package/out/main/socketio.js +172 -0
  20. package/out/main/wsdl.js +3 -3
  21. package/out/preload/index.js +123 -0
  22. package/out/renderer/assets/{GraphQLEditor-BXpdmpn6.js → GraphQLEditor-Dv2K4wdd.js} +59 -14
  23. package/out/renderer/assets/{SoapEditor-De6WlP6E.js → SoapEditor-Cnz3M4Dz.js} +3 -3
  24. package/out/renderer/assets/ajv-CJPwFjiP.js +6718 -0
  25. package/out/renderer/assets/{codemirror-F6m3Hm1M.js → codemirror-CdIFBPs2.js} +1214 -1207
  26. package/out/renderer/assets/{index-Gcd_t6pX.js → index-CtCdFiRx.js} +15213 -12165
  27. package/out/renderer/assets/index-DkhdKzQJ.css +627 -0
  28. package/out/renderer/index.html +4 -4
  29. package/package.json +12 -6
  30. package/out/renderer/assets/ajv-2MAg6kGF.js +0 -6153
  31. 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-B-F0K-pN.js");
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-DqIPcIQL.js");
5
- require("./handle-Bz0C56Eq.js");
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
3
+ const ms = (v) => v < 1 ? "&lt;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)} &times;${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> &times;${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 ? ` &middot; ${esc(meta.target)}` : ""} &middot; ${esc(result.pattern)} / ${esc(result.executor)}${meta.generatedAt ? ` &middot; ${esc(meta.generatedAt)}` : ""}${result.stopped ? " &middot; 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 &middot; ${result.iterations} iterations over ${ms(result.durationMs)}</footer>
122
+ </div></body></html>`;
123
+ }
124
+ exports.perfReportToHtml = perfReportToHtml;