actioncable-next 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.
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