actioncable 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +169 -0
- data/MIT-LICENSE +20 -0
- data/README.md +24 -0
- data/app/assets/javascripts/action_cable.js +517 -0
- data/lib/action_cable.rb +62 -0
- data/lib/action_cable/channel.rb +17 -0
- data/lib/action_cable/channel/base.rb +311 -0
- data/lib/action_cable/channel/broadcasting.rb +41 -0
- data/lib/action_cable/channel/callbacks.rb +37 -0
- data/lib/action_cable/channel/naming.rb +25 -0
- data/lib/action_cable/channel/periodic_timers.rb +78 -0
- data/lib/action_cable/channel/streams.rb +176 -0
- data/lib/action_cable/channel/test_case.rb +310 -0
- data/lib/action_cable/connection.rb +22 -0
- data/lib/action_cable/connection/authorization.rb +15 -0
- data/lib/action_cable/connection/base.rb +264 -0
- data/lib/action_cable/connection/client_socket.rb +157 -0
- data/lib/action_cable/connection/identification.rb +47 -0
- data/lib/action_cable/connection/internal_channel.rb +45 -0
- data/lib/action_cable/connection/message_buffer.rb +54 -0
- data/lib/action_cable/connection/stream.rb +117 -0
- data/lib/action_cable/connection/stream_event_loop.rb +136 -0
- data/lib/action_cable/connection/subscriptions.rb +79 -0
- data/lib/action_cable/connection/tagged_logger_proxy.rb +42 -0
- data/lib/action_cable/connection/test_case.rb +234 -0
- data/lib/action_cable/connection/web_socket.rb +41 -0
- data/lib/action_cable/engine.rb +79 -0
- data/lib/action_cable/gem_version.rb +17 -0
- data/lib/action_cable/helpers/action_cable_helper.rb +42 -0
- data/lib/action_cable/remote_connections.rb +71 -0
- data/lib/action_cable/server.rb +17 -0
- data/lib/action_cable/server/base.rb +94 -0
- data/lib/action_cable/server/broadcasting.rb +54 -0
- data/lib/action_cable/server/configuration.rb +56 -0
- data/lib/action_cable/server/connections.rb +36 -0
- data/lib/action_cable/server/worker.rb +75 -0
- data/lib/action_cable/server/worker/active_record_connection_management.rb +21 -0
- data/lib/action_cable/subscription_adapter.rb +12 -0
- data/lib/action_cable/subscription_adapter/async.rb +29 -0
- data/lib/action_cable/subscription_adapter/base.rb +30 -0
- data/lib/action_cable/subscription_adapter/channel_prefix.rb +28 -0
- data/lib/action_cable/subscription_adapter/inline.rb +37 -0
- data/lib/action_cable/subscription_adapter/postgresql.rb +132 -0
- data/lib/action_cable/subscription_adapter/redis.rb +181 -0
- data/lib/action_cable/subscription_adapter/subscriber_map.rb +59 -0
- 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 +10 -0
- data/lib/rails/generators/channel/USAGE +13 -0
- data/lib/rails/generators/channel/channel_generator.rb +52 -0
- data/lib/rails/generators/channel/templates/application_cable/channel.rb.tt +4 -0
- data/lib/rails/generators/channel/templates/application_cable/connection.rb.tt +4 -0
- data/lib/rails/generators/channel/templates/channel.rb.tt +16 -0
- data/lib/rails/generators/channel/templates/javascript/channel.js.tt +20 -0
- 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 +149 -0
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionCable
|
4
|
+
module Channel
|
5
|
+
module Naming
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
module ClassMethods
|
9
|
+
# Returns the name of the channel, underscored, without the <tt>Channel</tt> ending.
|
10
|
+
# If the channel is in a namespace, then the namespaces are represented by single
|
11
|
+
# colon separators in the channel name.
|
12
|
+
#
|
13
|
+
# ChatChannel.channel_name # => 'chat'
|
14
|
+
# Chats::AppearancesChannel.channel_name # => 'chats:appearances'
|
15
|
+
# FooChats::BarAppearancesChannel.channel_name # => 'foo_chats:bar_appearances'
|
16
|
+
def channel_name
|
17
|
+
@channel_name ||= name.sub(/Channel$/, "").gsub("::", ":").underscore
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
# Delegates to the class' <tt>channel_name</tt>
|
22
|
+
delegate :channel_name, to: :class
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionCable
|
4
|
+
module Channel
|
5
|
+
module PeriodicTimers
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
included do
|
9
|
+
class_attribute :periodic_timers, instance_reader: false, default: []
|
10
|
+
|
11
|
+
after_subscribe :start_periodic_timers
|
12
|
+
after_unsubscribe :stop_periodic_timers
|
13
|
+
end
|
14
|
+
|
15
|
+
module ClassMethods
|
16
|
+
# Periodically performs a task on the channel, like updating an online
|
17
|
+
# user counter, polling a backend for new status messages, sending
|
18
|
+
# regular "heartbeat" messages, or doing some internal work and giving
|
19
|
+
# progress updates.
|
20
|
+
#
|
21
|
+
# Pass a method name or lambda argument or provide a block to call.
|
22
|
+
# Specify the calling period in seconds using the <tt>every:</tt>
|
23
|
+
# keyword argument.
|
24
|
+
#
|
25
|
+
# periodically :transmit_progress, every: 5.seconds
|
26
|
+
#
|
27
|
+
# periodically every: 3.minutes do
|
28
|
+
# transmit action: :update_count, count: current_count
|
29
|
+
# end
|
30
|
+
#
|
31
|
+
def periodically(callback_or_method_name = nil, every:, &block)
|
32
|
+
callback =
|
33
|
+
if block_given?
|
34
|
+
raise ArgumentError, "Pass a block or provide a callback arg, not both" if callback_or_method_name
|
35
|
+
block
|
36
|
+
else
|
37
|
+
case callback_or_method_name
|
38
|
+
when Proc
|
39
|
+
callback_or_method_name
|
40
|
+
when Symbol
|
41
|
+
-> { __send__ callback_or_method_name }
|
42
|
+
else
|
43
|
+
raise ArgumentError, "Expected a Symbol method name or a Proc, got #{callback_or_method_name.inspect}"
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
unless every.kind_of?(Numeric) && every > 0
|
48
|
+
raise ArgumentError, "Expected every: to be a positive number of seconds, got #{every.inspect}"
|
49
|
+
end
|
50
|
+
|
51
|
+
self.periodic_timers += [[ callback, every: every ]]
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
def active_periodic_timers
|
57
|
+
@active_periodic_timers ||= []
|
58
|
+
end
|
59
|
+
|
60
|
+
def start_periodic_timers
|
61
|
+
self.class.periodic_timers.each do |callback, options|
|
62
|
+
active_periodic_timers << start_periodic_timer(callback, every: options.fetch(:every))
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def start_periodic_timer(callback, every:)
|
67
|
+
connection.server.event_loop.timer every do
|
68
|
+
connection.worker_pool.async_exec self, connection: connection, &callback
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def stop_periodic_timers
|
73
|
+
active_periodic_timers.each { |timer| timer.shutdown }
|
74
|
+
active_periodic_timers.clear
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,176 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionCable
|
4
|
+
module Channel
|
5
|
+
# Streams allow channels to route broadcastings to the subscriber. A broadcasting is, as discussed elsewhere, a pubsub queue where any data
|
6
|
+
# placed into it is automatically sent to the clients that are connected at that time. It's purely an online queue, though. If you're not
|
7
|
+
# streaming a broadcasting at the very moment it sends out an update, you will not get that update, even if you connect after it has been sent.
|
8
|
+
#
|
9
|
+
# Most commonly, the streamed broadcast is sent straight to the subscriber on the client-side. The channel just acts as a connector between
|
10
|
+
# the two parties (the broadcaster and the channel subscriber). Here's an example of a channel that allows subscribers to get all new
|
11
|
+
# comments on a given page:
|
12
|
+
#
|
13
|
+
# class CommentsChannel < ApplicationCable::Channel
|
14
|
+
# def follow(data)
|
15
|
+
# stream_from "comments_for_#{data['recording_id']}"
|
16
|
+
# end
|
17
|
+
#
|
18
|
+
# def unfollow
|
19
|
+
# stop_all_streams
|
20
|
+
# end
|
21
|
+
# end
|
22
|
+
#
|
23
|
+
# Based on the above example, the subscribers of this channel will get whatever data is put into the,
|
24
|
+
# let's say, <tt>comments_for_45</tt> broadcasting as soon as it's put there.
|
25
|
+
#
|
26
|
+
# An example broadcasting for this channel looks like so:
|
27
|
+
#
|
28
|
+
# ActionCable.server.broadcast "comments_for_45", author: 'DHH', content: 'Rails is just swell'
|
29
|
+
#
|
30
|
+
# If you have a stream that is related to a model, then the broadcasting used can be generated from the model and channel.
|
31
|
+
# The following example would subscribe to a broadcasting like <tt>comments:Z2lkOi8vVGVzdEFwcC9Qb3N0LzE</tt>.
|
32
|
+
#
|
33
|
+
# class CommentsChannel < ApplicationCable::Channel
|
34
|
+
# def subscribed
|
35
|
+
# post = Post.find(params[:id])
|
36
|
+
# stream_for post
|
37
|
+
# end
|
38
|
+
# end
|
39
|
+
#
|
40
|
+
# You can then broadcast to this channel using:
|
41
|
+
#
|
42
|
+
# CommentsChannel.broadcast_to(@post, @comment)
|
43
|
+
#
|
44
|
+
# If you don't just want to parlay the broadcast unfiltered to the subscriber, you can also supply a callback that lets you alter what is sent out.
|
45
|
+
# The below example shows how you can use this to provide performance introspection in the process:
|
46
|
+
#
|
47
|
+
# class ChatChannel < ApplicationCable::Channel
|
48
|
+
# def subscribed
|
49
|
+
# @room = Chat::Room[params[:room_number]]
|
50
|
+
#
|
51
|
+
# stream_for @room, coder: ActiveSupport::JSON do |message|
|
52
|
+
# if message['originated_at'].present?
|
53
|
+
# elapsed_time = (Time.now.to_f - message['originated_at']).round(2)
|
54
|
+
#
|
55
|
+
# ActiveSupport::Notifications.instrument :performance, measurement: 'Chat.message_delay', value: elapsed_time, action: :timing
|
56
|
+
# logger.info "Message took #{elapsed_time}s to arrive"
|
57
|
+
# end
|
58
|
+
#
|
59
|
+
# transmit message
|
60
|
+
# end
|
61
|
+
# end
|
62
|
+
# end
|
63
|
+
#
|
64
|
+
# You can stop streaming from all broadcasts by calling #stop_all_streams.
|
65
|
+
module Streams
|
66
|
+
extend ActiveSupport::Concern
|
67
|
+
|
68
|
+
included do
|
69
|
+
on_unsubscribe :stop_all_streams
|
70
|
+
end
|
71
|
+
|
72
|
+
# Start streaming from the named <tt>broadcasting</tt> pubsub queue. Optionally, you can pass a <tt>callback</tt> that'll be used
|
73
|
+
# instead of the default of just transmitting the updates straight to the subscriber.
|
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.
|
76
|
+
def stream_from(broadcasting, callback = nil, coder: nil, &block)
|
77
|
+
broadcasting = String(broadcasting)
|
78
|
+
|
79
|
+
# Don't send the confirmation until pubsub#subscribe is successful
|
80
|
+
defer_subscription_confirmation!
|
81
|
+
|
82
|
+
# Build a stream handler by wrapping the user-provided callback with
|
83
|
+
# a decoder or defaulting to a JSON-decoding retransmitter.
|
84
|
+
handler = worker_pool_stream_handler(broadcasting, callback || block, coder: coder)
|
85
|
+
streams << [ broadcasting, handler ]
|
86
|
+
|
87
|
+
connection.server.event_loop.post do
|
88
|
+
pubsub.subscribe(broadcasting, handler, lambda do
|
89
|
+
ensure_confirmation_sent
|
90
|
+
logger.info "#{self.class.name} is streaming from #{broadcasting}"
|
91
|
+
end)
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
# Start streaming the pubsub queue for the <tt>model</tt> in this channel. Optionally, you can pass a
|
96
|
+
# <tt>callback</tt> that'll be used instead of the default of just transmitting the updates straight
|
97
|
+
# to the subscriber.
|
98
|
+
#
|
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.
|
101
|
+
def stream_for(model, callback = nil, coder: nil, &block)
|
102
|
+
stream_from(broadcasting_for(model), callback || block, coder: coder)
|
103
|
+
end
|
104
|
+
|
105
|
+
# Unsubscribes all streams associated with this channel from the pubsub queue.
|
106
|
+
def stop_all_streams
|
107
|
+
streams.each do |broadcasting, callback|
|
108
|
+
pubsub.unsubscribe broadcasting, callback
|
109
|
+
logger.info "#{self.class.name} stopped streaming from #{broadcasting}"
|
110
|
+
end.clear
|
111
|
+
end
|
112
|
+
|
113
|
+
private
|
114
|
+
delegate :pubsub, to: :connection
|
115
|
+
|
116
|
+
def streams
|
117
|
+
@_streams ||= []
|
118
|
+
end
|
119
|
+
|
120
|
+
# Always wrap the outermost handler to invoke the user handler on the
|
121
|
+
# worker pool rather than blocking the event loop.
|
122
|
+
def worker_pool_stream_handler(broadcasting, user_handler, coder: nil)
|
123
|
+
handler = stream_handler(broadcasting, user_handler, coder: coder)
|
124
|
+
|
125
|
+
-> message do
|
126
|
+
connection.worker_pool.async_invoke handler, :call, message, connection: connection
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
# May be overridden to add instrumentation, logging, specialized error
|
131
|
+
# handling, or other forms of handler decoration.
|
132
|
+
#
|
133
|
+
# TODO: Tests demonstrating this.
|
134
|
+
def stream_handler(broadcasting, user_handler, coder: nil)
|
135
|
+
if user_handler
|
136
|
+
stream_decoder user_handler, coder: coder
|
137
|
+
else
|
138
|
+
default_stream_handler broadcasting, coder: coder
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
# May be overridden to change the default stream handling behavior
|
143
|
+
# which decodes JSON and transmits to the client.
|
144
|
+
#
|
145
|
+
# TODO: Tests demonstrating this.
|
146
|
+
#
|
147
|
+
# TODO: Room for optimization. Update transmit API to be coder-aware
|
148
|
+
# so we can no-op when pubsub and connection are both JSON-encoded.
|
149
|
+
# Then we can skip decode+encode if we're just proxying messages.
|
150
|
+
def default_stream_handler(broadcasting, coder:)
|
151
|
+
coder ||= ActiveSupport::JSON
|
152
|
+
stream_transmitter stream_decoder(coder: coder), broadcasting: broadcasting
|
153
|
+
end
|
154
|
+
|
155
|
+
def stream_decoder(handler = identity_handler, coder:)
|
156
|
+
if coder
|
157
|
+
-> message { handler.(coder.decode(message)) }
|
158
|
+
else
|
159
|
+
handler
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
def stream_transmitter(handler = identity_handler, broadcasting:)
|
164
|
+
via = "streamed from #{broadcasting}"
|
165
|
+
|
166
|
+
-> (message) do
|
167
|
+
transmit handler.(message), via: via
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
def identity_handler
|
172
|
+
-> message { message }
|
173
|
+
end
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
@@ -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_broadcasts_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
|
+
# Setup 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
|