airbrake-ruby 4.7.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.
Files changed (101) hide show
  1. checksums.yaml +7 -0
  2. data/lib/airbrake-ruby.rb +515 -0
  3. data/lib/airbrake-ruby/async_sender.rb +80 -0
  4. data/lib/airbrake-ruby/backtrace.rb +196 -0
  5. data/lib/airbrake-ruby/benchmark.rb +39 -0
  6. data/lib/airbrake-ruby/code_hunk.rb +51 -0
  7. data/lib/airbrake-ruby/config.rb +229 -0
  8. data/lib/airbrake-ruby/config/validator.rb +91 -0
  9. data/lib/airbrake-ruby/deploy_notifier.rb +36 -0
  10. data/lib/airbrake-ruby/file_cache.rb +54 -0
  11. data/lib/airbrake-ruby/filter_chain.rb +95 -0
  12. data/lib/airbrake-ruby/filters/context_filter.rb +29 -0
  13. data/lib/airbrake-ruby/filters/dependency_filter.rb +31 -0
  14. data/lib/airbrake-ruby/filters/exception_attributes_filter.rb +46 -0
  15. data/lib/airbrake-ruby/filters/gem_root_filter.rb +33 -0
  16. data/lib/airbrake-ruby/filters/git_last_checkout_filter.rb +92 -0
  17. data/lib/airbrake-ruby/filters/git_repository_filter.rb +64 -0
  18. data/lib/airbrake-ruby/filters/git_revision_filter.rb +66 -0
  19. data/lib/airbrake-ruby/filters/keys_blacklist.rb +49 -0
  20. data/lib/airbrake-ruby/filters/keys_filter.rb +140 -0
  21. data/lib/airbrake-ruby/filters/keys_whitelist.rb +48 -0
  22. data/lib/airbrake-ruby/filters/root_directory_filter.rb +28 -0
  23. data/lib/airbrake-ruby/filters/sql_filter.rb +125 -0
  24. data/lib/airbrake-ruby/filters/system_exit_filter.rb +23 -0
  25. data/lib/airbrake-ruby/filters/thread_filter.rb +92 -0
  26. data/lib/airbrake-ruby/hash_keyable.rb +37 -0
  27. data/lib/airbrake-ruby/ignorable.rb +44 -0
  28. data/lib/airbrake-ruby/inspectable.rb +39 -0
  29. data/lib/airbrake-ruby/loggable.rb +34 -0
  30. data/lib/airbrake-ruby/monotonic_time.rb +43 -0
  31. data/lib/airbrake-ruby/nested_exception.rb +38 -0
  32. data/lib/airbrake-ruby/notice.rb +162 -0
  33. data/lib/airbrake-ruby/notice_notifier.rb +134 -0
  34. data/lib/airbrake-ruby/performance_breakdown.rb +46 -0
  35. data/lib/airbrake-ruby/performance_notifier.rb +155 -0
  36. data/lib/airbrake-ruby/promise.rb +109 -0
  37. data/lib/airbrake-ruby/query.rb +54 -0
  38. data/lib/airbrake-ruby/request.rb +46 -0
  39. data/lib/airbrake-ruby/response.rb +74 -0
  40. data/lib/airbrake-ruby/stashable.rb +15 -0
  41. data/lib/airbrake-ruby/stat.rb +73 -0
  42. data/lib/airbrake-ruby/sync_sender.rb +113 -0
  43. data/lib/airbrake-ruby/tdigest.rb +393 -0
  44. data/lib/airbrake-ruby/thread_pool.rb +128 -0
  45. data/lib/airbrake-ruby/time_truncate.rb +17 -0
  46. data/lib/airbrake-ruby/timed_trace.rb +58 -0
  47. data/lib/airbrake-ruby/truncator.rb +115 -0
  48. data/lib/airbrake-ruby/version.rb +6 -0
  49. data/spec/airbrake_spec.rb +324 -0
  50. data/spec/async_sender_spec.rb +72 -0
  51. data/spec/backtrace_spec.rb +427 -0
  52. data/spec/benchmark_spec.rb +33 -0
  53. data/spec/code_hunk_spec.rb +115 -0
  54. data/spec/config/validator_spec.rb +184 -0
  55. data/spec/config_spec.rb +154 -0
  56. data/spec/deploy_notifier_spec.rb +48 -0
  57. data/spec/file_cache_spec.rb +34 -0
  58. data/spec/filter_chain_spec.rb +92 -0
  59. data/spec/filters/context_filter_spec.rb +23 -0
  60. data/spec/filters/dependency_filter_spec.rb +12 -0
  61. data/spec/filters/exception_attributes_filter_spec.rb +50 -0
  62. data/spec/filters/gem_root_filter_spec.rb +41 -0
  63. data/spec/filters/git_last_checkout_filter_spec.rb +46 -0
  64. data/spec/filters/git_repository_filter.rb +61 -0
  65. data/spec/filters/git_revision_filter_spec.rb +126 -0
  66. data/spec/filters/keys_blacklist_spec.rb +225 -0
  67. data/spec/filters/keys_whitelist_spec.rb +194 -0
  68. data/spec/filters/root_directory_filter_spec.rb +39 -0
  69. data/spec/filters/sql_filter_spec.rb +262 -0
  70. data/spec/filters/system_exit_filter_spec.rb +14 -0
  71. data/spec/filters/thread_filter_spec.rb +277 -0
  72. data/spec/fixtures/notroot.txt +7 -0
  73. data/spec/fixtures/project_root/code.rb +221 -0
  74. data/spec/fixtures/project_root/empty_file.rb +0 -0
  75. data/spec/fixtures/project_root/long_line.txt +1 -0
  76. data/spec/fixtures/project_root/short_file.rb +3 -0
  77. data/spec/fixtures/project_root/vendor/bundle/ignored_file.rb +5 -0
  78. data/spec/helpers.rb +9 -0
  79. data/spec/ignorable_spec.rb +14 -0
  80. data/spec/inspectable_spec.rb +45 -0
  81. data/spec/monotonic_time_spec.rb +12 -0
  82. data/spec/nested_exception_spec.rb +73 -0
  83. data/spec/notice_notifier/options_spec.rb +259 -0
  84. data/spec/notice_notifier_spec.rb +356 -0
  85. data/spec/notice_spec.rb +296 -0
  86. data/spec/performance_breakdown_spec.rb +12 -0
  87. data/spec/performance_notifier_spec.rb +491 -0
  88. data/spec/promise_spec.rb +197 -0
  89. data/spec/query_spec.rb +11 -0
  90. data/spec/request_spec.rb +11 -0
  91. data/spec/response_spec.rb +88 -0
  92. data/spec/spec_helper.rb +100 -0
  93. data/spec/stashable_spec.rb +23 -0
  94. data/spec/stat_spec.rb +47 -0
  95. data/spec/sync_sender_spec.rb +133 -0
  96. data/spec/tdigest_spec.rb +230 -0
  97. data/spec/thread_pool_spec.rb +158 -0
  98. data/spec/time_truncate_spec.rb +13 -0
  99. data/spec/timed_trace_spec.rb +125 -0
  100. data/spec/truncator_spec.rb +238 -0
  101. metadata +216 -0
@@ -0,0 +1,133 @@
1
+ RSpec.describe Airbrake::SyncSender do
2
+ before do
3
+ Airbrake::Config.instance = Airbrake::Config.new(
4
+ project_id: 1, project_key: 'banana'
5
+ )
6
+ end
7
+
8
+ describe "#send" do
9
+ let(:promise) { Airbrake::Promise.new }
10
+
11
+ let(:notice) { Airbrake::Notice.new(AirbrakeTestError.new) }
12
+ let(:endpoint) { 'https://api.airbrake.io/api/v3/projects/1/notices' }
13
+
14
+ before { stub_request(:post, endpoint).to_return(body: '{}') }
15
+
16
+ it "sets the Content-Type header to JSON" do
17
+ subject.send({}, promise)
18
+ expect(
19
+ a_request(:post, endpoint).with(
20
+ headers: { 'Content-Type' => 'application/json' }
21
+ )
22
+ ).to have_been_made.once
23
+ end
24
+
25
+ it "sets the User-Agent header to the notifier slug" do
26
+ subject.send({}, promise)
27
+ expect(
28
+ a_request(:post, endpoint).with(
29
+ headers: {
30
+ 'User-Agent' => %r{airbrake-ruby/\d+\.\d+\.\d+ Ruby/\d+\.\d+\.\d+}
31
+ }
32
+ )
33
+ ).to have_been_made.once
34
+ end
35
+
36
+ it "sets the Authorization header to the project key" do
37
+ subject.send({}, promise)
38
+ expect(
39
+ a_request(:post, endpoint).with(
40
+ headers: { 'Authorization' => 'Bearer banana' }
41
+ )
42
+ ).to have_been_made.once
43
+ end
44
+
45
+ it "catches exceptions raised while sending" do
46
+ https = double("foo")
47
+ allow(subject).to receive(:build_https).and_return(https)
48
+ allow(https).to receive(:request).and_raise(StandardError.new('foo'))
49
+ expect(Airbrake::Loggable.instance).to receive(:error).with(
50
+ /HTTP error: foo/
51
+ )
52
+ expect(subject.send({}, promise)).to be_an(Airbrake::Promise)
53
+ expect(promise.value).to eq('error' => '**Airbrake: HTTP error: foo')
54
+ end
55
+
56
+ context "when request body is nil" do
57
+ it "doesn't send data" do
58
+ expect_any_instance_of(Airbrake::Truncator)
59
+ .to receive(:reduce_max_size).and_return(0)
60
+
61
+ encoded = Base64.encode64("\xD3\xE6\xBC\x9D\xBA").encode!('ASCII-8BIT')
62
+ bad_string = Base64.decode64(encoded)
63
+
64
+ ex = AirbrakeTestError.new
65
+ backtrace = []
66
+ 10.times { backtrace << "bin/rails:3:in `<#{bad_string}>'" }
67
+ ex.set_backtrace(backtrace)
68
+
69
+ notice = Airbrake::Notice.new(ex)
70
+
71
+ expect(Airbrake::Loggable.instance).to receive(:error).with(
72
+ /data was not sent/
73
+ )
74
+ expect(Airbrake::Loggable.instance).to receive(:error).with(
75
+ /truncation failed/
76
+ )
77
+ expect(subject.send(notice, promise)).to be_an(Airbrake::Promise)
78
+ expect(promise.value)
79
+ .to match('error' => '**Airbrake: data was not sent because of missing body')
80
+ end
81
+ end
82
+
83
+ context "when IP is rate limited" do
84
+ let(:endpoint) { %r{https://api.airbrake.io/api/v3/projects/1/notices} }
85
+
86
+ before do
87
+ stub_request(:post, endpoint).to_return(
88
+ status: 429,
89
+ body: '{"message":"IP is rate limited"}',
90
+ headers: { 'X-RateLimit-Delay' => '1' }
91
+ )
92
+ end
93
+
94
+ it "returns error" do
95
+ p1 = Airbrake::Promise.new
96
+ subject.send({}, p1)
97
+ expect(p1.value).to match('error' => '**Airbrake: IP is rate limited')
98
+
99
+ p2 = Airbrake::Promise.new
100
+ subject.send({}, p2)
101
+ expect(p2.value).to match('error' => '**Airbrake: IP is rate limited')
102
+
103
+ # Wait for X-RateLimit-Delay and then make a new request to make sure p2
104
+ # was ignored (no request made for it).
105
+ sleep 1
106
+
107
+ p3 = Airbrake::Promise.new
108
+ subject.send({}, p3)
109
+ expect(p3.value).to match('error' => '**Airbrake: IP is rate limited')
110
+
111
+ expect(a_request(:post, endpoint)).to have_been_made.twice
112
+ end
113
+ end
114
+
115
+ context "when the provided method is :put" do
116
+ before { stub_request(:put, endpoint).to_return(status: 200, body: '') }
117
+
118
+ it "PUTs the request" do
119
+ sender = described_class.new(:put)
120
+ sender.send({}, promise)
121
+ expect(a_request(:put, endpoint)).to have_been_made
122
+ end
123
+ end
124
+
125
+ context "when the provided method is :post" do
126
+ it "POSTs the request" do
127
+ sender = described_class.new(:post)
128
+ sender.send({}, promise)
129
+ expect(a_request(:post, endpoint)).to have_been_made
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,230 @@
1
+ RSpec.describe Airbrake::TDigest do
2
+ describe "byte serialization" do
3
+ it "loads serialized data" do
4
+ subject.push(60, 100)
5
+ 10.times { subject.push(rand * 100) }
6
+ bytes = subject.as_bytes
7
+ new_tdigest = described_class.from_bytes(bytes)
8
+ expect(new_tdigest.percentile(0.9)).to eq(subject.percentile(0.9))
9
+ expect(new_tdigest.as_bytes).to eq(bytes)
10
+ end
11
+
12
+ it "handles zero size" do
13
+ bytes = subject.as_bytes
14
+ expect(described_class.from_bytes(bytes).size).to be_zero
15
+ end
16
+
17
+ it "preserves compression" do
18
+ td = described_class.new(0.001)
19
+ bytes = td.as_bytes
20
+ new_tdigest = described_class.from_bytes(bytes)
21
+ expect(new_tdigest.compression).to eq(td.compression)
22
+ end
23
+ end
24
+
25
+ describe "small byte serialization" do
26
+ it "loads serialized data" do
27
+ 10.times { subject.push(10) }
28
+ bytes = subject.as_small_bytes
29
+ new_tdigest = described_class.from_bytes(bytes)
30
+ # Expect some rounding error due to compression
31
+ expect(new_tdigest.percentile(0.9).round(5)).to eq(
32
+ subject.percentile(0.9).round(5)
33
+ )
34
+ expect(new_tdigest.as_small_bytes).to eq(bytes)
35
+ end
36
+
37
+ it "handles zero size" do
38
+ bytes = subject.as_small_bytes
39
+ expect(described_class.from_bytes(bytes).size).to be_zero
40
+ end
41
+ end
42
+
43
+ describe "JSON serialization" do
44
+ it "loads serialized data" do
45
+ subject.push(60, 100)
46
+ json = subject.as_json
47
+ new_tdigest = described_class.from_json(json)
48
+ expect(new_tdigest.percentile(0.9)).to eq(subject.percentile(0.9))
49
+ end
50
+ end
51
+
52
+ describe "#percentile" do
53
+ it "returns nil if empty" do
54
+ expect(subject.percentile(0.90)).to be_nil # This should not crash
55
+ end
56
+
57
+ it "raises ArgumentError of input not between 0 and 1" do
58
+ expect { subject.percentile(1.1) }.to raise_error(ArgumentError)
59
+ end
60
+
61
+ describe "with only single value" do
62
+ it "returns the value" do
63
+ subject.push(60, 100)
64
+ expect(subject.percentile(0.90)).to eq(60)
65
+ end
66
+
67
+ it "returns 0 for all percentiles when only 0 present" do
68
+ subject.push(0)
69
+ expect(subject.percentile([0.0, 0.5, 1.0])).to eq([0, 0, 0])
70
+ end
71
+ end
72
+
73
+ describe "with alot of uniformly distributed points" do
74
+ it "has minimal error" do
75
+ seed = srand(1234) # Makes the values a proper fixture
76
+ N = 100_000
77
+ maxerr = 0
78
+ values = Array.new(N).map { rand }
79
+ srand(seed)
80
+
81
+ subject.push(values)
82
+ subject.compress!
83
+
84
+ 0.step(1, 0.1).each do |i|
85
+ q = subject.percentile(i)
86
+ maxerr = [maxerr, (i - q).abs].max
87
+ end
88
+
89
+ expect(maxerr).to be < 0.01
90
+ end
91
+ end
92
+ end
93
+
94
+ describe "#push" do
95
+ it "calls _cumulate so won't crash because of uninitialized mean_cumn" do
96
+ subject.push(
97
+ [
98
+ 125000000.0,
99
+ 104166666.66666666,
100
+ 135416666.66666666,
101
+ 104166666.66666666,
102
+ 104166666.66666666,
103
+ 93750000.0,
104
+ 125000000.0,
105
+ 62500000.0,
106
+ 114583333.33333333,
107
+ 156250000.0,
108
+ 124909090.90909092,
109
+ 104090909.0909091,
110
+ 135318181.81818184,
111
+ 104090909.0909091,
112
+ 104090909.0909091,
113
+ 93681818.18181819,
114
+ 124909090.90909092,
115
+ 62454545.45454546,
116
+ 114500000.00000001,
117
+ 156136363.63636366,
118
+ 123567567.56756756,
119
+ 102972972.97297296,
120
+ 133864864.86486486,
121
+ 102972972.97297296,
122
+ 102972972.97297296,
123
+ 92675675.67567568,
124
+ 123567567.56756756,
125
+ 61783783.78378378,
126
+ 113270270.27027026,
127
+ 154459459.45945945,
128
+ 123829787.23404256,
129
+ 103191489.36170213
130
+ ]
131
+ )
132
+ end
133
+
134
+ it "does not blow up if data comes in sorted" do
135
+ subject.push(0..10_000)
136
+ expect(subject.centroids.size).to be < 5_000
137
+ subject.compress!
138
+ expect(subject.centroids.size).to be < 1_000
139
+ end
140
+ end
141
+
142
+ describe "#size" do
143
+ it "reports the number of observations" do
144
+ n = 10_000
145
+ n.times { subject.push(rand) }
146
+ subject.compress!
147
+ expect(subject.size).to eq(n)
148
+ end
149
+ end
150
+
151
+ describe "#+" do
152
+ it "works with empty tdigests" do
153
+ other = described_class.new(0.001, 50, 1.2)
154
+ expect((subject + other).centroids.size).to eq(0)
155
+ end
156
+
157
+ describe "adding two tdigests" do
158
+ before do
159
+ @other = described_class.new(0.001, 50, 1.2)
160
+ [subject, @other].each do |td|
161
+ td.push(60, 100)
162
+ 10.times { td.push(rand * 100) }
163
+ end
164
+ end
165
+
166
+ it "has the parameters of the left argument (the calling tdigest)" do
167
+ new_tdigest = subject + @other
168
+ expect(new_tdigest.instance_variable_get(:@delta)).to eq(
169
+ subject.instance_variable_get(:@delta)
170
+ )
171
+ expect(new_tdigest.instance_variable_get(:@k)).to eq(
172
+ subject.instance_variable_get(:@k)
173
+ )
174
+ expect(new_tdigest.instance_variable_get(:@cx)).to eq(
175
+ subject.instance_variable_get(:@cx)
176
+ )
177
+ end
178
+
179
+ it "returns a tdigest with less than or equal centroids" do
180
+ new_tdigest = subject + @other
181
+ expect(new_tdigest.centroids.size)
182
+ .to be <= subject.centroids.size + @other.centroids.size
183
+ end
184
+
185
+ it "has the size of the two digests combined" do
186
+ new_tdigest = subject + @other
187
+ expect(new_tdigest.size).to eq(subject.size + @other.size)
188
+ end
189
+ end
190
+ end
191
+
192
+ describe "#merge!" do
193
+ it "works with empty tdigests" do
194
+ other = described_class.new(0.001, 50, 1.2)
195
+ subject.merge!(other)
196
+ expect(subject.centroids.size).to be_zero
197
+ end
198
+
199
+ describe "with populated tdigests" do
200
+ before do
201
+ @other = described_class.new(0.001, 50, 1.2)
202
+ [subject, @other].each do |td|
203
+ td.push(60, 100)
204
+ 10.times { td.push(rand * 100) }
205
+ end
206
+ end
207
+
208
+ it "has the parameters of the calling tdigest" do
209
+ vars = %i[@delta @k @cx]
210
+ expected = Hash[vars.map { |v| [v, subject.instance_variable_get(v)] }]
211
+ subject.merge!(@other)
212
+ vars.each do |v|
213
+ expect(subject.instance_variable_get(v)).to eq(expected[v])
214
+ end
215
+ end
216
+
217
+ it "returns a tdigest with less than or equal centroids" do
218
+ combined_size = subject.centroids.size + @other.centroids.size
219
+ subject.merge!(@other)
220
+ expect(subject.centroids.size).to be <= combined_size
221
+ end
222
+
223
+ it "has the size of the two digests combined" do
224
+ combined_size = subject.size + @other.size
225
+ subject.merge!(@other)
226
+ expect(subject.size).to eq(combined_size)
227
+ end
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,158 @@
1
+ RSpec.describe Airbrake::ThreadPool do
2
+ let(:tasks) { [] }
3
+ let(:worker_size) { 1 }
4
+ let(:queue_size) { 2 }
5
+
6
+ subject do
7
+ described_class.new(
8
+ worker_size: worker_size,
9
+ queue_size: queue_size,
10
+ block: proc { |message| tasks << message }
11
+ )
12
+ end
13
+
14
+ describe "#<<" do
15
+ it "returns true" do
16
+ retval = subject << 1
17
+ subject.close
18
+ expect(retval).to eq(true)
19
+ end
20
+
21
+ it "performs work in background" do
22
+ subject << 2
23
+ subject << 1
24
+ subject.close
25
+
26
+ expect(tasks).to eq([2, 1])
27
+ end
28
+
29
+ context "when the queue is full" do
30
+ before do
31
+ allow(subject).to receive(:backlog).and_return(queue_size)
32
+ end
33
+
34
+ subject do
35
+ described_class.new(
36
+ worker_size: 1,
37
+ queue_size: 1,
38
+ block: proc { |message| tasks << message }
39
+ )
40
+ end
41
+
42
+ it "returns false" do
43
+ retval = subject << 1
44
+ subject.close
45
+ expect(retval).to eq(false)
46
+ end
47
+
48
+ it "discards tasks" do
49
+ 200.times { subject << 1 }
50
+ subject.close
51
+
52
+ expect(tasks.size).to be_zero
53
+ end
54
+ end
55
+ end
56
+
57
+ describe "#backlog" do
58
+ let(:worker_size) { 0 }
59
+
60
+ it "returns the size of the queue" do
61
+ subject << 1
62
+ expect(subject.backlog).to eq(1)
63
+ end
64
+ end
65
+
66
+ describe "#has_workers?" do
67
+ it "returns false when the thread pool is not closed, but has 0 workers" do
68
+ subject.workers.list.each do |worker|
69
+ worker.kill.join
70
+ end
71
+ expect(subject).not_to have_workers
72
+ end
73
+
74
+ it "returns false when the thread pool is closed" do
75
+ subject.close
76
+ expect(subject).not_to have_workers
77
+ end
78
+
79
+ it "respawns workers on fork()", skip: %w[jruby].include?(RUBY_ENGINE) do
80
+ pid = fork { expect(subject).to have_workers }
81
+ Process.wait(pid)
82
+ subject.close
83
+ expect(subject).not_to have_workers
84
+ end
85
+ end
86
+
87
+ describe "#close" do
88
+ context "when there's no work to do" do
89
+ it "joins the spawned thread" do
90
+ workers = subject.workers.list
91
+ expect(workers).to all(be_alive)
92
+
93
+ subject.close
94
+ expect(workers).to all(be_stop)
95
+ end
96
+ end
97
+
98
+ context "when there's some work to do" do
99
+ it "logs how many tasks are left to process" do
100
+ thread_pool = described_class.new(
101
+ worker_size: 0, queue_size: 2, block: proc {}
102
+ )
103
+
104
+ expect(Airbrake::Loggable.instance).to receive(:debug).with(
105
+ /waiting to process \d+ task\(s\)/
106
+ )
107
+ expect(Airbrake::Loggable.instance).to receive(:debug).with(/closed/)
108
+
109
+ 2.times { thread_pool << 1 }
110
+ thread_pool.close
111
+ end
112
+
113
+ it "waits until the queue gets empty" do
114
+ thread_pool = described_class.new(
115
+ worker_size: 1, queue_size: 2, block: proc {}
116
+ )
117
+
118
+ 10.times { subject << 1 }
119
+ thread_pool.close
120
+ expect(thread_pool.backlog).to be_zero
121
+ end
122
+ end
123
+
124
+ context "when it was already closed" do
125
+ it "doesn't increase the queue size" do
126
+ begin
127
+ subject.close
128
+ rescue Airbrake::Error
129
+ nil
130
+ end
131
+
132
+ expect(subject.backlog).to be_zero
133
+ end
134
+
135
+ it "raises error" do
136
+ subject.close
137
+ expect { subject.close }.to raise_error(
138
+ Airbrake::Error, 'this thread pool is closed already'
139
+ )
140
+ end
141
+ end
142
+ end
143
+
144
+ describe "#spawn_workers" do
145
+ it "spawns alive threads in an enclosed ThreadGroup" do
146
+ expect(subject.workers).to be_a(ThreadGroup)
147
+ expect(subject.workers.list).to all(be_alive)
148
+ expect(subject.workers).to be_enclosed
149
+
150
+ subject.close
151
+ end
152
+
153
+ it "spawns exactly `workers_size` workers" do
154
+ expect(subject.workers.list.size).to eq(worker_size)
155
+ subject.close
156
+ end
157
+ end
158
+ end