express-api-stress-tester 2.0.2 → 2.0.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "express-api-stress-tester",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
4
4
  "description": "High-performance distributed API stress testing platform for Express.js APIs — simulate up to 10M concurrent virtual users",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -11,7 +11,7 @@
11
11
  "express-api-stress-tester": "src/cli.js"
12
12
  },
13
13
  "scripts": {
14
- "test": "node --experimental-vm-modules node_modules/.bin/jest --forceExit",
14
+ "test": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --forceExit",
15
15
  "stress": "node src/cli.js"
16
16
  },
17
17
  "keywords": [
package/src/cli.js CHANGED
@@ -48,6 +48,40 @@ function printSummary(summary) {
48
48
  console.log(` Result: ${resultColor(summary.result)}`);
49
49
  console.log(chalk.bold.cyan('═══════════════════════════════════════════'));
50
50
  console.log('');
51
+
52
+ if (summary.statusCodes && typeof summary.statusCodes === 'object') {
53
+ const codes = Object.entries(summary.statusCodes)
54
+ .filter(([, c]) => Number(c) > 0)
55
+ .sort((a, b) => Number(b[1]) - Number(a[1]))
56
+ .map(([code, count]) => `${code}:${count}`)
57
+ .join(' ');
58
+ if (codes) {
59
+ console.log(chalk.bold('Status Codes:'));
60
+ console.log(` ${codes}`);
61
+ console.log('');
62
+ }
63
+ }
64
+
65
+ if (summary.perEndpoint && typeof summary.perEndpoint === 'object' && Object.keys(summary.perEndpoint).length > 0) {
66
+ console.log(chalk.bold('Per-Endpoint Metrics:'));
67
+ const entries = Object.entries(summary.perEndpoint).sort(
68
+ (a, b) => (b[1].requestsPerSec || 0) - (a[1].requestsPerSec || 0),
69
+ );
70
+ for (const [endpoint, m] of entries) {
71
+ const codes = m.statusCodes && typeof m.statusCodes === 'object'
72
+ ? Object.entries(m.statusCodes)
73
+ .filter(([, c]) => Number(c) > 0)
74
+ .sort((x, y) => Number(y[1]) - Number(x[1]))
75
+ .slice(0, 6)
76
+ .map(([code, count]) => `${code}:${count}`)
77
+ .join(' ')
78
+ : '';
79
+ console.log(
80
+ ` ${endpoint} | rps=${m.requestsPerSec ?? 0} avg=${m.avgResponseTime ?? 0}ms p95=${m.p95 ?? 0}ms err=${m.errorRate ?? 0}%${codes ? ` codes=${codes}` : ''}`,
81
+ );
82
+ }
83
+ console.log('');
84
+ }
51
85
  }
52
86
 
53
87
  async function runCommand(configPath, opts) {
@@ -94,6 +94,24 @@ let maxLatency = -Infinity;
94
94
  const statusCodes = {};
95
95
  const perEndpoint = {};
96
96
 
97
+ function normalizeBaseForRelativeUrlResolution(base, maybeRelativePath) {
98
+ if (!base || !maybeRelativePath) return base;
99
+ if (maybeRelativePath.startsWith('http://') || maybeRelativePath.startsWith('https://')) return base;
100
+ if (maybeRelativePath.startsWith('/')) return base;
101
+
102
+ try {
103
+ const parsedBase = new URL(base);
104
+ if (parsedBase.pathname && !parsedBase.pathname.endsWith('/')) {
105
+ parsedBase.pathname = `${parsedBase.pathname}/`;
106
+ return parsedBase.toString();
107
+ }
108
+ } catch {
109
+ // ignore
110
+ }
111
+
112
+ return base;
113
+ }
114
+
97
115
  /**
98
116
  * Resolve the full URL for a route.
99
117
  */
@@ -102,7 +120,8 @@ function resolveUrl(route) {
102
120
  const path = route.path || route.url || '';
103
121
  try {
104
122
  if (path) {
105
- return new URL(path, base).toString();
123
+ const normalizedBase = normalizeBaseForRelativeUrlResolution(base, path);
124
+ return new URL(path, normalizedBase).toString();
106
125
  }
107
126
  if (base) {
108
127
  return new URL(base).toString();
@@ -159,12 +178,13 @@ function getEndpointMetrics(endpoint) {
159
178
  maxLatency: -Infinity,
160
179
  responseTimes: [],
161
180
  sampleCount: 0,
181
+ statusCodes: {},
162
182
  };
163
183
  }
164
184
  return perEndpoint[endpoint];
165
185
  }
166
186
 
167
- function recordEndpoint(endpoint, elapsedMs, isError) {
187
+ function recordEndpoint(endpoint, elapsedMs, isError, status) {
168
188
  const metrics = getEndpointMetrics(endpoint);
169
189
  metrics.totalRequests++;
170
190
  metrics.totalResponseTime += elapsedMs;
@@ -175,6 +195,9 @@ function recordEndpoint(endpoint, elapsedMs, isError) {
175
195
  } else {
176
196
  metrics.successCount++;
177
197
  }
198
+ if (status) {
199
+ metrics.statusCodes[status] = (metrics.statusCodes[status] || 0) + 1;
200
+ }
178
201
  metrics.sampleCount++;
179
202
  if (metrics.responseTimes.length < MAX_SAMPLE_SIZE) {
180
203
  metrics.responseTimes.push(elapsedMs);
@@ -203,7 +226,7 @@ function recordRequestMetrics({ endpointKey, elapsedMs, isError, status }) {
203
226
  successCount++;
204
227
  }
205
228
 
206
- recordEndpoint(endpointKey, elapsedMs, isError);
229
+ recordEndpoint(endpointKey, elapsedMs, isError, status);
207
230
  }
208
231
 
209
232
  async function applyHeaderPlugins(headers) {
@@ -322,7 +345,9 @@ async function executeRequest(task) {
322
345
  parsed = new URL(targetUrl);
323
346
  } catch {
324
347
  try {
325
- parsed = new URL(targetUrl, config.baseUrl || config.url);
348
+ const base = config.baseUrl || config.url;
349
+ const normalizedBase = normalizeBaseForRelativeUrlResolution(base, targetUrl);
350
+ parsed = new URL(targetUrl, normalizedBase);
326
351
  } catch (err) {
327
352
  throw new Error(
328
353
  `Failed to resolve URL "${targetUrl}" with base "${config.baseUrl || config.url}": ${err.message}`,
@@ -13,6 +13,7 @@ export class ApiMetrics {
13
13
  this.maxLatency = -Infinity;
14
14
  this.responseTimes = [];
15
15
  this._sampleCount = 0;
16
+ this.statusCodes = {};
16
17
  }
17
18
 
18
19
  record(responseTimeMs, isError) {
@@ -55,6 +56,14 @@ export class ApiMetrics {
55
56
  if (partial.responseTimes) {
56
57
  this.mergeResponseTimes(partial.responseTimes);
57
58
  }
59
+
60
+ if (partial.statusCodes && typeof partial.statusCodes === 'object') {
61
+ for (const [code, count] of Object.entries(partial.statusCodes)) {
62
+ const n = Number(count) || 0;
63
+ if (!n) continue;
64
+ this.statusCodes[code] = (this.statusCodes[code] || 0) + n;
65
+ }
66
+ }
58
67
  }
59
68
 
60
69
  mergeResponseTimes(times) {
@@ -111,6 +120,7 @@ export class ApiMetrics {
111
120
  maxLatency: maxLat,
112
121
  errorRate: parseFloat(errorRate),
113
122
  successRate: parseFloat(successRate),
123
+ statusCodes: { ...this.statusCodes },
114
124
  };
115
125
  }
116
126
  }
@@ -21,6 +21,9 @@ export class MetricsCollector {
21
21
  this.responseTimes = [];
22
22
  this._sampleCount = 0;
23
23
  this.perEndpoint = new Map();
24
+
25
+ // Best-effort aggregate status codes across all requests
26
+ this.statusCodes = {};
24
27
  }
25
28
 
26
29
  start() {
@@ -73,6 +76,14 @@ export class MetricsCollector {
73
76
  this.mergeResponseTimes(partial.responseTimes);
74
77
  }
75
78
 
79
+ if (partial.statusCodes && typeof partial.statusCodes === 'object') {
80
+ for (const [code, count] of Object.entries(partial.statusCodes)) {
81
+ const n = Number(count) || 0;
82
+ if (!n) continue;
83
+ this.statusCodes[code] = (this.statusCodes[code] || 0) + n;
84
+ }
85
+ }
86
+
76
87
  if (partial.perEndpoint) {
77
88
  for (const [endpoint, metrics] of Object.entries(partial.perEndpoint)) {
78
89
  this._getEndpointMetrics(endpoint).merge(metrics);
@@ -186,6 +197,7 @@ export class MetricsCollector {
186
197
  result: passed ? 'PASSED' : 'FAILED',
187
198
  elapsedSeconds: elapsed.toFixed(1),
188
199
  perEndpoint: this._buildEndpointSummaries(elapsed),
200
+ statusCodes: { ...this.statusCodes },
189
201
  };
190
202
  }
191
203
 
@@ -32,7 +32,7 @@ export function generateHtmlReport(config, summary) {
32
32
  ];
33
33
 
34
34
  const configRows = [
35
- ['API URL', config.url || 'N/A'],
35
+ ['API URL', config.url || config.baseUrl || 'N/A'],
36
36
  ['Method', (config.method || 'GET').toUpperCase()],
37
37
  ['Concurrent Users', config.concurrency || 1],
38
38
  ...(config.duration ? [['Duration', `${config.duration}s`]] : []),
@@ -26,12 +26,29 @@ export function writeReport(config, summary, reportPath) {
26
26
 
27
27
  function buildTxtReport(config, summary) {
28
28
  const divider = '='.repeat(50);
29
+ const apiUrl = config.url || config.baseUrl || 'N/A';
30
+ const method = (() => {
31
+ if (config.method) return String(config.method).toUpperCase();
32
+ if (Array.isArray(config.routes) && config.routes.length === 1 && config.routes[0]?.method) {
33
+ return String(config.routes[0].method).toUpperCase();
34
+ }
35
+ if (Array.isArray(config.routes) && config.routes.length > 1) return 'MULTI';
36
+ return 'GET';
37
+ })();
38
+ const statusCodesLine = summary.statusCodes && typeof summary.statusCodes === 'object'
39
+ ? Object.entries(summary.statusCodes)
40
+ .filter(([, c]) => Number(c) > 0)
41
+ .sort((a, b) => Number(b[1]) - Number(a[1]))
42
+ .slice(0, 8)
43
+ .map(([code, c]) => `${code}:${c}`)
44
+ .join(' ')
45
+ : '';
29
46
  const lines = [
30
47
  divider,
31
48
  ` API Stress Test Report`,
32
49
  divider,
33
- `API URL: ${config.url}`,
34
- `Method: ${(config.method || 'GET').toUpperCase()}`,
50
+ `API URL: ${apiUrl}`,
51
+ `Method: ${method}`,
35
52
  `Concurrent Users: ${config.concurrency || 1}`,
36
53
  `Duration (s): ${summary.elapsedSeconds}`,
37
54
  `Total Requests: ${summary.totalRequests}`,
@@ -45,6 +62,7 @@ function buildTxtReport(config, summary) {
45
62
  `Success Rate: ${summary.successRate}%`,
46
63
  `CPU Usage: ${summary.cpuPercent}%`,
47
64
  `Memory Usage: ${summary.memoryMB}MB`,
65
+ ...(statusCodesLine ? [`Status Codes: ${statusCodesLine}`] : []),
48
66
  `Result: ${summary.result}`,
49
67
  divider,
50
68
  ...(buildEndpointLines(summary.perEndpoint) || []),