loadtest 7.1.1 → 8.0.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/lib/pool.js ADDED
@@ -0,0 +1,106 @@
1
+ import {HttpClient} from './httpClient.js'
2
+ import {TcpClient} from './tcpClient.js'
3
+ import {WebsocketClient} from './websocket.js'
4
+ import {HighResolutionTimer} from './hrtimer.js'
5
+
6
+ /**
7
+ * Used to keep track of individual load test runs.
8
+ */
9
+ let instanceIndex = 0;
10
+
11
+
12
+ /**
13
+ * A pool of clients.
14
+ */
15
+ export class Pool {
16
+ constructor(loadTest) {
17
+ this.loadTest = loadTest
18
+ this.options = loadTest.options
19
+ this.clients = [];
20
+ this.freeClients = []
21
+ this.requestTimer = null
22
+ this.instanceIndex = instanceIndex++
23
+ this.requestIndex = 0
24
+ }
25
+
26
+ /**
27
+ * Start a number of measuring clients.
28
+ */
29
+ start() {
30
+ if (this.options.requestsPerSecond) {
31
+ const interval = 1000 / this.options.requestsPerSecond;
32
+ this.requestTimer = new HighResolutionTimer(interval, () => this.makeRequest());
33
+ return
34
+ }
35
+ for (let index = 0; index < this.options.concurrency; index++) {
36
+ const client = this.addClient();
37
+ client.start();
38
+ }
39
+ }
40
+
41
+ addClient() {
42
+ const client = this.createClient();
43
+ this.clients.push(client)
44
+ this.freeClients.push(client)
45
+ return client
46
+ }
47
+
48
+ createClient() {
49
+ // TODO: || this.options.url.startsWith('wss:'))
50
+ if (this.options.url.startsWith('ws:')) {
51
+ return new WebsocketClient(this.loadTest)
52
+ }
53
+ if (this.options.tcp) {
54
+ return new TcpClient(this.loadTest)
55
+ }
56
+ return new HttpClient(this.loadTest);
57
+ }
58
+
59
+ makeRequest() {
60
+ if (!this.loadTest.running) {
61
+ return
62
+ }
63
+ if (!this.freeClients.length) {
64
+ this.addClient()
65
+ }
66
+ const client = this.freeClients.shift()
67
+ client.makeRequest()
68
+ }
69
+
70
+ /**
71
+ * Call after each request has finished.
72
+ */
73
+ finishRequest(client, result, error) {
74
+ if (this.options.statusCallback) {
75
+ result.requestIndex = this.requestIndex++
76
+ result.instanceIndex = this.loadTest.instanceIndex
77
+ this.options.statusCallback(error, result);
78
+ }
79
+ if (this.loadTest.checkStop()) {
80
+ return
81
+ }
82
+ if (!this.loadTest.latency.shouldSend()) {
83
+ if (this.requestTimer) {
84
+ this.requestTimer.stop()
85
+ }
86
+ }
87
+ if (!this.options.requestsPerSecond) {
88
+ client.makeRequest()
89
+ } else {
90
+ this.freeClients.push(client)
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Stop clients.
96
+ */
97
+ stop() {
98
+ if (this.requestTimer) {
99
+ this.requestTimer.stop()
100
+ }
101
+ for (const client of this.clients) {
102
+ client.stop();
103
+ }
104
+ }
105
+ }
106
+
package/lib/result.js CHANGED
@@ -11,9 +11,10 @@ export class Result {
11
11
  this.maxSeconds = 0
12
12
  this.concurrency = 0
13
13
  this.agent = null
14
- this.requestsPerSecond = 0
14
+ this.requestsPerSecond = null
15
+ this.clients = 0
15
16
  this.startTimeMs = Number.MAX_SAFE_INTEGER
16
- this.endTimeMs = 0
17
+ this.stopTimeMs = 0
17
18
  this.elapsedSeconds = 0
18
19
  this.totalRequests = 0
19
20
  this.totalErrors = 0
@@ -25,18 +26,24 @@ export class Result {
25
26
  this.histogramMs = {}
26
27
  }
27
28
 
28
- compute(options, latency) {
29
- // configuration
29
+ compute(latency) {
30
+ const options = latency.options
30
31
  this.url = options.url
31
32
  this.cores = options.cores
32
33
  this.maxRequests = parseInt(options.maxRequests)
33
34
  this.maxSeconds = parseInt(options.maxSeconds)
34
35
  this.concurrency = parseInt(options.concurrency)
35
- this.agent = options.agentKeepAlive ? 'keepalive' : 'none';
36
+ this.clients = latency.clients
37
+ if (options.tcp) {
38
+ this.agent = 'tcp'
39
+ } else if (options.agentKeepAlive) {
40
+ this.agent = 'keepalive'
41
+ } else {
42
+ this.agent = 'none'
43
+ }
36
44
  this.requestsPerSecond = parseInt(options.requestsPerSecond)
37
- // result
38
45
  this.startTimeMs = Number(latency.startTimeNs / 1000000n)
39
- this.endTimeMs = Number(latency.endTimeNs / 1000000n)
46
+ this.stopTimeMs = Number(latency.stopTimeNs / 1000000n)
40
47
  this.totalRequests = latency.totalRequests
41
48
  this.totalErrors = latency.totalErrors
42
49
  this.accumulatedMs = latency.totalTime
@@ -48,7 +55,7 @@ export class Result {
48
55
  }
49
56
 
50
57
  computeDerived() {
51
- this.elapsedSeconds = (this.endTimeMs - this.startTimeMs) / 1000
58
+ this.elapsedSeconds = (this.stopTimeMs - this.startTimeMs) / 1000
52
59
  this.totalTimeSeconds = this.elapsedSeconds // backwards compatibility
53
60
  const meanTime = this.accumulatedMs / this.totalRequests
54
61
  this.meanLatencyMs = Math.round(meanTime * 10) / 10
@@ -90,9 +97,10 @@ export class Result {
90
97
  this.concurrency = this.concurrency || result.concurrency
91
98
  this.agent = this.agent || result.agent
92
99
  this.requestsPerSecond += result.requestsPerSecond || 0
100
+ this.clients += result.clients
93
101
  // result
94
102
  this.startTimeMs = Math.min(this.startTimeMs, result.startTimeMs)
95
- this.endTimeMs = Math.max(this.endTimeMs, result.endTimeMs)
103
+ this.stopTimeMs = Math.max(this.stopTimeMs, result.stopTimeMs)
96
104
  this.totalRequests += result.totalRequests
97
105
  this.totalErrors += result.totalErrors
98
106
  this.accumulatedMs += result.accumulatedMs
@@ -125,14 +133,14 @@ export class Result {
125
133
  } else if (this.maxSeconds) {
126
134
  console.info('Max time (s): %s', this.maxSeconds);
127
135
  }
128
- console.info('Concurrency level: %s', this.concurrency);
136
+ if (this.requestsPerSecond) {
137
+ console.info('Target rps: %s', this.requestsPerSecond);
138
+ }
139
+ console.info('Concurrent clients: %s', this.clients)
129
140
  if (this.cores) {
130
141
  console.info('Running on cores: %s', this.cores);
131
142
  }
132
143
  console.info('Agent: %s', this.agent);
133
- if (this.requestsPerSecond) {
134
- console.info('Target rps: %s', this.requestsPerSecond);
135
- }
136
144
  console.info('');
137
145
  console.info('Completed requests: %s', this.totalRequests);
138
146
  console.info('Total errors: %s', this.totalErrors);
@@ -140,7 +148,7 @@ export class Result {
140
148
  console.info('Mean latency: %s ms', this.meanLatencyMs);
141
149
  console.info('Effective rps: %s', this.effectiveRps);
142
150
  console.info('');
143
- console.info('Percentage of the requests served within a certain time');
151
+ console.info('Percentage of requests served within a certain time');
144
152
 
145
153
  Object.keys(this.percentiles).forEach(percentile => {
146
154
  console.info(' %s% %s ms', percentile, this.percentiles[percentile]);
@@ -0,0 +1,198 @@
1
+ import * as urlLib from 'url'
2
+ import * as net from 'net'
3
+ import * as querystring from 'querystring'
4
+ import {addUserAgent} from './headers.js'
5
+ import {Parser} from './parser.js'
6
+
7
+ const forbiddenOptions = [
8
+ 'indexParam', 'statusCallback', 'requestGenerator'
9
+ ]
10
+
11
+
12
+ /**
13
+ * A client for an HTTP connection, using TCP sockets. Constructor:
14
+ * - `loadTest`: an object with the following attributes:
15
+ * - `latency`: a variable to measure latency.
16
+ * - `options`: same options as exports.loadTest.
17
+ * - `running`: if the loadTest is running or not.
18
+ */
19
+ export class TcpClient {
20
+ constructor(loadTest) {
21
+ this.loadTest = loadTest
22
+ this.latency = loadTest.latency
23
+ this.options = loadTest.options
24
+ this.running = true
25
+ this.connection = null
26
+ this.currentId = null
27
+ this.init();
28
+ }
29
+
30
+ /**
31
+ * Init params and message to send.
32
+ */
33
+ init() {
34
+ this.params = urlLib.parse(this.options.url);
35
+ this.params.headers = this.options.headers || {}
36
+ if (this.options.cert && this.options.key) {
37
+ this.params.cert = this.options.cert;
38
+ this.params.key = this.options.key;
39
+ }
40
+ this.params.agent = false;
41
+ this.params.method = this.options.method || 'GET';
42
+ if (this.options.cookies) {
43
+ if (Array.isArray(this.options.cookies)) {
44
+ this.params.headers.Cookie = this.options.cookies.join('; ');
45
+ } else if (typeof this.options.cookies == 'string') {
46
+ this.params.headers.Cookie = this.options.cookies;
47
+ } else {
48
+ throw new Error(`Invalid cookies ${JSON.stringify(this.options.cookies)}, please use an array or a string`);
49
+ }
50
+ }
51
+ addUserAgent(this.params.headers);
52
+ this.params.headers.Connection = 'keep-alive'
53
+ this.params.request = this.createRequest()
54
+ for (const option of forbiddenOptions) {
55
+ if (this.options[option]) {
56
+ throw new Error(`${option} not supported with TCP sockets`)
57
+ }
58
+ }
59
+ }
60
+
61
+ createRequest() {
62
+ const body = this.generateBody()
63
+ if (body?.length) {
64
+ this.params.headers['Content-Type'] = this.options.contentType || 'text/plain';
65
+ this.params.headers['Content-Length'] = Buffer.byteLength(body)
66
+ }
67
+ const lines = [`${this.params.method} ${this.params.path} HTTP/1.1`]
68
+ for (const header in this.params.headers) {
69
+ const value = this.params.headers[header]
70
+ const line = `${header}: ${value}`
71
+ lines.push(line)
72
+ }
73
+ lines.push(`\r\n`)
74
+ const text = lines.join('\r\n')
75
+ return Buffer.from(text)
76
+ }
77
+
78
+ generateBody() {
79
+ if (!this.options.body) {
80
+ return ''
81
+ }
82
+ if (typeof this.options.body == 'string') {
83
+ return this.options.body
84
+ } else if (typeof this.options.body == 'object') {
85
+ if (this.options.contentType === 'application/x-www-form-urlencoded') {
86
+ return querystring.stringify(this.options.body);
87
+ }
88
+ return JSON.stringify(this.options.body)
89
+ } else {
90
+ throw new Error(`Unrecognized body: ${typeof this.options.body}`);
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Start the HTTP client.
96
+ */
97
+ start() {
98
+ if (!this.options.requestsPerSecond) {
99
+ return this.makeRequest();
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Stop the HTTP client.
105
+ */
106
+ stop() {
107
+ this.running = false
108
+ if (this.connection) {
109
+ this.connection.end()
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Make a single request to the server.
115
+ */
116
+ makeRequest() {
117
+ if (!this.running) {
118
+ return
119
+ }
120
+ this.connect()
121
+ this.parser = new Parser(this.params.method)
122
+ const id = this.latency.begin();
123
+ this.currentId = id
124
+ this.connection.write(this.params.request)
125
+ }
126
+
127
+ connect() {
128
+ if (this.connection && !this.connection.destroyed) {
129
+ return
130
+ }
131
+ this.connection = net.connect(this.params.port, this.params.hostname)
132
+ this.connection.on('data', data => {
133
+ this.parser.addPacket(data)
134
+ if (this.parser.finished) {
135
+ this.finishRequest(null);
136
+ }
137
+ });
138
+ this.connection.on('error', error => {
139
+ this.finishRequest(`Connection ${this.currentId} failed: ${error}`);
140
+ this.connection = null
141
+ });
142
+ this.connection.on('end', () => {
143
+ this.connection = null
144
+ if (this.parser) {
145
+ // connection waiting for a response; remake
146
+ this.makeRequest()
147
+ }
148
+ })
149
+ }
150
+
151
+ finishRequest(error) {
152
+ // reset parser for next request
153
+ const parser = this.parser
154
+ this.parser = null
155
+ if (!this.running) {
156
+ return
157
+ }
158
+ let errorCode = null;
159
+ if (parser.statusCode >= 400) {
160
+ errorCode = parser.statusCode
161
+ }
162
+ if (error) {
163
+ // console.error(error)
164
+ errorCode = '-1';
165
+ }
166
+ const elapsed = this.latency.end(this.currentId, errorCode);
167
+ if (elapsed < 0) {
168
+ // not found or not running
169
+ return;
170
+ }
171
+ if (this.options.maxRequests && this.latency.totalRequests >= this.options.maxRequests) {
172
+ this.loadTest.stop()
173
+ return;
174
+
175
+ }
176
+ this.makeRequest()
177
+ }
178
+
179
+ createResult(connection, body) {
180
+ if (!this.options.statusCallback && !this.options.contentInspector) {
181
+ return null
182
+ }
183
+ const client = connection.connection || connection.client
184
+ const result = {
185
+ host: client._host,
186
+ path: connection.req.path,
187
+ method: connection.req.method,
188
+ statusCode: connection.statusCode,
189
+ body,
190
+ headers: connection.headers,
191
+ };
192
+ if (connection.req.labels) {
193
+ result.labels = connection.req.labels
194
+ }
195
+ return result
196
+ }
197
+ }
198
+
package/lib/testserver.js CHANGED
@@ -3,6 +3,7 @@ import {server as WebSocketServer} from 'websocket'
3
3
  import * as util from 'util'
4
4
  import * as net from 'net'
5
5
  import {Latency} from './latency.js'
6
+ import {readFileSync} from 'fs'
6
7
 
7
8
  const PORT = 7357;
8
9
  const LOG_HEADERS_INTERVAL_MS = 5000;
@@ -17,6 +18,8 @@ const LOG_HEADERS_INTERVAL_MS = 5000;
17
18
  * - percent: give an error (default 500) on some % of requests.
18
19
  * - error: set an HTTP error code, default is 500.
19
20
  * - logger: function to log all incoming requests.
21
+ * - body: to return in requests.
22
+ * - file: to read and return as body.
20
23
  * - `callback`: optional callback, called after the server has started.
21
24
  * If not present will return a promise.
22
25
  */
@@ -44,7 +47,12 @@ class TestServer {
44
47
  this.wsServer = null;
45
48
  this.latency = new Latency({});
46
49
  this.totalRequests = 0
50
+ this.partialRequests = 0
47
51
  this.debuggedTime = Date.now();
52
+ this.body = options.body || 'OK'
53
+ if (options.file) {
54
+ this.body = readFileSync(options.file)
55
+ }
48
56
  }
49
57
 
50
58
  /**
@@ -52,7 +60,7 @@ class TestServer {
52
60
  * The callback parameter will be called after the server has started.
53
61
  */
54
62
  start(callback) {
55
- if (this.options.socket) {
63
+ if (this.options.tcp) {
56
64
  // just for internal debugging
57
65
  this.server = net.createServer(() => this.socketListen());
58
66
  } else {
@@ -72,7 +80,7 @@ class TestServer {
72
80
  });
73
81
  this.server.listen(this.port, () => {
74
82
  if (!this.options.quiet) console.info(`Listening on http://localhost:${this.port}/`)
75
- callback(null, this.server)
83
+ callback(null, this)
76
84
  });
77
85
  this.wsServer.on('request', request => {
78
86
  // explicity omitting origin check here.
@@ -88,7 +96,7 @@ class TestServer {
88
96
  if (!this.options.quiet) console.info('Peer %s disconnected', connection.remoteAddress);
89
97
  });
90
98
  });
91
- return this.server
99
+ return this
92
100
  }
93
101
 
94
102
  /**
@@ -102,7 +110,7 @@ class TestServer {
102
110
  * Listen to an incoming request.
103
111
  */
104
112
  listen(request, response) {
105
- const id = this.latency.start();
113
+ const id = this.latency.begin();
106
114
  const bodyBuffers = []
107
115
  request.on('data', data => {
108
116
  bodyBuffers.push(data)
@@ -114,6 +122,7 @@ class TestServer {
114
122
  })
115
123
  request.on('end', () => {
116
124
  request.body = Buffer.concat(bodyBuffers).toString();
125
+ this.partialRequests += 1
117
126
  this.totalRequests += 1
118
127
  const elapsedMs = Date.now() - this.debuggedTime
119
128
  if (elapsedMs > LOG_HEADERS_INTERVAL_MS) {
@@ -154,7 +163,7 @@ class TestServer {
154
163
  const headers = util.inspect(request.headers)
155
164
  const now = Date.now()
156
165
  const elapsedMs = now - this.debuggedTime
157
- const rps = (this.totalRequests / elapsedMs) * 1000
166
+ const rps = (this.partialRequests / elapsedMs) * 1000
158
167
  if (rps > 1) {
159
168
  console.info(`Requests per second: ${rps.toFixed(0)}`)
160
169
  }
@@ -163,7 +172,7 @@ class TestServer {
163
172
  console.info(`Body: ${request.body}`);
164
173
  }
165
174
  this.debuggedTime = now;
166
- this.totalRequests = 0
175
+ this.partialRequests = 0
167
176
  }
168
177
 
169
178
  /**
@@ -175,7 +184,7 @@ class TestServer {
175
184
  response.writeHead(code);
176
185
  response.end('ERROR');
177
186
  } else {
178
- response.end('OK');
187
+ response.end(this.body);
179
188
  }
180
189
  this.latency.end(id);
181
190
  if (this.options.logger) {
@@ -197,5 +206,13 @@ class TestServer {
197
206
  }
198
207
  return (Math.random() < percent / 100);
199
208
  }
209
+
210
+ close(callback) {
211
+ if (callback) {
212
+ this.server.close(callback)
213
+ return
214
+ }
215
+ return this.server.close()
216
+ }
200
217
  }
201
218
 
package/lib/websocket.js CHANGED
@@ -4,19 +4,13 @@ import {BaseClient} from './baseClient.js'
4
4
  let latency;
5
5
 
6
6
 
7
- /**
8
- * Create a client for a websocket.
9
- */
10
- export function create(loadTest, options) {
11
- return new WebsocketClient(loadTest, options);
12
- }
13
-
14
7
  /**
15
8
  * A client that connects to a websocket.
16
9
  */
17
- class WebsocketClient extends BaseClient {
18
- constructor(loadTest, options) {
19
- super(loadTest, options);
10
+ export class WebsocketClient extends BaseClient {
11
+ constructor(loadTest) {
12
+ super(loadTest);
13
+ this.latency = loadTest.latency
20
14
  this.connection = null;
21
15
  this.lastCall = null;
22
16
  this.client = null;
@@ -55,7 +49,7 @@ class WebsocketClient extends BaseClient {
55
49
  * Make a single request to the server.
56
50
  */
57
51
  makeRequest() {
58
- const id = this.loadTest.latency.start();
52
+ const id = this.latency.begin();
59
53
  const requestFinished = this.getRequestFinisher(id);
60
54
 
61
55
  if (this.connection.connected) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loadtest",
3
- "version": "7.1.1",
3
+ "version": "8.0.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",
@@ -24,7 +24,8 @@
24
24
  },
25
25
  "devDependencies": {
26
26
  "eslint": "^8.47.0",
27
- "microprofiler": "^2.0.0"
27
+ "microprofiler": "^2.0.0",
28
+ "why-is-node-running": "^2.2.2"
28
29
  },
29
30
  "keywords": [
30
31
  "testing",
package/test/all.js CHANGED
@@ -15,6 +15,8 @@ import {test as testLoadtest} from './loadtest.js'
15
15
  import {test as testWebsocket} from './websocket.js'
16
16
  import {test as integrationTest} from './integration.js'
17
17
  import {test as testResult} from './result.js'
18
+ import {test as testTcpClient} from './tcpClient.js'
19
+ //import log from 'why-is-node-running'
18
20
 
19
21
 
20
22
  /**
@@ -25,10 +27,13 @@ function test() {
25
27
  testHrtimer, testHeaders, testLatency, testHttpClient,
26
28
  testServer, integrationTest, testLoadtest, testWebsocket,
27
29
  testRequestGenerator, testBodyGenerator, testResult,
30
+ testTcpClient,
28
31
  ];
29
32
  testing.run(tests, 4200);
30
33
  }
31
34
 
35
+ //setTimeout(log, 4000)
36
+
32
37
  test()
33
38
 
34
39
 
@@ -2,16 +2,16 @@ import testing from 'testing'
2
2
  import {loadTest} from '../lib/loadtest.js'
3
3
  import {startServer} from '../lib/testserver.js'
4
4
 
5
- const PORT = 10453;
5
+ const port = 10453;
6
6
 
7
7
 
8
8
  function testBodyGenerator(callback) {
9
- const server = startServer({port: PORT, quiet: true}, error => {
9
+ const server = startServer({port, quiet: true}, error => {
10
10
  if (error) {
11
11
  return callback('Could not start test server');
12
12
  }
13
13
  const options = {
14
- url: 'http://localhost:' + PORT,
14
+ url: `http://localhost:${port}`,
15
15
  requestsPerSecond: 1000,
16
16
  maxRequests: 100,
17
17
  concurrency: 10,
@@ -1,14 +1,14 @@
1
1
  import testing from 'testing'
2
- import {create} from '../lib/httpClient.js'
2
+ import {HttpClient} from '../lib/httpClient.js'
3
3
 
4
4
 
5
5
  function testHttpClient(callback) {
6
6
  const options = {
7
- url: 'http://localhost:7357/',
7
+ url: 'http://localhost:7358/',
8
8
  maxSeconds: 0.1,
9
9
  concurrency: 1,
10
10
  };
11
- create({}, options);
11
+ new HttpClient({options});
12
12
  testing.success(callback);
13
13
  }
14
14