actioncable 5.0.1 → 6.1.3

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 (67) hide show
  1. checksums.yaml +5 -5
  2. data/CHANGELOG.md +31 -117
  3. data/MIT-LICENSE +1 -1
  4. data/README.md +4 -535
  5. data/app/assets/javascripts/action_cable.js +517 -0
  6. data/lib/action_cable.rb +20 -10
  7. data/lib/action_cable/channel.rb +3 -0
  8. data/lib/action_cable/channel/base.rb +31 -23
  9. data/lib/action_cable/channel/broadcasting.rb +22 -10
  10. data/lib/action_cable/channel/callbacks.rb +4 -2
  11. data/lib/action_cable/channel/naming.rb +5 -2
  12. data/lib/action_cable/channel/periodic_timers.rb +4 -3
  13. data/lib/action_cable/channel/streams.rb +39 -11
  14. data/lib/action_cable/channel/test_case.rb +310 -0
  15. data/lib/action_cable/connection.rb +3 -2
  16. data/lib/action_cable/connection/authorization.rb +8 -6
  17. data/lib/action_cable/connection/base.rb +34 -26
  18. data/lib/action_cable/connection/client_socket.rb +20 -18
  19. data/lib/action_cable/connection/identification.rb +5 -4
  20. data/lib/action_cable/connection/internal_channel.rb +4 -2
  21. data/lib/action_cable/connection/message_buffer.rb +3 -2
  22. data/lib/action_cable/connection/stream.rb +9 -5
  23. data/lib/action_cable/connection/stream_event_loop.rb +4 -2
  24. data/lib/action_cable/connection/subscriptions.rb +14 -13
  25. data/lib/action_cable/connection/tagged_logger_proxy.rb +4 -2
  26. data/lib/action_cable/connection/test_case.rb +234 -0
  27. data/lib/action_cable/connection/web_socket.rb +7 -5
  28. data/lib/action_cable/engine.rb +7 -5
  29. data/lib/action_cable/gem_version.rb +5 -3
  30. data/lib/action_cable/helpers/action_cable_helper.rb +6 -4
  31. data/lib/action_cable/remote_connections.rb +9 -4
  32. data/lib/action_cable/server.rb +2 -1
  33. data/lib/action_cable/server/base.rb +17 -10
  34. data/lib/action_cable/server/broadcasting.rb +9 -3
  35. data/lib/action_cable/server/configuration.rb +21 -22
  36. data/lib/action_cable/server/connections.rb +2 -0
  37. data/lib/action_cable/server/worker.rb +11 -11
  38. data/lib/action_cable/server/worker/active_record_connection_management.rb +2 -0
  39. data/lib/action_cable/subscription_adapter.rb +4 -0
  40. data/lib/action_cable/subscription_adapter/async.rb +3 -1
  41. data/lib/action_cable/subscription_adapter/base.rb +6 -0
  42. data/lib/action_cable/subscription_adapter/channel_prefix.rb +28 -0
  43. data/lib/action_cable/subscription_adapter/inline.rb +2 -0
  44. data/lib/action_cable/subscription_adapter/postgresql.rb +40 -14
  45. data/lib/action_cable/subscription_adapter/redis.rb +19 -11
  46. data/lib/action_cable/subscription_adapter/subscriber_map.rb +3 -1
  47. data/lib/action_cable/subscription_adapter/test.rb +40 -0
  48. data/lib/action_cable/test_case.rb +11 -0
  49. data/lib/action_cable/test_helper.rb +133 -0
  50. data/lib/action_cable/version.rb +3 -1
  51. data/lib/rails/generators/channel/USAGE +5 -6
  52. data/lib/rails/generators/channel/channel_generator.rb +16 -11
  53. data/lib/rails/generators/channel/templates/application_cable/{channel.rb → channel.rb.tt} +0 -0
  54. data/lib/rails/generators/channel/templates/application_cable/{connection.rb → connection.rb.tt} +0 -0
  55. data/lib/rails/generators/channel/templates/{channel.rb → channel.rb.tt} +0 -0
  56. data/lib/rails/generators/channel/templates/{assets/channel.js → javascript/channel.js.tt} +6 -4
  57. data/lib/rails/generators/channel/templates/javascript/consumer.js.tt +6 -0
  58. data/lib/rails/generators/channel/templates/javascript/index.js.tt +5 -0
  59. data/lib/rails/generators/test_unit/channel_generator.rb +20 -0
  60. data/lib/rails/generators/test_unit/templates/channel_test.rb.tt +8 -0
  61. metadata +46 -38
  62. data/lib/action_cable/connection/faye_client_socket.rb +0 -48
  63. data/lib/action_cable/connection/faye_event_loop.rb +0 -44
  64. data/lib/action_cable/subscription_adapter/evented_redis.rb +0 -79
  65. data/lib/assets/compiled/action_cable.js +0 -597
  66. data/lib/rails/generators/channel/templates/assets/cable.js +0 -13
  67. data/lib/rails/generators/channel/templates/assets/channel.coffee +0 -14
data/lib/action_cable.rb CHANGED
@@ -1,5 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  #--
2
- # Copyright (c) 2015-2016 Basecamp, LLC
4
+ # Copyright (c) 2015-2020 Basecamp, LLC
3
5
  #
4
6
  # Permission is hereby granted, free of charge, to any person obtaining
5
7
  # a copy of this software and associated documentation files (the
@@ -21,22 +23,28 @@
21
23
  # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
22
24
  #++
23
25
 
24
- require 'active_support'
25
- require 'active_support/rails'
26
- require 'action_cable/version'
26
+ require "active_support"
27
+ require "active_support/rails"
28
+ require "action_cable/version"
27
29
 
28
30
  module ActionCable
29
31
  extend ActiveSupport::Autoload
30
32
 
31
33
  INTERNAL = {
32
34
  message_types: {
33
- welcome: 'welcome'.freeze,
34
- ping: 'ping'.freeze,
35
- confirmation: 'confirm_subscription'.freeze,
36
- rejection: 'reject_subscription'.freeze
35
+ welcome: "welcome",
36
+ disconnect: "disconnect",
37
+ ping: "ping",
38
+ confirmation: "confirm_subscription",
39
+ rejection: "reject_subscription"
40
+ },
41
+ disconnect_reasons: {
42
+ unauthorized: "unauthorized",
43
+ invalid_request: "invalid_request",
44
+ server_restart: "server_restart"
37
45
  },
38
- default_mount_path: '/cable'.freeze,
39
- protocols: ["actioncable-v1-json".freeze, "actioncable-unsupported".freeze].freeze
46
+ default_mount_path: "/cable",
47
+ protocols: ["actioncable-v1-json", "actioncable-unsupported"].freeze
40
48
  }
41
49
 
42
50
  # Singleton instance of the server
@@ -49,4 +57,6 @@ module ActionCable
49
57
  autoload :Channel
50
58
  autoload :RemoteConnections
51
59
  autoload :SubscriptionAdapter
60
+ autoload :TestHelper
61
+ autoload :TestCase
52
62
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ActionCable
2
4
  module Channel
3
5
  extend ActiveSupport::Autoload
@@ -9,6 +11,7 @@ module ActionCable
9
11
  autoload :Naming
10
12
  autoload :PeriodicTimers
11
13
  autoload :Streams
14
+ autoload :TestCase
12
15
  end
13
16
  end
14
17
  end
@@ -1,4 +1,7 @@
1
- require 'set'
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require "active_support/rescuable"
2
5
 
3
6
  module ActionCable
4
7
  module Channel
@@ -97,6 +100,7 @@ module ActionCable
97
100
  include Streams
98
101
  include Naming
99
102
  include Broadcasting
103
+ include ActiveSupport::Rescuable
100
104
 
101
105
  attr_reader :params, :connection, :identifier
102
106
  delegate :logger, to: :connection
@@ -122,16 +126,16 @@ module ActionCable
122
126
  end
123
127
  end
124
128
 
125
- protected
129
+ private
126
130
  # action_methods are cached and there is sometimes need to refresh
127
131
  # them. ::clear_action_methods! allows you to do that, so next time
128
132
  # you run action_methods, they will be recalculated.
129
- def clear_action_methods!
133
+ def clear_action_methods! # :doc:
130
134
  @action_methods = nil
131
135
  end
132
136
 
133
137
  # Refresh the cached action_methods when a new action_method is added.
134
- def method_added(name)
138
+ def method_added(name) # :doc:
135
139
  super
136
140
  clear_action_methods!
137
141
  end
@@ -189,24 +193,25 @@ module ActionCable
189
193
  end
190
194
  end
191
195
 
192
-
193
- protected
194
- # Called once a consumer has become a subscriber of the channel. Usually the place to setup any streams
196
+ private
197
+ # Called once a consumer has become a subscriber of the channel. Usually the place to set up any streams
195
198
  # you want this channel to be sending to the subscriber.
196
- def subscribed
199
+ def subscribed # :doc:
197
200
  # Override in subclasses
198
201
  end
199
202
 
200
203
  # Called once a consumer has cut its cable connection. Can be used for cleaning up connections or marking
201
204
  # users as offline or the like.
202
- def unsubscribed
205
+ def unsubscribed # :doc:
203
206
  # Override in subclasses
204
207
  end
205
208
 
206
209
  # Transmit a hash of data to the subscriber. The hash will automatically be wrapped in a JSON envelope with
207
210
  # the proper channel identifier marked as the recipient.
208
- def transmit(data, via: nil)
209
- logger.info "#{self.class.name} transmitting #{data.inspect.truncate(300)}".tap { |m| m << " (via #{via})" if via }
211
+ def transmit(data, via: nil) # :doc:
212
+ status = "#{self.class.name} transmitting #{data.inspect.truncate(300)}"
213
+ status += " (via #{via})" if via
214
+ logger.debug(status)
210
215
 
211
216
  payload = { channel_class: self.class.name, data: data, via: via }
212
217
  ActiveSupport::Notifications.instrument("transmit.action_cable", payload) do
@@ -214,33 +219,32 @@ module ActionCable
214
219
  end
215
220
  end
216
221
 
217
- def ensure_confirmation_sent
222
+ def ensure_confirmation_sent # :doc:
218
223
  return if subscription_rejected?
219
224
  @defer_subscription_confirmation_counter.decrement
220
225
  transmit_subscription_confirmation unless defer_subscription_confirmation?
221
226
  end
222
227
 
223
- def defer_subscription_confirmation!
228
+ def defer_subscription_confirmation! # :doc:
224
229
  @defer_subscription_confirmation_counter.increment
225
230
  end
226
231
 
227
- def defer_subscription_confirmation?
232
+ def defer_subscription_confirmation? # :doc:
228
233
  @defer_subscription_confirmation_counter.value > 0
229
234
  end
230
235
 
231
- def subscription_confirmation_sent?
236
+ def subscription_confirmation_sent? # :doc:
232
237
  @subscription_confirmation_sent
233
238
  end
234
239
 
235
- def reject
240
+ def reject # :doc:
236
241
  @reject_subscription = true
237
242
  end
238
243
 
239
- def subscription_rejected?
244
+ def subscription_rejected? # :doc:
240
245
  @reject_subscription
241
246
  end
242
247
 
243
- private
244
248
  def delegate_connection_identifiers
245
249
  connection.identifiers.each do |identifier|
246
250
  define_singleton_method(identifier) do
@@ -250,7 +254,7 @@ module ActionCable
250
254
  end
251
255
 
252
256
  def extract_action(data)
253
- (data['action'].presence || :receive).to_sym
257
+ (data["action"].presence || :receive).to_sym
254
258
  end
255
259
 
256
260
  def processable_action?(action)
@@ -265,11 +269,13 @@ module ActionCable
265
269
  else
266
270
  public_send action
267
271
  end
272
+ rescue Exception => exception
273
+ rescue_with_handler(exception) || raise
268
274
  end
269
275
 
270
276
  def action_signature(action, data)
271
- "#{self.class.name}##{action}".tap do |signature|
272
- if (arguments = data.except('action')).any?
277
+ (+"#{self.class.name}##{action}").tap do |signature|
278
+ if (arguments = data.except("action")).any?
273
279
  signature << "(#{arguments.inspect})"
274
280
  end
275
281
  end
@@ -277,7 +283,7 @@ module ActionCable
277
283
 
278
284
  def transmit_subscription_confirmation
279
285
  unless subscription_confirmation_sent?
280
- logger.info "#{self.class.name} is transmitting the subscription confirmation"
286
+ logger.debug "#{self.class.name} is transmitting the subscription confirmation"
281
287
 
282
288
  ActiveSupport::Notifications.instrument("transmit_subscription_confirmation.action_cable", channel_class: self.class.name) do
283
289
  connection.transmit identifier: @identifier, type: ActionCable::INTERNAL[:message_types][:confirmation]
@@ -292,7 +298,7 @@ module ActionCable
292
298
  end
293
299
 
294
300
  def transmit_subscription_rejection
295
- logger.info "#{self.class.name} is transmitting the subscription rejection"
301
+ logger.debug "#{self.class.name} is transmitting the subscription rejection"
296
302
 
297
303
  ActiveSupport::Notifications.instrument("transmit_subscription_rejection.action_cable", channel_class: self.class.name) do
298
304
  connection.transmit identifier: @identifier, type: ActionCable::INTERNAL[:message_types][:rejection]
@@ -301,3 +307,5 @@ module ActionCable
301
307
  end
302
308
  end
303
309
  end
310
+
311
+ ActiveSupport.run_load_hooks(:action_cable_channel, ActionCable::Channel::Base)
@@ -1,26 +1,38 @@
1
- require 'active_support/core_ext/object/to_param'
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/object/to_param"
2
4
 
3
5
  module ActionCable
4
6
  module Channel
5
7
  module Broadcasting
6
8
  extend ActiveSupport::Concern
7
9
 
8
- delegate :broadcasting_for, to: :class
10
+ delegate :broadcasting_for, :broadcast_to, to: :class
9
11
 
10
- class_methods do
12
+ module ClassMethods
11
13
  # Broadcast a hash to a unique broadcasting for this <tt>model</tt> in this channel.
12
14
  def broadcast_to(model, message)
13
- ActionCable.server.broadcast(broadcasting_for([ channel_name, model ]), message)
15
+ ActionCable.server.broadcast(broadcasting_for(model), message)
16
+ end
17
+
18
+ # Returns a unique broadcasting identifier for this <tt>model</tt> in this channel:
19
+ #
20
+ # CommentsChannel.broadcasting_for("all") # => "comments:all"
21
+ #
22
+ # You can pass any object as a target (e.g. Active Record model), and it
23
+ # would be serialized into a string under the hood.
24
+ def broadcasting_for(model)
25
+ serialize_broadcasting([ channel_name, model ])
14
26
  end
15
27
 
16
- def broadcasting_for(model) #:nodoc:
28
+ def serialize_broadcasting(object) #:nodoc:
17
29
  case
18
- when model.is_a?(Array)
19
- model.map { |m| broadcasting_for(m) }.join(':')
20
- when model.respond_to?(:to_gid_param)
21
- model.to_gid_param
30
+ when object.is_a?(Array)
31
+ object.map { |m| serialize_broadcasting(m) }.join(":")
32
+ when object.respond_to?(:to_gid_param)
33
+ object.to_gid_param
22
34
  else
23
- model.to_param
35
+ object.to_param
24
36
  end
25
37
  end
26
38
  end
@@ -1,4 +1,6 @@
1
- require 'active_support/callbacks'
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/callbacks"
2
4
 
3
5
  module ActionCable
4
6
  module Channel
@@ -11,7 +13,7 @@ module ActionCable
11
13
  define_callbacks :unsubscribe
12
14
  end
13
15
 
14
- class_methods do
16
+ module ClassMethods
15
17
  def before_subscribe(*methods, &block)
16
18
  set_callback(:subscribe, :before, *methods, &block)
17
19
  end
@@ -1,17 +1,20 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ActionCable
2
4
  module Channel
3
5
  module Naming
4
6
  extend ActiveSupport::Concern
5
7
 
6
- class_methods do
8
+ module ClassMethods
7
9
  # Returns the name of the channel, underscored, without the <tt>Channel</tt> ending.
8
10
  # If the channel is in a namespace, then the namespaces are represented by single
9
11
  # colon separators in the channel name.
10
12
  #
11
13
  # ChatChannel.channel_name # => 'chat'
12
14
  # Chats::AppearancesChannel.channel_name # => 'chats:appearances'
15
+ # FooChats::BarAppearancesChannel.channel_name # => 'foo_chats:bar_appearances'
13
16
  def channel_name
14
- @channel_name ||= name.sub(/Channel$/, '').gsub('::',':').underscore
17
+ @channel_name ||= name.delete_suffix("Channel").gsub("::", ":").underscore
15
18
  end
16
19
  end
17
20
 
@@ -1,11 +1,12 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ActionCable
2
4
  module Channel
3
5
  module PeriodicTimers
4
6
  extend ActiveSupport::Concern
5
7
 
6
8
  included do
7
- class_attribute :periodic_timers, instance_reader: false
8
- self.periodic_timers = []
9
+ class_attribute :periodic_timers, instance_reader: false, default: []
9
10
 
10
11
  after_subscribe :start_periodic_timers
11
12
  after_unsubscribe :stop_periodic_timers
@@ -30,7 +31,7 @@ module ActionCable
30
31
  def periodically(callback_or_method_name = nil, every:, &block)
31
32
  callback =
32
33
  if block_given?
33
- raise ArgumentError, 'Pass a block or provide a callback arg, not both' if callback_or_method_name
34
+ raise ArgumentError, "Pass a block or provide a callback arg, not both" if callback_or_method_name
34
35
  block
35
36
  else
36
37
  case callback_or_method_name
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ActionCable
2
4
  module Channel
3
5
  # Streams allow channels to route broadcastings to the subscriber. A broadcasting is, as discussed elsewhere, a pubsub queue where any data
@@ -19,14 +21,14 @@ module ActionCable
19
21
  # end
20
22
  #
21
23
  # Based on the above example, the subscribers of this channel will get whatever data is put into the,
22
- # let's say, `comments_for_45` broadcasting as soon as it's put there.
24
+ # let's say, <tt>comments_for_45</tt> broadcasting as soon as it's put there.
23
25
  #
24
26
  # An example broadcasting for this channel looks like so:
25
27
  #
26
- # ActionCable.server.broadcast "comments_for_45", author: 'DHH', content: 'Rails is just swell'
28
+ # ActionCable.server.broadcast "comments_for_45", { author: 'DHH', content: 'Rails is just swell' }
27
29
  #
28
30
  # If you have a stream that is related to a model, then the broadcasting used can be generated from the model and channel.
29
- # The following example would subscribe to a broadcasting like `comments:Z2lkOi8vVGVzdEFwcC9Qb3N0LzE`
31
+ # The following example would subscribe to a broadcasting like <tt>comments:Z2lkOi8vVGVzdEFwcC9Qb3N0LzE</tt>.
30
32
  #
31
33
  # class CommentsChannel < ApplicationCable::Channel
32
34
  # def subscribed
@@ -69,8 +71,8 @@ module ActionCable
69
71
 
70
72
  # Start streaming from the named <tt>broadcasting</tt> pubsub queue. Optionally, you can pass a <tt>callback</tt> that'll be used
71
73
  # instead of the default of just transmitting the updates straight to the subscriber.
72
- # Pass `coder: ActiveSupport::JSON` to decode messages as JSON before passing to the callback.
73
- # Defaults to `coder: nil` which does no decoding, passes raw messages.
74
+ # Pass <tt>coder: ActiveSupport::JSON</tt> to decode messages as JSON before passing to the callback.
75
+ # Defaults to <tt>coder: nil</tt> which does no decoding, passes raw messages.
74
76
  def stream_from(broadcasting, callback = nil, coder: nil, &block)
75
77
  broadcasting = String(broadcasting)
76
78
 
@@ -80,7 +82,7 @@ module ActionCable
80
82
  # Build a stream handler by wrapping the user-provided callback with
81
83
  # a decoder or defaulting to a JSON-decoding retransmitter.
82
84
  handler = worker_pool_stream_handler(broadcasting, callback || block, coder: coder)
83
- streams << [ broadcasting, handler ]
85
+ streams[broadcasting] = handler
84
86
 
85
87
  connection.server.event_loop.post do
86
88
  pubsub.subscribe(broadcasting, handler, lambda do
@@ -94,10 +96,24 @@ module ActionCable
94
96
  # <tt>callback</tt> that'll be used instead of the default of just transmitting the updates straight
95
97
  # to the subscriber.
96
98
  #
97
- # Pass `coder: ActiveSupport::JSON` to decode messages as JSON before passing to the callback.
98
- # Defaults to `coder: nil` which does no decoding, passes raw messages.
99
+ # Pass <tt>coder: ActiveSupport::JSON</tt> to decode messages as JSON before passing to the callback.
100
+ # Defaults to <tt>coder: nil</tt> which does no decoding, passes raw messages.
99
101
  def stream_for(model, callback = nil, coder: nil, &block)
100
- stream_from(broadcasting_for([ channel_name, model ]), callback || block, coder: coder)
102
+ stream_from(broadcasting_for(model), callback || block, coder: coder)
103
+ end
104
+
105
+ # Unsubscribes streams from the named <tt>broadcasting</tt>.
106
+ def stop_stream_from(broadcasting)
107
+ callback = streams.delete(broadcasting)
108
+ if callback
109
+ pubsub.unsubscribe(broadcasting, callback)
110
+ logger.info "#{self.class.name} stopped streaming from #{broadcasting}"
111
+ end
112
+ end
113
+
114
+ # Unsubscribes streams for the <tt>model</tt>.
115
+ def stop_stream_for(model)
116
+ stop_stream_from(broadcasting_for(model))
101
117
  end
102
118
 
103
119
  # Unsubscribes all streams associated with this channel from the pubsub queue.
@@ -108,11 +124,23 @@ module ActionCable
108
124
  end.clear
109
125
  end
110
126
 
127
+ # Calls stream_for if record is present, otherwise calls reject.
128
+ # This method is intended to be called when you're looking
129
+ # for a record based on a parameter, if its found it will start
130
+ # streaming. If the record is nil then it will reject the connection.
131
+ def stream_or_reject_for(record)
132
+ if record
133
+ stream_for record
134
+ else
135
+ reject
136
+ end
137
+ end
138
+
111
139
  private
112
140
  delegate :pubsub, to: :connection
113
141
 
114
142
  def streams
115
- @_streams ||= []
143
+ @_streams ||= {}
116
144
  end
117
145
 
118
146
  # Always wrap the outermost handler to invoke the user handler on the
@@ -138,7 +166,7 @@ module ActionCable
138
166
  end
139
167
 
140
168
  # May be overridden to change the default stream handling behavior
141
- # which decodes JSON and transmits to client.
169
+ # which decodes JSON and transmits to the client.
142
170
  #
143
171
  # TODO: Tests demonstrating this.
144
172
  #
@@ -0,0 +1,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/test_case"
5
+ require "active_support/core_ext/hash/indifferent_access"
6
+ require "json"
7
+
8
+ module ActionCable
9
+ module Channel
10
+ class NonInferrableChannelError < ::StandardError
11
+ def initialize(name)
12
+ super "Unable to determine the channel to test from #{name}. " +
13
+ "You'll need to specify it using `tests YourChannel` in your " +
14
+ "test case definition."
15
+ end
16
+ end
17
+
18
+ # Stub +stream_from+ to track streams for the channel.
19
+ # Add public aliases for +subscription_confirmation_sent?+ and
20
+ # +subscription_rejected?+.
21
+ module ChannelStub
22
+ def confirmed?
23
+ subscription_confirmation_sent?
24
+ end
25
+
26
+ def rejected?
27
+ subscription_rejected?
28
+ end
29
+
30
+ def stream_from(broadcasting, *)
31
+ streams << broadcasting
32
+ end
33
+
34
+ def stop_all_streams
35
+ @_streams = []
36
+ end
37
+
38
+ def streams
39
+ @_streams ||= []
40
+ end
41
+
42
+ # Make periodic timers no-op
43
+ def start_periodic_timers; end
44
+ alias stop_periodic_timers start_periodic_timers
45
+ end
46
+
47
+ class ConnectionStub
48
+ attr_reader :transmissions, :identifiers, :subscriptions, :logger
49
+
50
+ def initialize(identifiers = {})
51
+ @transmissions = []
52
+
53
+ identifiers.each do |identifier, val|
54
+ define_singleton_method(identifier) { val }
55
+ end
56
+
57
+ @subscriptions = ActionCable::Connection::Subscriptions.new(self)
58
+ @identifiers = identifiers.keys
59
+ @logger = ActiveSupport::TaggedLogging.new ActiveSupport::Logger.new(StringIO.new)
60
+ end
61
+
62
+ def transmit(cable_message)
63
+ transmissions << cable_message.with_indifferent_access
64
+ end
65
+ end
66
+
67
+ # Superclass for Action Cable channel functional tests.
68
+ #
69
+ # == Basic example
70
+ #
71
+ # Functional tests are written as follows:
72
+ # 1. First, one uses the +subscribe+ method to simulate subscription creation.
73
+ # 2. Then, one asserts whether the current state is as expected. "State" can be anything:
74
+ # transmitted messages, subscribed streams, etc.
75
+ #
76
+ # For example:
77
+ #
78
+ # class ChatChannelTest < ActionCable::Channel::TestCase
79
+ # def test_subscribed_with_room_number
80
+ # # Simulate a subscription creation
81
+ # subscribe room_number: 1
82
+ #
83
+ # # Asserts that the subscription was successfully created
84
+ # assert subscription.confirmed?
85
+ #
86
+ # # Asserts that the channel subscribes connection to a stream
87
+ # assert_has_stream "chat_1"
88
+ #
89
+ # # Asserts that the channel subscribes connection to a specific
90
+ # # stream created for a model
91
+ # assert_has_stream_for Room.find(1)
92
+ # end
93
+ #
94
+ # def test_does_not_stream_with_incorrect_room_number
95
+ # subscribe room_number: -1
96
+ #
97
+ # # Asserts that not streams was started
98
+ # assert_no_streams
99
+ # end
100
+ #
101
+ # def test_does_not_subscribe_without_room_number
102
+ # subscribe
103
+ #
104
+ # # Asserts that the subscription was rejected
105
+ # assert subscription.rejected?
106
+ # end
107
+ # end
108
+ #
109
+ # You can also perform actions:
110
+ # def test_perform_speak
111
+ # subscribe room_number: 1
112
+ #
113
+ # perform :speak, message: "Hello, Rails!"
114
+ #
115
+ # assert_equal "Hello, Rails!", transmissions.last["text"]
116
+ # end
117
+ #
118
+ # == Special methods
119
+ #
120
+ # ActionCable::Channel::TestCase will also automatically provide the following instance
121
+ # methods for use in the tests:
122
+ #
123
+ # <b>connection</b>::
124
+ # An ActionCable::Channel::ConnectionStub, representing the current HTTP connection.
125
+ # <b>subscription</b>::
126
+ # An instance of the current channel, created when you call +subscribe+.
127
+ # <b>transmissions</b>::
128
+ # A list of all messages that have been transmitted into the channel.
129
+ #
130
+ #
131
+ # == Channel is automatically inferred
132
+ #
133
+ # ActionCable::Channel::TestCase will automatically infer the channel under test
134
+ # from the test class name. If the channel cannot be inferred from the test
135
+ # class name, you can explicitly set it with +tests+.
136
+ #
137
+ # class SpecialEdgeCaseChannelTest < ActionCable::Channel::TestCase
138
+ # tests SpecialChannel
139
+ # end
140
+ #
141
+ # == Specifying connection identifiers
142
+ #
143
+ # You need to set up your connection manually to provide values for the identifiers.
144
+ # To do this just use:
145
+ #
146
+ # stub_connection(user: users(:john))
147
+ #
148
+ # == Testing broadcasting
149
+ #
150
+ # ActionCable::Channel::TestCase enhances ActionCable::TestHelper assertions (e.g.
151
+ # +assert_broadcasts+) to handle broadcasting to models:
152
+ #
153
+ #
154
+ # # in your channel
155
+ # def speak(data)
156
+ # broadcast_to room, text: data["message"]
157
+ # end
158
+ #
159
+ # def test_speak
160
+ # subscribe room_id: rooms(:chat).id
161
+ #
162
+ # assert_broadcast_on(rooms(:chat), text: "Hello, Rails!") do
163
+ # perform :speak, message: "Hello, Rails!"
164
+ # end
165
+ # end
166
+ class TestCase < ActiveSupport::TestCase
167
+ module Behavior
168
+ extend ActiveSupport::Concern
169
+
170
+ include ActiveSupport::Testing::ConstantLookup
171
+ include ActionCable::TestHelper
172
+
173
+ CHANNEL_IDENTIFIER = "test_stub"
174
+
175
+ included do
176
+ class_attribute :_channel_class
177
+
178
+ attr_reader :connection, :subscription
179
+
180
+ ActiveSupport.run_load_hooks(:action_cable_channel_test_case, self)
181
+ end
182
+
183
+ module ClassMethods
184
+ def tests(channel)
185
+ case channel
186
+ when String, Symbol
187
+ self._channel_class = channel.to_s.camelize.constantize
188
+ when Module
189
+ self._channel_class = channel
190
+ else
191
+ raise NonInferrableChannelError.new(channel)
192
+ end
193
+ end
194
+
195
+ def channel_class
196
+ if channel = self._channel_class
197
+ channel
198
+ else
199
+ tests determine_default_channel(name)
200
+ end
201
+ end
202
+
203
+ def determine_default_channel(name)
204
+ channel = determine_constant_from_test_name(name) do |constant|
205
+ Class === constant && constant < ActionCable::Channel::Base
206
+ end
207
+ raise NonInferrableChannelError.new(name) if channel.nil?
208
+ channel
209
+ end
210
+ end
211
+
212
+ # Set up test connection with the specified identifiers:
213
+ #
214
+ # class ApplicationCable < ActionCable::Connection::Base
215
+ # identified_by :user, :token
216
+ # end
217
+ #
218
+ # stub_connection(user: users[:john], token: 'my-secret-token')
219
+ def stub_connection(identifiers = {})
220
+ @connection = ConnectionStub.new(identifiers)
221
+ end
222
+
223
+ # Subscribe to the channel under test. Optionally pass subscription parameters as a Hash.
224
+ def subscribe(params = {})
225
+ @connection ||= stub_connection
226
+ @subscription = self.class.channel_class.new(connection, CHANNEL_IDENTIFIER, params.with_indifferent_access)
227
+ @subscription.singleton_class.include(ChannelStub)
228
+ @subscription.subscribe_to_channel
229
+ @subscription
230
+ end
231
+
232
+ # Unsubscribe the subscription under test.
233
+ def unsubscribe
234
+ check_subscribed!
235
+ subscription.unsubscribe_from_channel
236
+ end
237
+
238
+ # Perform action on a channel.
239
+ #
240
+ # NOTE: Must be subscribed.
241
+ def perform(action, data = {})
242
+ check_subscribed!
243
+ subscription.perform_action(data.stringify_keys.merge("action" => action.to_s))
244
+ end
245
+
246
+ # Returns messages transmitted into channel
247
+ def transmissions
248
+ # Return only directly sent message (via #transmit)
249
+ connection.transmissions.map { |data| data["message"] }.compact
250
+ end
251
+
252
+ # Enhance TestHelper assertions to handle non-String
253
+ # broadcastings
254
+ def assert_broadcasts(stream_or_object, *args)
255
+ super(broadcasting_for(stream_or_object), *args)
256
+ end
257
+
258
+ def assert_broadcast_on(stream_or_object, *args)
259
+ super(broadcasting_for(stream_or_object), *args)
260
+ end
261
+
262
+ # Asserts that no streams have been started.
263
+ #
264
+ # def test_assert_no_started_stream
265
+ # subscribe
266
+ # assert_no_streams
267
+ # end
268
+ #
269
+ def assert_no_streams
270
+ assert subscription.streams.empty?, "No streams started was expected, but #{subscription.streams.count} found"
271
+ end
272
+
273
+ # Asserts that the specified stream has been started.
274
+ #
275
+ # def test_assert_started_stream
276
+ # subscribe
277
+ # assert_has_stream 'messages'
278
+ # end
279
+ #
280
+ def assert_has_stream(stream)
281
+ assert subscription.streams.include?(stream), "Stream #{stream} has not been started"
282
+ end
283
+
284
+ # Asserts that the specified stream for a model has started.
285
+ #
286
+ # def test_assert_started_stream_for
287
+ # subscribe id: 42
288
+ # assert_has_stream_for User.find(42)
289
+ # end
290
+ #
291
+ def assert_has_stream_for(object)
292
+ assert_has_stream(broadcasting_for(object))
293
+ end
294
+
295
+ private
296
+ def check_subscribed!
297
+ raise "Must be subscribed!" if subscription.nil? || subscription.rejected?
298
+ end
299
+
300
+ def broadcasting_for(stream_or_object)
301
+ return stream_or_object if stream_or_object.is_a?(String)
302
+
303
+ self.class.channel_class.broadcasting_for(stream_or_object)
304
+ end
305
+ end
306
+
307
+ include Behavior
308
+ end
309
+ end
310
+ end