actioncable 7.0.8.7 → 7.1.0.beta1

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -171
  3. data/MIT-LICENSE +1 -1
  4. data/README.md +4 -4
  5. data/app/assets/javascripts/action_cable.js +25 -4
  6. data/app/assets/javascripts/actioncable.esm.js +25 -4
  7. data/app/assets/javascripts/actioncable.js +25 -4
  8. data/lib/action_cable/channel/base.rb +17 -5
  9. data/lib/action_cable/channel/broadcasting.rb +3 -1
  10. data/lib/action_cable/channel/callbacks.rb +16 -0
  11. data/lib/action_cable/channel/naming.rb +4 -2
  12. data/lib/action_cable/channel/streams.rb +2 -0
  13. data/lib/action_cable/channel/test_case.rb +6 -1
  14. data/lib/action_cable/connection/authorization.rb +1 -1
  15. data/lib/action_cable/connection/base.rb +18 -5
  16. data/lib/action_cable/connection/callbacks.rb +51 -0
  17. data/lib/action_cable/connection/internal_channel.rb +3 -1
  18. data/lib/action_cable/connection/stream.rb +1 -3
  19. data/lib/action_cable/connection/stream_event_loop.rb +0 -1
  20. data/lib/action_cable/connection/subscriptions.rb +2 -0
  21. data/lib/action_cable/connection/tagged_logger_proxy.rb +6 -4
  22. data/lib/action_cable/connection/test_case.rb +3 -1
  23. data/lib/action_cable/connection/web_socket.rb +2 -0
  24. data/lib/action_cable/deprecator.rb +7 -0
  25. data/lib/action_cable/engine.rb +13 -5
  26. data/lib/action_cable/gem_version.rb +4 -4
  27. data/lib/action_cable/remote_connections.rb +11 -2
  28. data/lib/action_cable/server/base.rb +3 -0
  29. data/lib/action_cable/server/broadcasting.rb +2 -0
  30. data/lib/action_cable/server/configuration.rb +12 -2
  31. data/lib/action_cable/server/connections.rb +3 -1
  32. data/lib/action_cable/server/worker.rb +0 -1
  33. data/lib/action_cable/subscription_adapter/async.rb +0 -2
  34. data/lib/action_cable/subscription_adapter/postgresql.rb +0 -1
  35. data/lib/action_cable/subscription_adapter/redis.rb +3 -6
  36. data/lib/action_cable/subscription_adapter/test.rb +3 -5
  37. data/lib/action_cable/test_helper.rb +53 -22
  38. data/lib/action_cable/version.rb +1 -1
  39. data/lib/action_cable.rb +24 -12
  40. data/lib/rails/generators/channel/USAGE +14 -8
  41. data/lib/rails/generators/channel/channel_generator.rb +21 -7
  42. metadata +31 -19
  43. data/lib/action_cable/channel.rb +0 -17
  44. data/lib/action_cable/connection.rb +0 -22
  45. data/lib/action_cable/server.rb +0 -16
  46. data/lib/action_cable/subscription_adapter.rb +0 -12
@@ -5,7 +5,7 @@ module ActionCable
5
5
  module Authorization
6
6
  class UnauthorizedError < StandardError; end
7
7
 
8
- # Closes the WebSocket connection if it is open and returns a 404 "File not Found" response.
8
+ # Closes the WebSocket connection if it is open and returns an "unauthorized" reason.
9
9
  def reject_unauthorized_connection
10
10
  logger.error "An unauthorized connection attempt was rejected"
11
11
  raise UnauthorizedError
@@ -5,6 +5,8 @@ require "active_support/rescuable"
5
5
 
6
6
  module ActionCable
7
7
  module Connection
8
+ # = Action Cable \Connection \Base
9
+ #
8
10
  # For every WebSocket connection the Action Cable server accepts, a Connection object will be instantiated. This instance becomes the parent
9
11
  # of all of the channel subscriptions that are created from there on. Incoming messages are then routed to these channel subscriptions
10
12
  # based on an identifier sent by the Action Cable consumer. The Connection itself does not deal with any specific application logic beyond
@@ -47,10 +49,11 @@ module ActionCable
47
49
  include Identification
48
50
  include InternalChannel
49
51
  include Authorization
52
+ include Callbacks
50
53
  include ActiveSupport::Rescuable
51
54
 
52
55
  attr_reader :server, :env, :subscriptions, :logger, :worker_pool, :protocol
53
- delegate :event_loop, :pubsub, to: :server
56
+ delegate :event_loop, :pubsub, :config, to: :server
54
57
 
55
58
  def initialize(server, env, coder: ActiveSupport::JSON)
56
59
  @server, @env, @coder = server, env, coder
@@ -86,12 +89,18 @@ module ActionCable
86
89
 
87
90
  def dispatch_websocket_message(websocket_message) # :nodoc:
88
91
  if websocket.alive?
89
- subscriptions.execute_command decode(websocket_message)
92
+ handle_channel_command decode(websocket_message)
90
93
  else
91
94
  logger.error "Ignoring message processed after the WebSocket was closed: #{websocket_message.inspect})"
92
95
  end
93
96
  end
94
97
 
98
+ def handle_channel_command(payload)
99
+ run_callbacks :command do
100
+ subscriptions.execute_command payload
101
+ end
102
+ end
103
+
95
104
  def transmit(cable_message) # :nodoc:
96
105
  websocket.transmit encode(cable_message)
97
106
  end
@@ -143,6 +152,10 @@ module ActionCable
143
152
  send_async :handle_close
144
153
  end
145
154
 
155
+ def inspect # :nodoc:
156
+ "#<#{self.class.name}:#{'%#016x' % (object_id << 1)}>"
157
+ end
158
+
146
159
  private
147
160
  attr_reader :websocket
148
161
  attr_reader :message_buffer
@@ -222,7 +235,7 @@ module ActionCable
222
235
 
223
236
  logger.error invalid_request_message
224
237
  logger.info finished_request_message
225
- [ 404, { "Content-Type" => "text/plain" }, [ "Page not found" ] ]
238
+ [ 404, { Rack::CONTENT_TYPE => "text/plain; charset=utf-8" }, [ "Page not found" ] ]
226
239
  end
227
240
 
228
241
  # Tags are declared in the server but computed in the connection. This allows us per-connection tailored tags.
@@ -237,7 +250,7 @@ module ActionCable
237
250
  request.filtered_path,
238
251
  websocket.possible? ? " [WebSocket]" : "[non-WebSocket]",
239
252
  request.ip,
240
- Time.now.to_default_s ]
253
+ Time.now.to_s ]
241
254
  end
242
255
 
243
256
  def finished_request_message
@@ -245,7 +258,7 @@ module ActionCable
245
258
  request.filtered_path,
246
259
  websocket.possible? ? " [WebSocket]" : "[non-WebSocket]",
247
260
  request.ip,
248
- Time.now.to_default_s ]
261
+ Time.now.to_s ]
249
262
  end
250
263
 
251
264
  def invalid_request_message
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/callbacks"
4
+
5
+ module ActionCable
6
+ module Connection
7
+ # = Action Cable \Connection \Callbacks
8
+ #
9
+ # There are <tt>before_command</tt>, <tt>after_command</tt>, and <tt>around_command</tt> callbacks
10
+ # available to be invoked before, after or around every command received by a client respectively.
11
+ # The term "command" here refers to any interaction received by a client (subscribing, unsubscribing or performing actions):
12
+ #
13
+ # module ApplicationCable
14
+ # class Connection < ActionCable::Connection::Base
15
+ # identified_by :user
16
+ #
17
+ # around_command :set_current_account
18
+ #
19
+ # private
20
+ #
21
+ # def set_current_account
22
+ # # Now all channels could use Current.account
23
+ # Current.set(account: user.account) { yield }
24
+ # end
25
+ # end
26
+ # end
27
+ #
28
+ module Callbacks
29
+ extend ActiveSupport::Concern
30
+ include ActiveSupport::Callbacks
31
+
32
+ included do
33
+ define_callbacks :command
34
+ end
35
+
36
+ module ClassMethods
37
+ def before_command(*methods, &block)
38
+ set_callback(:command, :before, *methods, &block)
39
+ end
40
+
41
+ def after_command(*methods, &block)
42
+ set_callback(:command, :after, *methods, &block)
43
+ end
44
+
45
+ def around_command(*methods, &block)
46
+ set_callback(:command, :around, *methods, &block)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  module ActionCable
4
4
  module Connection
5
+ # = Action Cable \InternalChannel
6
+ #
5
7
  # Makes it possible for the RemoteConnection to disconnect a specific connection.
6
8
  module InternalChannel
7
9
  extend ActiveSupport::Concern
@@ -32,7 +34,7 @@ module ActionCable
32
34
  case message["type"]
33
35
  when "disconnect"
34
36
  logger.info "Removing connection (#{connection_identifier})"
35
- websocket.close
37
+ close(reason: ActionCable::INTERNAL[:disconnect_reasons][:remote], reconnect: message.fetch("reconnect", true))
36
38
  end
37
39
  rescue Exception => e
38
40
  logger.error "There was an exception - #{e.class}(#{e.message})"
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "thread"
4
-
5
3
  module ActionCable
6
4
  module Connection
7
5
  #--
@@ -100,7 +98,7 @@ module ActionCable
100
98
 
101
99
  # This should return the underlying io according to the SPEC:
102
100
  @rack_hijack_io = @socket_object.env["rack.hijack"].call
103
- # Retain existing behaviour if required:
101
+ # Retain existing behavior if required:
104
102
  @rack_hijack_io ||= @socket_object.env["rack.hijack_io"]
105
103
 
106
104
  @event_loop.attach(@rack_hijack_io, self)
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "nio"
4
- require "thread"
5
4
 
6
5
  module ActionCable
7
6
  module Connection
@@ -4,6 +4,8 @@ require "active_support/core_ext/hash/indifferent_access"
4
4
 
5
5
  module ActionCable
6
6
  module Connection
7
+ # = Action Cable \Connection \Subscriptions
8
+ #
7
9
  # Collection class for all the channel subscriptions established on a given connection. Responsible for routing incoming commands that arrive on
8
10
  # the connection to the proper channel.
9
11
  class Subscriptions # :nodoc:
@@ -2,6 +2,8 @@
2
2
 
3
3
  module ActionCable
4
4
  module Connection
5
+ # = Action Cable \Connection \TaggedLoggerProxy
6
+ #
5
7
  # Allows the use of per-connection tags against the server logger. This wouldn't work using the traditional
6
8
  # ActiveSupport::TaggedLogging enhanced Rails.logger, as that logger will reset the tags between requests.
7
9
  # The connection is long-lived, so it needs its own set of tags for its independent duration.
@@ -28,14 +30,14 @@ module ActionCable
28
30
  end
29
31
 
30
32
  %i( debug info warn error fatal unknown ).each do |severity|
31
- define_method(severity) do |message|
32
- log severity, message
33
+ define_method(severity) do |message = nil, &block|
34
+ log severity, message, &block
33
35
  end
34
36
  end
35
37
 
36
38
  private
37
- def log(type, message) # :doc:
38
- tag(@logger) { @logger.send type, message }
39
+ def log(type, message, &block) # :doc:
40
+ tag(@logger) { @logger.send type, message, &block }
39
41
  end
40
42
  end
41
43
  end
@@ -56,6 +56,8 @@ module ActionCable
56
56
  end
57
57
  end
58
58
 
59
+ # = Action Cable \Connection \TestCase
60
+ #
59
61
  # Unit test Action Cable connections.
60
62
  #
61
63
  # Useful to check whether a connection's +identified_by+ gets assigned properly
@@ -116,7 +118,7 @@ module ActionCable
116
118
  # assert_equal "1", connection.user_id
117
119
  # end
118
120
  #
119
- # == Connection is automatically inferred
121
+ # == \Connection is automatically inferred
120
122
  #
121
123
  # ActionCable::Connection::TestCase will automatically infer the connection under test
122
124
  # from the test class name. If the channel cannot be inferred from the test
@@ -4,6 +4,8 @@ require "websocket/driver"
4
4
 
5
5
  module ActionCable
6
6
  module Connection
7
+ # = Action Cable \Connection \WebSocket
8
+ #
7
9
  # Wrap the real socket to minimize the externally-presented API
8
10
  class WebSocket # :nodoc:
9
11
  def initialize(env, event_target, event_loop, protocols: ActionCable::INTERNAL[:protocols])
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionCable
4
+ def self.deprecator # :nodoc:
5
+ @deprecator ||= ActiveSupport::Deprecation.new
6
+ end
7
+ end
@@ -2,7 +2,6 @@
2
2
 
3
3
  require "rails"
4
4
  require "action_cable"
5
- require "action_cable/helpers/action_cable_helper"
6
5
  require "active_support/core_ext/hash/indifferent_access"
7
6
 
8
7
  module ActionCable
@@ -11,7 +10,9 @@ module ActionCable
11
10
  config.action_cable.mount_path = ActionCable::INTERNAL[:default_mount_path]
12
11
  config.action_cable.precompile_assets = true
13
12
 
14
- config.eager_load_namespaces << ActionCable
13
+ initializer "action_cable.deprecator", before: :load_environment_config do |app|
14
+ app.deprecators[:action_cable] = ActionCable.deprecator
15
+ end
15
16
 
16
17
  initializer "action_cable.helpers" do
17
18
  ActiveSupport.on_load(:action_view) do
@@ -23,6 +24,12 @@ module ActionCable
23
24
  ActiveSupport.on_load(:action_cable) { self.logger ||= ::Rails.logger }
24
25
  end
25
26
 
27
+ initializer "action_cable.health_check_application" do
28
+ ActiveSupport.on_load(:action_cable) {
29
+ self.health_check_application = ->(env) { Rails::HealthController.action(:show).call(env) }
30
+ }
31
+ end
32
+
26
33
  initializer "action_cable.asset" do
27
34
  config.after_initialize do |app|
28
35
  if app.config.respond_to?(:assets) && app.config.action_cable.precompile_assets
@@ -39,11 +46,12 @@ module ActionCable
39
46
 
40
47
  ActiveSupport.on_load(:action_cable) do
41
48
  if (config_path = Pathname.new(app.config.paths["config/cable"].first)).exist?
42
- self.cable = Rails.application.config_for(config_path).to_h.with_indifferent_access
49
+ self.cable = app.config_for(config_path).to_h.with_indifferent_access
43
50
  end
44
51
 
45
52
  previous_connection_class = connection_class
46
53
  self.connection_class = -> { "ApplicationCable::Connection".safe_constantize || previous_connection_class.call }
54
+ self.filter_parameters += app.config.filter_parameters
47
55
 
48
56
  options.each { |k, v| send("#{k}=", v) }
49
57
  end
@@ -63,7 +71,7 @@ module ActionCable
63
71
  initializer "action_cable.set_work_hooks" do |app|
64
72
  ActiveSupport.on_load(:action_cable) do
65
73
  ActionCable::Server::Worker.set_callback :work, :around, prepend: true do |_, inner|
66
- app.executor.wrap do
74
+ app.executor.wrap(source: "application.action_cable") do
67
75
  # If we took a while to get the lock, we may have been halted
68
76
  # in the meantime. As we haven't started doing any real work
69
77
  # yet, we should pretend that we never made it off the queue.
@@ -74,7 +82,7 @@ module ActionCable
74
82
  end
75
83
 
76
84
  wrap = lambda do |_, inner|
77
- app.executor.wrap(&inner)
85
+ app.executor.wrap(source: "application.action_cable", &inner)
78
86
  end
79
87
  ActionCable::Channel::Base.set_callback :subscribe, :around, prepend: true, &wrap
80
88
  ActionCable::Channel::Base.set_callback :unsubscribe, :around, prepend: true, &wrap
@@ -1,16 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActionCable
4
- # Returns the currently loaded version of Action Cable as a <tt>Gem::Version</tt>.
4
+ # Returns the currently loaded version of Action Cable as a +Gem::Version+.
5
5
  def self.gem_version
6
6
  Gem::Version.new VERSION::STRING
7
7
  end
8
8
 
9
9
  module VERSION
10
10
  MAJOR = 7
11
- MINOR = 0
12
- TINY = 8
13
- PRE = "7"
11
+ MINOR = 1
12
+ TINY = 0
13
+ PRE = "beta1"
14
14
 
15
15
  STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".")
16
16
  end
@@ -3,6 +3,8 @@
3
3
  require "active_support/core_ext/module/redefine_method"
4
4
 
5
5
  module ActionCable
6
+ # = Action Cable Remote Connections
7
+ #
6
8
  # If you need to disconnect a given connection, you can go through the
7
9
  # RemoteConnections. You can find the connections you're looking for by
8
10
  # searching for the identifier declared on the connection. For example:
@@ -19,6 +21,11 @@ module ActionCable
19
21
  # This will disconnect all the connections established for
20
22
  # <tt>User.find(1)</tt>, across all servers running on all machines, because
21
23
  # it uses the internal channel that all of these servers are subscribed to.
24
+ #
25
+ # By default, server sends a "disconnect" message with "reconnect" flag set to true.
26
+ # You can override it by specifying the +reconnect+ option:
27
+ #
28
+ # ActionCable.server.remote_connections.where(current_user: User.find(1)).disconnect(reconnect: false)
22
29
  class RemoteConnections
23
30
  attr_reader :server
24
31
 
@@ -31,6 +38,8 @@ module ActionCable
31
38
  end
32
39
 
33
40
  private
41
+ # = Action Cable Remote \Connection
42
+ #
34
43
  # Represents a single remote connection found via <tt>ActionCable.server.remote_connections.where(*)</tt>.
35
44
  # Exists solely for the purpose of calling #disconnect on that connection.
36
45
  class RemoteConnection
@@ -44,8 +53,8 @@ module ActionCable
44
53
  end
45
54
 
46
55
  # Uses the internal channel to disconnect the connection.
47
- def disconnect
48
- server.broadcast internal_channel, { type: "disconnect" }
56
+ def disconnect(reconnect: true)
57
+ server.broadcast internal_channel, { type: "disconnect", reconnect: reconnect }
49
58
  end
50
59
 
51
60
  # Returns all the identifiers that were applied to this connection.
@@ -4,6 +4,8 @@ require "monitor"
4
4
 
5
5
  module ActionCable
6
6
  module Server
7
+ # = Action Cable \Server \Base
8
+ #
7
9
  # A singleton ActionCable::Server instance is available via ActionCable.server. It's used by the Rack process that starts the Action Cable server, but
8
10
  # is also used by the user to reach the RemoteConnections object, which is used for finding and disconnecting connections across all servers.
9
11
  #
@@ -29,6 +31,7 @@ module ActionCable
29
31
 
30
32
  # Called by Rack to set up the server.
31
33
  def call(env)
34
+ return config.health_check_application.call(env) if env["PATH_INFO"] == config.health_check_path
32
35
  setup_heartbeat_timer
33
36
  config.connection_class.call.new(self, env).process
34
37
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  module ActionCable
4
4
  module Server
5
+ # = Action Cable \Server \Broadcasting
6
+ #
5
7
  # Broadcasting is how other parts of your application can send messages to a channel's subscribers. As explained in Channel, most of the time, these
6
8
  # broadcastings are streamed directly to the clients subscribed to the named broadcasting. Let's explain with a full-stack example:
7
9
  #
@@ -1,15 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rack"
4
+
3
5
  module ActionCable
4
6
  module Server
7
+ # = Action Cable \Server \Configuration
8
+ #
5
9
  # An instance of this configuration object is available via ActionCable.server.config, which allows you to tweak Action Cable configuration
6
- # in a Rails config initializer.
10
+ # in a \Rails config initializer.
7
11
  class Configuration
8
12
  attr_accessor :logger, :log_tags
9
13
  attr_accessor :connection_class, :worker_pool_size
10
- attr_accessor :disable_request_forgery_protection, :allowed_request_origins, :allow_same_origin_as_host
14
+ attr_accessor :disable_request_forgery_protection, :allowed_request_origins, :allow_same_origin_as_host, :filter_parameters
11
15
  attr_accessor :cable, :url, :mount_path
12
16
  attr_accessor :precompile_assets
17
+ attr_accessor :health_check_path, :health_check_application
13
18
 
14
19
  def initialize
15
20
  @log_tags = []
@@ -19,6 +24,11 @@ module ActionCable
19
24
 
20
25
  @disable_request_forgery_protection = false
21
26
  @allow_same_origin_as_host = true
27
+ @filter_parameters = []
28
+
29
+ @health_check_application = ->(env) {
30
+ [200, { Rack::CONTENT_TYPE => "text/html", "date" => Time.now.httpdate }, []]
31
+ }
22
32
  end
23
33
 
24
34
  # Returns constant of subscription adapter specified in config/cable.yml.
@@ -2,6 +2,8 @@
2
2
 
3
3
  module ActionCable
4
4
  module Server
5
+ # = Action Cable \Server \Connections
6
+ #
5
7
  # Collection class for all the connections that have been established on this specific server. Remember, usually you'll run many Action Cable servers, so
6
8
  # you can't use this collection as a full list of all of the connections established against your application. Instead, use RemoteConnections for that.
7
9
  module Connections # :nodoc:
@@ -24,7 +26,7 @@ module ActionCable
24
26
  # disconnect.
25
27
  def setup_heartbeat_timer
26
28
  @heartbeat_timer ||= event_loop.timer(BEAT_INTERVAL) do
27
- event_loop.post { connections.map(&:beat) }
29
+ event_loop.post { connections.each(&:beat) }
28
30
  end
29
31
  end
30
32
 
@@ -2,7 +2,6 @@
2
2
 
3
3
  require "active_support/callbacks"
4
4
  require "active_support/core_ext/module/attribute_accessors_per_thread"
5
- require "action_cable/server/worker/active_record_connection_management"
6
5
  require "concurrent"
7
6
 
8
7
  module ActionCable
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "action_cable/subscription_adapter/inline"
4
-
5
3
  module ActionCable
6
4
  module SubscriptionAdapter
7
5
  class Async < Inline # :nodoc:
@@ -2,7 +2,6 @@
2
2
 
3
3
  gem "pg", "~> 1.1"
4
4
  require "pg"
5
- require "thread"
6
5
  require "openssl"
7
6
 
8
7
  module ActionCable
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "thread"
4
-
5
- gem "redis", ">= 3", "< 6"
3
+ gem "redis", ">= 4", "< 6"
6
4
  require "redis"
7
5
 
8
6
  require "active_support/core_ext/hash/except"
@@ -209,7 +207,7 @@ module ActionCable
209
207
  end
210
208
 
211
209
  if ::Redis::VERSION < "5"
212
- ConnectionError = ::Redis::ConnectionError
210
+ ConnectionError = ::Redis::BaseConnectionError
213
211
 
214
212
  class SubscribedClient
215
213
  def initialize(raw_client)
@@ -240,8 +238,7 @@ module ActionCable
240
238
  end
241
239
 
242
240
  def extract_subscribed_client(conn)
243
- raw_client = conn.respond_to?(:_client) ? conn._client : conn.client
244
- SubscribedClient.new(raw_client)
241
+ SubscribedClient.new(conn._client)
245
242
  end
246
243
  else
247
244
  ConnectionError = RedisClient::ConnectionError
@@ -1,17 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "async"
4
-
5
3
  module ActionCable
6
4
  module SubscriptionAdapter
7
- # == Test adapter for Action Cable
5
+ # == \Test adapter for Action Cable
8
6
  #
9
7
  # The test adapter should be used only in testing. Along with
10
- # ActionCable::TestHelper it makes a great tool to test your Rails application.
8
+ # ActionCable::TestHelper it makes a great tool to test your \Rails application.
11
9
  #
12
10
  # To use the test adapter set +adapter+ value to +test+ in your +config/cable.yml+ file.
13
11
  #
14
- # NOTE: Test adapter extends the <tt>ActionCable::SubscriptionsAdapter::Async</tt> adapter,
12
+ # NOTE: +Test+ adapter extends the +ActionCable::SubscriptionAdapter::Async+ adapter,
15
13
  # so it could be used in system tests too.
16
14
  class Test < Async
17
15
  def broadcast(channel, payload)
@@ -29,30 +29,33 @@ module ActionCable
29
29
  # end
30
30
  #
31
31
  # If a block is passed, that block should cause the specified number of
32
- # messages to be broadcasted.
32
+ # messages to be broadcasted. It returns the messages that were broadcasted.
33
33
  #
34
34
  # def test_broadcasts_again
35
- # assert_broadcasts('messages', 1) do
35
+ # message = assert_broadcasts('messages', 1) do
36
36
  # ActionCable.server.broadcast 'messages', { text: 'hello' }
37
37
  # end
38
+ # assert_equal({ text: 'hello' }, message)
38
39
  #
39
- # assert_broadcasts('messages', 2) do
40
+ # messages = assert_broadcasts('messages', 2) do
40
41
  # ActionCable.server.broadcast 'messages', { text: 'hi' }
41
42
  # ActionCable.server.broadcast 'messages', { text: 'how are you?' }
42
43
  # end
44
+ # assert_equal 2, messages.length
45
+ # assert_equal({ text: 'hi' }, messages.first)
46
+ # assert_equal({ text: 'how are you?' }, messages.last)
43
47
  # end
44
48
  #
45
49
  def assert_broadcasts(stream, number, &block)
46
50
  if block_given?
47
- original_count = broadcasts_size(stream)
48
- _assert_nothing_raised_or_warn("assert_broadcasts", &block)
49
- new_count = broadcasts_size(stream)
50
- actual_count = new_count - original_count
51
+ new_messages = new_broadcasts_from(broadcasts(stream), stream, "assert_broadcasts", &block)
52
+
53
+ actual_count = new_messages.size
54
+ assert_equal number, actual_count, "#{number} broadcasts to #{stream} expected, but #{actual_count} were sent"
51
55
  else
52
- actual_count = broadcasts_size(stream)
56
+ actual_count = broadcasts(stream).size
57
+ assert_equal number, actual_count, "#{number} broadcasts to #{stream} expected, but #{actual_count} were sent"
53
58
  end
54
-
55
- assert_equal number, actual_count, "#{number} broadcasts to #{stream} expected, but #{actual_count} were sent"
56
59
  end
57
60
 
58
61
  # Asserts that no messages have been sent to the stream.
@@ -79,6 +82,22 @@ module ActionCable
79
82
  assert_broadcasts stream, 0, &block
80
83
  end
81
84
 
85
+ # Returns the messages that are broadcasted in the block.
86
+ #
87
+ # def test_broadcasts
88
+ # messages = capture_broadcasts('messages') do
89
+ # ActionCable.server.broadcast 'messages', { text: 'hi' }
90
+ # ActionCable.server.broadcast 'messages', { text: 'how are you?' }
91
+ # end
92
+ # assert_equal 2, messages.length
93
+ # assert_equal({ text: 'hi' }, messages.first)
94
+ # assert_equal({ text: 'how are you?' }, messages.last)
95
+ # end
96
+ #
97
+ def capture_broadcasts(stream, &block)
98
+ new_broadcasts_from(broadcasts(stream), stream, "capture_broadcasts", &block).map { |m| ActiveSupport::JSON.decode(m) }
99
+ end
100
+
82
101
  # Asserts that the specified message has been sent to the stream.
83
102
  #
84
103
  # def test_assert_transmitted_message
@@ -103,20 +122,22 @@ module ActionCable
103
122
 
104
123
  new_messages = broadcasts(stream)
105
124
  if block_given?
106
- old_messages = new_messages
107
- clear_messages(stream)
108
-
109
- _assert_nothing_raised_or_warn("assert_broadcast_on", &block)
110
- new_messages = broadcasts(stream)
111
- clear_messages(stream)
112
-
113
- # Restore all sent messages
114
- (old_messages + new_messages).each { |m| pubsub_adapter.broadcast(stream, m) }
125
+ new_messages = new_broadcasts_from(new_messages, stream, "assert_broadcast_on", &block)
115
126
  end
116
127
 
117
128
  message = new_messages.find { |msg| ActiveSupport::JSON.decode(msg) == serialized_msg }
118
129
 
119
- assert message, "No messages sent with #{data} to #{stream}"
130
+ error_message = "No messages sent with #{data} to #{stream}"
131
+
132
+ if new_messages.any?
133
+ error_message = new_messages.inject("#{error_message}\nMessage(s) found:\n") do |error_message, new_message|
134
+ error_message + "#{ActiveSupport::JSON.decode(new_message)}\n"
135
+ end
136
+ else
137
+ error_message = "#{error_message}\nNo message found for #{stream}"
138
+ end
139
+
140
+ assert message, error_message
120
141
  end
121
142
 
122
143
  def pubsub_adapter # :nodoc:
@@ -126,8 +147,18 @@ module ActionCable
126
147
  delegate :broadcasts, :clear_messages, to: :pubsub_adapter
127
148
 
128
149
  private
129
- def broadcasts_size(channel)
130
- broadcasts(channel).size
150
+ def new_broadcasts_from(current_messages, stream, assertion, &block)
151
+ old_messages = current_messages
152
+ clear_messages(stream)
153
+
154
+ _assert_nothing_raised_or_warn(assertion, &block)
155
+ new_messages = broadcasts(stream)
156
+ clear_messages(stream)
157
+
158
+ # Restore all sent messages
159
+ (old_messages + new_messages).each { |m| pubsub_adapter.broadcast(stream, m) }
160
+
161
+ new_messages
131
162
  end
132
163
  end
133
164
  end