comq 0.15.7 → 0.15.8

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.
Files changed (49) hide show
  1. package/.releaserc +1 -8
  2. package/features/recovery.feature +30 -0
  3. package/features/silence.feature +46 -0
  4. package/features/steps/.types/context.d.ts +17 -0
  5. package/features/steps/backpressure.js +1 -1
  6. package/features/steps/broker.js +1 -1
  7. package/features/steps/brokers.js +1 -1
  8. package/features/steps/common.js +1 -1
  9. package/features/steps/connection.js +41 -1
  10. package/features/steps/context.js +12 -2
  11. package/features/steps/events.js +1 -1
  12. package/features/steps/hooks.js +1 -0
  13. package/features/steps/network.js +50 -0
  14. package/features/steps/networks.js +90 -0
  15. package/features/steps/parameters.js +6 -0
  16. package/features/steps/poison.js +1 -1
  17. package/features/steps/rpc.js +1 -1
  18. package/features/steps/tasks.js +1 -1
  19. package/package.json +3 -6
  20. package/readme.md +13 -2
  21. package/source/attributes/lazy.test.js +1 -1
  22. package/source/attributes/track.test.js +3 -3
  23. package/source/connection.js +92 -11
  24. package/source/emitter.js +23 -1
  25. package/source/encoders/index.js +0 -2
  26. package/source/events.js +1 -1
  27. package/source/io.js +6 -5
  28. package/source/shards/channel.js +20 -7
  29. package/source/shards/connection.js +0 -5
  30. package/test/amqplib.mock.js +16 -4
  31. package/test/channel.fixtures.js +1 -1
  32. package/test/channel.test.js +1 -1
  33. package/test/connection.test.js +126 -6
  34. package/test/decode.test.js +0 -13
  35. package/test/emitter.test.js +71 -0
  36. package/test/encode.test.js +0 -9
  37. package/test/encodings.js +1 -1
  38. package/test/helpers.js +77 -0
  39. package/test/io.close.test.js +4 -3
  40. package/test/io.diagnostics.test.js +1 -1
  41. package/test/io.reply.test.js +2 -2
  42. package/test/io.request.test.js +16 -2
  43. package/test/pipeline.test.js +1 -1
  44. package/test/shards/channel.test.js +63 -9
  45. package/test/shards/connection.test.js +4 -3
  46. package/types/encoding.d.ts +1 -2
  47. package/AGENTS.md +0 -35
  48. package/CHANGELOG.md +0 -85
  49. package/source/encoders/msgpack.js +0 -6
@@ -50,10 +50,7 @@ class Connection {
50
50
  * @param {string} url
51
51
  */
52
52
  constructor (url) {
53
- this.#url = url
54
-
55
- // EventEmitter throws on 'error' with no listeners
56
- this.#diagnostics.on('error', noop)
53
+ this.#url = heartbeaten(url)
57
54
  }
58
55
 
59
56
  get connected () {
@@ -200,7 +197,14 @@ class Connection {
200
197
  }
201
198
 
202
199
  connection.removeAllListeners()
203
- connection.connection?.stream?.destroy()
200
+
201
+ // amqplib keeps its heartbeater running on a connection it does not know is
202
+ // gone and emits 'error' on it, which throws once no listener is left
203
+ connection.on('error', noop)
204
+
205
+ // a socket destroyed without an error tells amqplib nothing, leaving its
206
+ // timers running and everything pending on it hanging forever
207
+ connection.connection?.stream?.destroy(silence())
204
208
  }
205
209
 
206
210
  #recover () {
@@ -215,7 +219,7 @@ class Connection {
215
219
  * @return {Promise<comq.amqp.Connection>}
216
220
  */
217
221
  async #connect () {
218
- const connecting = amqp.connect(this.#url, { timeout: CONNECT_MS })
222
+ const connecting = amqp.connect(this.#url, SOCKET_OPTIONS)
219
223
 
220
224
  let timer
221
225
 
@@ -248,19 +252,23 @@ class Connection {
248
252
  * @param {comq.amqp.Connection} connection
249
253
  * @param {number} [timeoutMs]
250
254
  */
251
- #armWatchdog (connection, timeoutMs = global.COMQ_TESTING_WATCHDOG_INTERVAL ?? WATCHDOG_MS) {
255
+ #armWatchdog (connection, timeoutMs = tolerance(connection)) {
256
+ this.#disarmWatchdog()
257
+
252
258
  const socket = connection.connection?.stream
253
259
 
254
- if (socket === undefined) return
260
+ if (socket === undefined || timeoutMs === undefined) return
255
261
 
256
262
  const reset = () => {
257
263
  clearTimeout(this.#heartbeatTimer)
258
- this.#heartbeatTimer = setTimeout(() => socket.destroy(), timeoutMs)
264
+ // destroying a socket without an error tells amqplib nothing: it only
265
+ // listens for 'error' and 'end', so a bare destroy() leaves the connection
266
+ // silently dead — no 'close' event, no recovery, every pending operation
267
+ // hanging forever
268
+ this.#heartbeatTimer = setTimeout(() => socket.destroy(silence()), timeoutMs)
259
269
  this.#heartbeatTimer.unref()
260
270
  }
261
271
 
262
- this.#disarmWatchdog()
263
-
264
272
  this.#heartbeatSocket = socket
265
273
  this.#heartbeatReset = reset
266
274
 
@@ -289,7 +297,25 @@ class Connection {
289
297
  }
290
298
  }
291
299
 
300
+ /**
301
+ * The heartbeat to ask for when the caller has not, in seconds. A broker is free
302
+ * to suggest one that leaves a connection lost for minutes before anything
303
+ * notices, and RabbitMQ suggests 60 by default.
304
+ *
305
+ * @type {number}
306
+ */
307
+ const HEARTBEAT_S = 15
308
+
292
309
  /** @type {number} */
310
+ const KEEPALIVE_MS = 10_000
311
+
312
+ /** How many heartbeats a connection may miss before it is destroyed. */
313
+ const MISSED_HEARTBEATS = 3
314
+
315
+ /** @type {number} */
316
+ const WATCHDOG_MIN_MS = 15_000
317
+
318
+ /** The watchdog interval used when the negotiated heartbeat is unknown. */
293
319
  const WATCHDOG_MS = 60_000
294
320
 
295
321
  /** @type {number} */
@@ -298,6 +324,15 @@ const CONNECT_MS = 30_000
298
324
  /** @type {number} */
299
325
  const SHUTDOWN_MS = 5_000
300
326
 
327
+ const SOCKET_OPTIONS = {
328
+ timeout: CONNECT_MS,
329
+ // a peer that went away without a word is noticed by the kernel as well
330
+ keepAlive: true,
331
+ keepAliveDelay: KEEPALIVE_MS
332
+ }
333
+
334
+ const HEARTBEAT_SET = /[?&]heartbeat=/
335
+
301
336
  const TRANSIENT_CODES = new Set([
302
337
  'ECONNREFUSED',
303
338
  'EAI_AGAIN',
@@ -323,6 +358,52 @@ function expiration () {
323
358
  return delay(timeoutMs, undefined, { ref: false })
324
359
  }
325
360
 
361
+ /**
362
+ * amqplib reads the heartbeat from the URL only, hence it cannot be passed along
363
+ * with the socket options.
364
+ *
365
+ * @param {string} url
366
+ * @return {string}
367
+ */
368
+ function heartbeaten (url) {
369
+ if (HEARTBEAT_SET.test(url)) return url
370
+
371
+ return url + (url.includes('?') ? '&' : '?') + 'heartbeat=' + HEARTBEAT_S
372
+ }
373
+
374
+ /**
375
+ * The watchdog measures silence, so it cannot be shorter than the interval at
376
+ * which a healthy broker is expected to say something, which is a heartbeat
377
+ * frame every half a heartbeat. A connection that has agreed to no heartbeats
378
+ * says nothing at all while it is idle, leaving nothing to measure.
379
+ *
380
+ * @param {comq.amqp.Connection} connection
381
+ * @return {number | undefined}
382
+ */
383
+ function tolerance (connection) {
384
+ const override = global.COMQ_TESTING_WATCHDOG_INTERVAL
385
+
386
+ if (override !== undefined) return override
387
+
388
+ const heartbeat = connection.connection?.heartbeat
389
+
390
+ if (heartbeat === undefined) return WATCHDOG_MS
391
+ if (heartbeat === 0) return undefined
392
+
393
+ return Math.max(heartbeat * MISSED_HEARTBEATS * 1000, WATCHDOG_MIN_MS)
394
+ }
395
+
396
+ /**
397
+ * @return {Error}
398
+ */
399
+ function silence () {
400
+ const exception = new Error('Connection is silent')
401
+
402
+ exception.code = 'ETIMEDOUT'
403
+
404
+ return exception
405
+ }
406
+
326
407
  function noop () {}
327
408
 
328
409
  exports.Connection = Connection
package/source/emitter.js CHANGED
@@ -2,8 +2,30 @@
2
2
 
3
3
  const { EventEmitter } = require('node:events')
4
4
 
5
+ /**
6
+ * A listener must not be able to break what it observes: an exception thrown by
7
+ * one used to leave the reconnection it was reporting on unfinished, or take the
8
+ * process down through the emitter amqplib was calling.
9
+ */
10
+ class Emitter extends EventEmitter {
11
+ emit (event, ...args) {
12
+ // raw listeners are used so that `once` still removes itself
13
+ const listeners = this.rawListeners(event)
14
+
15
+ for (const listener of listeners) {
16
+ try {
17
+ listener.apply(this, args)
18
+ } catch {
19
+ // a diagnostic listener has no one to report to
20
+ }
21
+ }
22
+
23
+ return listeners.length > 0
24
+ }
25
+ }
26
+
5
27
  function create () {
6
- const emitter = new EventEmitter()
28
+ const emitter = new Emitter()
7
29
 
8
30
  emitter.setMaxListeners(0)
9
31
 
@@ -1,9 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const json = require('./json')
4
- const msgpack = require('./msgpack')
5
4
  const plain = require('./plain')
6
5
 
7
6
  exports['application/json'] = json
8
- exports['application/msgpack'] = msgpack
9
7
  exports['text/plain'] = plain
package/source/events.js CHANGED
@@ -4,4 +4,4 @@
4
4
  exports.connection = ['open', 'close', 'error', 'reconnect']
5
5
 
6
6
  /** @type {comq.diagnostics.Event[]} */
7
- exports.channel = ['flow', 'drain', 'recover', 'discard', 'pause', 'resume', 'return', 'lost']
7
+ exports.channel = ['flow', 'drain', 'recover', 'discard', 'pause', 'resume', 'return', 'lost', 'remove']
package/source/io.js CHANGED
@@ -1,7 +1,6 @@
1
1
  'use strict'
2
2
 
3
3
  const stream = require('node:stream')
4
- const { EventEmitter } = require('node:events')
5
4
  const { randomBytes } = require('node:crypto')
6
5
  const { setTimeout } = require('node:timers/promises')
7
6
  const { Promex } = require('promex')
@@ -11,6 +10,7 @@ const { decode } = require('./decode')
11
10
  const { encode } = require('./encode')
12
11
  const { pipeline, transform } = require('./pipeline')
13
12
  const events = require('./events')
13
+ const emitter = require('./emitter')
14
14
  const io = require('./.io')
15
15
 
16
16
  /**
@@ -44,7 +44,7 @@ class IO {
44
44
  /** @type {Set<comq.Destroyable>} */
45
45
  #replyPipes = new Set()
46
46
 
47
- #diagnostics = new EventEmitter()
47
+ #diagnostics = emitter.create()
48
48
 
49
49
  /**
50
50
  * @param {comq.Connection} connection
@@ -52,9 +52,6 @@ class IO {
52
52
  constructor (connection) {
53
53
  this.#connection = connection
54
54
 
55
- // EventEmitter throws on 'error' with no listeners
56
- this.#diagnostics.on('error', noop)
57
-
58
55
  for (const event of events.connection) {
59
56
  this.#connection.diagnose(event, (...args) => this.#diagnostics.emit(event, ...args))
60
57
  }
@@ -220,6 +217,10 @@ class IO {
220
217
  } else {
221
218
  this.#requests.diagnose('recover', this.#retransmit)
222
219
  }
220
+
221
+ // a reply that could not be routed is dropped by the broker, and the queue
222
+ // it was addressed to only exists again once this channel has recovered
223
+ this.#replies.diagnose('recover', this.#retransmit)
223
224
  }
224
225
 
225
226
  /**
@@ -31,6 +31,9 @@ class Channel {
31
31
  /** @type {Map<comq.Channel, Promex>} */
32
32
  #bench = new Map()
33
33
 
34
+ /** @type {boolean} */
35
+ #paused = false
36
+
34
37
  /** @type {comq.topology.type} */
35
38
  #type
36
39
 
@@ -126,6 +129,9 @@ class Channel {
126
129
  this.#alive[index] = false
127
130
  this.#down[index].resolve()
128
131
 
132
+ // the channel stays in the pool, yet there is one less shard to publish to
133
+ this.#update()
134
+
129
135
  if (error !== undefined && connection.closed !== true) {
130
136
  this.#diagnostics.emit(LOST, index)
131
137
  }
@@ -134,6 +140,8 @@ class Channel {
134
140
  connection.diagnose('open', () => {
135
141
  this.#alive[index] = true
136
142
  this.#down[index] = new Promex()
143
+
144
+ this.#update()
137
145
  })
138
146
  }
139
147
 
@@ -159,6 +167,7 @@ class Channel {
159
167
  for (const event of events.channel) {
160
168
  if (event === RETURN) continue // returns are retried before being reported
161
169
  if (event === LOST) continue // a shard is lost with its connection, not its channel
170
+ if (event === REMOVE) continue // it is the pool that removes a channel, not the channel
162
171
 
163
172
  channel.diagnose(event, (...args) => this.#diagnostics.emit(event, ...args, channel.index))
164
173
  }
@@ -214,20 +223,23 @@ class Channel {
214
223
  this.#bench.set(channel, new Promex())
215
224
  this.#channels.delete(channel)
216
225
  this.#update()
217
- this.#diagnostics.emit('remove', channel.index)
226
+ this.#diagnostics.emit(REMOVE, channel.index)
218
227
  }
219
228
 
229
+ /**
230
+ * A shard leaves the pool when it rejects a publish, and stops being reachable
231
+ * when its connection is lost, which the pool is not told about. Both leave
232
+ * `#one` waiting, so both are reported the same way.
233
+ */
220
234
  #update () {
221
- const from = this.#pool?.length
222
- const to = this.#channels.size
223
-
224
235
  this.#pool = Array.from(this.#channels)
225
236
 
226
- if (from === undefined) return
237
+ const paused = this.#reachable().length === 0
227
238
 
228
- if (from !== 0 && to === 0) { this.#diagnostics.emit('pause') }
239
+ if (paused === this.#paused) return
229
240
 
230
- if (from === 0 && to !== 0) { this.#diagnostics.emit('resume') }
241
+ this.#paused = paused
242
+ this.#diagnostics.emit(paused ? 'pause' : 'resume')
231
243
  }
232
244
 
233
245
  /**
@@ -369,6 +381,7 @@ async function create (connections, type) {
369
381
 
370
382
  const DEFAULT = ''
371
383
  const RETURN = 'return'
384
+ const REMOVE = 'remove'
372
385
  const RETURN_HEADER = 'x-return'
373
386
  const LOST = 'lost'
374
387
 
@@ -19,9 +19,6 @@ class Connection {
19
19
  constructor (connections) {
20
20
  this.#connections = connections
21
21
 
22
- // EventEmitter throws on 'error' with no listeners
23
- this.#diagnostics.on('error', noop)
24
-
25
22
  connections.map(this.#pipe)
26
23
  }
27
24
 
@@ -75,6 +72,4 @@ class Connection {
75
72
  }
76
73
  }
77
74
 
78
- function noop () {}
79
-
80
75
  exports.Connection = Connection
@@ -18,14 +18,26 @@ class Channel extends EventEmitter {
18
18
  }
19
19
 
20
20
  class Connection extends EventEmitter {
21
- constructor () {
21
+ constructor (url = '') {
22
22
  super()
23
23
 
24
24
  const stream = new EventEmitter()
25
25
 
26
- stream.destroy = jest.fn()
26
+ // amqplib listens for 'error' and 'end' on the socket only, so destroying it
27
+ // without an error leaves the connection unaware and silent forever
28
+ stream.destroy = jest.fn((error) => {
29
+ if (error === undefined || stream.destroyed === true) return
27
30
 
28
- this.connection = { stream }
31
+ stream.destroyed = true
32
+
33
+ this.emit('error', error)
34
+ this.emit('close', error)
35
+ })
36
+
37
+ // a broker accepts the heartbeat the client asks for
38
+ const heartbeat = Number(/[?&]heartbeat=(\d+)/.exec(url)?.[1] ?? 60)
39
+
40
+ this.connection = { stream, heartbeat }
29
41
 
30
42
  // noinspection JSValidateTypes
31
43
  this.removeAllListeners = jest.spyOn(this, 'removeAllListeners')
@@ -39,7 +51,7 @@ class Connection extends EventEmitter {
39
51
  close = jest.fn(async () => undefined)
40
52
  }
41
53
 
42
- const connect = async () => new Connection()
54
+ const connect = async (url) => new Connection(url)
43
55
 
44
56
  /** @type {jest.MockedObject<import('amqplib')>} */
45
57
  const amqplib = {
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const { random, flip } = require('@toa.io/generic')
3
+ const { random, flip } = require('./helpers')
4
4
 
5
5
  const preset = () => ({
6
6
  prefetch: random(10),
@@ -4,7 +4,7 @@
4
4
 
5
5
  const { randomBytes } = require('node:crypto')
6
6
  const { generate } = require('randomstring')
7
- const { flip, random, timeout } = require('@toa.io/generic')
7
+ const { flip, random, timeout } = require('./helpers')
8
8
 
9
9
  const backpressure = require('./backpressure')
10
10
  const { amqplib } = require('./amqplib.mock')
@@ -4,7 +4,8 @@
4
4
 
5
5
  const { generate } = require('randomstring')
6
6
 
7
- const { timeout, promex, random } = require('@toa.io/generic')
7
+ const { Promex } = require('promex')
8
+ const { timeout, random } = require('./helpers')
8
9
  const { amqplib, connect } = require('./amqplib.mock')
9
10
  const { channel: create } = require('./connection.mock')
10
11
  const mock = { amqplib, channel: { create } }
@@ -37,7 +38,8 @@ describe('initial connection', () => {
37
38
  it('should connect', async () => {
38
39
  await connection.open()
39
40
 
40
- expect(amqplib.connect).toHaveBeenCalledWith(url, { timeout: expect.any(Number) })
41
+ expect(amqplib.connect).toHaveBeenCalledWith(expect.stringContaining(url),
42
+ expect.objectContaining({ timeout: expect.any(Number) }))
41
43
  })
42
44
 
43
45
  it.each(/** @type {[string, Partial<Error>][]} */[
@@ -138,6 +140,41 @@ describe('reconnection', () => {
138
140
  expect(amqplib.connect.mock.calls.length).toBeGreaterThanOrEqual(3)
139
141
  }, 15000)
140
142
 
143
+ it('should keep an error sink on a dropped connection', async () => {
144
+ const channel = await connection.createChannel('request')
145
+
146
+ channel.recover
147
+ .mockRejectedValueOnce(new Error('Channel closed'))
148
+ .mockResolvedValue(undefined)
149
+
150
+ conn.emit('close', new Error('lost'))
151
+
152
+ await timeout(10)
153
+
154
+ const dropped = await amqplib.connect.mock.results[1].value
155
+
156
+ // amqplib goes on emitting on a connection it does not know is gone, and an
157
+ // 'error' with no listener left takes the process down
158
+ expect(() => dropped.emit('error', new Error('Heartbeat timeout'))).not.toThrow()
159
+
160
+ // let the attempt that follows the failed recovery settle
161
+ const start = Date.now()
162
+
163
+ while (channel.recover.mock.calls.length < 2 && Date.now() - start < 10000) await timeout(50)
164
+ }, 15000)
165
+
166
+ it('should reconnect despite a listener that throws', async () => {
167
+ // a diagnostic listener has no business breaking the reconnection it reports
168
+ connection.diagnose('reconnect', () => { throw new Error('listener') })
169
+ connection.diagnose('open', () => { throw new Error('listener') })
170
+
171
+ conn.emit('close', new Error('lost'))
172
+
173
+ await expect(connection.createChannel('event')).resolves.toBeDefined()
174
+
175
+ expect(amqplib.connect).toHaveBeenCalledTimes(2)
176
+ }, 10000)
177
+
141
178
  it('should emit error when reconnect open fails', async () => {
142
179
  const errors = []
143
180
  const unhandled = jest.fn()
@@ -339,7 +376,7 @@ describe('create channel', () => {
339
376
 
340
377
  jest.clearAllMocks()
341
378
 
342
- const promise = promex()
379
+ const promise = new Promex()
343
380
 
344
381
  amqplib.connect.mockImplementationOnce(() => promise)
345
382
 
@@ -373,11 +410,23 @@ describe('watchdog', () => {
373
410
 
374
411
  const conn = await amqplib.connect.mock.results[0].value
375
412
 
376
- await jest.advanceTimersByTimeAsync(60_000)
413
+ await jest.advanceTimersByTimeAsync(46_000)
377
414
 
378
415
  expect(conn.connection.stream.destroy).toHaveBeenCalled()
379
416
  })
380
417
 
418
+ it('should reconnect after the watchdog destroys a silent connection', async () => {
419
+ jest.useFakeTimers()
420
+
421
+ await connection.open()
422
+
423
+ await jest.advanceTimersByTimeAsync(46_000)
424
+
425
+ // a socket destroyed without an error is a socket amqplib never reports,
426
+ // which leaves the connection silently dead instead of recovering
427
+ expect(amqplib.connect).toHaveBeenCalledTimes(2)
428
+ })
429
+
381
430
  it('should not let a replaced socket disarm the watchdog', async () => {
382
431
  jest.useFakeTimers()
383
432
 
@@ -407,11 +456,11 @@ describe('watchdog', () => {
407
456
 
408
457
  const conn = await amqplib.connect.mock.results[0].value
409
458
 
410
- await jest.advanceTimersByTimeAsync(50_000)
459
+ await jest.advanceTimersByTimeAsync(40_000)
411
460
 
412
461
  conn.connection.stream.emit('data', Buffer.alloc(0))
413
462
 
414
- await jest.advanceTimersByTimeAsync(50_000)
463
+ await jest.advanceTimersByTimeAsync(40_000)
415
464
 
416
465
  expect(conn.connection.stream.destroy).not.toHaveBeenCalled()
417
466
 
@@ -419,6 +468,77 @@ describe('watchdog', () => {
419
468
 
420
469
  expect(conn.connection.stream.destroy).toHaveBeenCalled()
421
470
  })
471
+
472
+ it('should derive the watchdog from the negotiated heartbeat', async () => {
473
+ jest.useFakeTimers()
474
+
475
+ const connection = new Connection('amqp://localhost?heartbeat=30')
476
+
477
+ await connection.open()
478
+
479
+ const conn = await amqplib.connect.mock.results[0].value
480
+
481
+ // the broker is only expected to say something every 15 seconds
482
+ await jest.advanceTimersByTimeAsync(60_000)
483
+
484
+ expect(conn.connection.stream.destroy).not.toHaveBeenCalled()
485
+
486
+ await jest.advanceTimersByTimeAsync(31_000)
487
+
488
+ expect(conn.connection.stream.destroy).toHaveBeenCalled()
489
+ })
490
+
491
+ it('should not arm the watchdog when heartbeats are disabled', async () => {
492
+ jest.useFakeTimers()
493
+
494
+ const connection = new Connection('amqp://localhost?heartbeat=0')
495
+
496
+ await connection.open()
497
+
498
+ const conn = await amqplib.connect.mock.results[0].value
499
+
500
+ // an idle connection that has agreed to no heartbeats is silent by design
501
+ await jest.advanceTimersByTimeAsync(600_000)
502
+
503
+ expect(conn.connection.stream.destroy).not.toHaveBeenCalled()
504
+ })
505
+ })
506
+
507
+ describe('heartbeat', () => {
508
+ it('should ask for a heartbeat', async () => {
509
+ const connection = new Connection('amqp://developer@localhost')
510
+
511
+ await connection.open()
512
+
513
+ expect(amqplib.connect).toHaveBeenCalledWith('amqp://developer@localhost?heartbeat=15',
514
+ expect.anything())
515
+ })
516
+
517
+ it('should append the heartbeat to an existing query', async () => {
518
+ const connection = new Connection('amqp://localhost?frameMax=8192')
519
+
520
+ await connection.open()
521
+
522
+ expect(amqplib.connect).toHaveBeenCalledWith('amqp://localhost?frameMax=8192&heartbeat=15',
523
+ expect.anything())
524
+ })
525
+
526
+ it.each(['amqp://localhost?heartbeat=5', 'amqp://localhost?heartbeat=0'])(
527
+ 'should keep the heartbeat requested by the caller (%s)',
528
+ async (url) => {
529
+ const connection = new Connection(url)
530
+
531
+ await connection.open()
532
+
533
+ expect(amqplib.connect).toHaveBeenCalledWith(url, expect.anything())
534
+ })
535
+
536
+ it('should keep the socket alive', async () => {
537
+ await connection.open()
538
+
539
+ expect(amqplib.connect)
540
+ .toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ keepAlive: true }))
541
+ })
422
542
  })
423
543
 
424
544
  describe('close', () => {
@@ -2,7 +2,6 @@
2
2
 
3
3
  const { randomBytes } = require('node:crypto')
4
4
  const { generate } = require('randomstring')
5
- const { pack } = require('msgpackr')
6
5
 
7
6
  const { decode } = require('../source/decode')
8
7
 
@@ -23,18 +22,6 @@ it('should decode application/json', async () => {
23
22
  expect(decoded).toStrictEqual(object)
24
23
  })
25
24
 
26
- it('should decode application/msgpack', async () => {
27
- const object = { [generate()]: generate() }
28
- const content = pack(object)
29
- const contentType = 'application/msgpack'
30
- const properties = { contentType }
31
- const message = /** @type {comq.amqp.Message} */ { content, properties }
32
-
33
- const decoded = decode(message)
34
-
35
- expect(decoded).toStrictEqual(object)
36
- })
37
-
38
25
  it('should return buffer if content type is not defined', async () => {
39
26
  const object = { [generate()]: generate() }
40
27
  const json = JSON.stringify(object)
@@ -0,0 +1,71 @@
1
+ 'use strict'
2
+
3
+ const { generate } = require('randomstring')
4
+ const emitter = require('../source/emitter')
5
+
6
+ /** @type {import('node:events').EventEmitter} */
7
+ let instance
8
+
9
+ beforeEach(() => {
10
+ instance = emitter.create()
11
+ })
12
+
13
+ it('should be', async () => {
14
+ expect(emitter.create).toBeInstanceOf(Function)
15
+ })
16
+
17
+ it('should emit', async () => {
18
+ const listener = jest.fn()
19
+ const event = generate()
20
+ const args = [generate(), generate()]
21
+
22
+ instance.on(event, listener)
23
+
24
+ expect(instance.emit(event, ...args)).toStrictEqual(true)
25
+ expect(listener).toHaveBeenCalledWith(...args)
26
+ })
27
+
28
+ it('should report an event no one listens to', async () => {
29
+ expect(instance.emit(generate())).toStrictEqual(false)
30
+ })
31
+
32
+ it('should not throw on error without listeners', async () => {
33
+ expect(() => instance.emit('error', new Error(generate()))).not.toThrow()
34
+ })
35
+
36
+ it('should not let a listener break the emission', async () => {
37
+ const event = generate()
38
+ const listener = jest.fn()
39
+
40
+ instance.on(event, () => { throw new Error(generate()) })
41
+ instance.on(event, listener)
42
+
43
+ expect(() => instance.emit(event)).not.toThrow()
44
+
45
+ // the listeners that follow a broken one are still called
46
+ expect(listener).toHaveBeenCalled()
47
+ })
48
+
49
+ it('should call a `once` listener once', async () => {
50
+ const event = generate()
51
+ const listener = jest.fn()
52
+
53
+ instance.once(event, listener)
54
+
55
+ instance.emit(event)
56
+ instance.emit(event)
57
+
58
+ expect(listener).toHaveBeenCalledTimes(1)
59
+ })
60
+
61
+ it('should not call a listener that has been removed', async () => {
62
+ const event = generate()
63
+ const listener = jest.fn()
64
+
65
+ instance.on(event, listener)
66
+ instance.off(event, listener)
67
+
68
+ instance.emit(event)
69
+
70
+ expect(listener).not.toHaveBeenCalled()
71
+ })