diff options
author | Willian Gustavo Veiga <beberveiga@gmail.com> | 2018-10-02 12:55:36 -0300 |
---|---|---|
committer | Willian Gustavo Veiga <beberveiga@gmail.com> | 2018-10-02 12:57:37 -0300 |
commit | 2d4df1349efdf8dd2c8cc4503fd5a871b0066500 (patch) | |
tree | 9f71a297cf1bb30f1d59039997a60f97357d0782 /actioncable/lib | |
parent | 00c50c2b5966fa1d719c8a58564811c672a0e8c6 (diff) | |
parent | cf608ee34dd833b0357ef4eefa692db33242d2aa (diff) | |
download | rails-2d4df1349efdf8dd2c8cc4503fd5a871b0066500.tar.gz rails-2d4df1349efdf8dd2c8cc4503fd5a871b0066500.tar.bz2 rails-2d4df1349efdf8dd2c8cc4503fd5a871b0066500.zip |
Merge branch 'master' into feature/reselect-method
Diffstat (limited to 'actioncable/lib')
15 files changed, 484 insertions, 39 deletions
diff --git a/actioncable/lib/action_cable.rb b/actioncable/lib/action_cable.rb index e7456e3c1b..d261d4112e 100644 --- a/actioncable/lib/action_cable.rb +++ b/actioncable/lib/action_cable.rb @@ -32,13 +32,13 @@ module ActionCable INTERNAL = { message_types: { - welcome: "welcome".freeze, - ping: "ping".freeze, - confirmation: "confirm_subscription".freeze, - rejection: "reject_subscription".freeze + welcome: "welcome", + ping: "ping", + confirmation: "confirm_subscription", + rejection: "reject_subscription" }, - default_mount_path: "/cable".freeze, - protocols: ["actioncable-v1-json".freeze, "actioncable-unsupported".freeze].freeze + default_mount_path: "/cable", + protocols: ["actioncable-v1-json", "actioncable-unsupported"].freeze } # Singleton instance of the server @@ -51,4 +51,6 @@ module ActionCable autoload :Channel autoload :RemoteConnections autoload :SubscriptionAdapter + autoload :TestHelper + autoload :TestCase end diff --git a/actioncable/lib/action_cable/channel.rb b/actioncable/lib/action_cable/channel.rb index d2f6fbbbc7..d5118b9dc9 100644 --- a/actioncable/lib/action_cable/channel.rb +++ b/actioncable/lib/action_cable/channel.rb @@ -11,6 +11,7 @@ module ActionCable autoload :Naming autoload :PeriodicTimers autoload :Streams + autoload :TestCase end end end diff --git a/actioncable/lib/action_cable/channel/base.rb b/actioncable/lib/action_cable/channel/base.rb index c5ad749bfe..70c93ec0f3 100644 --- a/actioncable/lib/action_cable/channel/base.rb +++ b/actioncable/lib/action_cable/channel/base.rb @@ -270,7 +270,7 @@ module ActionCable end def action_signature(action, data) - "#{self.class.name}##{action}".dup.tap do |signature| + (+"#{self.class.name}##{action}").tap do |signature| if (arguments = data.except("action")).any? signature << "(#{arguments.inspect})" end diff --git a/actioncable/lib/action_cable/channel/test_case.rb b/actioncable/lib/action_cable/channel/test_case.rb new file mode 100644 index 0000000000..dd2762ccd0 --- /dev/null +++ b/actioncable/lib/action_cable/channel/test_case.rb @@ -0,0 +1,271 @@ +# frozen_string_literal: true + +require "active_support" +require "active_support/test_case" +require "active_support/core_ext/hash/indifferent_access" +require "json" + +module ActionCable + module Channel + class NonInferrableChannelError < ::StandardError + def initialize(name) + super "Unable to determine the channel to test from #{name}. " + + "You'll need to specify it using `tests YourChannel` in your " + + "test case definition." + end + end + + # Stub `stream_from` to track streams for the channel. + # Add public aliases for `subscription_confirmation_sent?` and + # `subscription_rejected?`. + module ChannelStub + def confirmed? + subscription_confirmation_sent? + end + + def rejected? + subscription_rejected? + end + + def stream_from(broadcasting, *) + streams << broadcasting + end + + def stop_all_streams + @_streams = [] + end + + def streams + @_streams ||= [] + end + + # Make periodic timers no-op + def start_periodic_timers; end + alias stop_periodic_timers start_periodic_timers + end + + class ConnectionStub + attr_reader :transmissions, :identifiers, :subscriptions, :logger + + def initialize(identifiers = {}) + @transmissions = [] + + identifiers.each do |identifier, val| + define_singleton_method(identifier) { val } + end + + @subscriptions = ActionCable::Connection::Subscriptions.new(self) + @identifiers = identifiers.keys + @logger = ActiveSupport::TaggedLogging.new ActiveSupport::Logger.new(StringIO.new) + end + + def transmit(cable_message) + transmissions << cable_message.with_indifferent_access + end + end + + # Superclass for Action Cable channel functional tests. + # + # == Basic example + # + # Functional tests are written as follows: + # 1. First, one uses the +subscribe+ method to simulate subscription creation. + # 2. Then, one asserts whether the current state is as expected. "State" can be anything: + # transmitted messages, subscribed streams, etc. + # + # For example: + # + # class ChatChannelTest < ActionCable::Channel::TestCase + # def test_subscribed_with_room_number + # # Simulate a subscription creation + # subscribe room_number: 1 + # + # # Asserts that the subscription was successfully created + # assert subscription.confirmed? + # + # # Asserts that the channel subscribes connection to a stream + # assert_equal "chat_1", streams.last + # end + # + # def test_does_not_subscribe_without_room_number + # subscribe + # + # # Asserts that the subscription was rejected + # assert subscription.rejected? + # end + # end + # + # You can also perform actions: + # def test_perform_speak + # subscribe room_number: 1 + # + # perform :speak, message: "Hello, Rails!" + # + # assert_equal "Hello, Rails!", transmissions.last["text"] + # end + # + # == Special methods + # + # ActionCable::Channel::TestCase will also automatically provide the following instance + # methods for use in the tests: + # + # <b>connection</b>:: + # An ActionCable::Channel::ConnectionStub, representing the current HTTP connection. + # <b>subscription</b>:: + # An instance of the current channel, created when you call `subscribe`. + # <b>transmissions</b>:: + # A list of all messages that have been transmitted into the channel. + # <b>streams</b>:: + # A list of all created streams subscriptions (as identifiers) for the subscription. + # + # + # == Channel is automatically inferred + # + # ActionCable::Channel::TestCase will automatically infer the channel under test + # from the test class name. If the channel cannot be inferred from the test + # class name, you can explicitly set it with +tests+. + # + # class SpecialEdgeCaseChannelTest < ActionCable::Channel::TestCase + # tests SpecialChannel + # end + # + # == Specifying connection identifiers + # + # You need to set up your connection manually to provide values for the identifiers. + # To do this just use: + # + # stub_connection(user: users[:john]) + # + # == Testing broadcasting + # + # ActionCable::Channel::TestCase enhances ActionCable::TestHelper assertions (e.g. + # +assert_broadcasts+) to handle broadcasting to models: + # + # + # # in your channel + # def speak(data) + # broadcast_to room, text: data["message"] + # end + # + # def test_speak + # subscribe room_id: rooms[:chat].id + # + # assert_broadcasts_on(rooms[:chat], text: "Hello, Rails!") do + # perform :speak, message: "Hello, Rails!" + # end + # end + class TestCase < ActiveSupport::TestCase + module Behavior + extend ActiveSupport::Concern + + include ActiveSupport::Testing::ConstantLookup + include ActionCable::TestHelper + + CHANNEL_IDENTIFIER = "test_stub" + + included do + class_attribute :_channel_class + + attr_reader :connection, :subscription + delegate :streams, to: :subscription + + ActiveSupport.run_load_hooks(:action_cable_channel_test_case, self) + end + + module ClassMethods + def tests(channel) + case channel + when String, Symbol + self._channel_class = channel.to_s.camelize.constantize + when Module + self._channel_class = channel + else + raise NonInferrableChannelError.new(channel) + end + end + + def channel_class + if channel = self._channel_class + channel + else + tests determine_default_channel(name) + end + end + + def determine_default_channel(name) + channel = determine_constant_from_test_name(name) do |constant| + Class === constant && constant < ActionCable::Channel::Base + end + raise NonInferrableChannelError.new(name) if channel.nil? + channel + end + end + + # Setup test connection with the specified identifiers: + # + # class ApplicationCable < ActionCable::Connection::Base + # identified_by :user, :token + # end + # + # stub_connection(user: users[:john], token: 'my-secret-token') + def stub_connection(identifiers = {}) + @connection = ConnectionStub.new(identifiers) + end + + # Subscribe to the channel under test. Optionally pass subscription parameters as a Hash. + def subscribe(params = {}) + @connection ||= stub_connection + @subscription = self.class.channel_class.new(connection, CHANNEL_IDENTIFIER, params.with_indifferent_access) + @subscription.singleton_class.include(ChannelStub) + @subscription.subscribe_to_channel + @subscription + end + + # Unsubscribe the subscription under test. + def unsubscribe + check_subscribed! + subscription.unsubscribe_from_channel + end + + # Perform action on a channel. + # + # NOTE: Must be subscribed. + def perform(action, data = {}) + check_subscribed! + subscription.perform_action(data.stringify_keys.merge("action" => action.to_s)) + end + + # Returns messages transmitted into channel + def transmissions + # Return only directly sent message (via #transmit) + connection.transmissions.map { |data| data["message"] }.compact + end + + # Enhance TestHelper assertions to handle non-String + # broadcastings + def assert_broadcasts(stream_or_object, *args) + super(broadcasting_for(stream_or_object), *args) + end + + def assert_broadcast_on(stream_or_object, *args) + super(broadcasting_for(stream_or_object), *args) + end + + private + def check_subscribed! + raise "Must be subscribed!" if subscription.nil? || subscription.rejected? + end + + def broadcasting_for(stream_or_object) + return stream_or_object if stream_or_object.is_a?(String) + + self.class.channel_class.broadcasting_for( + [self.class.channel_class.channel_name, stream_or_object] + ) + end + end + + include Behavior + end + end +end diff --git a/actioncable/lib/action_cable/subscription_adapter.rb b/actioncable/lib/action_cable/subscription_adapter.rb index bcece8d33b..6a9d5c2080 100644 --- a/actioncable/lib/action_cable/subscription_adapter.rb +++ b/actioncable/lib/action_cable/subscription_adapter.rb @@ -5,6 +5,7 @@ module ActionCable extend ActiveSupport::Autoload autoload :Base + autoload :Test autoload :SubscriberMap autoload :ChannelPrefix end diff --git a/actioncable/lib/action_cable/subscription_adapter/redis.rb b/actioncable/lib/action_cable/subscription_adapter/redis.rb index c28951608f..ad8fa52760 100644 --- a/actioncable/lib/action_cable/subscription_adapter/redis.rb +++ b/actioncable/lib/action_cable/subscription_adapter/redis.rb @@ -13,7 +13,8 @@ module ActionCable # Overwrite this factory method for Redis connections if you want to use a different Redis library than the redis gem. # This is needed, for example, when using Makara proxies for distributed Redis. cattr_accessor :redis_connector, default: ->(config) do - ::Redis.new(config.slice(:url, :host, :port, :db, :password)) + config[:id] ||= "ActionCable-PID-#{$$}" + ::Redis.new(config.slice(:url, :host, :port, :db, :password, :id)) end def initialize(*) diff --git a/actioncable/lib/action_cable/subscription_adapter/test.rb b/actioncable/lib/action_cable/subscription_adapter/test.rb new file mode 100644 index 0000000000..ce604cc88e --- /dev/null +++ b/actioncable/lib/action_cable/subscription_adapter/test.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require_relative "async" + +module ActionCable + module SubscriptionAdapter + # == Test adapter for Action Cable + # + # The test adapter should be used only in testing. Along with + # <tt>ActionCable::TestHelper</tt> it makes a great tool to test your Rails application. + # + # To use the test adapter set +adapter+ value to +test+ in your +config/cable.yml+ file. + # + # NOTE: Test adapter extends the <tt>ActionCable::SubscriptionsAdapter::Async</tt> adapter, + # so it could be used in system tests too. + class Test < Async + def broadcast(channel, payload) + broadcasts(channel) << payload + super + end + + def broadcasts(channel) + channels_data[channel] ||= [] + end + + def clear_messages(channel) + channels_data[channel] = [] + end + + def clear + @channels_data = nil + end + + private + def channels_data + @channels_data ||= {} + end + end + end +end diff --git a/actioncable/lib/action_cable/test_case.rb b/actioncable/lib/action_cable/test_case.rb new file mode 100644 index 0000000000..d153259bf6 --- /dev/null +++ b/actioncable/lib/action_cable/test_case.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "active_support/test_case" + +module ActionCable + class TestCase < ActiveSupport::TestCase + include ActionCable::TestHelper + + ActiveSupport.run_load_hooks(:action_cable_test_case, self) + end +end diff --git a/actioncable/lib/action_cable/test_helper.rb b/actioncable/lib/action_cable/test_helper.rb new file mode 100644 index 0000000000..7bc877663c --- /dev/null +++ b/actioncable/lib/action_cable/test_helper.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +module ActionCable + # Provides helper methods for testing Action Cable broadcasting + module TestHelper + def before_setup # :nodoc: + server = ActionCable.server + test_adapter = ActionCable::SubscriptionAdapter::Test.new(server) + + @old_pubsub_adapter = server.pubsub + + server.instance_variable_set(:@pubsub, test_adapter) + super + end + + def after_teardown # :nodoc: + super + ActionCable.server.instance_variable_set(:@pubsub, @old_pubsub_adapter) + end + + # Asserts that the number of broadcasted messages to the stream matches the given number. + # + # def test_broadcasts + # assert_broadcasts 'messages', 0 + # ActionCable.server.broadcast 'messages', { text: 'hello' } + # assert_broadcasts 'messages', 1 + # ActionCable.server.broadcast 'messages', { text: 'world' } + # assert_broadcasts 'messages', 2 + # end + # + # If a block is passed, that block should cause the specified number of + # messages to be broadcasted. + # + # def test_broadcasts_again + # assert_broadcasts('messages', 1) do + # ActionCable.server.broadcast 'messages', { text: 'hello' } + # end + # + # assert_broadcasts('messages', 2) do + # ActionCable.server.broadcast 'messages', { text: 'hi' } + # ActionCable.server.broadcast 'messages', { text: 'how are you?' } + # end + # end + # + def assert_broadcasts(stream, number) + if block_given? + original_count = broadcasts_size(stream) + yield + new_count = broadcasts_size(stream) + actual_count = new_count - original_count + else + actual_count = broadcasts_size(stream) + end + + assert_equal number, actual_count, "#{number} broadcasts to #{stream} expected, but #{actual_count} were sent" + end + + # Asserts that no messages have been sent to the stream. + # + # def test_no_broadcasts + # assert_no_broadcasts 'messages' + # ActionCable.server.broadcast 'messages', { text: 'hi' } + # assert_broadcasts 'messages', 1 + # end + # + # If a block is passed, that block should not cause any message to be sent. + # + # def test_broadcasts_again + # assert_no_broadcasts 'messages' do + # # No job messages should be sent from this block + # end + # end + # + # Note: This assertion is simply a shortcut for: + # + # assert_broadcasts 'messages', 0, &block + # + def assert_no_broadcasts(stream, &block) + assert_broadcasts stream, 0, &block + end + + # Asserts that the specified message has been sent to the stream. + # + # def test_assert_transmited_message + # ActionCable.server.broadcast 'messages', text: 'hello' + # assert_broadcast_on('messages', text: 'hello') + # end + # + # If a block is passed, that block should cause a message with the specified data to be sent. + # + # def test_assert_broadcast_on_again + # assert_broadcast_on('messages', text: 'hello') do + # ActionCable.server.broadcast 'messages', text: 'hello' + # end + # end + # + def assert_broadcast_on(stream, data) + # Encode to JSON and backâwe want to use this value to compare + # with decoded JSON. + # Comparing JSON strings doesn't work due to the order if the keys. + serialized_msg = + ActiveSupport::JSON.decode(ActiveSupport::JSON.encode(data)) + + new_messages = broadcasts(stream) + if block_given? + old_messages = new_messages + clear_messages(stream) + + yield + new_messages = broadcasts(stream) + clear_messages(stream) + + # Restore all sent messages + (old_messages + new_messages).each { |m| pubsub_adapter.broadcast(stream, m) } + end + + message = new_messages.find { |msg| ActiveSupport::JSON.decode(msg) == serialized_msg } + + assert message, "No messages sent with #{data} to #{stream}" + end + + def pubsub_adapter # :nodoc: + ActionCable.server.pubsub + end + + delegate :broadcasts, :clear_messages, to: :pubsub_adapter + + private + def broadcasts_size(channel) + broadcasts(channel).size + end + end +end diff --git a/actioncable/lib/rails/generators/channel/USAGE b/actioncable/lib/rails/generators/channel/USAGE index dd109fda80..ea9662436c 100644 --- a/actioncable/lib/rails/generators/channel/USAGE +++ b/actioncable/lib/rails/generators/channel/USAGE @@ -1,14 +1,12 @@ Description: ============ - Stubs out a new cable channel for the server (in Ruby) and client (in CoffeeScript). + Stubs out a new cable channel for the server (in Ruby) and client (in JavaScript). Pass the channel name, either CamelCased or under_scored, and an optional list of channel actions as arguments. - Note: Turn on the cable connection in app/assets/javascripts/cable.js after generating any channels. - Example: ======== rails generate channel Chat speak - creates a Chat channel class and CoffeeScript asset: + creates a Chat channel class and JavaScript asset: Channel: app/channels/chat_channel.rb - Assets: app/assets/javascripts/channels/chat.coffee + Assets: app/javascript/channels/chat_channel.js diff --git a/actioncable/lib/rails/generators/channel/channel_generator.rb b/actioncable/lib/rails/generators/channel/channel_generator.rb index 427eef1f55..ef51981e89 100644 --- a/actioncable/lib/rails/generators/channel/channel_generator.rb +++ b/actioncable/lib/rails/generators/channel/channel_generator.rb @@ -16,10 +16,11 @@ module Rails if options[:assets] if behavior == :invoke - template "assets/cable.js", "app/assets/javascripts/cable.js" + template "javascript/index.js", "app/javascript/channels/index.js" + template "javascript/consumer.js", "app/javascript/channels/consumer.js" end - js_template "assets/channel", File.join("app/assets/javascripts/channels", class_path, "#{file_name}") + js_template "javascript/channel", File.join("app/javascript/channels", class_path, "#{file_name}_channel") end generate_application_cable_files diff --git a/actioncable/lib/rails/generators/channel/templates/assets/channel.coffee.tt b/actioncable/lib/rails/generators/channel/templates/assets/channel.coffee.tt deleted file mode 100644 index 5467811aba..0000000000 --- a/actioncable/lib/rails/generators/channel/templates/assets/channel.coffee.tt +++ /dev/null @@ -1,14 +0,0 @@ -App.<%= class_name.underscore %> = App.cable.subscriptions.create "<%= class_name %>Channel", - connected: -> - # Called when the subscription is ready for use on the server - - disconnected: -> - # Called when the subscription has been terminated by the server - - received: (data) -> - # Called when there's incoming data on the websocket for this channel -<% actions.each do |action| -%> - - <%= action %>: -> - @perform '<%= action %>' -<% end -%> diff --git a/actioncable/lib/rails/generators/channel/templates/assets/channel.js.tt b/actioncable/lib/rails/generators/channel/templates/javascript/channel.js.tt index ab0e68b11a..33baaa5a22 100644 --- a/actioncable/lib/rails/generators/channel/templates/assets/channel.js.tt +++ b/actioncable/lib/rails/generators/channel/templates/javascript/channel.js.tt @@ -1,4 +1,6 @@ -App.<%= class_name.underscore %> = App.cable.subscriptions.create("<%= class_name %>Channel", { +import consumer from "./consumer" + +consumer.subscriptions.create("<%= class_name %>Channel", { connected: function() { // Called when the subscription is ready for use on the server }, diff --git a/actioncable/lib/rails/generators/channel/templates/assets/cable.js.tt b/actioncable/lib/rails/generators/channel/templates/javascript/consumer.js.tt index 739aa5f022..76ca3d0f2f 100644 --- a/actioncable/lib/rails/generators/channel/templates/assets/cable.js.tt +++ b/actioncable/lib/rails/generators/channel/templates/javascript/consumer.js.tt @@ -1,13 +1,6 @@ // Action Cable provides the framework to deal with WebSockets in Rails. // You can generate new channels where WebSocket features live using the `rails generate channel` command. -// -//= require action_cable -//= require_self -//= require_tree ./channels -(function() { - this.App || (this.App = {}); +import ActionCable from "actioncable" - App.cable = ActionCable.createConsumer(); - -}).call(this); +export default ActionCable.createConsumer() diff --git a/actioncable/lib/rails/generators/channel/templates/javascript/index.js.tt b/actioncable/lib/rails/generators/channel/templates/javascript/index.js.tt new file mode 100644 index 0000000000..5da1ce2dce --- /dev/null +++ b/actioncable/lib/rails/generators/channel/templates/javascript/index.js.tt @@ -0,0 +1,5 @@ +// Load all the channels within this directory and all subdirectories. +// Channel files must be named *_channel.js. + +const channels = require.context('.', true, /\_channel\.js$/) +channels.keys().forEach(channels) |