comq 0.13.0 → 0.14.0

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.
@@ -1,73 +1,43 @@
1
1
  name: Release
2
2
 
3
3
  on:
4
- pull_request:
5
- types: [closed]
4
+ push:
6
5
  branches:
7
- - release
8
- - alpha
9
- paths:
10
- - 'source/**'
11
- - 'types/**'
12
- - 'package-lock.json'
6
+ - dev
13
7
 
14
- env:
15
- CI: 1
8
+ permissions:
9
+ contents: write
10
+ issues: write
11
+ pull-requests: write
12
+ id-token: write
16
13
 
17
14
  jobs:
18
- publish:
19
- if: github.event.pull_request.merged == true
15
+ release:
20
16
  runs-on: ubuntu-latest
17
+ env:
18
+ HUSKY: 0
21
19
 
22
20
  steps:
23
- - name: Setup Node
24
- uses: actions/setup-node@v6
25
- with:
26
- node-version: 20
27
- registry-url: 'https://registry.npmjs.org'
28
-
29
21
  - name: Checkout
30
22
  uses: actions/checkout@v6
31
23
  with:
32
24
  fetch-depth: 0
25
+ persist-credentials: false
33
26
 
34
- - name: Run tests
35
- run: npm i && npm test
36
-
37
- - name: Import GPG key
38
- id: gpg
39
- uses: crazy-max/ghaction-import-gpg@v6
27
+ - name: Setup Node
28
+ uses: actions/setup-node@v6
40
29
  with:
41
- gpg_private_key: ${{ secrets.TOABOT_GPG_PRIVATE_KEY }}
42
- passphrase: ${{ secrets.TOABOT_GPG_PASSPHRASE }}
43
- git_user_signingkey: true
44
- git_commit_gpgsign: true
30
+ node-version: "24"
31
+ registry-url: https://registry.npmjs.org
32
+ package-manager-cache: false
45
33
 
46
- - name: Bump version
47
- run: |
48
- if [ ${{ github.base_ref }} = release ]; then
49
- npx lerna@7.3.0 version --conventional-graduate --no-commit-hooks --no-changelog --yes --message "ci: %s" --sign-git-commit --sign-git-tag
50
- else
51
- npx lerna@7.3.0 version --conventional-prerelease --preid alpha --dist-tag alpha --no-commit-hooks --no-changelog --yes --message "ci: %s" --git-tag-command "echo %s" --sign-git-commit
52
- fi
53
-
54
- git fetch
55
- git checkout dev
56
- git merge ${{ github.base_ref }}
57
- git push --all origin --no-verify
58
- env:
59
- GITHUB_TOKEN: ${{ secrets.TOABOT_GITHUB_TOKEN }}
60
- GIT_AUTHOR_NAME: ${{ steps.gpg.outputs.name }}
61
- GIT_AUTHOR_EMAIL: ${{ steps.gpg.outputs.email }}
62
- GIT_COMMITTER_NAME: ${{ steps.gpg.outputs.name }}
63
- GIT_COMMITTER_EMAIL: ${{ steps.gpg.outputs.email }}
34
+ - name: Install dependencies
35
+ run: npm ci
36
+
37
+ - name: Run tests
38
+ run: npm test
64
39
 
65
- - name: Publish packages
40
+ - name: Semantic release
66
41
  env:
67
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
68
- run: |
69
- if [ ${{ github.base_ref }} = release ]; then
70
- npm publish
71
- else
72
- npm publish --tag alpha
73
- fi
42
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
43
+ run: npx semantic-release
package/.releaserc ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "branches": [
3
+ "dev"
4
+ ],
5
+ "plugins": [
6
+ "@semantic-release/commit-analyzer",
7
+ "@semantic-release/release-notes-generator",
8
+ [
9
+ "@semantic-release/changelog",
10
+ {
11
+ "changelogFile": "CHANGELOG.md"
12
+ }
13
+ ],
14
+ [
15
+ "@semantic-release/npm"
16
+ ],
17
+ [
18
+ "@semantic-release/git",
19
+ {
20
+ "assets": [
21
+ "package.json",
22
+ "package-lock.json",
23
+ "CHANGELOG.md"
24
+ ],
25
+ "message": "chore(release): ${nextRelease.version}\n\n${nextRelease.notes}"
26
+ }
27
+ ],
28
+ [
29
+ "@semantic-release/github",
30
+ {
31
+ "owner": "toa-io"
32
+ }
33
+ ]
34
+ ]
35
+ }
package/AGENTS.md ADDED
@@ -0,0 +1,35 @@
1
+ # AGENTS.md
2
+
3
+ ## Cursor Cloud specific instructions
4
+
5
+ ### Project Overview
6
+
7
+ ComQ is a Node.js communication library over AMQP (RabbitMQ). It provides RPC, pub/sub, task queues, pipelines, and sharded connections.
8
+
9
+ ### Services
10
+
11
+ Feature tests start two RabbitMQ instances automatically via Testcontainers (`features/steps/brokers.js`). Credentials: `developer` / `secret`. AMQP ports are assigned dynamically on the host.
12
+
13
+ ### Docker
14
+
15
+ Feature tests require a running Docker daemon. In nested container environments, start Docker with:
16
+
17
+ ```bash
18
+ sudo dockerd --exec-opt native.cgroupdriver=cgroupfs &
19
+ sudo chmod 666 /var/run/docker.sock
20
+ ```
21
+
22
+ Do not use `docker compose up` for feature tests — Testcontainers manages broker lifecycle. `docker compose` may fail here because `deploy.resources.limits.memory` requires cgroupv2 memory limits that are unavailable in nested setups.
23
+
24
+ ### Commands
25
+
26
+ - `npm test` — runs `standard` (lint) + `jest` (300 unit tests, no RabbitMQ required)
27
+ - `npm run features` — runs all Cucumber feature tests (starts RabbitMQ via Testcontainers)
28
+ - `npm run lint` — runs `standard --fix --verbose | snazzy`
29
+
30
+ ### Testing Notes
31
+
32
+ - Unit tests (`jest`) use mocks and do NOT require running RabbitMQ.
33
+ - Feature tests (`cucumber-js`) start brokers in `BeforeAll` and stop them in `AfterAll`.
34
+ - Recovery/connection tests (in `features/connection.feature`, `features/recovery.feature`, `features/shards.feature`) stop/kill broker containers and take 5-15 minutes to complete.
35
+ - To run only fast feature tests (no broker stop/start): `npx cucumber-js --fail-fast -t 'not @manual' features/events.feature features/rpc.feature features/tasks.feature features/singleton.feature features/properties.feature features/tasks.shards.feature`
package/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # [0.14.0](https://github.com/toa-io/comq/compare/v0.13.0...v0.14.0) (2026-05-24)
2
+
3
+
4
+ ### Features
5
+
6
+ * **features:** manage RabbitMQ brokers with Testcontainers ([1d9ca59](https://github.com/toa-io/comq/commit/1d9ca591ff458c75caf87ab63224b15942ba7d07))
7
+ * **features:** use Testcontainers for RabbitMQ in Gherkin tests ([#219](https://github.com/toa-io/comq/issues/219)) ([a33e66b](https://github.com/toa-io/comq/commit/a33e66bfc357f0f089c728bc60c08f6f24b2c8d1))
8
+
9
+ # Changelog
10
+
11
+ All notable changes to this project are documented in this file by [semantic-release](https://github.com/semantic-release/semantic-release).
@@ -8,5 +8,4 @@ Feature: Back pressure handling
8
8
  """
9
9
  () => { return Buffer.from('ok') }
10
10
  """
11
- When I'm sending 500kB requests to the `flood` queue at 5kHz for 0.5 seconds
12
- Then back pressure was applied
11
+ When I'm flooding the `flood` queue until back pressure is applied
@@ -1,4 +1,11 @@
1
1
  # Features
2
2
 
3
- To run features, start the RabbitMQ server by running `docker compose up -d` in the package root,
4
- then execute `npm run features`.
3
+ Feature tests start two RabbitMQ instances automatically via [Testcontainers](https://node.testcontainers.org/modules/rabbitmq/).
4
+
5
+ Run them with:
6
+
7
+ ```bash
8
+ npm run features
9
+ ```
10
+
11
+ Docker must be available on the host. No manual `docker compose` setup is required.
@@ -41,7 +41,7 @@ Feature: Sharded Connection
41
41
  Scenario: Shard crashes while publishing events
42
42
  Given an active sharded connection
43
43
  And events are exclusively consumed from the `flood` exchange
44
- And I'm publishing 1kB events to the `flood` exchange at 100Hz
44
+ And I'm publishing 1kB events to the `flood` exchange at 10Hz
45
45
  When one of the brokers has crashed
46
46
  Then no exceptions are thrown
47
47
  And all events have been received
@@ -4,43 +4,27 @@ const assert = require('node:assert')
4
4
  const { randomBytes } = require('node:crypto')
5
5
  const { quantity, timeout } = require('@toa.io/generic')
6
6
 
7
- const { When, Then } = require('@cucumber/cucumber')
7
+ const { When } = require('@cucumber/cucumber')
8
8
 
9
- When('I\'m sending {quantity}B requests to the {token} queue at {quantity}Hz for {number} second(s)',
9
+ const PAYLOAD = quantity('500k')
10
+ const BATCH = 20
11
+ const DEADLINE = 25_000
12
+
13
+ When('I\'m flooding the {token} queue until back pressure is applied',
10
14
  /**
11
- * @param {string} bytesQ
12
15
  * @param {string} queue
13
- * @param {string} frequencyQ
14
- * @param {number} seconds
15
16
  * @this {comq.features.Context}
16
17
  */
17
- async function (bytesQ, queue, frequencyQ, seconds) {
18
- const bytes = quantity(bytesQ)
19
- const frequency = quantity(frequencyQ)
20
- const buffer = randomBytes(bytes)
21
- const times = seconds * frequency
22
-
23
- // intervals less than 1ms are not accurate
24
- const interval = Math.max((1000 / frequency), 1)
25
- const each = Math.max(1 / (1000 / frequency), 1)
26
-
27
- const promises = []
18
+ async function (queue) {
19
+ const buffer = randomBytes(PAYLOAD)
20
+ const deadline = Date.now() + DEADLINE
21
+ const pending = []
28
22
 
29
- for (let i = 0; i < times; i++) {
30
- const promise = this.io.request(queue, buffer)
23
+ while (!this.events.flow && Date.now() < deadline) {
24
+ for (let i = 0; i < BATCH; i++) { pending.push(this.io.request(queue, buffer).catch(() => {})) }
31
25
 
32
- promises.push(promise)
33
-
34
- if ((i + 1) % each === 0) await timeout(interval)
26
+ await timeout(0)
35
27
  }
36
28
 
37
- await Promise.all(promises)
38
- })
39
-
40
- Then('back pressure was applied',
41
- /**
42
- * @this {comq.features.Context}
43
- */
44
- function () {
45
29
  assert.equal(this.events.flow, true, 'Back pressure hasn\'t been applied')
46
30
  })
@@ -1,13 +1,13 @@
1
1
  'use strict'
2
2
 
3
- const { timeout, random } = require('@toa.io/generic')
4
- const { execute } = require('@toa.io/command')
3
+ const { random } = require('@toa.io/generic')
5
4
 
6
5
  const { Given } = require('@cucumber/cucumber')
6
+ const { BROKERS_AMOUNT, actions } = require('./brokers')
7
7
 
8
8
  Given('the broker is/has {status}',
9
9
  /**
10
- * @param {'up' | 'down'} status
10
+ * @param {'up' | 'down' | 'crashed'} status
11
11
  * @this {comq.features.Context}
12
12
  */
13
13
  async function (status) {
@@ -18,7 +18,7 @@ Given('the broker is/has {status}',
18
18
 
19
19
  Given('one of the brokers is/has {status}',
20
20
  /**
21
- * @param {'up' | 'down'} status
21
+ * @param {'up' | 'down' | 'crashed'} status
22
22
  * @this {comq.features.Context}
23
23
  */
24
24
  async function (status) {
@@ -28,29 +28,3 @@ Given('one of the brokers is/has {status}',
28
28
 
29
29
  await actions[status](shard)
30
30
  })
31
-
32
- const actions = {
33
- up: async (n = 0) => {
34
- await execute('docker start comq-rmq-' + n)
35
- await healthy(n)
36
- },
37
- down: async (n = 0) => {
38
- await execute('docker stop comq-rmq-' + n)
39
- },
40
- crashed: async (n = 0) => {
41
- await execute('docker kill comq-rmq-' + n)
42
- }
43
- }
44
-
45
- async function healthy (n) {
46
- let process
47
-
48
- do {
49
- await timeout(HEALTHCHECK_INTERVAL)
50
-
51
- process = await execute('docker inspect -f {{.State.Health.Status}} comq-rmq-' + n)
52
- } while (process.output !== 'healthy')
53
- }
54
-
55
- const BROKERS_AMOUNT = 2
56
- const HEALTHCHECK_INTERVAL = global.COMQ_TESTING_HEALTHCHECK_INTERVAL ?? 1000
@@ -0,0 +1,125 @@
1
+ 'use strict'
2
+
3
+ const { execFile } = require('node:child_process')
4
+ const { promisify } = require('node:util')
5
+ const { timeout } = require('@toa.io/generic')
6
+ const { RabbitMQContainer } = require('@testcontainers/rabbitmq')
7
+ const { getContainerRuntimeClient } = require('testcontainers')
8
+
9
+ const docker = promisify(execFile)
10
+
11
+ const IMAGE = 'rabbitmq:3.10.0-management'
12
+ const USER = 'developer'
13
+ const PASSWORD = 'secret'
14
+ const AMQP_PORT = 5672
15
+ const BROKERS_AMOUNT = 2
16
+ const HOST_PORTS = [5673, 5674]
17
+ const HEALTHCHECK_INTERVAL = global.COMQ_TESTING_HEALTHCHECK_INTERVAL ?? 1000
18
+
19
+ /** @type {import('@testcontainers/rabbitmq').StartedRabbitMQContainer[]} */
20
+ const brokers = []
21
+
22
+ async function startBrokers () {
23
+ if (brokers.length > 0) return
24
+
25
+ for (let n = 0; n < BROKERS_AMOUNT; n++) await removeStale(`comq-rmq-${n}`)
26
+
27
+ const started = await Promise.all(
28
+ HOST_PORTS.map((hostPort, n) =>
29
+ new RabbitMQContainer(IMAGE)
30
+ .withEnvironment({
31
+ RABBITMQ_DEFAULT_USER: USER,
32
+ RABBITMQ_DEFAULT_PASS: PASSWORD
33
+ })
34
+ .withAutoRemove(false)
35
+ .withName(`comq-rmq-${n}`)
36
+ .withExposedPorts({ container: AMQP_PORT, host: hostPort })
37
+ .withHealthCheck({
38
+ test: ['CMD', 'rabbitmq-diagnostics', '-q', 'ping'],
39
+ interval: 5000,
40
+ timeout: 1000,
41
+ retries: 5
42
+ })
43
+ .start()
44
+ )
45
+ )
46
+
47
+ brokers.push(...started)
48
+
49
+ await Promise.all(
50
+ Array.from({ length: BROKERS_AMOUNT }, (_, n) => healthy(n))
51
+ )
52
+ }
53
+
54
+ async function stopBrokers () {
55
+ await Promise.all(brokers.map((broker) => broker.stop({ remove: true })))
56
+ brokers.length = 0
57
+ }
58
+
59
+ /**
60
+ * @param {string} name
61
+ */
62
+ async function removeStale (name) {
63
+ const client = await getContainerRuntimeClient()
64
+ const listed = await client.container.list()
65
+
66
+ for (const info of listed) {
67
+ if (!info.Names.some((entry) => entry === `/${name}`)) continue
68
+
69
+ const container = client.container.getById(info.Id)
70
+
71
+ try {
72
+ await client.container.stop(container, { timeout: 0 })
73
+ } catch {
74
+ // already stopped
75
+ }
76
+
77
+ await container.remove({ force: true })
78
+ }
79
+ }
80
+
81
+ /**
82
+ * @param {number} [n]
83
+ * @returns {string}
84
+ */
85
+ function getAddress (n = 0) {
86
+ if (brokers[n] === undefined) throw new Error(`Broker ${n} is not started`)
87
+
88
+ return `localhost:${HOST_PORTS[n]}`
89
+ }
90
+
91
+ /**
92
+ * @param {number} [n]
93
+ */
94
+ async function healthy (n = 0) {
95
+ do {
96
+ await timeout(HEALTHCHECK_INTERVAL)
97
+
98
+ const { stdout } = await docker('docker', ['inspect', '-f', '{{.State.Health.Status}}', `comq-rmq-${n}`])
99
+
100
+ if (stdout.trim() === 'healthy') return
101
+ } while (true)
102
+ }
103
+
104
+ const actions = {
105
+ up: async (n = 0) => {
106
+ await docker('docker', ['start', `comq-rmq-${n}`])
107
+ await healthy(n)
108
+ },
109
+ down: async (n = 0) => {
110
+ await docker('docker', ['stop', `comq-rmq-${n}`])
111
+ },
112
+ crashed: async (n = 0) => {
113
+ await docker('docker', ['kill', `comq-rmq-${n}`])
114
+ }
115
+ }
116
+
117
+ module.exports = {
118
+ BROKERS_AMOUNT,
119
+ USER,
120
+ PASSWORD,
121
+ startBrokers,
122
+ stopBrokers,
123
+ getAddress,
124
+ actions
125
+ }
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { World } = require('@cucumber/cucumber')
4
4
  const { connect, assert } = require('../../')
5
+ const { getAddress, USER, PASSWORD } = require('./brokers')
5
6
 
6
7
  /**
7
8
  * @implements {comq.features.Context}
@@ -88,14 +89,11 @@ class Context extends World {
88
89
  }
89
90
 
90
91
  #url (i, user, password) {
91
- return PROTOCOL + user + ':' + password + '@' + SHARDS[i]
92
+ return PROTOCOL + user + ':' + password + '@' + getAddress(i)
92
93
  }
93
94
  }
94
95
 
95
96
  const PROTOCOL = 'amqp://'
96
- const SHARDS = ['localhost:5673', 'localhost:5674']
97
- const USER = 'developer'
98
- const PASSWORD = 'secret'
99
97
 
100
98
  /** @type {comq.diagnostics.Event[]} */
101
99
  const EVENTS = ['open', 'close', 'flow', 'discard']
@@ -1,11 +1,24 @@
1
1
  'use strict'
2
2
 
3
- const { After } = require('@cucumber/cucumber')
3
+ const { BeforeAll, After, AfterAll } = require('@cucumber/cucumber')
4
+ const { startBrokers, stopBrokers, actions, BROKERS_AMOUNT } = require('./brokers')
5
+
6
+ BeforeAll({ timeout: 120_000 }, async function () {
7
+ await startBrokers()
8
+ })
4
9
 
5
10
  After(
6
11
  /**
7
12
  * @this {comq.features.Context}
8
13
  */
9
14
  async function () {
15
+ await Promise.all(
16
+ Array.from({ length: BROKERS_AMOUNT }, (_, n) => actions.up(n))
17
+ )
18
+
10
19
  await this.disconnect()
11
20
  })
21
+
22
+ AfterAll(async function () {
23
+ await stopBrokers()
24
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comq",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "ComQ",
5
5
  "author": {
6
6
  "name": "Artem Gurtovoi",
@@ -24,17 +24,24 @@
24
24
  "main": "source/index.js",
25
25
  "types": "types/index.d.ts",
26
26
  "devDependencies": {
27
- "@cucumber/cucumber": "^10.0.1",
28
- "@toa.io/command": "^0.4.0",
29
- "@toa.io/generic": "^1.0.0-alpha.0",
30
- "@types/amqplib": "^0.10.3",
31
- "@types/jest": "^29.5.8",
32
- "husky": "^9.0.11",
33
- "jest": "^29.5.0",
27
+ "@semantic-release/changelog": "^6.0.3",
28
+ "@semantic-release/commit-analyzer": "^13.0.1",
29
+ "@semantic-release/git": "^10.0.1",
30
+ "@semantic-release/github": "^12.0.2",
31
+ "@semantic-release/npm": "^13.1.2",
32
+ "@semantic-release/release-notes-generator": "^14.1.0",
33
+ "@cucumber/cucumber": "^12.9.0",
34
+ "@testcontainers/rabbitmq": "^12.0.0",
35
+ "@toa.io/generic": "^1.0.0-alpha.208",
36
+ "@types/amqplib": "^0.10.8",
37
+ "@types/jest": "^30.0.0",
38
+ "husky": "^9.1.7",
39
+ "jest": "^30.4.2",
34
40
  "js-yaml": "^4.1.1",
35
- "randomstring": "^1.3.0",
41
+ "randomstring": "^1.3.1",
36
42
  "snazzy": "^9.0.0",
37
- "standard": "^17.1.0"
43
+ "standard": "^17.1.2",
44
+ "testcontainers": "^12.0.0"
38
45
  },
39
46
  "dependencies": {
40
47
  "amqplib": "^2.0.1",
@@ -43,7 +50,7 @@
43
50
  "reretry": "^0.1.0"
44
51
  },
45
52
  "scripts": {
46
- "prepare": "echo run: husky install",
53
+ "prepare": "husky",
47
54
  "test": "standard && jest",
48
55
  "features": "cucumber-js --fail-fast -t 'not @manual'",
49
56
  "lint": "standard --fix --verbose | snazzy"
@@ -52,8 +59,5 @@
52
59
  "env": [
53
60
  "jest"
54
61
  ]
55
- },
56
- "workspaces": [
57
- "."
58
- ]
62
+ }
59
63
  }
package/lerna.json DELETED
@@ -1,4 +0,0 @@
1
- {
2
- "version": "0.12.0",
3
- "conventionalCommits": true
4
- }