loadtest 7.0.0 → 7.1.1
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/.github/FUNDING.yml +13 -0
- package/README.md +20 -22
- package/lib/baseClient.js +27 -27
- package/lib/httpClient.js +146 -158
- package/lib/latency.js +19 -35
- package/lib/loadtest.js +11 -8
- package/lib/testserver.js +3 -2
- package/lib/websocket.js +8 -8
- package/package.json +3 -2
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# These are supported funding model platforms
|
|
2
|
+
|
|
3
|
+
github: alexfernandez
|
|
4
|
+
patreon: # Replace with a single Patreon username
|
|
5
|
+
open_collective: # Replace with a single Open Collective username
|
|
6
|
+
ko_fi: # Replace with a single Ko-fi username
|
|
7
|
+
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
|
8
|
+
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
|
9
|
+
liberapay: # Replace with a single Liberapay username
|
|
10
|
+
issuehunt: # Replace with a single IssueHunt username
|
|
11
|
+
otechie: # Replace with a single Otechie username
|
|
12
|
+
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
|
13
|
+
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
package/README.md
CHANGED
|
@@ -28,8 +28,9 @@ Versions 6 and later should be used at least with Node.js v16 or later:
|
|
|
28
28
|
|
|
29
29
|
* Node.js v16 or later: ^6.0.0
|
|
30
30
|
* Node.js v10 or later: ^5.0.0
|
|
31
|
-
* Node.js v8 or later: 4.x.y
|
|
32
|
-
* Node.js v6 or earlier: ^3.1.0
|
|
31
|
+
* Node.js v8 or later: 4.x.y
|
|
32
|
+
* Node.js v6 or earlier: ^3.1.0
|
|
33
|
+
* ES5 support (no `let`, `const` or arrow functions): ^2.0.0.
|
|
33
34
|
|
|
34
35
|
## Usage
|
|
35
36
|
|
|
@@ -84,14 +85,18 @@ so that you can abort deployment e.g. if 99% of the requests don't finish in 10
|
|
|
84
85
|
but it is still limited.
|
|
85
86
|
`loadtest` saturates a single CPU pretty quickly,
|
|
86
87
|
so it uses half the available cores in your processor.
|
|
87
|
-
|
|
88
|
-
which happens approx. when your load is above 4000~
|
|
89
|
-
please adjust the number of cores.
|
|
88
|
+
The Node.js processes can reach 100% usage in `top`,
|
|
89
|
+
which happens approx. when your load is above 4000~7000 rps per core.
|
|
90
|
+
In this case please adjust the number of cores.
|
|
90
91
|
So for instance with eight cores you can expect to get a maximum performance of
|
|
91
|
-
8 * 5000
|
|
92
|
-
|
|
92
|
+
8 * 5000 = 40 krps.
|
|
93
|
+
|
|
94
|
+
You can measure the practical limits of `loadtest` on your specific test machines by running it against a simple
|
|
93
95
|
[test server](#test-server)
|
|
94
|
-
and seeing when it reaches 100% CPU.
|
|
96
|
+
and seeing when it reaches 100% CPU. Run the following commands on two different consoles:
|
|
97
|
+
|
|
98
|
+
$ node bin/testserver.js
|
|
99
|
+
$ node bin/loadtest.js -n 1000000 -c 100 http://localhost:7357/
|
|
95
100
|
|
|
96
101
|
If you have reached the limits of `loadtest` even after using all cores,
|
|
97
102
|
there are other tools that you can try.
|
|
@@ -99,7 +104,7 @@ there are other tools that you can try.
|
|
|
99
104
|
* [AutoCannon](https://www.npmjs.com/package/autocannon): also an `npm` package,
|
|
100
105
|
awesome tool with an interface similar to `wrk`.
|
|
101
106
|
* [Apache `ab`](http://httpd.apache.org/docs/2.2/programs/ab.html)
|
|
102
|
-
has great performance, but it is
|
|
107
|
+
has great performance, but it is limited by a single CPU performance.
|
|
103
108
|
Its practical limit is somewhere around ~40 krps.
|
|
104
109
|
* [weighttp](http://redmine.lighttpd.net/projects/weighttp/wiki) is also `ab`-compatible
|
|
105
110
|
and is supposed to be very fast (the author has not personally used it).
|
|
@@ -557,15 +562,14 @@ const server = await startServer({port: 8000})
|
|
|
557
562
|
await server.close()
|
|
558
563
|
```
|
|
559
564
|
|
|
560
|
-
The following options are available
|
|
565
|
+
The following options are available,
|
|
566
|
+
see [doc/api.md](doc/api.md) for details.
|
|
567
|
+
|
|
561
568
|
* `port`: optional port to use for the server, default 7357.
|
|
562
569
|
* `delay`: milliseconds to wait before answering each request.
|
|
563
570
|
* `error`: HTTP status code to return, default 200 (no error).
|
|
564
571
|
* `percent`: return error only for the given % of requests.
|
|
565
|
-
* `logger(request, response)
|
|
566
|
-
|
|
567
|
-
A function to be called as `logger(request, response)` after every request served by the test server.
|
|
568
|
-
Where `request` and `response` are the usual HTTP objects.
|
|
572
|
+
* `logger(request, response)`: function to call after every request.
|
|
569
573
|
|
|
570
574
|
### Configuration file
|
|
571
575
|
|
|
@@ -611,14 +615,8 @@ For more information about the actual configuration file name, read the [confino
|
|
|
611
615
|
|
|
612
616
|
### Complete Example
|
|
613
617
|
|
|
614
|
-
The file `test/integration.js`
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
## Versioning
|
|
618
|
-
|
|
619
|
-
Version 3.x uses ES2015 (ES6) features,
|
|
620
|
-
such as `const` or `let` and arrow functions.
|
|
621
|
-
For ES5 support please use versions 2.x.
|
|
618
|
+
The file `test/integration.js` contains complete examples, which are also a full integration test suite:
|
|
619
|
+
they start the server with different options, send requests, waits for finalization and close down the server.
|
|
622
620
|
|
|
623
621
|
## Licensed under The MIT License
|
|
624
622
|
|
package/lib/baseClient.js
CHANGED
|
@@ -3,9 +3,9 @@ import {addUserAgent} from './headers.js'
|
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
export class BaseClient {
|
|
6
|
-
constructor(
|
|
7
|
-
this.
|
|
8
|
-
this.
|
|
6
|
+
constructor(loadTest, options) {
|
|
7
|
+
this.loadTest = loadTest;
|
|
8
|
+
this.options = options;
|
|
9
9
|
this.generateMessage = undefined;
|
|
10
10
|
}
|
|
11
11
|
|
|
@@ -22,44 +22,44 @@ export class BaseClient {
|
|
|
22
22
|
errorCode = '-1';
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
|
-
this.
|
|
25
|
+
this.loadTest.latency.end(id, errorCode);
|
|
26
26
|
let callback;
|
|
27
|
-
if (!this.
|
|
27
|
+
if (!this.options.requestsPerSecond) {
|
|
28
28
|
callback = () => this.makeRequest();
|
|
29
29
|
}
|
|
30
|
-
this.
|
|
30
|
+
this.loadTest.finishRequest(error, result, callback);
|
|
31
31
|
};
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
|
-
* Init
|
|
35
|
+
* Init params and message to send.
|
|
36
36
|
*/
|
|
37
37
|
init() {
|
|
38
|
-
this.
|
|
39
|
-
this.
|
|
40
|
-
if (this.
|
|
41
|
-
this.
|
|
38
|
+
this.params = urlLib.parse(this.options.url);
|
|
39
|
+
this.params.headers = {};
|
|
40
|
+
if (this.options.headers) {
|
|
41
|
+
this.params.headers = this.options.headers;
|
|
42
42
|
}
|
|
43
|
-
if (this.
|
|
44
|
-
this.
|
|
45
|
-
this.
|
|
43
|
+
if (this.options.cert && this.options.key) {
|
|
44
|
+
this.params.cert = this.options.cert;
|
|
45
|
+
this.params.key = this.options.key;
|
|
46
46
|
}
|
|
47
|
-
this.
|
|
48
|
-
if (this.
|
|
49
|
-
if (typeof this.
|
|
50
|
-
this.generateMessage = () => this.
|
|
51
|
-
} else if (typeof this.
|
|
52
|
-
this.generateMessage = () => this.
|
|
53
|
-
} else if (typeof this.
|
|
54
|
-
this.generateMessage = this.
|
|
47
|
+
this.params.agent = false;
|
|
48
|
+
if (this.options.body) {
|
|
49
|
+
if (typeof this.options.body == 'string') {
|
|
50
|
+
this.generateMessage = () => this.options.body;
|
|
51
|
+
} else if (typeof this.options.body == 'object') {
|
|
52
|
+
this.generateMessage = () => this.options.body;
|
|
53
|
+
} else if (typeof this.options.body == 'function') {
|
|
54
|
+
this.generateMessage = this.options.body;
|
|
55
55
|
} else {
|
|
56
|
-
console.error('Unrecognized body: %s', typeof this.
|
|
56
|
+
console.error('Unrecognized body: %s', typeof this.options.body);
|
|
57
57
|
}
|
|
58
|
-
this.
|
|
58
|
+
this.params.headers['Content-Type'] = this.options.contentType || 'text/plain';
|
|
59
59
|
}
|
|
60
|
-
addUserAgent(this.
|
|
61
|
-
if (this.
|
|
62
|
-
this.
|
|
60
|
+
addUserAgent(this.params.headers);
|
|
61
|
+
if (this.options.secureProtocol) {
|
|
62
|
+
this.params.secureProtocol = this.options.secureProtocol;
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
}
|
package/lib/httpClient.js
CHANGED
|
@@ -15,94 +15,94 @@ let uniqueIndex = 1
|
|
|
15
15
|
* Create a new HTTP client.
|
|
16
16
|
* Seem parameters below.
|
|
17
17
|
*/
|
|
18
|
-
export function create(
|
|
19
|
-
return new HttpClient(
|
|
18
|
+
export function create(loadTest, options) {
|
|
19
|
+
return new HttpClient(loadTest, options);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
* A client for an HTTP connection.
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
23
|
+
* A client for an HTTP connection. Constructor:
|
|
24
|
+
* - `loadTest`: an object with the following attributes:
|
|
25
|
+
* - latency: a variable to measure latency.
|
|
26
|
+
* - running: if the loadTest is running or not.
|
|
27
|
+
* - `options`: same options as exports.loadTest.
|
|
28
28
|
*/
|
|
29
29
|
class HttpClient {
|
|
30
|
-
constructor(
|
|
31
|
-
this.
|
|
32
|
-
this.
|
|
30
|
+
constructor(loadTest, options) {
|
|
31
|
+
this.loadTest = loadTest
|
|
32
|
+
this.options = options
|
|
33
33
|
this.stopped = false
|
|
34
34
|
this.init();
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
|
-
* Init
|
|
38
|
+
* Init and message to send.
|
|
39
39
|
*/
|
|
40
40
|
init() {
|
|
41
|
-
this.
|
|
42
|
-
this.
|
|
43
|
-
if (this.
|
|
44
|
-
this.
|
|
45
|
-
this.
|
|
41
|
+
this.params = urlLib.parse(this.options.url);
|
|
42
|
+
this.params.headers = this.options.headers || {}
|
|
43
|
+
if (this.options.cert && this.options.key) {
|
|
44
|
+
this.params.cert = this.options.cert;
|
|
45
|
+
this.params.key = this.options.key;
|
|
46
46
|
}
|
|
47
|
-
this.
|
|
48
|
-
if (this.
|
|
47
|
+
this.params.agent = false;
|
|
48
|
+
if (this.options.requestsPerSecond) {
|
|
49
49
|
// rps for each client is total / concurrency (# of clients)
|
|
50
|
-
this.
|
|
50
|
+
this.params.requestsPerSecond = this.options.requestsPerSecond / this.options.concurrency
|
|
51
51
|
}
|
|
52
|
-
if (this.
|
|
53
|
-
const KeepAlive = (this.
|
|
52
|
+
if (this.options.agentKeepAlive) {
|
|
53
|
+
const KeepAlive = (this.params.protocol == 'https:') ? agentkeepalive.HttpsAgent : agentkeepalive.default;
|
|
54
54
|
let maxSockets = 10;
|
|
55
|
-
if (this.
|
|
56
|
-
maxSockets += Math.floor(this.
|
|
55
|
+
if (this.params.requestsPerSecond) {
|
|
56
|
+
maxSockets += Math.floor(this.params.requestsPerSecond);
|
|
57
57
|
}
|
|
58
|
-
this.
|
|
58
|
+
this.params.agent = new KeepAlive({
|
|
59
59
|
maxSockets: maxSockets,
|
|
60
60
|
maxKeepAliveRequests: 0, // max requests per keepalive socket, default is 0, no limit
|
|
61
61
|
maxKeepAliveTime: 30000 // keepalive for 30 seconds
|
|
62
62
|
});
|
|
63
63
|
}
|
|
64
|
-
if (this.
|
|
65
|
-
this.
|
|
64
|
+
if (this.options.method) {
|
|
65
|
+
this.params.method = this.options.method;
|
|
66
66
|
}
|
|
67
|
-
if (this.
|
|
68
|
-
if (typeof this.
|
|
69
|
-
this.generateMessage = () => this.
|
|
70
|
-
} else if (typeof this.
|
|
71
|
-
if (this.
|
|
72
|
-
this.
|
|
67
|
+
if (this.options.body) {
|
|
68
|
+
if (typeof this.options.body == 'string') {
|
|
69
|
+
this.generateMessage = () => this.options.body;
|
|
70
|
+
} else if (typeof this.options.body == 'object') {
|
|
71
|
+
if (this.options.contentType === 'application/x-www-form-urlencoded') {
|
|
72
|
+
this.options.body = querystring.stringify(this.options.body);
|
|
73
73
|
}
|
|
74
|
-
this.generateMessage = () => this.
|
|
75
|
-
} else if (typeof this.
|
|
76
|
-
this.generateMessage = this.
|
|
74
|
+
this.generateMessage = () => this.options.body;
|
|
75
|
+
} else if (typeof this.options.body == 'function') {
|
|
76
|
+
this.generateMessage = this.options.body;
|
|
77
77
|
} else {
|
|
78
|
-
throw new Error(`Unrecognized body: ${typeof this.
|
|
78
|
+
throw new Error(`Unrecognized body: ${typeof this.options.body}`);
|
|
79
79
|
}
|
|
80
|
-
this.
|
|
80
|
+
this.params.headers['Content-Type'] = this.options.contentType || 'text/plain';
|
|
81
81
|
}
|
|
82
|
-
if (this.
|
|
83
|
-
if (Array.isArray(this.
|
|
84
|
-
this.
|
|
85
|
-
} else if (typeof this.
|
|
86
|
-
this.
|
|
82
|
+
if (this.options.cookies) {
|
|
83
|
+
if (Array.isArray(this.options.cookies)) {
|
|
84
|
+
this.params.headers.Cookie = this.options.cookies.join('; ');
|
|
85
|
+
} else if (typeof this.options.cookies == 'string') {
|
|
86
|
+
this.params.headers.Cookie = this.options.cookies;
|
|
87
87
|
} else {
|
|
88
|
-
throw new Error(`Invalid cookies ${JSON.stringify(this.
|
|
88
|
+
throw new Error(`Invalid cookies ${JSON.stringify(this.options.cookies)}, please use an array or a string`);
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
|
-
addUserAgent(this.
|
|
92
|
-
if (this.
|
|
93
|
-
this.
|
|
91
|
+
addUserAgent(this.params.headers);
|
|
92
|
+
if (this.options.secureProtocol) {
|
|
93
|
+
this.params.secureProtocol = this.options.secureProtocol;
|
|
94
94
|
}
|
|
95
95
|
// adding proxy configuration
|
|
96
|
-
if (this.
|
|
97
|
-
const proxy = this.
|
|
96
|
+
if (this.options.proxy) {
|
|
97
|
+
const proxy = this.options.proxy;
|
|
98
98
|
const agent = new HttpsProxyAgent(proxy);
|
|
99
|
-
this.
|
|
99
|
+
this.params.agent = agent;
|
|
100
100
|
}
|
|
101
|
-
if (this.
|
|
102
|
-
this.
|
|
101
|
+
if (this.options.indexParam) {
|
|
102
|
+
this.params.indexParamFinder = new RegExp(this.options.indexParam, 'g');
|
|
103
103
|
}
|
|
104
104
|
// Disable certificate checking
|
|
105
|
-
if (this.
|
|
105
|
+
if (this.options.insecure === true) {
|
|
106
106
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
|
107
107
|
}
|
|
108
108
|
}
|
|
@@ -116,10 +116,10 @@ class HttpClient {
|
|
|
116
116
|
// so sometimes they are stopped before they have even started
|
|
117
117
|
return
|
|
118
118
|
}
|
|
119
|
-
if (!this.
|
|
119
|
+
if (!this.params.requestsPerSecond) {
|
|
120
120
|
return this.makeRequest();
|
|
121
121
|
}
|
|
122
|
-
const interval = 1000 / this.
|
|
122
|
+
const interval = 1000 / this.params.requestsPerSecond;
|
|
123
123
|
this.requestTimer = new HighResolutionTimer(interval, () => this.makeRequest());
|
|
124
124
|
}
|
|
125
125
|
|
|
@@ -137,159 +137,147 @@ class HttpClient {
|
|
|
137
137
|
* Make a single request to the server.
|
|
138
138
|
*/
|
|
139
139
|
makeRequest() {
|
|
140
|
-
if (!this.
|
|
140
|
+
if (!this.loadTest.running) {
|
|
141
141
|
return;
|
|
142
142
|
}
|
|
143
|
-
if (this.
|
|
144
|
-
this.
|
|
143
|
+
if (this.loadTest.options.maxRequests && this.loadTest.requests >= this.loadTest.options.maxRequests) return
|
|
144
|
+
this.loadTest.requests += 1;
|
|
145
145
|
|
|
146
|
-
const id = this.
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
this.
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const timeout = parseInt(this.params.timeout);
|
|
146
|
+
const id = this.loadTest.latency.start();
|
|
147
|
+
const params = {...this.params, headers: {...this.params.headers}}
|
|
148
|
+
this.customizeIndex(params)
|
|
149
|
+
const request = this.getRequest(id, params)
|
|
150
|
+
if (this.options.timeout) {
|
|
151
|
+
const timeout = parseInt(this.options.timeout);
|
|
153
152
|
if (!timeout) {
|
|
154
|
-
console.error(
|
|
153
|
+
console.error(`Invalid timeout ${this.options.timeout}`);
|
|
155
154
|
}
|
|
156
155
|
request.setTimeout(timeout, () => {
|
|
157
|
-
|
|
156
|
+
this.finishRequest(id, 'Connection timed out');
|
|
158
157
|
});
|
|
159
158
|
}
|
|
160
|
-
const message = this.getMessage(id,
|
|
159
|
+
const message = this.getMessage(id, params)
|
|
161
160
|
if (message) {
|
|
162
161
|
request.write(message);
|
|
163
|
-
|
|
162
|
+
params.headers['Content-Length'] = Buffer.byteLength(message);
|
|
164
163
|
}
|
|
165
164
|
request.on('error', error => {
|
|
166
|
-
|
|
165
|
+
this.finishRequest(id, `Connection error: ${error.message}`);
|
|
167
166
|
});
|
|
168
167
|
request.end();
|
|
169
168
|
}
|
|
170
169
|
|
|
171
|
-
customizeIndex(
|
|
172
|
-
if (!this.
|
|
170
|
+
customizeIndex(params) {
|
|
171
|
+
if (!this.params.indexParamFinder) {
|
|
173
172
|
return
|
|
174
173
|
}
|
|
175
|
-
|
|
176
|
-
|
|
174
|
+
params.customIndex = this.getCustomIndex()
|
|
175
|
+
params.path = this.params.path.replace(this.params.indexParamFinder, params.customIndex);
|
|
177
176
|
}
|
|
178
177
|
|
|
179
178
|
getCustomIndex() {
|
|
180
|
-
if (this.
|
|
181
|
-
return this.
|
|
179
|
+
if (this.params.indexParamCallback instanceof Function) {
|
|
180
|
+
return this.params.indexParamCallback();
|
|
182
181
|
}
|
|
183
182
|
const customIndex = uniqueIndex
|
|
184
183
|
uniqueIndex += 1
|
|
185
184
|
return customIndex
|
|
186
185
|
}
|
|
187
186
|
|
|
188
|
-
|
|
189
|
-
if (this.options.protocol == 'https:') {
|
|
190
|
-
return https;
|
|
191
|
-
}
|
|
192
|
-
if (this.options.protocol == 'ws:') {
|
|
193
|
-
return websocket;
|
|
194
|
-
}
|
|
195
|
-
return http;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
getMessage(id, options) {
|
|
187
|
+
getMessage(id, params) {
|
|
199
188
|
if (!this.generateMessage) {
|
|
200
189
|
return
|
|
201
190
|
}
|
|
202
191
|
const candidate = this.generateMessage(id);
|
|
203
192
|
const message = typeof candidate === 'object' ? JSON.stringify(candidate) : candidate
|
|
204
|
-
if (this.
|
|
205
|
-
return message.replace(this.
|
|
193
|
+
if (this.params.indexParamFinder) {
|
|
194
|
+
return message.replace(this.params.indexParamFinder, params.customIndex);
|
|
206
195
|
}
|
|
207
196
|
return message
|
|
208
197
|
}
|
|
209
198
|
|
|
210
|
-
getRequest(id,
|
|
199
|
+
getRequest(id, params) {
|
|
211
200
|
const lib = this.getLib()
|
|
212
|
-
if (typeof this.
|
|
213
|
-
|
|
214
|
-
return this.params.requestGenerator(this.params, options, lib.request, connect);
|
|
201
|
+
if (typeof this.options.requestGenerator == 'function') {
|
|
202
|
+
return this.options.requestGenerator(this.options, params, lib.request, connection => this.connect(connection, id))
|
|
215
203
|
}
|
|
216
|
-
return lib.request(
|
|
204
|
+
return lib.request(params, connection => this.connect(connection, id))
|
|
217
205
|
}
|
|
218
206
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
207
|
+
getLib() {
|
|
208
|
+
if (this.params.protocol == 'https:') {
|
|
209
|
+
return https;
|
|
210
|
+
}
|
|
211
|
+
if (this.params.protocol == 'ws:') {
|
|
212
|
+
return websocket;
|
|
213
|
+
}
|
|
214
|
+
return http;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
finishRequest(id, error, result) {
|
|
218
|
+
let errorCode = null;
|
|
219
|
+
if (error) {
|
|
220
|
+
if (result) {
|
|
221
|
+
errorCode = result.statusCode;
|
|
222
|
+
if (result.customErrorCode !== undefined) {
|
|
223
|
+
errorCode = errorCode + ":" + result.customErrorCode
|
|
233
224
|
}
|
|
225
|
+
} else {
|
|
226
|
+
errorCode = '-1';
|
|
234
227
|
}
|
|
228
|
+
}
|
|
229
|
+
const elapsed = this.loadTest.latency.end(id, errorCode);
|
|
230
|
+
if (elapsed < 0) {
|
|
231
|
+
// not found or not running
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (result) {
|
|
235
|
+
result.requestElapsed = elapsed;
|
|
236
|
+
}
|
|
237
|
+
this.loadTest.finishRequest(error, result, () => this.makeRequest());
|
|
238
|
+
}
|
|
235
239
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
+
connect(connection, id) {
|
|
241
|
+
const bodyBuffers = []
|
|
242
|
+
connection.on('data', chunk => {
|
|
243
|
+
bodyBuffers.push(chunk)
|
|
244
|
+
});
|
|
245
|
+
connection.on('error', error => {
|
|
246
|
+
this.finishRequest(id, `Connection ${id} failed: ${error}`);
|
|
247
|
+
});
|
|
248
|
+
connection.on('end', () => {
|
|
249
|
+
const body = Buffer.concat(bodyBuffers).toString()
|
|
250
|
+
const result = this.createResult(connection, body)
|
|
251
|
+
if (this.options.contentInspector) {
|
|
252
|
+
this.options.contentInspector(result)
|
|
240
253
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
result.requestElapsed = elapsed;
|
|
244
|
-
result.requestIndex = index;
|
|
245
|
-
result.instanceIndex = this.operation.instanceIndex;
|
|
254
|
+
if (connection.statusCode >= 400) {
|
|
255
|
+
return this.finishRequest(id, `Status code ${connection.statusCode}`, result);
|
|
246
256
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
callback = this.makeRequest.bind(this);
|
|
257
|
+
if (result?.customError) {
|
|
258
|
+
return this.finishRequest(id, `Custom error: ${result.customError}`, result);
|
|
250
259
|
}
|
|
251
|
-
this.
|
|
252
|
-
};
|
|
260
|
+
this.finishRequest(id, null, result);
|
|
261
|
+
});
|
|
253
262
|
}
|
|
254
263
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
connection.
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
});
|
|
268
|
-
connection.on('end', () => {
|
|
269
|
-
const client = connection.connection || connection.client
|
|
270
|
-
const result = {
|
|
271
|
-
host: client._host,
|
|
272
|
-
path: connection.req.path,
|
|
273
|
-
method: connection.req.method,
|
|
274
|
-
statusCode: connection.statusCode,
|
|
275
|
-
body: body,
|
|
276
|
-
headers: connection.headers,
|
|
277
|
-
};
|
|
278
|
-
if (connection.req.labels) {
|
|
279
|
-
result.labels = connection.req.labels
|
|
280
|
-
}
|
|
281
|
-
if (contentInspector) {
|
|
282
|
-
contentInspector(result)
|
|
283
|
-
}
|
|
284
|
-
if (connection.statusCode >= 400) {
|
|
285
|
-
return callback('Status code ' + connection.statusCode, result);
|
|
286
|
-
}
|
|
287
|
-
if (result.customError) {
|
|
288
|
-
return callback('Custom error: ' + result.customError, result);
|
|
289
|
-
}
|
|
290
|
-
callback(null, result);
|
|
291
|
-
});
|
|
264
|
+
createResult(connection, body) {
|
|
265
|
+
if (!this.options.statusCallback && !this.options.contentInspector) {
|
|
266
|
+
return null
|
|
267
|
+
}
|
|
268
|
+
const client = connection.connection || connection.client
|
|
269
|
+
const result = {
|
|
270
|
+
host: client._host,
|
|
271
|
+
path: connection.req.path,
|
|
272
|
+
method: connection.req.method,
|
|
273
|
+
statusCode: connection.statusCode,
|
|
274
|
+
body,
|
|
275
|
+
headers: connection.headers,
|
|
292
276
|
};
|
|
277
|
+
if (connection.req.labels) {
|
|
278
|
+
result.labels = connection.req.labels
|
|
279
|
+
}
|
|
280
|
+
return result
|
|
293
281
|
}
|
|
294
282
|
}
|
|
295
283
|
|
package/lib/latency.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {randomUUID} from 'crypto'
|
|
2
2
|
import {Result} from './result.js'
|
|
3
3
|
|
|
4
4
|
|
|
@@ -13,7 +13,7 @@ export class Latency {
|
|
|
13
13
|
constructor(options, callback) {
|
|
14
14
|
this.options = options
|
|
15
15
|
this.callback = callback
|
|
16
|
-
this.requests =
|
|
16
|
+
this.requests = new Map();
|
|
17
17
|
this.partialRequests = 0;
|
|
18
18
|
this.partialTime = 0;
|
|
19
19
|
this.partialErrors = 0;
|
|
@@ -28,25 +28,14 @@ export class Latency {
|
|
|
28
28
|
this.errorCodes = {};
|
|
29
29
|
this.running = true;
|
|
30
30
|
this.totalsShown = false;
|
|
31
|
-
this.requestIndex = 0;
|
|
32
|
-
this.requestIdToIndex = {};
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Return the index of the request. This is useful for determining the order
|
|
37
|
-
* in which requests returned values.
|
|
38
|
-
*/
|
|
39
|
-
getRequestIndex(requestId) {
|
|
40
|
-
return this.requestIdToIndex[requestId];
|
|
41
31
|
}
|
|
42
32
|
|
|
43
33
|
/**
|
|
44
34
|
* Start the request with the given id.
|
|
45
35
|
*/
|
|
46
36
|
start(requestId) {
|
|
47
|
-
requestId = requestId ||
|
|
48
|
-
this.requests
|
|
49
|
-
this.requestIdToIndex[requestId] = this.requestIndex++;
|
|
37
|
+
requestId = requestId || randomUUID();
|
|
38
|
+
this.requests.set(requestId, this.getTimeNs())
|
|
50
39
|
return requestId;
|
|
51
40
|
}
|
|
52
41
|
|
|
@@ -55,15 +44,15 @@ export class Latency {
|
|
|
55
44
|
* Accepts an optional error code signaling an error.
|
|
56
45
|
*/
|
|
57
46
|
end(requestId, errorCode) {
|
|
58
|
-
if (!
|
|
47
|
+
if (!this.requests.has(requestId)) {
|
|
59
48
|
return -2;
|
|
60
49
|
}
|
|
61
50
|
if (!this.running) {
|
|
62
51
|
return -1;
|
|
63
52
|
}
|
|
64
|
-
const elapsed = this.getElapsedMs(this.requests
|
|
53
|
+
const elapsed = this.getElapsedMs(this.requests.get(requestId));
|
|
65
54
|
this.add(elapsed, errorCode);
|
|
66
|
-
|
|
55
|
+
this.requests.delete(requestId);
|
|
67
56
|
return elapsed;
|
|
68
57
|
}
|
|
69
58
|
|
|
@@ -77,7 +66,7 @@ export class Latency {
|
|
|
77
66
|
this.totalTime += time;
|
|
78
67
|
this.totalRequests++;
|
|
79
68
|
if (errorCode) {
|
|
80
|
-
errorCode =
|
|
69
|
+
errorCode = String(errorCode);
|
|
81
70
|
this.partialErrors++;
|
|
82
71
|
this.totalErrors++;
|
|
83
72
|
if (!(errorCode in this.errorCodes)) {
|
|
@@ -111,15 +100,12 @@ export class Latency {
|
|
|
111
100
|
meanLatencyMs: Math.round(meanTime * 10) / 10,
|
|
112
101
|
rps: Math.round(this.partialRequests / elapsedSeconds)
|
|
113
102
|
};
|
|
114
|
-
|
|
115
|
-
if (this.options.maxRequests) {
|
|
116
|
-
percent = ' (' + Math.round(100 * this.totalRequests / this.options.maxRequests) + '%)';
|
|
117
|
-
}
|
|
103
|
+
const requestPercent = this.getRequestPercent()
|
|
118
104
|
if (!this.options.quiet) {
|
|
119
|
-
console.info(
|
|
105
|
+
console.info(`Requests: ${this.totalRequests}${requestPercent}, requests per second: ${result.rps}, mean latency: ${result.meanLatencyMs} ms`)
|
|
120
106
|
if (this.totalErrors) {
|
|
121
|
-
|
|
122
|
-
console.info(
|
|
107
|
+
const errorPercent = Math.round(100 * 10 * this.totalErrors / this.totalRequests) / 10;
|
|
108
|
+
console.info(`Errors: ${this.partialErrors}, accumulated errors: ${this.totalErrors}, ${errorPercent}% of total requests`)
|
|
123
109
|
}
|
|
124
110
|
}
|
|
125
111
|
this.partialTime = 0;
|
|
@@ -128,6 +114,13 @@ export class Latency {
|
|
|
128
114
|
this.lastShownNs = this.getTimeNs();
|
|
129
115
|
}
|
|
130
116
|
|
|
117
|
+
getRequestPercent() {
|
|
118
|
+
if (!this.options.maxRequests) {
|
|
119
|
+
return ''
|
|
120
|
+
}
|
|
121
|
+
return ` (${Math.round(100 * this.totalRequests / this.options.maxRequests)}%)`
|
|
122
|
+
}
|
|
123
|
+
|
|
131
124
|
/**
|
|
132
125
|
* Returns the current high-resolution real time in nanoseconds as a big int.
|
|
133
126
|
* @return {*}
|
|
@@ -194,12 +187,3 @@ export class Latency {
|
|
|
194
187
|
}
|
|
195
188
|
}
|
|
196
189
|
|
|
197
|
-
/**
|
|
198
|
-
* Create a unique, random token.
|
|
199
|
-
*/
|
|
200
|
-
function createId() {
|
|
201
|
-
const value = '' + Date.now() + Math.random();
|
|
202
|
-
const hash = crypto.createHash('sha256');
|
|
203
|
-
return hash.update(value).digest('hex').toLowerCase();
|
|
204
|
-
}
|
|
205
|
-
|
package/lib/loadtest.js
CHANGED
|
@@ -47,12 +47,12 @@ export function loadTest(options, callback) {
|
|
|
47
47
|
|
|
48
48
|
async function loadTestAsync(options) {
|
|
49
49
|
const processed = await processOptions(options)
|
|
50
|
-
return await
|
|
50
|
+
return await runLoadTest(processed)
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
function
|
|
53
|
+
function runLoadTest(options) {
|
|
54
54
|
return new Promise((resolve, reject) => {
|
|
55
|
-
const operation = new
|
|
55
|
+
const operation = new LoadTest(options, (error, result) => {
|
|
56
56
|
if (error) {
|
|
57
57
|
return reject(error)
|
|
58
58
|
}
|
|
@@ -63,14 +63,14 @@ function runOperation(options) {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
|
-
* Used to keep track of individual load test
|
|
66
|
+
* Used to keep track of individual load test runs.
|
|
67
67
|
*/
|
|
68
68
|
let operationInstanceIndex = 0;
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
71
|
* A load test operation.
|
|
72
72
|
*/
|
|
73
|
-
class
|
|
73
|
+
class LoadTest {
|
|
74
74
|
constructor(options, callback) {
|
|
75
75
|
this.options = options;
|
|
76
76
|
this.finalCallback = callback;
|
|
@@ -82,6 +82,7 @@ class Operation {
|
|
|
82
82
|
this.showTimer = null;
|
|
83
83
|
this.stopTimeout = null;
|
|
84
84
|
this.instanceIndex = operationInstanceIndex++;
|
|
85
|
+
this.requestIndex = 0
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
/**
|
|
@@ -98,19 +99,21 @@ class Operation {
|
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
/**
|
|
101
|
-
* Call after each
|
|
102
|
+
* Call after each request has finished.
|
|
102
103
|
*/
|
|
103
|
-
|
|
104
|
+
finishRequest(error, result, next) {
|
|
104
105
|
this.completedRequests += 1;
|
|
105
106
|
if (this.options.maxRequests) {
|
|
106
107
|
if (this.completedRequests == this.options.maxRequests) {
|
|
107
108
|
this.stop();
|
|
108
109
|
}
|
|
109
110
|
}
|
|
110
|
-
if (this.running &&
|
|
111
|
+
if (this.running && !this.options.requestsPerSecond) {
|
|
111
112
|
next();
|
|
112
113
|
}
|
|
113
114
|
if (this.options.statusCallback) {
|
|
115
|
+
result.requestIndex = this.requestIndex++
|
|
116
|
+
result.instanceIndex = this.instanceIndex
|
|
114
117
|
this.options.statusCallback(error, result);
|
|
115
118
|
}
|
|
116
119
|
}
|
package/lib/testserver.js
CHANGED
|
@@ -103,9 +103,9 @@ class TestServer {
|
|
|
103
103
|
*/
|
|
104
104
|
listen(request, response) {
|
|
105
105
|
const id = this.latency.start();
|
|
106
|
-
|
|
106
|
+
const bodyBuffers = []
|
|
107
107
|
request.on('data', data => {
|
|
108
|
-
|
|
108
|
+
bodyBuffers.push(data)
|
|
109
109
|
});
|
|
110
110
|
request.on('error', () => {
|
|
111
111
|
// ignore request
|
|
@@ -113,6 +113,7 @@ class TestServer {
|
|
|
113
113
|
this.latency.end(id, -1);
|
|
114
114
|
})
|
|
115
115
|
request.on('end', () => {
|
|
116
|
+
request.body = Buffer.concat(bodyBuffers).toString();
|
|
116
117
|
this.totalRequests += 1
|
|
117
118
|
const elapsedMs = Date.now() - this.debuggedTime
|
|
118
119
|
if (elapsedMs > LOG_HEADERS_INTERVAL_MS) {
|
package/lib/websocket.js
CHANGED
|
@@ -7,16 +7,16 @@ let latency;
|
|
|
7
7
|
/**
|
|
8
8
|
* Create a client for a websocket.
|
|
9
9
|
*/
|
|
10
|
-
export function create(
|
|
11
|
-
return new WebsocketClient(
|
|
10
|
+
export function create(loadTest, options) {
|
|
11
|
+
return new WebsocketClient(loadTest, options);
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* A client that connects to a websocket.
|
|
16
16
|
*/
|
|
17
17
|
class WebsocketClient extends BaseClient {
|
|
18
|
-
constructor(
|
|
19
|
-
super(
|
|
18
|
+
constructor(loadTest, options) {
|
|
19
|
+
super(loadTest, options);
|
|
20
20
|
this.connection = null;
|
|
21
21
|
this.lastCall = null;
|
|
22
22
|
this.client = null;
|
|
@@ -30,7 +30,7 @@ class WebsocketClient extends BaseClient {
|
|
|
30
30
|
this.client = new websocket.client();
|
|
31
31
|
this.client.on('connectFailed', () => {});
|
|
32
32
|
this.client.on('connect', connection => this.connect(connection));
|
|
33
|
-
this.client.connect(this.
|
|
33
|
+
this.client.connect(this.options.url, []);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
/**
|
|
@@ -55,7 +55,7 @@ class WebsocketClient extends BaseClient {
|
|
|
55
55
|
* Make a single request to the server.
|
|
56
56
|
*/
|
|
57
57
|
makeRequest() {
|
|
58
|
-
const id = this.
|
|
58
|
+
const id = this.loadTest.latency.start();
|
|
59
59
|
const requestFinished = this.getRequestFinisher(id);
|
|
60
60
|
|
|
61
61
|
if (this.connection.connected) {
|
|
@@ -115,7 +115,7 @@ class WebsocketClient extends BaseClient {
|
|
|
115
115
|
if (this.generateMessage) {
|
|
116
116
|
message = this.generateMessage(id);
|
|
117
117
|
}
|
|
118
|
-
if (typeof this.
|
|
118
|
+
if (typeof this.options.requestGenerator == 'function') {
|
|
119
119
|
// create a 'fake' object which can function like the http client
|
|
120
120
|
const req = () => {
|
|
121
121
|
return {
|
|
@@ -124,7 +124,7 @@ class WebsocketClient extends BaseClient {
|
|
|
124
124
|
}
|
|
125
125
|
};
|
|
126
126
|
};
|
|
127
|
-
this.
|
|
127
|
+
this.options.requestGenerator(this.options, this.params, req, requestFinished);
|
|
128
128
|
} else {
|
|
129
129
|
this.connection.sendUTF(JSON.stringify(message));
|
|
130
130
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loadtest",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.1.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Run load tests for your web application. Mostly ab-compatible interface, with an option to force requests per second. Includes an API for automated load testing.",
|
|
6
6
|
"homepage": "https://github.com/alexfernandez/loadtest",
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"websocket": "^1.0.34"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"eslint": "^8.47.0"
|
|
26
|
+
"eslint": "^8.47.0",
|
|
27
|
+
"microprofiler": "^2.0.0"
|
|
27
28
|
},
|
|
28
29
|
"keywords": [
|
|
29
30
|
"testing",
|