actioncable 5.2.4.5 → 6.0.0.beta1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -57
  3. data/MIT-LICENSE +1 -1
  4. data/README.md +1 -544
  5. data/app/assets/javascripts/action_cable.js +492 -0
  6. data/lib/action_cable.rb +15 -7
  7. data/lib/action_cable/channel.rb +1 -0
  8. data/lib/action_cable/channel/base.rb +5 -1
  9. data/lib/action_cable/channel/test_case.rb +312 -0
  10. data/lib/action_cable/connection.rb +1 -0
  11. data/lib/action_cable/connection/authorization.rb +1 -1
  12. data/lib/action_cable/connection/base.rb +9 -7
  13. data/lib/action_cable/connection/message_buffer.rb +1 -4
  14. data/lib/action_cable/connection/stream.rb +4 -2
  15. data/lib/action_cable/connection/subscriptions.rb +1 -5
  16. data/lib/action_cable/connection/test_case.rb +236 -0
  17. data/lib/action_cable/connection/web_socket.rb +1 -3
  18. data/lib/action_cable/gem_version.rb +4 -4
  19. data/lib/action_cable/server/base.rb +3 -1
  20. data/lib/action_cable/server/worker.rb +5 -7
  21. data/lib/action_cable/subscription_adapter.rb +1 -0
  22. data/lib/action_cable/subscription_adapter/postgresql.rb +24 -8
  23. data/lib/action_cable/subscription_adapter/redis.rb +2 -1
  24. data/lib/action_cable/subscription_adapter/test.rb +40 -0
  25. data/lib/action_cable/test_case.rb +11 -0
  26. data/lib/action_cable/test_helper.rb +133 -0
  27. data/lib/rails/generators/channel/USAGE +4 -5
  28. data/lib/rails/generators/channel/channel_generator.rb +6 -3
  29. data/lib/rails/generators/channel/templates/{assets → javascript}/channel.js.tt +3 -1
  30. data/lib/rails/generators/channel/templates/{assets/cable.js.tt → javascript/consumer.js.tt} +2 -9
  31. data/lib/rails/generators/channel/templates/javascript/index.js.tt +5 -0
  32. data/lib/rails/generators/test_unit/channel_generator.rb +20 -0
  33. data/lib/rails/generators/test_unit/templates/channel_test.rb.tt +8 -0
  34. metadata +21 -14
  35. data/lib/assets/compiled/action_cable.js +0 -601
  36. data/lib/rails/generators/channel/templates/assets/channel.coffee.tt +0 -14
data/lib/action_cable.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  #--
4
- # Copyright (c) 2015-2018 Basecamp, LLC
4
+ # Copyright (c) 2015-2019 Basecamp, LLC
5
5
  #
6
6
  # Permission is hereby granted, free of charge, to any person obtaining
7
7
  # a copy of this software and associated documentation files (the
@@ -32,13 +32,19 @@ module ActionCable
32
32
 
33
33
  INTERNAL = {
34
34
  message_types: {
35
- welcome: "welcome".freeze,
36
- ping: "ping".freeze,
37
- confirmation: "confirm_subscription".freeze,
38
- rejection: "reject_subscription".freeze
35
+ welcome: "welcome",
36
+ disconnect: "disconnect",
37
+ ping: "ping",
38
+ confirmation: "confirm_subscription",
39
+ rejection: "reject_subscription"
39
40
  },
40
- default_mount_path: "/cable".freeze,
41
- protocols: ["actioncable-v1-json".freeze, "actioncable-unsupported".freeze].freeze
41
+ disconnect_reasons: {
42
+ unauthorized: "unauthorized",
43
+ invalid_request: "invalid_request",
44
+ server_restart: "server_restart"
45
+ },
46
+ default_mount_path: "/cable",
47
+ protocols: ["actioncable-v1-json", "actioncable-unsupported"].freeze
42
48
  }
43
49
 
44
50
  # Singleton instance of the server
@@ -51,4 +57,6 @@ module ActionCable
51
57
  autoload :Channel
52
58
  autoload :RemoteConnections
53
59
  autoload :SubscriptionAdapter
60
+ autoload :TestHelper
61
+ autoload :TestCase
54
62
  end
@@ -11,6 +11,7 @@ module ActionCable
11
11
  autoload :Naming
12
12
  autoload :PeriodicTimers
13
13
  autoload :Streams
14
+ autoload :TestCase
14
15
  end
15
16
  end
16
17
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "set"
4
+ require "active_support/rescuable"
4
5
 
5
6
  module ActionCable
6
7
  module Channel
@@ -99,6 +100,7 @@ module ActionCable
99
100
  include Streams
100
101
  include Naming
101
102
  include Broadcasting
103
+ include ActiveSupport::Rescuable
102
104
 
103
105
  attr_reader :params, :connection, :identifier
104
106
  delegate :logger, to: :connection
@@ -267,10 +269,12 @@ module ActionCable
267
269
  else
268
270
  public_send action
269
271
  end
272
+ rescue Exception => exception
273
+ rescue_with_handler(exception) || raise
270
274
  end
271
275
 
272
276
  def action_signature(action, data)
273
- "#{self.class.name}##{action}".dup.tap do |signature|
277
+ (+"#{self.class.name}##{action}").tap do |signature|
274
278
  if (arguments = data.except("action")).any?
275
279
  signature << "(#{arguments.inspect})"
276
280
  end
@@ -0,0 +1,312 @@
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(
304
+ [self.class.channel_class.channel_name, stream_or_object]
305
+ )
306
+ end
307
+ end
308
+
309
+ include Behavior
310
+ end
311
+ end
312
+ end
@@ -15,6 +15,7 @@ module ActionCable
15
15
  autoload :StreamEventLoop
16
16
  autoload :Subscriptions
17
17
  autoload :TaggedLoggerProxy
18
+ autoload :TestCase
18
19
  autoload :WebSocket
19
20
  end
20
21
  end
@@ -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 a 404 "File not Found" response.
9
9
  def reject_unauthorized_connection
10
10
  logger.error "An unauthorized connection attempt was rejected"
11
11
  raise UnauthorizedError
@@ -95,7 +95,12 @@ module ActionCable
95
95
  end
96
96
 
97
97
  # Close the WebSocket connection.
98
- def close
98
+ def close(reason: nil, reconnect: true)
99
+ transmit(
100
+ type: ActionCable::INTERNAL[:message_types][:disconnect],
101
+ reason: reason,
102
+ reconnect: reconnect
103
+ )
99
104
  websocket.close
100
105
  end
101
106
 
@@ -136,13 +141,10 @@ module ActionCable
136
141
  send_async :handle_close
137
142
  end
138
143
 
139
- # TODO Change this to private once we've dropped Ruby 2.2 support.
140
- # Workaround for Ruby 2.2 "private attribute?" warning.
141
- protected
144
+ private
142
145
  attr_reader :websocket
143
146
  attr_reader :message_buffer
144
147
 
145
- private
146
148
  # The request that initiated the WebSocket connection is available here. This gives access to the environment, cookies, etc.
147
149
  def request # :doc:
148
150
  @request ||= begin
@@ -173,7 +175,7 @@ module ActionCable
173
175
  message_buffer.process!
174
176
  server.add_connection(self)
175
177
  rescue ActionCable::Connection::Authorization::UnauthorizedError
176
- respond_to_invalid_request
178
+ close(reason: ActionCable::INTERNAL[:disconnect_reasons][:unauthorized], reconnect: false) if websocket.alive?
177
179
  end
178
180
 
179
181
  def handle_close
@@ -214,7 +216,7 @@ module ActionCable
214
216
  end
215
217
 
216
218
  def respond_to_invalid_request
217
- close if websocket.alive?
219
+ close(reason: ActionCable::INTERNAL[:disconnect_reasons][:invalid_request]) if websocket.alive?
218
220
 
219
221
  logger.error invalid_request_message
220
222
  logger.info finished_request_message