actioncable-next 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +17 -0
  5. data/lib/action_cable/channel/base.rb +335 -0
  6. data/lib/action_cable/channel/broadcasting.rb +50 -0
  7. data/lib/action_cable/channel/callbacks.rb +76 -0
  8. data/lib/action_cable/channel/naming.rb +28 -0
  9. data/lib/action_cable/channel/periodic_timers.rb +81 -0
  10. data/lib/action_cable/channel/streams.rb +213 -0
  11. data/lib/action_cable/channel/test_case.rb +329 -0
  12. data/lib/action_cable/connection/authorization.rb +18 -0
  13. data/lib/action_cable/connection/base.rb +165 -0
  14. data/lib/action_cable/connection/callbacks.rb +57 -0
  15. data/lib/action_cable/connection/identification.rb +51 -0
  16. data/lib/action_cable/connection/internal_channel.rb +50 -0
  17. data/lib/action_cable/connection/subscriptions.rb +124 -0
  18. data/lib/action_cable/connection/test_case.rb +294 -0
  19. data/lib/action_cable/deprecator.rb +9 -0
  20. data/lib/action_cable/engine.rb +98 -0
  21. data/lib/action_cable/gem_version.rb +19 -0
  22. data/lib/action_cable/helpers/action_cable_helper.rb +45 -0
  23. data/lib/action_cable/remote_connections.rb +82 -0
  24. data/lib/action_cable/server/base.rb +163 -0
  25. data/lib/action_cable/server/broadcasting.rb +62 -0
  26. data/lib/action_cable/server/configuration.rb +75 -0
  27. data/lib/action_cable/server/connections.rb +44 -0
  28. data/lib/action_cable/server/socket/client_socket.rb +159 -0
  29. data/lib/action_cable/server/socket/message_buffer.rb +56 -0
  30. data/lib/action_cable/server/socket/stream.rb +117 -0
  31. data/lib/action_cable/server/socket/web_socket.rb +47 -0
  32. data/lib/action_cable/server/socket.rb +180 -0
  33. data/lib/action_cable/server/stream_event_loop.rb +119 -0
  34. data/lib/action_cable/server/tagged_logger_proxy.rb +46 -0
  35. data/lib/action_cable/server/worker/active_record_connection_management.rb +23 -0
  36. data/lib/action_cable/server/worker.rb +75 -0
  37. data/lib/action_cable/subscription_adapter/async.rb +14 -0
  38. data/lib/action_cable/subscription_adapter/base.rb +39 -0
  39. data/lib/action_cable/subscription_adapter/channel_prefix.rb +30 -0
  40. data/lib/action_cable/subscription_adapter/inline.rb +40 -0
  41. data/lib/action_cable/subscription_adapter/postgresql.rb +130 -0
  42. data/lib/action_cable/subscription_adapter/redis.rb +257 -0
  43. data/lib/action_cable/subscription_adapter/subscriber_map.rb +80 -0
  44. data/lib/action_cable/subscription_adapter/test.rb +41 -0
  45. data/lib/action_cable/test_case.rb +13 -0
  46. data/lib/action_cable/test_helper.rb +163 -0
  47. data/lib/action_cable/version.rb +12 -0
  48. data/lib/action_cable.rb +81 -0
  49. data/lib/actioncable-next.rb +5 -0
  50. data/lib/rails/generators/channel/USAGE +19 -0
  51. data/lib/rails/generators/channel/channel_generator.rb +127 -0
  52. data/lib/rails/generators/channel/templates/application_cable/channel.rb.tt +4 -0
  53. data/lib/rails/generators/channel/templates/application_cable/connection.rb.tt +4 -0
  54. data/lib/rails/generators/channel/templates/channel.rb.tt +16 -0
  55. data/lib/rails/generators/channel/templates/javascript/channel.js.tt +20 -0
  56. data/lib/rails/generators/channel/templates/javascript/consumer.js.tt +6 -0
  57. data/lib/rails/generators/channel/templates/javascript/index.js.tt +1 -0
  58. data/lib/rails/generators/test_unit/channel_generator.rb +22 -0
  59. data/lib/rails/generators/test_unit/templates/channel_test.rb.tt +8 -0
  60. metadata +191 -0
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ gem "pg", "~> 1.1"
6
+ require "pg"
7
+ require "openssl"
8
+
9
+ module ActionCable
10
+ module SubscriptionAdapter
11
+ class PostgreSQL < Base # :nodoc:
12
+ prepend ChannelPrefix
13
+
14
+ def initialize(*)
15
+ super
16
+ @mutex = Mutex.new
17
+ @listener = nil
18
+ end
19
+
20
+ def broadcast(channel, payload)
21
+ with_broadcast_connection do |pg_conn|
22
+ pg_conn.exec("NOTIFY #{pg_conn.escape_identifier(channel_identifier(channel))}, '#{pg_conn.escape_string(payload)}'")
23
+ end
24
+ end
25
+
26
+ def subscribe(channel, callback, success_callback = nil)
27
+ listener.add_subscriber(channel_identifier(channel), callback, success_callback)
28
+ end
29
+
30
+ def unsubscribe(channel, callback)
31
+ listener.remove_subscriber(channel_identifier(channel), callback)
32
+ end
33
+
34
+ def shutdown
35
+ listener.shutdown
36
+ end
37
+
38
+ def with_subscriptions_connection(&block) # :nodoc:
39
+ ar_conn = ActiveRecord::Base.connection_pool.checkout.tap do |conn|
40
+ # Action Cable is taking ownership over this database connection, and will
41
+ # perform the necessary cleanup tasks
42
+ ActiveRecord::Base.connection_pool.remove(conn)
43
+ end
44
+ pg_conn = ar_conn.raw_connection
45
+
46
+ verify!(pg_conn)
47
+ pg_conn.exec("SET application_name = #{pg_conn.escape_identifier(identifier)}")
48
+ yield pg_conn
49
+ ensure
50
+ ar_conn.disconnect!
51
+ end
52
+
53
+ def with_broadcast_connection(&block) # :nodoc:
54
+ ActiveRecord::Base.connection_pool.with_connection do |ar_conn|
55
+ pg_conn = ar_conn.raw_connection
56
+ verify!(pg_conn)
57
+ yield pg_conn
58
+ end
59
+ end
60
+
61
+ private
62
+ def channel_identifier(channel)
63
+ channel.size > 63 ? OpenSSL::Digest::SHA1.hexdigest(channel) : channel
64
+ end
65
+
66
+ def listener
67
+ @listener || @mutex.synchronize { @listener ||= Listener.new(self, executor) }
68
+ end
69
+
70
+ def verify!(pg_conn)
71
+ unless pg_conn.is_a?(PG::Connection)
72
+ raise "The Active Record database must be PostgreSQL in order to use the PostgreSQL Action Cable storage adapter"
73
+ end
74
+ end
75
+
76
+ class Listener < SubscriberMap::Async
77
+ def initialize(adapter, executor)
78
+ super(executor)
79
+
80
+ @adapter = adapter
81
+ @queue = Queue.new
82
+
83
+ @thread = Thread.new do
84
+ Thread.current.abort_on_exception = true
85
+ listen
86
+ end
87
+ end
88
+
89
+ def listen
90
+ @adapter.with_subscriptions_connection do |pg_conn|
91
+ catch :shutdown do
92
+ loop do
93
+ until @queue.empty?
94
+ action, channel, callback = @queue.pop(true)
95
+
96
+ case action
97
+ when :listen
98
+ pg_conn.exec("LISTEN #{pg_conn.escape_identifier channel}")
99
+ @executor.post(&callback) if callback
100
+ when :unlisten
101
+ pg_conn.exec("UNLISTEN #{pg_conn.escape_identifier channel}")
102
+ when :shutdown
103
+ throw :shutdown
104
+ end
105
+ end
106
+
107
+ pg_conn.wait_for_notify(1) do |chan, pid, message|
108
+ broadcast(chan, message)
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
114
+
115
+ def shutdown
116
+ @queue.push([:shutdown])
117
+ Thread.pass while @thread.alive?
118
+ end
119
+
120
+ def add_channel(channel, on_success)
121
+ @queue.push([:listen, channel, on_success])
122
+ end
123
+
124
+ def remove_channel(channel)
125
+ @queue.push([:unlisten, channel])
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ gem "redis", ">= 4", "< 6"
6
+ require "redis"
7
+
8
+ require "active_support/core_ext/hash/except"
9
+
10
+ module ActionCable
11
+ module SubscriptionAdapter
12
+ class Redis < Base # :nodoc:
13
+ prepend ChannelPrefix
14
+
15
+ # Overwrite this factory method for Redis connections if you want to use a
16
+ # different Redis library than the redis gem. This is needed, for example, when
17
+ # using Makara proxies for distributed Redis.
18
+ cattr_accessor :redis_connector, default: ->(config) do
19
+ ::Redis.new(config.except(:adapter, :channel_prefix))
20
+ end
21
+
22
+ def initialize(*)
23
+ super
24
+ @listener = nil
25
+ @mutex = Mutex.new
26
+ @redis_connection_for_broadcasts = nil
27
+ end
28
+
29
+ def broadcast(channel, payload)
30
+ redis_connection_for_broadcasts.publish(channel, payload)
31
+ end
32
+
33
+ def subscribe(channel, callback, success_callback = nil)
34
+ listener.add_subscriber(channel, callback, success_callback)
35
+ end
36
+
37
+ def unsubscribe(channel, callback)
38
+ listener.remove_subscriber(channel, callback)
39
+ end
40
+
41
+ def shutdown
42
+ @listener.shutdown if @listener
43
+ end
44
+
45
+ def redis_connection_for_subscriptions
46
+ redis_connection
47
+ end
48
+
49
+ private
50
+ def listener
51
+ @listener || @mutex.synchronize { @listener ||= Listener.new(self, config_options, executor) }
52
+ end
53
+
54
+ def redis_connection_for_broadcasts
55
+ @redis_connection_for_broadcasts || @mutex.synchronize do
56
+ @redis_connection_for_broadcasts ||= redis_connection
57
+ end
58
+ end
59
+
60
+ def redis_connection
61
+ self.class.redis_connector.call(config_options)
62
+ end
63
+
64
+ def config_options
65
+ @config_options ||= config.cable.deep_symbolize_keys.merge(id: identifier)
66
+ end
67
+
68
+ class Listener < SubscriberMap::Async
69
+ delegate :logger, to: :@adapter
70
+
71
+ def initialize(adapter, config_options, executor)
72
+ super(executor)
73
+
74
+ @adapter = adapter
75
+
76
+ @subscribe_callbacks = Hash.new { |h, k| h[k] = [] }
77
+ @subscription_lock = Mutex.new
78
+
79
+ @reconnect_attempt = 0
80
+ # Use the same config as used by Redis conn
81
+ @reconnect_attempts = config_options.fetch(:reconnect_attempts, 1)
82
+ @reconnect_attempts = Array.new(@reconnect_attempts, 0) if @reconnect_attempts.is_a?(Integer)
83
+
84
+ @subscribed_client = nil
85
+
86
+ @when_connected = []
87
+
88
+ @thread = nil
89
+ end
90
+
91
+ def listen(conn)
92
+ conn.without_reconnect do
93
+ original_client = extract_subscribed_client(conn)
94
+
95
+ conn.subscribe("_action_cable_internal") do |on|
96
+ on.subscribe do |chan, count|
97
+ @subscription_lock.synchronize do
98
+ if count == 1
99
+ @reconnect_attempt = 0
100
+ @subscribed_client = original_client
101
+
102
+ until @when_connected.empty?
103
+ @when_connected.shift.call
104
+ end
105
+ end
106
+
107
+ if callbacks = @subscribe_callbacks[chan]
108
+ next_callback = callbacks.shift
109
+ @executor.post(&next_callback) if next_callback
110
+ @subscribe_callbacks.delete(chan) if callbacks.empty?
111
+ end
112
+ end
113
+ end
114
+
115
+ on.message do |chan, message|
116
+ broadcast(chan, message)
117
+ end
118
+
119
+ on.unsubscribe do |chan, count|
120
+ if count == 0
121
+ @subscription_lock.synchronize do
122
+ @subscribed_client = nil
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+
130
+ def shutdown
131
+ @subscription_lock.synchronize do
132
+ return if @thread.nil?
133
+
134
+ when_connected do
135
+ @subscribed_client.unsubscribe
136
+ @subscribed_client = nil
137
+ end
138
+ end
139
+
140
+ Thread.pass while @thread.alive?
141
+ end
142
+
143
+ def add_channel(channel, on_success)
144
+ @subscription_lock.synchronize do
145
+ ensure_listener_running
146
+ @subscribe_callbacks[channel] << on_success
147
+ when_connected { @subscribed_client.subscribe(channel) }
148
+ end
149
+ end
150
+
151
+ def remove_channel(channel)
152
+ @subscription_lock.synchronize do
153
+ when_connected { @subscribed_client.unsubscribe(channel) }
154
+ end
155
+ end
156
+
157
+ private
158
+ def ensure_listener_running
159
+ @thread ||= Thread.new do
160
+ Thread.current.abort_on_exception = true
161
+
162
+ begin
163
+ conn = @adapter.redis_connection_for_subscriptions
164
+ listen conn
165
+ rescue ConnectionError => e
166
+ reset
167
+ if retry_connecting?
168
+ logger&.warn "Redis connection failed: #{e.message}. Trying to reconnect..."
169
+ when_connected { resubscribe }
170
+ retry
171
+ else
172
+ logger&.error "Failed to reconnect to Redis after #{@reconnect_attempt} attempts."
173
+ end
174
+ end
175
+ end
176
+ end
177
+
178
+ def when_connected(&block)
179
+ if @subscribed_client
180
+ block.call
181
+ else
182
+ @when_connected << block
183
+ end
184
+ end
185
+
186
+ def retry_connecting?
187
+ @reconnect_attempt += 1
188
+
189
+ return false if @reconnect_attempt > @reconnect_attempts.size
190
+
191
+ sleep_t = @reconnect_attempts[@reconnect_attempt - 1]
192
+
193
+ sleep(sleep_t) if sleep_t > 0
194
+
195
+ true
196
+ end
197
+
198
+ def resubscribe
199
+ channels = @sync.synchronize do
200
+ @subscribers.keys
201
+ end
202
+ @subscribed_client.subscribe(*channels) unless channels.empty?
203
+ end
204
+
205
+ def reset
206
+ @subscription_lock.synchronize do
207
+ @subscribed_client = nil
208
+ @subscribe_callbacks.clear
209
+ @when_connected.clear
210
+ end
211
+ end
212
+
213
+ if ::Redis::VERSION < "5"
214
+ ConnectionError = ::Redis::BaseConnectionError
215
+
216
+ class SubscribedClient
217
+ def initialize(raw_client)
218
+ @raw_client = raw_client
219
+ end
220
+
221
+ def subscribe(*channel)
222
+ send_command("subscribe", *channel)
223
+ end
224
+
225
+ def unsubscribe(*channel)
226
+ send_command("unsubscribe", *channel)
227
+ end
228
+
229
+ private
230
+ def send_command(*command)
231
+ @raw_client.write(command)
232
+
233
+ very_raw_connection =
234
+ @raw_client.connection.instance_variable_defined?(:@connection) &&
235
+ @raw_client.connection.instance_variable_get(:@connection)
236
+
237
+ if very_raw_connection && very_raw_connection.respond_to?(:flush)
238
+ very_raw_connection.flush
239
+ end
240
+ nil
241
+ end
242
+ end
243
+
244
+ def extract_subscribed_client(conn)
245
+ SubscribedClient.new(conn._client)
246
+ end
247
+ else
248
+ ConnectionError = RedisClient::ConnectionError
249
+
250
+ def extract_subscribed_client(conn)
251
+ conn
252
+ end
253
+ end
254
+ end
255
+ end
256
+ end
257
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ module ActionCable
6
+ module SubscriptionAdapter
7
+ class SubscriberMap
8
+ def initialize
9
+ @subscribers = Hash.new { |h, k| h[k] = [] }
10
+ @sync = Mutex.new
11
+ end
12
+
13
+ def add_subscriber(channel, subscriber, on_success)
14
+ @sync.synchronize do
15
+ new_channel = !@subscribers.key?(channel)
16
+
17
+ @subscribers[channel] << subscriber
18
+
19
+ if new_channel
20
+ add_channel channel, on_success
21
+ elsif on_success
22
+ on_success.call
23
+ end
24
+ end
25
+ end
26
+
27
+ def remove_subscriber(channel, subscriber)
28
+ @sync.synchronize do
29
+ @subscribers[channel].delete(subscriber)
30
+
31
+ if @subscribers[channel].empty?
32
+ @subscribers.delete channel
33
+ remove_channel channel
34
+ end
35
+ end
36
+ end
37
+
38
+ def broadcast(channel, message)
39
+ list = @sync.synchronize do
40
+ return if !@subscribers.key?(channel)
41
+ @subscribers[channel].dup
42
+ end
43
+
44
+ list.each do |subscriber|
45
+ invoke_callback(subscriber, message)
46
+ end
47
+ end
48
+
49
+ def add_channel(channel, on_success)
50
+ on_success.call if on_success
51
+ end
52
+
53
+ def remove_channel(channel)
54
+ end
55
+
56
+ def invoke_callback(callback, message)
57
+ callback.call message
58
+ end
59
+
60
+ class Async < self
61
+ def initialize(executor)
62
+ @executor = executor
63
+ super()
64
+ end
65
+
66
+ def add_subscriber(*)
67
+ @executor.post { super }
68
+ end
69
+
70
+ def remove_subscriber(*)
71
+ @executor.post { super }
72
+ end
73
+
74
+ def invoke_callback(*)
75
+ @executor.post { super }
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ module ActionCable
6
+ module SubscriptionAdapter
7
+ # ## Test adapter for Action Cable
8
+ #
9
+ # The test adapter should be used only in testing. Along with
10
+ # ActionCable::TestHelper it makes a great tool to test your Rails application.
11
+ #
12
+ # To use the test adapter set `adapter` value to `test` in your
13
+ # `config/cable.yml` file.
14
+ #
15
+ # NOTE: `Test` adapter extends the `ActionCable::SubscriptionAdapter::Async`
16
+ # adapter, so it could be used in system tests too.
17
+ class Test < Async
18
+ def broadcast(channel, payload)
19
+ broadcasts(channel) << payload
20
+ super
21
+ end
22
+
23
+ def broadcasts(channel)
24
+ channels_data[channel] ||= []
25
+ end
26
+
27
+ def clear_messages(channel)
28
+ channels_data[channel] = []
29
+ end
30
+
31
+ def clear
32
+ @channels_data = nil
33
+ end
34
+
35
+ private
36
+ def channels_data
37
+ @channels_data ||= {}
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ require "active_support/test_case"
6
+
7
+ module ActionCable
8
+ class TestCase < ActiveSupport::TestCase
9
+ include ActionCable::TestHelper
10
+
11
+ ActiveSupport.run_load_hooks(:action_cable_test_case, self)
12
+ end
13
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ module ActionCable
6
+ # Provides helper methods for testing Action Cable broadcasting
7
+ module TestHelper
8
+ def before_setup # :nodoc:
9
+ server = ActionCable.server
10
+ test_adapter = ActionCable::SubscriptionAdapter::Test.new(server)
11
+
12
+ @old_pubsub_adapter = server.pubsub
13
+
14
+ server.instance_variable_set(:@pubsub, test_adapter)
15
+ super
16
+ end
17
+
18
+ def after_teardown # :nodoc:
19
+ super
20
+ ActionCable.server.instance_variable_set(:@pubsub, @old_pubsub_adapter)
21
+ end
22
+
23
+ # Asserts that the number of broadcasted messages to the stream matches the
24
+ # given number.
25
+ #
26
+ # def test_broadcasts
27
+ # assert_broadcasts 'messages', 0
28
+ # ActionCable.server.broadcast 'messages', { text: 'hello' }
29
+ # assert_broadcasts 'messages', 1
30
+ # ActionCable.server.broadcast 'messages', { text: 'world' }
31
+ # assert_broadcasts 'messages', 2
32
+ # end
33
+ #
34
+ # If a block is passed, that block should cause the specified number of messages
35
+ # to be broadcasted.
36
+ #
37
+ # def test_broadcasts_again
38
+ # assert_broadcasts('messages', 1) do
39
+ # ActionCable.server.broadcast 'messages', { text: 'hello' }
40
+ # end
41
+ #
42
+ # assert_broadcasts('messages', 2) do
43
+ # ActionCable.server.broadcast 'messages', { text: 'hi' }
44
+ # ActionCable.server.broadcast 'messages', { text: 'how are you?' }
45
+ # end
46
+ # end
47
+ #
48
+ def assert_broadcasts(stream, number, &block)
49
+ if block_given?
50
+ new_messages = new_broadcasts_from(broadcasts(stream), stream, "assert_broadcasts", &block)
51
+
52
+ actual_count = new_messages.size
53
+ assert_equal number, actual_count, "#{number} broadcasts to #{stream} expected, but #{actual_count} were sent"
54
+ else
55
+ actual_count = broadcasts(stream).size
56
+ assert_equal number, actual_count, "#{number} broadcasts to #{stream} expected, but #{actual_count} were sent"
57
+ end
58
+ end
59
+
60
+ # Asserts that no messages have been sent to the stream.
61
+ #
62
+ # def test_no_broadcasts
63
+ # assert_no_broadcasts 'messages'
64
+ # ActionCable.server.broadcast 'messages', { text: 'hi' }
65
+ # assert_broadcasts 'messages', 1
66
+ # end
67
+ #
68
+ # If a block is passed, that block should not cause any message to be sent.
69
+ #
70
+ # def test_broadcasts_again
71
+ # assert_no_broadcasts 'messages' do
72
+ # # No job messages should be sent from this block
73
+ # end
74
+ # end
75
+ #
76
+ # Note: This assertion is simply a shortcut for:
77
+ #
78
+ # assert_broadcasts 'messages', 0, &block
79
+ #
80
+ def assert_no_broadcasts(stream, &block)
81
+ assert_broadcasts stream, 0, &block
82
+ end
83
+
84
+ # Returns the messages that are broadcasted in the block.
85
+ #
86
+ # def test_broadcasts
87
+ # messages = capture_broadcasts('messages') do
88
+ # ActionCable.server.broadcast 'messages', { text: 'hi' }
89
+ # ActionCable.server.broadcast 'messages', { text: 'how are you?' }
90
+ # end
91
+ # assert_equal 2, messages.length
92
+ # assert_equal({ text: 'hi' }, messages.first)
93
+ # assert_equal({ text: 'how are you?' }, messages.last)
94
+ # end
95
+ #
96
+ def capture_broadcasts(stream, &block)
97
+ new_broadcasts_from(broadcasts(stream), stream, "capture_broadcasts", &block).map { |m| ActiveSupport::JSON.decode(m) }
98
+ end
99
+
100
+ # Asserts that the specified message has been sent to the stream.
101
+ #
102
+ # def test_assert_transmitted_message
103
+ # ActionCable.server.broadcast 'messages', text: 'hello'
104
+ # assert_broadcast_on('messages', text: 'hello')
105
+ # end
106
+ #
107
+ # If a block is passed, that block should cause a message with the specified
108
+ # data to be sent.
109
+ #
110
+ # def test_assert_broadcast_on_again
111
+ # assert_broadcast_on('messages', text: 'hello') do
112
+ # ActionCable.server.broadcast 'messages', text: 'hello'
113
+ # end
114
+ # end
115
+ #
116
+ def assert_broadcast_on(stream, data, &block)
117
+ # Encode to JSON and back–we want to use this value to compare with decoded
118
+ # JSON. Comparing JSON strings doesn't work due to the order if the keys.
119
+ serialized_msg =
120
+ ActiveSupport::JSON.decode(ActiveSupport::JSON.encode(data))
121
+
122
+ new_messages = broadcasts(stream)
123
+ if block_given?
124
+ new_messages = new_broadcasts_from(new_messages, stream, "assert_broadcast_on", &block)
125
+ end
126
+
127
+ message = new_messages.find { |msg| ActiveSupport::JSON.decode(msg) == serialized_msg }
128
+
129
+ error_message = "No messages sent with #{data} to #{stream}"
130
+
131
+ if new_messages.any?
132
+ error_message = new_messages.inject("#{error_message}\nMessage(s) found:\n") do |error_message, new_message|
133
+ error_message + "#{ActiveSupport::JSON.decode(new_message)}\n"
134
+ end
135
+ else
136
+ error_message = "#{error_message}\nNo message found for #{stream}"
137
+ end
138
+
139
+ assert message, error_message
140
+ end
141
+
142
+ def pubsub_adapter # :nodoc:
143
+ ActionCable.server.pubsub
144
+ end
145
+
146
+ delegate :broadcasts, :clear_messages, to: :pubsub_adapter
147
+
148
+ private
149
+ def new_broadcasts_from(current_messages, stream, assertion, &block)
150
+ old_messages = current_messages
151
+ clear_messages(stream)
152
+
153
+ _assert_nothing_raised_or_warn(assertion, &block)
154
+ new_messages = broadcasts(stream)
155
+ clear_messages(stream)
156
+
157
+ # Restore all sent messages
158
+ (old_messages + new_messages).each { |m| pubsub_adapter.broadcast(stream, m) }
159
+
160
+ new_messages
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :markup: markdown
4
+
5
+ require_relative "gem_version"
6
+
7
+ module ActionCable
8
+ # Returns the currently loaded version of Action Cable as a `Gem::Version`.
9
+ def self.version
10
+ gem_version
11
+ end
12
+ end