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 +2 -2
- package/src/cli.js +34 -0
- package/src/core/worker.js +29 -4
- package/src/metrics/apiMetrics.js +10 -0
- package/src/metrics/metricsCollector.js +12 -0
- package/src/reporting/htmlReport.js +1 -1
- package/src/reporting/reportWriter.js +20 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "express-api-stress-tester",
|
|
3
|
-
"version": "2.0.
|
|
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
|
|
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) {
|
package/src/core/worker.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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: ${
|
|
34
|
-
`Method: ${
|
|
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) || []),
|