actioncable 5.0.1 → 6.1.3

Sign up to get free protection for your applications and to get access to all the features.
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