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.
- checksums.yaml +5 -5
- data/CHANGELOG.md +31 -117
- data/MIT-LICENSE +1 -1
- data/README.md +4 -535
- data/app/assets/javascripts/action_cable.js +517 -0
- data/lib/action_cable.rb +20 -10
- data/lib/action_cable/channel.rb +3 -0
- data/lib/action_cable/channel/base.rb +31 -23
- data/lib/action_cable/channel/broadcasting.rb +22 -10
- data/lib/action_cable/channel/callbacks.rb +4 -2
- data/lib/action_cable/channel/naming.rb +5 -2
- data/lib/action_cable/channel/periodic_timers.rb +4 -3
- data/lib/action_cable/channel/streams.rb +39 -11
- data/lib/action_cable/channel/test_case.rb +310 -0
- data/lib/action_cable/connection.rb +3 -2
- data/lib/action_cable/connection/authorization.rb +8 -6
- data/lib/action_cable/connection/base.rb +34 -26
- data/lib/action_cable/connection/client_socket.rb +20 -18
- data/lib/action_cable/connection/identification.rb +5 -4
- data/lib/action_cable/connection/internal_channel.rb +4 -2
- data/lib/action_cable/connection/message_buffer.rb +3 -2
- data/lib/action_cable/connection/stream.rb +9 -5
- data/lib/action_cable/connection/stream_event_loop.rb +4 -2
- data/lib/action_cable/connection/subscriptions.rb +14 -13
- data/lib/action_cable/connection/tagged_logger_proxy.rb +4 -2
- data/lib/action_cable/connection/test_case.rb +234 -0
- data/lib/action_cable/connection/web_socket.rb +7 -5
- data/lib/action_cable/engine.rb +7 -5
- data/lib/action_cable/gem_version.rb +5 -3
- data/lib/action_cable/helpers/action_cable_helper.rb +6 -4
- data/lib/action_cable/remote_connections.rb +9 -4
- data/lib/action_cable/server.rb +2 -1
- data/lib/action_cable/server/base.rb +17 -10
- data/lib/action_cable/server/broadcasting.rb +9 -3
- data/lib/action_cable/server/configuration.rb +21 -22
- data/lib/action_cable/server/connections.rb +2 -0
- data/lib/action_cable/server/worker.rb +11 -11
- data/lib/action_cable/server/worker/active_record_connection_management.rb +2 -0
- data/lib/action_cable/subscription_adapter.rb +4 -0
- data/lib/action_cable/subscription_adapter/async.rb +3 -1
- data/lib/action_cable/subscription_adapter/base.rb +6 -0
- data/lib/action_cable/subscription_adapter/channel_prefix.rb +28 -0
- data/lib/action_cable/subscription_adapter/inline.rb +2 -0
- data/lib/action_cable/subscription_adapter/postgresql.rb +40 -14
- data/lib/action_cable/subscription_adapter/redis.rb +19 -11
- data/lib/action_cable/subscription_adapter/subscriber_map.rb +3 -1
- data/lib/action_cable/subscription_adapter/test.rb +40 -0
- data/lib/action_cable/test_case.rb +11 -0
- data/lib/action_cable/test_helper.rb +133 -0
- data/lib/action_cable/version.rb +3 -1
- data/lib/rails/generators/channel/USAGE +5 -6
- data/lib/rails/generators/channel/channel_generator.rb +16 -11
- data/lib/rails/generators/channel/templates/application_cable/{channel.rb → channel.rb.tt} +0 -0
- data/lib/rails/generators/channel/templates/application_cable/{connection.rb → connection.rb.tt} +0 -0
- data/lib/rails/generators/channel/templates/{channel.rb → channel.rb.tt} +0 -0
- data/lib/rails/generators/channel/templates/{assets/channel.js → javascript/channel.js.tt} +6 -4
- data/lib/rails/generators/channel/templates/javascript/consumer.js.tt +6 -0
- data/lib/rails/generators/channel/templates/javascript/index.js.tt +5 -0
- data/lib/rails/generators/test_unit/channel_generator.rb +20 -0
- data/lib/rails/generators/test_unit/templates/channel_test.rb.tt +8 -0
- metadata +46 -38
- data/lib/action_cable/connection/faye_client_socket.rb +0 -48
- data/lib/action_cable/connection/faye_event_loop.rb +0 -44
- data/lib/action_cable/subscription_adapter/evented_redis.rb +0 -79
- data/lib/assets/compiled/action_cable.js +0 -597
- data/lib/rails/generators/channel/templates/assets/cable.js +0 -13
- 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-
|
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
|
25
|
-
require
|
26
|
-
require
|
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:
|
34
|
-
|
35
|
-
|
36
|
-
|
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:
|
39
|
-
protocols: ["actioncable-v1-json"
|
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
|
data/lib/action_cable/channel.rb
CHANGED
@@ -1,4 +1,7 @@
|
|
1
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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[
|
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(
|
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.
|
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.
|
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
|
-
|
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
|
-
|
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(
|
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
|
28
|
+
def serialize_broadcasting(object) #:nodoc:
|
17
29
|
case
|
18
|
-
when
|
19
|
-
|
20
|
-
when
|
21
|
-
|
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
|
-
|
35
|
+
object.to_param
|
24
36
|
end
|
25
37
|
end
|
26
38
|
end
|
@@ -1,4 +1,6 @@
|
|
1
|
-
|
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
|
-
|
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
|
-
|
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.
|
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,
|
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,
|
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
|
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
|
73
|
-
# Defaults to
|
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
|
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
|
98
|
-
# Defaults to
|
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(
|
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
|