busybee 0.1.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.
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "base64"
5
+ require "json"
6
+ require "busybee/grpc"
7
+ require_relative "activated_job"
8
+
9
+ module Busybee
10
+ module Testing
11
+ # Raised when no job is available for activation
12
+ class NoJobAvailable < StandardError; end
13
+
14
+ # RSpec helper methods for testing BPMN workflows against Zeebe.
15
+ module Helpers
16
+ # Deploy a BPMN process file to Zeebe.
17
+ #
18
+ # By default, deploys the BPMN file as-is using its original process ID.
19
+ # Optionally, you can uniquify the process ID for test isolation.
20
+ #
21
+ # @param path [String] path to BPMN file
22
+ # @param uniquify [nil, true, String] uniquification behavior:
23
+ # - nil (default): deploy as-is with original process ID
24
+ # - true: auto-generate unique process ID like "test-process-abc123"
25
+ # - String: use provided string as custom process ID
26
+ # @return [Hash] deployment info with keys:
27
+ # - :process [ProcessMetadata] - GRPC process metadata object
28
+ # - :process_id [String] - the BPMN process ID (uniquified or original)
29
+ #
30
+ # @example Deploy as-is (most common)
31
+ # result = deploy_process("path/to/process.bpmn")
32
+ # result[:process_id] #=> "simple-process" (from BPMN file)
33
+ #
34
+ # @example Deploy with auto-generated unique ID (for test isolation)
35
+ # result = deploy_process("path/to/process.bpmn", uniquify: true)
36
+ # result[:process_id] #=> "test-process-a1b2c3d4e5f6"
37
+ #
38
+ # @example Deploy with custom ID
39
+ # result = deploy_process("path/to/process.bpmn", uniquify: "my-test-process")
40
+ # result[:process_id] #=> "my-test-process"
41
+ def deploy_process(path, uniquify: nil)
42
+ if uniquify
43
+ process_id = uniquify == true ? unique_process_id : uniquify
44
+ bpmn_content = bpmn_with_unique_id(path, process_id)
45
+ else
46
+ bpmn_content = File.read(path)
47
+ process_id = extract_process_id(bpmn_content)
48
+ end
49
+
50
+ resource = Busybee::GRPC::Resource.new(
51
+ name: File.basename(path),
52
+ content: bpmn_content
53
+ )
54
+
55
+ request = Busybee::GRPC::DeployResourceRequest.new(
56
+ resources: [resource]
57
+ )
58
+
59
+ response = grpc_client.deploy_resource(request)
60
+
61
+ {
62
+ process: response.deployments.first.process,
63
+ process_id: process_id
64
+ }
65
+ end
66
+
67
+ # Create a process instance, yield its key, and cancel on block exit.
68
+ #
69
+ # @param process_name [String] BPMN process ID
70
+ # @param variables [Hash] variables to start the process with
71
+ # @yield [Integer] the process instance key
72
+ def with_process_instance(process_name, variables = {})
73
+ request = Busybee::GRPC::CreateProcessInstanceRequest.new(
74
+ bpmnProcessId: process_name,
75
+ version: -1,
76
+ variables: JSON.generate(variables)
77
+ )
78
+
79
+ response = grpc_client.create_process_instance(request)
80
+ @current_process_instance_key = response.processInstanceKey
81
+
82
+ yield @current_process_instance_key
83
+ ensure
84
+ if @current_process_instance_key
85
+ cancel_process_instance(@current_process_instance_key)
86
+ @last_process_instance_key = @current_process_instance_key
87
+ @current_process_instance_key = nil
88
+ end
89
+ end
90
+
91
+ # Returns the current process instance key (set by with_process_instance).
92
+ #
93
+ # @return [Integer, nil]
94
+ def process_instance_key
95
+ @current_process_instance_key
96
+ end
97
+
98
+ # Returns the last process instance key from the most recent with_process_instance call.
99
+ # Useful for debugging failed tests by tying failures to residual data in ElasticSearch.
100
+ #
101
+ # @return [Integer, nil]
102
+ def last_process_instance_key
103
+ @last_process_instance_key
104
+ end
105
+
106
+ # Checks if Zeebe is available and responsive.
107
+ #
108
+ # This method attempts to connect to Zeebe and call the topology endpoint
109
+ # to verify the service is running and healthy.
110
+ #
111
+ # @param timeout [Integer] timeout in seconds for the connection check
112
+ # @return [Boolean] true if Zeebe is available, false otherwise
113
+ def zeebe_available?(timeout: 5)
114
+ request = Busybee::GRPC::TopologyRequest.new
115
+ grpc_client.topology(request, deadline: Time.now + timeout)
116
+ true
117
+ rescue GRPC::Unavailable, GRPC::DeadlineExceeded, GRPC::Core::CallError, GRPC::Unauthenticated
118
+ false
119
+ end
120
+
121
+ # Activate a single job of the given type.
122
+ #
123
+ # @param type [String] job type
124
+ # @return [ActivatedJob]
125
+ # @raise [NoJobAvailable] if no job is available
126
+ def activate_job(type)
127
+ jobs = activate_jobs_raw(type, max_jobs: 1)
128
+ raise NoJobAvailable, "No job of type '#{type}' available" if jobs.empty?
129
+
130
+ ActivatedJob.new(jobs.first, client: grpc_client)
131
+ end
132
+
133
+ # Activate multiple jobs of the given type.
134
+ #
135
+ # @param type [String] job type
136
+ # @param max_jobs [Integer] maximum number of jobs to activate
137
+ # @return [Enumerator<ActivatedJob>]
138
+ def activate_jobs(type, max_jobs:)
139
+ Enumerator.new do |yielder|
140
+ activate_jobs_raw(type, max_jobs: max_jobs).each do |raw_job|
141
+ yielder << ActivatedJob.new(raw_job, client: grpc_client)
142
+ end
143
+ end
144
+ end
145
+
146
+ # Publish a message to Zeebe.
147
+ #
148
+ # @param name [String] message name
149
+ # @param correlation_key [String] correlation key
150
+ # @param variables [Hash] message variables
151
+ # @param ttl_ms [Integer] time-to-live in milliseconds
152
+ def publish_message(name, correlation_key:, variables: {}, ttl_ms: 5000)
153
+ request = Busybee::GRPC::PublishMessageRequest.new(
154
+ name: name,
155
+ correlationKey: correlation_key,
156
+ variables: JSON.generate(variables),
157
+ timeToLive: ttl_ms
158
+ )
159
+ grpc_client.publish_message(request)
160
+ end
161
+
162
+ # Set variables on a process scope.
163
+ #
164
+ # @param scope_key [Integer] element instance key
165
+ # @param variables [Hash] variables to set
166
+ # @param local [Boolean] whether variables are local to scope
167
+ def set_variables(scope_key, variables, local: true)
168
+ request = Busybee::GRPC::SetVariablesRequest.new(
169
+ elementInstanceKey: scope_key,
170
+ variables: JSON.generate(variables),
171
+ local: local
172
+ )
173
+ grpc_client.set_variables(request)
174
+ end
175
+
176
+ # Assert that the current process instance has completed.
177
+ #
178
+ # @param wait [Float] seconds to wait before checking
179
+ # @raise [RuntimeError] if process is still running
180
+ def assert_process_completed!(wait: 0.25)
181
+ sleep(wait) if wait.positive?
182
+
183
+ request = Busybee::GRPC::CancelProcessInstanceRequest.new(
184
+ processInstanceKey: process_instance_key
185
+ )
186
+
187
+ begin
188
+ grpc_client.cancel_process_instance(request)
189
+ rescue ::GRPC::NotFound
190
+ true # Expected - process has completed
191
+ else
192
+ raise "Process instance #{process_instance_key} is still running (expected it to be completed)"
193
+ end
194
+ end
195
+
196
+ private
197
+
198
+ def unique_process_id
199
+ "test-process-#{SecureRandom.hex(6)}"
200
+ end
201
+
202
+ def extract_process_id(bpmn_content)
203
+ match = bpmn_content.match(/<bpmn:process id="([^"]+)"/)
204
+ match ? match[1] : nil
205
+ end
206
+
207
+ def bpmn_with_unique_id(bpmn_path, process_id)
208
+ bpmn_content = File.read(bpmn_path)
209
+ bpmn_content
210
+ .gsub(/(<bpmn:process id=")[^"]+/, "\\1#{process_id}")
211
+ # Possessive quantifiers (++, *+) prevent polynomial backtracking
212
+ .gsub(/(<bpmndi:BPMNPlane\s++[^>]*+bpmnElement=")[^"]++/, "\\1#{process_id}")
213
+ end
214
+
215
+ def cancel_process_instance(key)
216
+ request = Busybee::GRPC::CancelProcessInstanceRequest.new(
217
+ processInstanceKey: key
218
+ )
219
+ grpc_client.cancel_process_instance(request)
220
+ true
221
+ rescue ::GRPC::NotFound
222
+ # Process already completed, ignore
223
+ false
224
+ end
225
+
226
+ def activate_jobs_raw(type, max_jobs:)
227
+ worker = "#{type}-#{SecureRandom.hex(4)}"
228
+ request = Busybee::GRPC::ActivateJobsRequest.new(
229
+ type: type,
230
+ worker: worker,
231
+ timeout: 30_000,
232
+ maxJobsToActivate: max_jobs,
233
+ requestTimeout: Busybee::Testing.activate_request_timeout
234
+ )
235
+
236
+ jobs = []
237
+ grpc_client.activate_jobs(request).each do |response|
238
+ jobs.concat(response.jobs.to_a)
239
+ end
240
+ jobs
241
+ end
242
+
243
+ def grpc_client
244
+ @grpc_client ||= Busybee::GRPC::Gateway::Stub.new(
245
+ Busybee::Testing.address,
246
+ :this_channel_is_insecure
247
+ )
248
+ end
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/expectations"
4
+ require_relative "../activated_job"
5
+
6
+ RSpec::Matchers.define :have_activated do |job_type|
7
+ match do |helper|
8
+ @job_type = job_type
9
+ @helper = helper
10
+
11
+ begin
12
+ @activated_job = helper.activate_job(job_type)
13
+ @job_activated = true
14
+ rescue Busybee::Testing::NoJobAvailable
15
+ @job_activated = false
16
+ return false
17
+ end
18
+
19
+ # If we have chained expectations, validate them
20
+ if @expected_variables
21
+ expected_stringified = @expected_variables.transform_keys(&:to_s)
22
+ actual = @activated_job.variables
23
+ unless actual.slice(*expected_stringified.keys) == expected_stringified
24
+ @variables_mismatch = true
25
+ @actual_variables = actual
26
+ return false
27
+ end
28
+ end
29
+
30
+ if @expected_headers
31
+ expected_stringified = @expected_headers.transform_keys(&:to_s)
32
+ actual = @activated_job.headers
33
+ unless actual.slice(*expected_stringified.keys) == expected_stringified
34
+ @headers_mismatch = true
35
+ @actual_headers = actual
36
+ return false
37
+ end
38
+ end
39
+
40
+ true
41
+ end
42
+
43
+ chain :with_variables do |expected|
44
+ @expected_variables = expected
45
+ end
46
+
47
+ chain :with_headers do |expected|
48
+ @expected_headers = expected
49
+ end
50
+
51
+ failure_message do
52
+ if !@job_activated
53
+ "No job of type '#{@job_type}' was activated"
54
+ elsif @variables_mismatch
55
+ "expected job variables to include #{@expected_variables.inspect}\n" \
56
+ "actual variables: #{@actual_variables.inspect}"
57
+ elsif @headers_mismatch
58
+ "expected job headers to include #{@expected_headers.inspect}\n" \
59
+ "actual headers: #{@actual_headers.inspect}"
60
+ end
61
+ end
62
+
63
+ failure_message_when_negated do
64
+ "expected no job of type '#{@job_type}' to be activated, but one was"
65
+ end
66
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/expectations"
4
+
5
+ RSpec::Matchers.define :have_received_headers do |expected|
6
+ match do |job|
7
+ expected_stringified = expected.transform_keys(&:to_s)
8
+ @actual = job.headers
9
+ @actual.slice(*expected_stringified.keys) == expected_stringified
10
+ end
11
+
12
+ failure_message do
13
+ "expected job headers to include #{expected.inspect}\n" \
14
+ "actual headers: #{@actual.inspect}"
15
+ end
16
+
17
+ failure_message_when_negated do
18
+ "expected job headers not to include #{expected.inspect}\n" \
19
+ "actual headers: #{@actual.inspect}"
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/expectations"
4
+
5
+ RSpec::Matchers.define :have_received_variables do |expected|
6
+ match do |job|
7
+ expected_stringified = expected.transform_keys(&:to_s)
8
+ @actual = job.variables
9
+ @actual.slice(*expected_stringified.keys) == expected_stringified
10
+ end
11
+
12
+ failure_message do
13
+ "expected job variables to include #{expected.inspect}\n" \
14
+ "actual variables: #{@actual.inspect}"
15
+ end
16
+
17
+ failure_message_when_negated do
18
+ "expected job variables not to include #{expected.inspect}\n" \
19
+ "actual variables: #{@actual.inspect}"
20
+ end
21
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "busybee/grpc"
4
+
5
+ module Busybee
6
+ # Testing support for BPMN workflows with RSpec.
7
+ #
8
+ # @example Configuration
9
+ # Busybee::Testing.configure do |config|
10
+ # config.address = "localhost:26500"
11
+ # config.username = "demo"
12
+ # config.password = "demo"
13
+ # config.activate_request_timeout = 2000
14
+ # end
15
+ #
16
+ module Testing
17
+ class << self
18
+ attr_writer :address, :username, :password, :activate_request_timeout
19
+
20
+ def configure
21
+ yield self
22
+ end
23
+
24
+ def address
25
+ @address || ENV["ZEEBE_ADDRESS"] || "localhost:26500"
26
+ end
27
+
28
+ def username
29
+ @username || ENV["ZEEBE_USERNAME"] || "demo"
30
+ end
31
+
32
+ def password
33
+ @password || ENV["ZEEBE_PASSWORD"] || "demo"
34
+ end
35
+
36
+ def activate_request_timeout
37
+ @activate_request_timeout || 1000
38
+ end
39
+ end
40
+ end
41
+ end
42
+
43
+ # Auto-load RSpec integration if RSpec is available
44
+ if defined?(RSpec)
45
+ require "busybee/testing/helpers"
46
+ require "busybee/testing/activated_job"
47
+ require "busybee/testing/matchers/have_received_variables"
48
+ require "busybee/testing/matchers/have_received_headers"
49
+ require "busybee/testing/matchers/have_activated"
50
+
51
+ RSpec.configure do |config|
52
+ config.include Busybee::Testing::Helpers
53
+ end
54
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Busybee
4
+ VERSION = "0.1.0"
5
+ end
data/lib/busybee.rb ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "busybee/version"
4
+
5
+ # Top-level gem module, only holds configuration values.
6
+ module Busybee
7
+ end
metadata ADDED
@@ -0,0 +1,94 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: busybee
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Andy Rusterholz
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2025-12-30 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: base64
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: grpc
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.76'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.76'
41
+ description: The missing Ruby gem for Camunda 8. Production-ready worker framework
42
+ that runs out of the box - define your job handlers and go. Idiomatic Zeebe client
43
+ with sensible defaults and configuration where you want it. RSpec testing helpers
44
+ and CI/CD deployment tooling for BPMNs.
45
+ email:
46
+ - andyrusterholz@gmail.com
47
+ executables: []
48
+ extensions: []
49
+ extra_rdoc_files: []
50
+ files:
51
+ - CHANGELOG.md
52
+ - LICENSE.txt
53
+ - README.md
54
+ - docs/grpc.md
55
+ - docs/testing.md
56
+ - lib/busybee.rb
57
+ - lib/busybee/grpc.rb
58
+ - lib/busybee/grpc/gateway_pb.rb
59
+ - lib/busybee/grpc/gateway_services_pb.rb
60
+ - lib/busybee/testing.rb
61
+ - lib/busybee/testing/activated_job.rb
62
+ - lib/busybee/testing/helpers.rb
63
+ - lib/busybee/testing/matchers/have_activated.rb
64
+ - lib/busybee/testing/matchers/have_received_headers.rb
65
+ - lib/busybee/testing/matchers/have_received_variables.rb
66
+ - lib/busybee/version.rb
67
+ homepage: https://github.com/rusterholz/busybee
68
+ licenses:
69
+ - MIT
70
+ metadata:
71
+ homepage_uri: https://github.com/rusterholz/busybee
72
+ source_code_uri: https://github.com/rusterholz/busybee
73
+ changelog_uri: https://github.com/rusterholz/busybee/blob/main/CHANGELOG.md
74
+ rubygems_mfa_required: 'true'
75
+ post_install_message:
76
+ rdoc_options: []
77
+ require_paths:
78
+ - lib
79
+ required_ruby_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: 3.2.0
84
+ required_rubygems_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ requirements: []
90
+ rubygems_version: 3.5.22
91
+ signing_key:
92
+ specification_version: 4
93
+ summary: A complete Ruby toolkit for BPMN workflow orchestration.
94
+ test_files: []