loadtest 6.1.0 → 6.2.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/README.md +80 -46
- package/bin/loadtest.js +1 -2
- package/lib/httpClient.js +22 -14
- package/lib/latency.js +4 -16
- package/lib/loadtest.js +18 -17
- package/lib/options.js +104 -125
- package/lib/result.js +68 -0
- package/package.json +2 -2
- package/test/body-generator.js +1 -1
- package/test/integration.js +4 -4
- package/test/loadtest.js +29 -7
- package/test/request-generator.js +1 -1
- package/lib/show.js +0 -47
package/README.md
CHANGED
|
@@ -481,7 +481,28 @@ thus allowing you to load test your application in your own tests.
|
|
|
481
481
|
|
|
482
482
|
### Invoke Load Test
|
|
483
483
|
|
|
484
|
-
To run a load test, just
|
|
484
|
+
To run a load test, just `await` for the exported function `loadTest()` with the desired options, described below:
|
|
485
|
+
|
|
486
|
+
```javascript
|
|
487
|
+
import {loadTest} from 'loadtest'
|
|
488
|
+
|
|
489
|
+
const options = {
|
|
490
|
+
url: 'http://localhost:8000',
|
|
491
|
+
maxRequests: 1000,
|
|
492
|
+
}
|
|
493
|
+
const result = await loadTest(options)
|
|
494
|
+
result.show()
|
|
495
|
+
console.log('Tests run successfully')
|
|
496
|
+
})
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
The call returns a `Result` object that contains all info about the load test, also described below.
|
|
500
|
+
Call `result.show()` to display the results in the standard format on the console.
|
|
501
|
+
|
|
502
|
+
As a legacy from before promises existed,
|
|
503
|
+
if an optional callback is passed as second parameter then it will not behave as `async`:
|
|
504
|
+
the callback `function(error, result)` will be invoked when the max number of requests is reached,
|
|
505
|
+
or when the max number of seconds has elapsed.
|
|
485
506
|
|
|
486
507
|
```javascript
|
|
487
508
|
import {loadTest} from 'loadtest'
|
|
@@ -494,16 +515,51 @@ loadTest(options, function(error, result) {
|
|
|
494
515
|
if (error) {
|
|
495
516
|
return console.error('Got an error: %s', error)
|
|
496
517
|
}
|
|
518
|
+
result.show()
|
|
497
519
|
console.log('Tests run successfully')
|
|
498
520
|
})
|
|
499
521
|
```
|
|
500
522
|
|
|
501
|
-
The callback `function(error, result)` will be invoked when the max number of requests is reached,
|
|
502
|
-
or when the max number of seconds has elapsed.
|
|
503
523
|
|
|
504
524
|
Beware: if there are no `maxRequests` and no `maxSeconds`, then tests will run forever
|
|
505
525
|
and will not call the callback.
|
|
506
526
|
|
|
527
|
+
### Result
|
|
528
|
+
|
|
529
|
+
The latency result returned at the end of the load test contains a full set of data, including:
|
|
530
|
+
mean latency, number of errors and percentiles.
|
|
531
|
+
An example follows:
|
|
532
|
+
|
|
533
|
+
```javascript
|
|
534
|
+
{
|
|
535
|
+
url: 'http://localhost:80/',
|
|
536
|
+
maxRequests: 1000,
|
|
537
|
+
maxSeconds: 0,
|
|
538
|
+
concurrency: 10,
|
|
539
|
+
agent: 'none',
|
|
540
|
+
requestsPerSecond: undefined,
|
|
541
|
+
totalRequests: 1000,
|
|
542
|
+
percentiles: {
|
|
543
|
+
'50': 7,
|
|
544
|
+
'90': 10,
|
|
545
|
+
'95': 11,
|
|
546
|
+
'99': 15
|
|
547
|
+
},
|
|
548
|
+
rps: 2824,
|
|
549
|
+
totalTimeSeconds: 0.354108,
|
|
550
|
+
meanLatencyMs: 7.72,
|
|
551
|
+
maxLatencyMs: 20,
|
|
552
|
+
totalErrors: 3,
|
|
553
|
+
errorCodes: {
|
|
554
|
+
'0': 1,
|
|
555
|
+
'500': 2
|
|
556
|
+
},
|
|
557
|
+
}
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
The `result` object also has a `result.show()` function
|
|
561
|
+
that displays the results on the console in the standard format.
|
|
562
|
+
|
|
507
563
|
### Options
|
|
508
564
|
|
|
509
565
|
All options but `url` are, as their name implies, optional.
|
|
@@ -591,6 +647,9 @@ function(params, options, client, callback) {
|
|
|
591
647
|
}
|
|
592
648
|
```
|
|
593
649
|
|
|
650
|
+
See [`sample/request-generator.js`](sample/request-generator.js) for some sample code including a body
|
|
651
|
+
(or [`sample/request-generator.ts`](sample/request-generator.ts) for ES6/TypeScript).
|
|
652
|
+
|
|
594
653
|
#### `agentKeepAlive`
|
|
595
654
|
|
|
596
655
|
Use an agent with 'Connection: Keep-alive'.
|
|
@@ -677,6 +736,19 @@ In addition, the following three properties are added to the `result` object:
|
|
|
677
736
|
|
|
678
737
|
You will need to check if `error` is populated in order to determine which object to check for these properties.
|
|
679
738
|
|
|
739
|
+
The second parameter contains info about the current request:
|
|
740
|
+
|
|
741
|
+
```javascript
|
|
742
|
+
{
|
|
743
|
+
host: 'localhost',
|
|
744
|
+
path: '/',
|
|
745
|
+
method: 'GET',
|
|
746
|
+
statusCode: 200,
|
|
747
|
+
body: '<html><body>hi</body></html>',
|
|
748
|
+
headers: [...]
|
|
749
|
+
}
|
|
750
|
+
```
|
|
751
|
+
|
|
680
752
|
Example:
|
|
681
753
|
|
|
682
754
|
```javascript
|
|
@@ -703,7 +775,6 @@ loadTest(options, function(error) {
|
|
|
703
775
|
console.log('Tests run successfully')
|
|
704
776
|
})
|
|
705
777
|
```
|
|
706
|
-
|
|
707
778
|
|
|
708
779
|
In some situations request data needs to be available in the statusCallBack.
|
|
709
780
|
This data can be assigned to `request.labels` in the requestGenerator:
|
|
@@ -757,46 +828,6 @@ function contentInspector(result) {
|
|
|
757
828
|
}
|
|
758
829
|
},
|
|
759
830
|
```
|
|
760
|
-
|
|
761
|
-
### Result
|
|
762
|
-
|
|
763
|
-
The latency result passed to your callback at the end of the load test contains a full set of data, including:
|
|
764
|
-
mean latency, number of errors and percentiles.
|
|
765
|
-
An example follows:
|
|
766
|
-
|
|
767
|
-
```javascript
|
|
768
|
-
{
|
|
769
|
-
totalRequests: 1000,
|
|
770
|
-
percentiles: {
|
|
771
|
-
'50': 7,
|
|
772
|
-
'90': 10,
|
|
773
|
-
'95': 11,
|
|
774
|
-
'99': 15
|
|
775
|
-
},
|
|
776
|
-
rps: 2824,
|
|
777
|
-
totalTimeSeconds: 0.354108,
|
|
778
|
-
meanLatencyMs: 7.72,
|
|
779
|
-
maxLatencyMs: 20,
|
|
780
|
-
totalErrors: 3,
|
|
781
|
-
errorCodes: {
|
|
782
|
-
'0': 1,
|
|
783
|
-
'500': 2
|
|
784
|
-
}
|
|
785
|
-
}
|
|
786
|
-
```
|
|
787
|
-
|
|
788
|
-
The second parameter contains info about the current request:
|
|
789
|
-
|
|
790
|
-
```javascript
|
|
791
|
-
{
|
|
792
|
-
host: 'localhost',
|
|
793
|
-
path: '/',
|
|
794
|
-
method: 'GET',
|
|
795
|
-
statusCode: 200,
|
|
796
|
-
body: '<html><body>hi</body></html>',
|
|
797
|
-
headers: [...]
|
|
798
|
-
}
|
|
799
|
-
```
|
|
800
831
|
|
|
801
832
|
### Start Test Server
|
|
802
833
|
|
|
@@ -805,11 +836,14 @@ To start the test server use the exported function `startServer()` with a set of
|
|
|
805
836
|
```javascript
|
|
806
837
|
import {startServer} from 'loadtest'
|
|
807
838
|
const server = await startServer({port: 8000})
|
|
839
|
+
// do your thing
|
|
840
|
+
await server.close()
|
|
808
841
|
```
|
|
809
842
|
|
|
810
|
-
This function returns
|
|
843
|
+
This function returns when the server is up and running,
|
|
844
|
+
with an HTTP server which can be `close()`d when it is no longer useful.
|
|
811
845
|
As a legacy from before promises existed,
|
|
812
|
-
if
|
|
846
|
+
if an optional callback is passed as second parameter then it will not behave as `async`:
|
|
813
847
|
|
|
814
848
|
```
|
|
815
849
|
const server = startServer({port: 8000}, error => console.error(error))
|
package/bin/loadtest.js
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
import {readFile} from 'fs/promises'
|
|
4
4
|
import * as stdio from 'stdio'
|
|
5
5
|
import {loadTest} from '../lib/loadtest.js'
|
|
6
|
-
import {showResult} from '../lib/show.js'
|
|
7
6
|
|
|
8
7
|
|
|
9
8
|
const options = stdio.getopt({
|
|
@@ -54,7 +53,7 @@ async function processAndRun(options) {
|
|
|
54
53
|
options.url = options.args[0];
|
|
55
54
|
try {
|
|
56
55
|
const result = await loadTest(options)
|
|
57
|
-
|
|
56
|
+
result.show()
|
|
58
57
|
} catch(error) {
|
|
59
58
|
console.error(error.message)
|
|
60
59
|
help()
|
package/lib/httpClient.js
CHANGED
|
@@ -28,6 +28,7 @@ class HttpClient {
|
|
|
28
28
|
constructor(operation, params) {
|
|
29
29
|
this.operation = operation
|
|
30
30
|
this.params = params
|
|
31
|
+
this.stopped = false
|
|
31
32
|
this.init();
|
|
32
33
|
}
|
|
33
34
|
|
|
@@ -36,20 +37,21 @@ class HttpClient {
|
|
|
36
37
|
*/
|
|
37
38
|
init() {
|
|
38
39
|
this.options = urlLib.parse(this.params.url);
|
|
39
|
-
this.options.headers = {}
|
|
40
|
-
if (this.params.headers) {
|
|
41
|
-
this.options.headers = this.params.headers;
|
|
42
|
-
}
|
|
40
|
+
this.options.headers = this.params.headers || {}
|
|
43
41
|
if (this.params.cert && this.params.key) {
|
|
44
42
|
this.options.cert = this.params.cert;
|
|
45
43
|
this.options.key = this.params.key;
|
|
46
44
|
}
|
|
47
45
|
this.options.agent = false;
|
|
46
|
+
if (this.params.requestsPerSecond) {
|
|
47
|
+
// rps for each client is total / concurrency (# of clients)
|
|
48
|
+
this.options.requestsPerSecond = this.params.requestsPerSecond / this.params.concurrency
|
|
49
|
+
}
|
|
48
50
|
if (this.params.agentKeepAlive) {
|
|
49
|
-
const KeepAlive = (this.options.protocol == 'https:') ? agentkeepalive.HttpsAgent : agentkeepalive;
|
|
51
|
+
const KeepAlive = (this.options.protocol == 'https:') ? agentkeepalive.HttpsAgent : agentkeepalive.default;
|
|
50
52
|
let maxSockets = 10;
|
|
51
|
-
if (this.
|
|
52
|
-
maxSockets += Math.floor(this.
|
|
53
|
+
if (this.options.requestsPerSecond) {
|
|
54
|
+
maxSockets += Math.floor(this.options.requestsPerSecond);
|
|
53
55
|
}
|
|
54
56
|
this.options.agent = new KeepAlive({
|
|
55
57
|
maxSockets: maxSockets,
|
|
@@ -71,7 +73,7 @@ class HttpClient {
|
|
|
71
73
|
} else if (typeof this.params.body == 'function') {
|
|
72
74
|
this.generateMessage = this.params.body;
|
|
73
75
|
} else {
|
|
74
|
-
|
|
76
|
+
throw new Error(`Unrecognized body: ${typeof this.params.body}`);
|
|
75
77
|
}
|
|
76
78
|
this.options.headers['Content-Type'] = this.params.contentType || 'text/plain';
|
|
77
79
|
}
|
|
@@ -81,7 +83,7 @@ class HttpClient {
|
|
|
81
83
|
} else if (typeof this.params.cookies == 'string') {
|
|
82
84
|
this.options.headers.Cookie = this.params.cookies;
|
|
83
85
|
} else {
|
|
84
|
-
|
|
86
|
+
throw new Error(`Invalid cookies ${JSON.stringify(this.params.cookies)}, please use an array or a string`);
|
|
85
87
|
}
|
|
86
88
|
}
|
|
87
89
|
addUserAgent(this.options.headers);
|
|
@@ -94,10 +96,15 @@ class HttpClient {
|
|
|
94
96
|
* Start the HTTP client.
|
|
95
97
|
*/
|
|
96
98
|
start() {
|
|
97
|
-
if (
|
|
99
|
+
if (this.stopped) {
|
|
100
|
+
// solves testing issue: with requestsPerSecond clients are started at random,
|
|
101
|
+
// so sometimes they are stopped before they have even started
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
if (!this.options.requestsPerSecond) {
|
|
98
105
|
return this.makeRequest();
|
|
99
106
|
}
|
|
100
|
-
const interval = 1000 / this.
|
|
107
|
+
const interval = 1000 / this.options.requestsPerSecond;
|
|
101
108
|
this.requestTimer = new HighResolutionTimer(interval, () => this.makeRequest());
|
|
102
109
|
}
|
|
103
110
|
|
|
@@ -105,6 +112,7 @@ class HttpClient {
|
|
|
105
112
|
* Stop the HTTP client.
|
|
106
113
|
*/
|
|
107
114
|
stop() {
|
|
115
|
+
this.stopped = true
|
|
108
116
|
if (this.requestTimer) {
|
|
109
117
|
this.requestTimer.stop();
|
|
110
118
|
}
|
|
@@ -133,7 +141,6 @@ class HttpClient {
|
|
|
133
141
|
// adding proxy configuration
|
|
134
142
|
if (this.params.proxy) {
|
|
135
143
|
const proxy = this.params.proxy;
|
|
136
|
-
//console.log('using proxy server %j', proxy);
|
|
137
144
|
const agent = new HttpsProxyAgent(proxy);
|
|
138
145
|
this.options.agent = agent;
|
|
139
146
|
}
|
|
@@ -154,7 +161,8 @@ class HttpClient {
|
|
|
154
161
|
delete this.options.headers['Content-Length'];
|
|
155
162
|
}
|
|
156
163
|
if (typeof this.params.requestGenerator == 'function') {
|
|
157
|
-
|
|
164
|
+
const connect = this.getConnect(id, requestFinished, this.params.contentInspector)
|
|
165
|
+
request = this.params.requestGenerator(this.params, this.options, lib.request, connect);
|
|
158
166
|
} else {
|
|
159
167
|
request = lib.request(this.options, this.getConnect(id, requestFinished, this.params.contentInspector));
|
|
160
168
|
}
|
|
@@ -205,7 +213,7 @@ class HttpClient {
|
|
|
205
213
|
result.instanceIndex = this.operation.instanceIndex;
|
|
206
214
|
}
|
|
207
215
|
let callback;
|
|
208
|
-
if (!this.
|
|
216
|
+
if (!this.options.requestsPerSecond) {
|
|
209
217
|
callback = this.makeRequest.bind(this);
|
|
210
218
|
}
|
|
211
219
|
this.operation.callback(error, result, callback);
|
package/lib/latency.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as crypto from 'crypto'
|
|
2
|
-
import {
|
|
2
|
+
import {Result} from './result.js'
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -174,19 +174,8 @@ export class Latency {
|
|
|
174
174
|
* Get final result.
|
|
175
175
|
*/
|
|
176
176
|
getResult() {
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
return {
|
|
180
|
-
totalRequests: this.totalRequests,
|
|
181
|
-
totalErrors: this.totalErrors,
|
|
182
|
-
totalTimeSeconds: elapsedSeconds,
|
|
183
|
-
rps: Math.round(this.totalRequests / elapsedSeconds),
|
|
184
|
-
meanLatencyMs: Math.round(meanTime * 10) / 10,
|
|
185
|
-
maxLatencyMs: this.maxLatencyMs,
|
|
186
|
-
minLatencyMs: this.minLatencyMs,
|
|
187
|
-
percentiles: this.computePercentiles(),
|
|
188
|
-
errorCodes: this.errorCodes
|
|
189
|
-
};
|
|
177
|
+
const result = new Result(this.options, this)
|
|
178
|
+
return result
|
|
190
179
|
}
|
|
191
180
|
|
|
192
181
|
/**
|
|
@@ -226,11 +215,10 @@ export class Latency {
|
|
|
226
215
|
}
|
|
227
216
|
this.totalsShown = true;
|
|
228
217
|
const result = this.getResult();
|
|
229
|
-
|
|
218
|
+
result.show()
|
|
230
219
|
}
|
|
231
220
|
}
|
|
232
221
|
|
|
233
|
-
|
|
234
222
|
/**
|
|
235
223
|
* Create a unique, random token.
|
|
236
224
|
*/
|
package/lib/loadtest.js
CHANGED
|
@@ -16,19 +16,21 @@ https.globalAgent.maxSockets = 1000;
|
|
|
16
16
|
* Run a load test.
|
|
17
17
|
* Parameters:
|
|
18
18
|
* - `options`: an object which may have:
|
|
19
|
-
* - url:
|
|
20
|
-
* - concurrency: how many concurrent clients to use.
|
|
21
|
-
* - maxRequests: how many requests to send
|
|
22
|
-
* - maxSeconds: how long to run the tests.
|
|
23
|
-
* - cookies: a string or an array of strings, each with name:value.
|
|
24
|
-
* - headers: a map with headers: {key1: value1, key2: value2}.
|
|
25
|
-
* - method: the method to use: POST, PUT. Default: GET, what else.
|
|
26
|
-
* - body: the contents to send along a POST or PUT request.
|
|
27
|
-
* - contentType: the MIME type to use for the body, default text/plain.
|
|
28
|
-
* - requestsPerSecond: how many requests per second to send.
|
|
19
|
+
* - url [string]: URL to access (mandatory).
|
|
20
|
+
* - concurrency [number]: how many concurrent clients to use.
|
|
21
|
+
* - maxRequests [number]: how many requests to send
|
|
22
|
+
* - maxSeconds [number]: how long to run the tests.
|
|
23
|
+
* - cookies [array]: a string or an array of strings, each with name:value.
|
|
24
|
+
* - headers [map]: a map with headers: {key1: value1, key2: value2}.
|
|
25
|
+
* - method [string]: the method to use: POST, PUT. Default: GET, what else.
|
|
26
|
+
* - body [string]: the contents to send along a POST or PUT request.
|
|
27
|
+
* - contentType [string]: the MIME type to use for the body, default text/plain.
|
|
28
|
+
* - requestsPerSecond [number]: how many requests per second to send.
|
|
29
29
|
* - agentKeepAlive: if true, then use connection keep-alive.
|
|
30
|
-
* - indexParam: string to replace with a unique index.
|
|
30
|
+
* - indexParam [string]: string to replace with a unique index.
|
|
31
31
|
* - insecure: allow https using self-signed certs.
|
|
32
|
+
* - secureProtocol [string]: TLS/SSL secure protocol method to use.
|
|
33
|
+
* - proxy [string]: use a proxy for requests e.g. http://localhost:8080.
|
|
32
34
|
* - quiet: do not log any messages.
|
|
33
35
|
* - debug: show debug messages (deprecated).
|
|
34
36
|
* - `callback`: optional `function(result, error)` called if/when the test finishes;
|
|
@@ -38,12 +40,12 @@ export function loadTest(options, callback) {
|
|
|
38
40
|
if (!callback) {
|
|
39
41
|
return loadTestAsync(options)
|
|
40
42
|
}
|
|
41
|
-
loadTestAsync(options).then(result => callback(null, result)).catch(error => callback(
|
|
43
|
+
loadTestAsync(options).then(result => callback(null, result)).catch(error => callback(error))
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
async function loadTestAsync(options) {
|
|
45
|
-
await processOptions(options)
|
|
46
|
-
return await runOperation(
|
|
47
|
+
const processed = await processOptions(options)
|
|
48
|
+
return await runOperation(processed)
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
function runOperation(options) {
|
|
@@ -157,15 +159,14 @@ class Operation {
|
|
|
157
159
|
* Stop clients.
|
|
158
160
|
*/
|
|
159
161
|
stop() {
|
|
162
|
+
this.running = false;
|
|
163
|
+
this.latency.running = false;
|
|
160
164
|
if (this.showTimer) {
|
|
161
165
|
this.showTimer.stop();
|
|
162
166
|
}
|
|
163
167
|
if (this.stopTimeout) {
|
|
164
168
|
clearTimeout(this.stopTimeout);
|
|
165
169
|
}
|
|
166
|
-
this.running = false;
|
|
167
|
-
this.latency.running = false;
|
|
168
|
-
|
|
169
170
|
Object.keys(this.clients).forEach(index => {
|
|
170
171
|
this.clients[index].stop();
|
|
171
172
|
});
|
package/lib/options.js
CHANGED
|
@@ -1,152 +1,131 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
1
|
import {readFile} from 'fs/promises'
|
|
4
2
|
import * as path from 'path'
|
|
5
3
|
import * as urlLib from 'url'
|
|
6
4
|
import {addHeaders} from '../lib/headers.js'
|
|
7
5
|
import {loadConfig} from '../lib/config.js'
|
|
8
6
|
|
|
7
|
+
const acceptedMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'get', 'post', 'put', 'delete', 'patch'];
|
|
8
|
+
|
|
9
9
|
|
|
10
10
|
export async function processOptions(options) {
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
11
|
+
const processed = new Options(options)
|
|
12
|
+
await processed.process()
|
|
13
|
+
return processed
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class Options {
|
|
17
|
+
constructor(options) {
|
|
18
|
+
this.url = this.getUrl(options)
|
|
19
|
+
const configuration = loadConfig();
|
|
20
|
+
this.concurrency = options.concurrency || configuration.concurrency || 1
|
|
21
|
+
const rps = options.rps ? parseFloat(options.rps) : null
|
|
22
|
+
this.requestsPerSecond = options.requestsPerSecond || rps || configuration.requestsPerSecond
|
|
23
|
+
this.agentKeepAlive = options.keepalive || options.agent || options.agentKeepAlive || configuration.agentKeepAlive;
|
|
24
|
+
this.indexParam = options.index || options.indexParam || configuration.indexParam;
|
|
25
|
+
this.method = options.method || configuration.method || 'GET'
|
|
26
|
+
this.readBodyAndMethod(options, configuration)
|
|
27
|
+
this.key = null
|
|
28
|
+
this.keyFile = options.key || configuration.key
|
|
29
|
+
this.cert = null
|
|
30
|
+
this.certFile = options.cert || configuration.cert
|
|
31
|
+
this.headers = configuration.headers || {}
|
|
32
|
+
this.headers['host'] = urlLib.parse(options.url).host;
|
|
33
|
+
this.headers['accept'] = '*/*';
|
|
34
|
+
if (options.headers) {
|
|
35
|
+
addHeaders(options.headers, this.headers);
|
|
25
36
|
}
|
|
37
|
+
this.requestGenerator = options.requestGenerator || configuration.requestGenerator
|
|
38
|
+
this.maxRequests = options.maxRequests || configuration.maxRequests
|
|
39
|
+
this.maxSeconds = options.maxSeconds || configuration.maxSeconds
|
|
40
|
+
this.cookies = options.cookies || configuration.cookies
|
|
41
|
+
this.contentType = options.contentType || configuration.contentType
|
|
42
|
+
this.timeout = options.timeout || configuration.timeout
|
|
43
|
+
this.secureProtocol = options.secureProtocol || configuration.secureProtocol
|
|
44
|
+
this.insecure = options.insecure || configuration.insecure
|
|
45
|
+
this.recover = options.recover || configuration.recover
|
|
46
|
+
this.proxy = options.proxy || configuration.proxy
|
|
47
|
+
this.quiet = options.quiet || configuration.quiet
|
|
26
48
|
}
|
|
27
|
-
const configuration = loadConfig();
|
|
28
49
|
|
|
29
|
-
options
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
//TODO: add index Param
|
|
33
|
-
// Allow a post body string in options
|
|
34
|
-
// Ex -P '{"foo": "bar"}'
|
|
35
|
-
if (options.postBody) {
|
|
36
|
-
options.method = 'POST';
|
|
37
|
-
options.body = options.postBody;
|
|
38
|
-
}
|
|
39
|
-
if (options.postFile) {
|
|
40
|
-
options.method = 'POST';
|
|
41
|
-
options.body = await readBody(options.postFile, '-p');
|
|
42
|
-
}
|
|
43
|
-
if (options.data) {
|
|
44
|
-
options.body = options.data
|
|
45
|
-
}
|
|
46
|
-
if (options.method) {
|
|
47
|
-
const acceptedMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'get', 'post', 'put', 'delete', 'patch'];
|
|
48
|
-
if (acceptedMethods.indexOf(options.method) === -1) {
|
|
49
|
-
options.method = 'GET';
|
|
50
|
+
getUrl(options) {
|
|
51
|
+
if (!options.url) {
|
|
52
|
+
throw new Error('Missing URL in options')
|
|
50
53
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
options.method = 'PUT';
|
|
54
|
-
options.body = await readBody(options.putFile, '-u');
|
|
55
|
-
}
|
|
56
|
-
if (options.patchBody) {
|
|
57
|
-
options.method = 'PATCH';
|
|
58
|
-
options.body = options.patchBody;
|
|
59
|
-
}
|
|
60
|
-
if (options.patchFile) {
|
|
61
|
-
options.method = 'PATCH';
|
|
62
|
-
options.body = await readBody(options.patchFile, '-a');
|
|
63
|
-
}
|
|
64
|
-
if (!options.method) {
|
|
65
|
-
options.method = configuration.method;
|
|
66
|
-
}
|
|
67
|
-
if (!options.body) {
|
|
68
|
-
if(configuration.body) {
|
|
69
|
-
options.body = configuration.body;
|
|
70
|
-
} else if (configuration.file) {
|
|
71
|
-
options.body = await readBody(configuration.file, 'configuration.request.file');
|
|
54
|
+
if (!options.url.startsWith('http://') && !options.url.startsWith('https://') && !options.url.startsWith('ws://')) {
|
|
55
|
+
throw new Error(`Invalid URL ${options.url}, must be http://, https:// or ws://'`)
|
|
72
56
|
}
|
|
57
|
+
if (options.url.startsWith('ws:')) {
|
|
58
|
+
if (options.requestsPerSecond) {
|
|
59
|
+
throw new Error(`"requestsPerSecond" not supported for WebSockets`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return options.url
|
|
73
63
|
}
|
|
74
|
-
options.requestsPerSecond = options.rps ? parseFloat(options.rps) : configuration.requestsPerSecond;
|
|
75
|
-
if (!options.key) {
|
|
76
|
-
options.key = configuration.key;
|
|
77
|
-
}
|
|
78
|
-
if (options.key) {
|
|
79
|
-
options.key = await readFile(options.key)
|
|
80
|
-
}
|
|
81
|
-
if (!options.cert) {
|
|
82
|
-
options.cert = configuration.cert;
|
|
83
|
-
}
|
|
84
|
-
if (options.cert) {
|
|
85
|
-
options.cert = await readFile(options.cert);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const defaultHeaders = options.headers || !configuration.headers ? {} : configuration.headers;
|
|
89
|
-
defaultHeaders['host'] = urlLib.parse(options.url).host;
|
|
90
|
-
defaultHeaders['user-agent'] = 'loadtest/' + packageJson.version;
|
|
91
|
-
defaultHeaders['accept'] = '*/*';
|
|
92
|
-
|
|
93
|
-
if (options.headers) {
|
|
94
|
-
addHeaders(options.headers, defaultHeaders);
|
|
95
|
-
console.log('headers: %s, %j', typeof defaultHeaders, defaultHeaders);
|
|
96
|
-
}
|
|
97
|
-
options.headers = defaultHeaders;
|
|
98
64
|
|
|
99
|
-
|
|
100
|
-
options.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
65
|
+
readBodyAndMethod(options, configuration) {
|
|
66
|
+
if (options.data) {
|
|
67
|
+
this.body = options.data
|
|
68
|
+
}
|
|
69
|
+
// Allow a post body string in options
|
|
70
|
+
// Ex -P '{"foo": "bar"}'
|
|
71
|
+
if (options.postBody) {
|
|
72
|
+
this.method = 'POST';
|
|
73
|
+
this.body = options.postBody
|
|
74
|
+
}
|
|
75
|
+
if (options.postFile) {
|
|
76
|
+
this.method = 'POST';
|
|
77
|
+
this.bodyFile = options.postFile
|
|
78
|
+
}
|
|
79
|
+
if (options.putFile) {
|
|
80
|
+
this.method = 'PUT';
|
|
81
|
+
this.bodyFile = options.putFile
|
|
82
|
+
}
|
|
83
|
+
if (options.patchBody) {
|
|
84
|
+
this.method = 'PATCH';
|
|
85
|
+
this.body = options.patchBody
|
|
86
|
+
}
|
|
87
|
+
if (options.patchFile) {
|
|
88
|
+
this.method = 'PATCH';
|
|
89
|
+
this.bodyFile = options.patchFile
|
|
90
|
+
}
|
|
91
|
+
// sanity check
|
|
92
|
+
if (acceptedMethods.indexOf(this.method) === -1) {
|
|
93
|
+
throw new Error(`Invalid method ${this.method}`)
|
|
94
|
+
}
|
|
95
|
+
if (!options.body) {
|
|
96
|
+
if (configuration.body) {
|
|
97
|
+
this.body = configuration.body;
|
|
98
|
+
} else if (configuration.file) {
|
|
99
|
+
this.bodyFile = configuration.file
|
|
100
|
+
}
|
|
101
|
+
}
|
|
104
102
|
}
|
|
105
103
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
if(!options.cookies) {
|
|
123
|
-
options.cookies = configuration.cookies;
|
|
124
|
-
}
|
|
125
|
-
if(!options.secureProtocol) {
|
|
126
|
-
options.secureProtocol = configuration.secureProtocol;
|
|
127
|
-
}
|
|
128
|
-
if(!options.insecure) {
|
|
129
|
-
options.insecure = configuration.insecure;
|
|
130
|
-
}
|
|
131
|
-
if(!options.recover) {
|
|
132
|
-
options.recover = configuration.recover;
|
|
133
|
-
}
|
|
134
|
-
if(!options.proxy) {
|
|
135
|
-
options.proxy = configuration.proxy;
|
|
104
|
+
async process() {
|
|
105
|
+
const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url)))
|
|
106
|
+
this.headers['user-agent'] = 'loadtest/' + packageJson.version;
|
|
107
|
+
if (this.keyFile) {
|
|
108
|
+
this.key = await readFile(this.keyFile)
|
|
109
|
+
}
|
|
110
|
+
if (this.certFile) {
|
|
111
|
+
this.cert = await readFile(this.certFile);
|
|
112
|
+
}
|
|
113
|
+
if (typeof this.requestGenerator == 'string') {
|
|
114
|
+
this.requestGenerator = await import(this.requestGenerator)
|
|
115
|
+
}
|
|
116
|
+
if (this.bodyFile) {
|
|
117
|
+
this.body = await readBody(this.bodyFile);
|
|
118
|
+
}
|
|
136
119
|
}
|
|
137
120
|
}
|
|
138
121
|
|
|
139
|
-
async function readBody(filename
|
|
122
|
+
async function readBody(filename) {
|
|
140
123
|
if (typeof filename !== 'string') {
|
|
141
|
-
throw new Error(`Invalid file to open
|
|
124
|
+
throw new Error(`Invalid file to open for body: ${filename}`);
|
|
142
125
|
}
|
|
143
|
-
|
|
144
126
|
if (path.extname(filename) === '.js') {
|
|
145
127
|
return await import(new URL(filename, `file://${process.cwd()}/`))
|
|
146
128
|
}
|
|
147
|
-
|
|
148
|
-
const ret = await readFile(filename, {encoding: 'utf8'}).replace("\n", "");
|
|
149
|
-
|
|
150
|
-
return ret;
|
|
129
|
+
return await readFile(filename, {encoding: 'utf8'}).replace("\n", "");
|
|
151
130
|
}
|
|
152
131
|
|
package/lib/result.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Result of a load test.
|
|
5
|
+
*/
|
|
6
|
+
export class Result {
|
|
7
|
+
constructor(options, latency) {
|
|
8
|
+
// options
|
|
9
|
+
this.url = options.url
|
|
10
|
+
this.maxRequests = options.maxRequests
|
|
11
|
+
this.maxSeconds = options.maxSeconds
|
|
12
|
+
this.concurrency = options.concurrency
|
|
13
|
+
this.agent = options.agentKeepAlive ? 'keepalive' : 'none';
|
|
14
|
+
this.requestsPerSecond = options.requestsPerSecond
|
|
15
|
+
// results
|
|
16
|
+
this.elapsedSeconds = latency.getElapsed(latency.initialTime) / 1000
|
|
17
|
+
const meanTime = latency.totalTime / latency.totalRequests
|
|
18
|
+
this.totalRequests = latency.totalRequests
|
|
19
|
+
this.totalErrors = latency.totalErrors
|
|
20
|
+
this.totalTimeSeconds = this.elapsedSeconds
|
|
21
|
+
this.rps = Math.round(latency.totalRequests / this.elapsedSeconds)
|
|
22
|
+
this.meanLatencyMs = Math.round(meanTime * 10) / 10
|
|
23
|
+
this.maxLatencyMs = latency.maxLatencyMs
|
|
24
|
+
this.minLatencyMs = latency.minLatencyMs
|
|
25
|
+
this.percentiles = latency.computePercentiles()
|
|
26
|
+
this.errorCodes = latency.errorCodes
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Show result of a load test.
|
|
31
|
+
*/
|
|
32
|
+
show() {
|
|
33
|
+
console.info('');
|
|
34
|
+
console.info('Target URL: %s', this.url);
|
|
35
|
+
if (this.maxRequests) {
|
|
36
|
+
console.info('Max requests: %s', this.maxRequests);
|
|
37
|
+
} else if (this.maxSeconds) {
|
|
38
|
+
console.info('Max time (s): %s', this.maxSeconds);
|
|
39
|
+
}
|
|
40
|
+
console.info('Concurrency level: %s', this.concurrency);
|
|
41
|
+
console.info('Agent: %s', this.agent);
|
|
42
|
+
if (this.requestsPerSecond) {
|
|
43
|
+
console.info('Requests per second: %s', this.requestsPerSecond);
|
|
44
|
+
}
|
|
45
|
+
console.info('');
|
|
46
|
+
console.info('Completed requests: %s', this.totalRequests);
|
|
47
|
+
console.info('Total errors: %s', this.totalErrors);
|
|
48
|
+
console.info('Total time: %s s', this.totalTimeSeconds);
|
|
49
|
+
console.info('Requests per second: %s', this.rps);
|
|
50
|
+
console.info('Mean latency: %s ms', this.meanLatencyMs);
|
|
51
|
+
console.info('');
|
|
52
|
+
console.info('Percentage of the requests served within a certain time');
|
|
53
|
+
|
|
54
|
+
Object.keys(this.percentiles).forEach(percentile => {
|
|
55
|
+
console.info(' %s% %s ms', percentile, this.percentiles[percentile]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
console.info(' 100% %s ms (longest request)', this.maxLatencyMs);
|
|
59
|
+
if (this.totalErrors) {
|
|
60
|
+
console.info('');
|
|
61
|
+
Object.keys(this.errorCodes).forEach(errorCode => {
|
|
62
|
+
const padding = ' '.repeat(errorCode.length < 4 ? 4 - errorCode.length : 1);
|
|
63
|
+
console.info(' %s%s: %s errors', padding, errorCode, this.errorCodes[errorCode]);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loadtest",
|
|
3
|
-
"version": "6.1
|
|
3
|
+
"version": "6.2.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",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"confinode": "^2.1.1",
|
|
20
20
|
"https-proxy-agent": "^2.2.1",
|
|
21
21
|
"stdio": "0.2.7",
|
|
22
|
-
"testing": "^3.0
|
|
22
|
+
"testing": "^3.1.0",
|
|
23
23
|
"websocket": "^1.0.34"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
package/test/body-generator.js
CHANGED
package/test/integration.js
CHANGED
|
@@ -112,16 +112,16 @@ function testWSIntegration(callback) {
|
|
|
112
112
|
*/
|
|
113
113
|
function testDelay(callback) {
|
|
114
114
|
const delay = 10;
|
|
115
|
-
|
|
115
|
+
const serverOptions = {
|
|
116
116
|
port: PORT + 1,
|
|
117
|
-
delay
|
|
117
|
+
delay,
|
|
118
118
|
quiet: true,
|
|
119
119
|
};
|
|
120
|
-
const server = startServer(
|
|
120
|
+
const server = startServer(serverOptions, error => {
|
|
121
121
|
if (error) {
|
|
122
122
|
return callback(error);
|
|
123
123
|
}
|
|
124
|
-
options = {
|
|
124
|
+
const options = {
|
|
125
125
|
url: 'http://localhost:' + (PORT + 1),
|
|
126
126
|
maxRequests: 10,
|
|
127
127
|
quiet: true,
|
package/test/loadtest.js
CHANGED
|
@@ -2,9 +2,6 @@ import testing from 'testing'
|
|
|
2
2
|
import {loadTest} from '../lib/loadtest.js'
|
|
3
3
|
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
* A load test with max seconds.
|
|
7
|
-
*/
|
|
8
5
|
function testMaxSeconds(callback) {
|
|
9
6
|
const options = {
|
|
10
7
|
url: 'http://localhost:7357/',
|
|
@@ -15,10 +12,6 @@ function testMaxSeconds(callback) {
|
|
|
15
12
|
loadTest(options, callback);
|
|
16
13
|
}
|
|
17
14
|
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* A load test with max seconds.
|
|
21
|
-
*/
|
|
22
15
|
function testWSEcho(callback) {
|
|
23
16
|
const options = {
|
|
24
17
|
url: 'ws://localhost:7357/',
|
|
@@ -83,6 +76,34 @@ function testIndexParamWithCallbackAndBody(callback) {
|
|
|
83
76
|
loadTest(options, callback);
|
|
84
77
|
}
|
|
85
78
|
|
|
79
|
+
function testError(callback) {
|
|
80
|
+
const options = {
|
|
81
|
+
maxSeconds: 0.1,
|
|
82
|
+
concurrency: 1,
|
|
83
|
+
quiet: true,
|
|
84
|
+
};
|
|
85
|
+
loadTest(options, (error) => {
|
|
86
|
+
if (!error) {
|
|
87
|
+
return callback('Should error without URL')
|
|
88
|
+
}
|
|
89
|
+
return callback(false, 'OK')
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A load test with keep-alive.
|
|
95
|
+
*/
|
|
96
|
+
function testKeepAlive(callback) {
|
|
97
|
+
const options = {
|
|
98
|
+
url: 'http://localhost:7357/',
|
|
99
|
+
maxSeconds: 0.1,
|
|
100
|
+
concurrency: 1,
|
|
101
|
+
quiet: true,
|
|
102
|
+
keepalive: true,
|
|
103
|
+
};
|
|
104
|
+
loadTest(options, callback)
|
|
105
|
+
}
|
|
106
|
+
|
|
86
107
|
|
|
87
108
|
/**
|
|
88
109
|
* Run all tests.
|
|
@@ -91,6 +112,7 @@ export function test(callback) {
|
|
|
91
112
|
testing.run([
|
|
92
113
|
testMaxSeconds, testWSEcho, testIndexParam, testIndexParamWithBody,
|
|
93
114
|
testIndexParamWithCallback, testIndexParamWithCallbackAndBody,
|
|
115
|
+
testError, testKeepAlive,
|
|
94
116
|
], callback);
|
|
95
117
|
}
|
|
96
118
|
|
|
@@ -13,7 +13,7 @@ function testRequestGenerator(callback) {
|
|
|
13
13
|
const options = {
|
|
14
14
|
url: 'http://localhost:' + PORT,
|
|
15
15
|
method: 'POST',
|
|
16
|
-
requestsPerSecond:
|
|
16
|
+
requestsPerSecond: 1000,
|
|
17
17
|
maxRequests: 100,
|
|
18
18
|
concurrency: 10,
|
|
19
19
|
requestGenerator: (params, options, client, callback) => {
|
package/lib/show.js
DELETED
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Show result of a load test.
|
|
5
|
-
*/
|
|
6
|
-
export function showResult(options, result) {
|
|
7
|
-
console.info('');
|
|
8
|
-
console.info('Target URL: %s', options.url);
|
|
9
|
-
if (options.maxRequests) {
|
|
10
|
-
console.info('Max requests: %s', options.maxRequests);
|
|
11
|
-
} else if (options.maxSeconds) {
|
|
12
|
-
console.info('Max time (s): %s', options.maxSeconds);
|
|
13
|
-
}
|
|
14
|
-
console.info('Concurrency level: %s', options.concurrency);
|
|
15
|
-
let agent = 'none';
|
|
16
|
-
if (options.agentKeepAlive) {
|
|
17
|
-
agent = 'keepalive';
|
|
18
|
-
}
|
|
19
|
-
console.info('Agent: %s', agent);
|
|
20
|
-
if (options.requestsPerSecond) {
|
|
21
|
-
console.info('Requests per second: %s', options.requestsPerSecond * options.concurrency);
|
|
22
|
-
}
|
|
23
|
-
console.info('');
|
|
24
|
-
console.info('Completed requests: %s', result.totalRequests);
|
|
25
|
-
console.info('Total errors: %s', result.totalErrors);
|
|
26
|
-
console.info('Total time: %s s', result.totalTimeSeconds);
|
|
27
|
-
console.info('Requests per second: %s', result.rps);
|
|
28
|
-
console.info('Mean latency: %s ms', result.meanLatencyMs);
|
|
29
|
-
console.info('');
|
|
30
|
-
console.info('Percentage of the requests served within a certain time');
|
|
31
|
-
|
|
32
|
-
Object.keys(result.percentiles).forEach(percentile => {
|
|
33
|
-
console.info(' %s% %s ms', percentile, result.percentiles[percentile]);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
console.info(' 100% %s ms (longest request)', result.maxLatencyMs);
|
|
37
|
-
if (result.totalErrors) {
|
|
38
|
-
console.info('');
|
|
39
|
-
Object.keys(result.errorCodes).forEach(errorCode => {
|
|
40
|
-
const padding = ' '.repeat(errorCode.length < 4 ? 4 - errorCode.length : 1);
|
|
41
|
-
console.info(' %s%s: %s errors', padding, errorCode, result.errorCodes[errorCode]);
|
|
42
|
-
});
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|