diff --git a/Rakefile b/Rakefile index c01e62f408..bacbbc3b28 100644 --- a/Rakefile +++ b/Rakefile @@ -50,6 +50,10 @@ Cucumber::Rake::Task.new(:cucumber) do |t| tags << "~@system_test" end + if version.to_f < 6.0 + tags << "~@rails_post_6" + end + cucumber_flag = tags.map { |tag| "--tag #{tag}" } t.cucumber_opts = cucumber_flag diff --git a/example_app_generator/generate_stuff.rb b/example_app_generator/generate_stuff.rb index 725c7f7530..3bfac428a4 100644 --- a/example_app_generator/generate_stuff.rb +++ b/example_app_generator/generate_stuff.rb @@ -111,6 +111,13 @@ def using_source_path(path) rescue LoadError end +begin + require 'action_cable' + require 'action_cable/test_helper' + generate('channel chat') +rescue LoadError +end + file "app/views/things/custom_action.html.erb", "This is a template for a custom action.", :force => true diff --git a/features/matchers/have_broadcasted_matcher.feature b/features/matchers/have_broadcasted_matcher.feature new file mode 100644 index 0000000000..8eeb2d96b0 --- /dev/null +++ b/features/matchers/have_broadcasted_matcher.feature @@ -0,0 +1,151 @@ +@rails_post_6 +Feature: have_broadcasted matcher + + The `have_broadcasted_to` (also aliased as `broadcast_to`) matcher is used to check if a message has been broadcasted to a given stream. + + Background: + Given action cable testing is available + + Scenario: Checking stream name + Given a file named "spec/models/broadcaster_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe "broadcasting" do + it "matches with stream name" do + expect { + ActionCable.server.broadcast( + "notifications", text: 'Hello!' + ) + }.to have_broadcasted_to("notifications") + end + end + """ + When I run `rspec spec/models/broadcaster_spec.rb` + Then the examples should all pass + + Scenario: Checking passed message to stream + Given a file named "spec/models/broadcaster_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe "broadcasting" do + it "matches with message" do + expect { + ActionCable.server.broadcast( + "notifications", text: 'Hello!' + ) + }.to have_broadcasted_to("notifications").with(text: 'Hello!') + end + end + """ + When I run `rspec spec/models/broadcaster_spec.rb` + Then the examples should all pass + + Scenario: Checking that message passed to stream matches + Given a file named "spec/models/broadcaster_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe "broadcasting" do + it "matches with message" do + expect { + ActionCable.server.broadcast( + "notifications", text: 'Hello!', user_id: 12 + ) + }.to have_broadcasted_to("notifications").with(a_hash_including(text: 'Hello!')) + end + end + """ + When I run `rspec spec/models/broadcaster_spec.rb` + Then the examples should all pass + + Scenario: Checking passed message with block + Given a file named "spec/models/broadcaster_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe "broadcasting" do + it "matches with message" do + expect { + ActionCable.server.broadcast( + "notifications", text: 'Hello!', user_id: 12 + ) + }.to have_broadcasted_to("notifications").with { |data| + expect(data['user_id']).to eq 12 + } + end + end + """ + When I run `rspec spec/models/broadcaster_spec.rb` + Then the examples should all pass + + Scenario: Using alias method + Given a file named "spec/models/broadcaster_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe "broadcasting" do + it "matches with stream name" do + expect { + ActionCable.server.broadcast( + "notifications", text: 'Hello!' + ) + }.to broadcast_to("notifications") + end + end + """ + When I run `rspec spec/models/broadcaster_spec.rb` + Then the examples should all pass + + Scenario: Checking broadcast to a record + Given a file named "spec/channels/chat_channel_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe ChatChannel, :type => :channel do + it "successfully subscribes" do + user = User.new(42) + + expect { + ChatChannel.broadcast_to(user, text: 'Hi') + }.to have_broadcasted_to(user) + end + end + """ + And a file named "app/models/user.rb" with: + """ruby + class User < Struct.new(:name) + def to_gid_param + name + end + end + """ + When I run `rspec spec/channels/chat_channel_spec.rb` + Then the example should pass + + Scenario: Checking broadcast to a record in non-channel spec + Given a file named "spec/models/broadcaster_spec.rb" with: + """ruby + require "rails_helper" + + RSpec.describe "broadcasting" do + it "matches with stream name" do + user = User.new(42) + + expect { + ChatChannel.broadcast_to(user, text: 'Hi') + }.to broadcast_to(ChatChannel.broadcasting_for(user)) + end + end + """ + And a file named "app/models/user.rb" with: + """ruby + class User < Struct.new(:name) + def to_gid_param + name + end + end + """ + When I run `rspec spec/models/broadcaster_spec.rb` + Then the example should pass diff --git a/features/step_definitions/additional_cli_steps.rb b/features/step_definitions/additional_cli_steps.rb index 5f024f4aa4..622ddbec33 100644 --- a/features/step_definitions/additional_cli_steps.rb +++ b/features/step_definitions/additional_cli_steps.rb @@ -2,6 +2,11 @@ require "active_job" rescue LoadError # rubocop:disable Lint/HandleExceptions end +begin + require "action_cable" +rescue LoadError # rubocop:disable Lint/HandleExceptions +end + require "rails/version" require "rspec/rails/feature_check" @@ -27,3 +32,9 @@ pending "file fixtures are not available" end end + +Given /action cable testing is available/ do + if !RSpec::Rails::FeatureCheck.has_action_cable_testing? + pending "Action Cable testing is not available" + end +end diff --git a/lib/rspec/rails/configuration.rb b/lib/rspec/rails/configuration.rb index 73a129e696..1f551fc5c7 100644 --- a/lib/rspec/rails/configuration.rb +++ b/lib/rspec/rails/configuration.rb @@ -25,6 +25,7 @@ class Configuration # # @api private DIRECTORY_MAPPINGS = { + :channel => %w[spec channels], :controller => %w[spec controllers], :helper => %w[spec helpers], :job => %w[spec jobs], diff --git a/lib/rspec/rails/feature_check.rb b/lib/rspec/rails/feature_check.rb index 35006f2c8c..6e9af4a290 100644 --- a/lib/rspec/rails/feature_check.rb +++ b/lib/rspec/rails/feature_check.rb @@ -33,6 +33,10 @@ def has_action_mailer_preview? has_action_mailer? && defined?(::ActionMailer::Preview) end + def has_action_cable_testing? + defined?(::ActionCable) && ActionCable::VERSION::MAJOR >= 6 + end + def has_action_mailer_show_preview? has_action_mailer_preview? && ::ActionMailer::Base.respond_to?(:show_previews=) diff --git a/lib/rspec/rails/matchers.rb b/lib/rspec/rails/matchers.rb index c90412519e..f9195a5af1 100644 --- a/lib/rspec/rails/matchers.rb +++ b/lib/rspec/rails/matchers.rb @@ -25,6 +25,10 @@ module Matchers require 'rspec/rails/matchers/active_job' end +if RSpec::Rails::FeatureCheck.has_action_cable_testing? + require 'rspec/rails/matchers/action_cable' +end + if RSpec::Rails::FeatureCheck.has_action_mailbox? require 'rspec/rails/matchers/action_mailbox' end diff --git a/lib/rspec/rails/matchers/action_cable.rb b/lib/rspec/rails/matchers/action_cable.rb new file mode 100644 index 0000000000..42534b59c0 --- /dev/null +++ b/lib/rspec/rails/matchers/action_cable.rb @@ -0,0 +1,65 @@ +require "rspec/rails/matchers/action_cable/have_broadcasted_to" + +module RSpec + module Rails + module Matchers + # Namespace for various implementations of ActionCable features + # + # @api private + module ActionCable + end + + # @api public + # Passes if a message has been sent to a stream/object inside a block. + # May chain `at_least`, `at_most` or `exactly` to specify a number of times. + # To specify channel from which message has been broadcasted to object use `from_channel`. + # + # + # @example + # expect { + # ActionCable.server.broadcast "messages", text: 'Hi!' + # }.to have_broadcasted_to("messages") + # + # expect { + # SomeChannel.broadcast_to(user) + # }.to have_broadcasted_to(user).from_channel(SomeChannel) + # + # # Using alias + # expect { + # ActionCable.server.broadcast "messages", text: 'Hi!' + # }.to broadcast_to("messages") + # + # expect { + # ActionCable.server.broadcast "messages", text: 'Hi!' + # ActionCable.server.broadcast "all", text: 'Hi!' + # }.to have_broadcasted_to("messages").exactly(:once) + # + # expect { + # 3.times { ActionCable.server.broadcast "messages", text: 'Hi!' } + # }.to have_broadcasted_to("messages").at_least(2).times + # + # expect { + # ActionCable.server.broadcast "messages", text: 'Hi!' + # }.to have_broadcasted_to("messages").at_most(:twice) + # + # expect { + # ActionCable.server.broadcast "messages", text: 'Hi!' + # }.to have_broadcasted_to("messages").with(text: 'Hi!') + def have_broadcasted_to(target = nil) + check_action_cable_adapter + + ActionCable::HaveBroadcastedTo.new(target, :channel => described_class) + end + alias_method :broadcast_to, :have_broadcasted_to + + private + + # @private + def check_action_cable_adapter + return if ::ActionCable::SubscriptionAdapter::Test === ::ActionCable.server.pubsub + + raise StandardError, "To use ActionCable matchers set `adapter: test` in your cable.yml" + end + end + end +end diff --git a/lib/rspec/rails/matchers/action_cable/have_broadcasted_to.rb b/lib/rspec/rails/matchers/action_cable/have_broadcasted_to.rb new file mode 100644 index 0000000000..8baab6116d --- /dev/null +++ b/lib/rspec/rails/matchers/action_cable/have_broadcasted_to.rb @@ -0,0 +1,170 @@ +module RSpec + module Rails + module Matchers + module ActionCable + # rubocop: disable Metrics/ClassLength + # @private + class HaveBroadcastedTo < RSpec::Matchers::BuiltIn::BaseMatcher + def initialize(target, channel:) + @target = target + @channel = channel + @block = Proc.new {} + @data = nil + set_expected_number(:exactly, 1) + end + + def with(data = nil) + @data = data + @data = @data.with_indifferent_access if @data.is_a?(Hash) + @block = Proc.new if block_given? + self + end + + def exactly(count) + set_expected_number(:exactly, count) + self + end + + def at_least(count) + set_expected_number(:at_least, count) + self + end + + def at_most(count) + set_expected_number(:at_most, count) + self + end + + def times + self + end + + def once + exactly(:once) + end + + def twice + exactly(:twice) + end + + def thrice + exactly(:thrice) + end + + def failure_message + "expected to broadcast #{base_message}".tap do |msg| + if @unmatching_msgs.any? + msg << "\nBroadcasted messages to #{stream}:" + @unmatching_msgs.each do |data| + msg << "\n #{data}" + end + end + end + end + + def failure_message_when_negated + "expected not to broadcast #{base_message}" + end + + def message_expectation_modifier + case @expectation_type + when :exactly then "exactly" + when :at_most then "at most" + when :at_least then "at least" + end + end + + def supports_block_expectations? + true + end + + def matches?(proc) + raise ArgumentError, "have_broadcasted_to and broadcast_to only support block expectations" unless Proc === proc + + original_sent_messages_count = pubsub_adapter.broadcasts(stream).size + proc.call + in_block_messages = pubsub_adapter.broadcasts(stream).drop(original_sent_messages_count) + + check(in_block_messages) + end + + def from_channel(channel) + @channel = channel + self + end + + private + + def stream + @stream ||= if @target.is_a?(String) + @target + else + check_channel_presence + @channel.broadcasting_for(@target) + end + end + + def check(messages) + @matching_msgs, @unmatching_msgs = messages.partition do |msg| + decoded = ActiveSupport::JSON.decode(msg) + decoded = decoded.with_indifferent_access if decoded.is_a?(Hash) + + if @data.nil? || @data === decoded + @block.call(decoded) + true + else + false + end + end + + @matching_msgs_count = @matching_msgs.size + + case @expectation_type + when :exactly then @expected_number == @matching_msgs_count + when :at_most then @expected_number >= @matching_msgs_count + when :at_least then @expected_number <= @matching_msgs_count + end + end + + def set_expected_number(relativity, count) + @expectation_type = relativity + @expected_number = + case count + when :once then 1 + when :twice then 2 + when :thrice then 3 + else Integer(count) + end + end + + def base_message + "#{message_expectation_modifier} #{@expected_number} messages to #{stream}".tap do |msg| + msg << " with #{data_description(@data)}" unless @data.nil? + msg << ", but broadcast #{@matching_msgs_count}" + end + end + + def data_description(data) + if data.is_a?(RSpec::Matchers::Composable) + data.description + else + data + end + end + + def pubsub_adapter + ::ActionCable.server.pubsub + end + + def check_channel_presence + return if @channel.present? && @channel.respond_to?(:channel_name) + + error_msg = "Broadcasting channel can't be infered. Please, specify it with `from_channel`" + raise ArgumentError, error_msg + end + end + # rubocop: enable Metrics/ClassLength + end + end + end +end diff --git a/spec/rspec/rails/matchers/action_cable/have_broadcasted_to_spec.rb b/spec/rspec/rails/matchers/action_cable/have_broadcasted_to_spec.rb new file mode 100644 index 0000000000..f2e3d3f75e --- /dev/null +++ b/spec/rspec/rails/matchers/action_cable/have_broadcasted_to_spec.rb @@ -0,0 +1,220 @@ +require "spec_helper" +require "rspec/rails/feature_check" + +if RSpec::Rails::FeatureCheck.has_action_cable_testing? + require "rspec/rails/matchers/action_cable" + + class CableGlobalIdModel + include GlobalID::Identification + + attr_reader :id + + def initialize(id) + @id = id + end + + def to_global_id(options = {}) + @global_id ||= GlobalID.create(self, :app => "rspec-suite") + end + end +end + +RSpec.describe "have_broadcasted_to matchers", :skip => !RSpec::Rails::FeatureCheck.has_action_cable_testing? do + let(:channel) do + Class.new(ActionCable::Channel::Base) do + def self.channel_name + "broadcast" + end + end + end + + def broadcast(stream, msg) + ActionCable.server.broadcast stream, msg + end + + before do + server = ActionCable.server + test_adapter = ActionCable::SubscriptionAdapter::Test.new(server) + server.instance_variable_set(:@pubsub, test_adapter) + end + + describe "have_broadcasted_to" do + it "raises ArgumentError when no Proc passed to expect" do + expect { + expect(true).to have_broadcasted_to('stream') + }.to raise_error(ArgumentError) + end + + it "passes with default messages count (exactly one)" do + expect { + broadcast('stream', 'hello') + }.to have_broadcasted_to('stream') + end + + it "passes when using alias" do + expect { + broadcast('stream', 'hello') + }.to broadcast_to('stream') + end + + it "counts only messages sent in block" do + broadcast('stream', 'one') + expect { + broadcast('stream', 'two') + }.to have_broadcasted_to('stream').exactly(1) + end + + it "passes when negated" do + expect { }.not_to have_broadcasted_to('stream') + end + + it "fails when message is not sent" do + expect { + expect { }.to have_broadcasted_to('stream') + }.to raise_error(/expected to broadcast exactly 1 messages to stream, but broadcast 0/) + end + + it "fails when too many messages broadcast" do + expect { + expect { + broadcast('stream', 'one') + broadcast('stream', 'two') + }.to have_broadcasted_to('stream').exactly(1) + }.to raise_error(/expected to broadcast exactly 1 messages to stream, but broadcast 2/) + end + + it "reports correct number in fail error message" do + broadcast('stream', 'one') + expect { + expect { }.to have_broadcasted_to('stream').exactly(1) + }.to raise_error(/expected to broadcast exactly 1 messages to stream, but broadcast 0/) + end + + it "fails when negated and message is sent" do + expect { + expect { broadcast('stream', 'one') }.not_to have_broadcasted_to('stream') + }.to raise_error(/expected not to broadcast exactly 1 messages to stream, but broadcast 1/) + end + + it "passes with multiple streams" do + expect { + broadcast('stream_a', 'A') + broadcast('stream_b', 'B') + broadcast('stream_c', 'C') + }.to have_broadcasted_to('stream_a').and have_broadcasted_to('stream_b') + end + + it "passes with :once count" do + expect { + broadcast('stream', 'one') + }.to have_broadcasted_to('stream').exactly(:once) + end + + it "passes with :twice count" do + expect { + broadcast('stream', 'one') + broadcast('stream', 'two') + }.to have_broadcasted_to('stream').exactly(:twice) + end + + it "passes with :thrice count" do + expect { + broadcast('stream', 'one') + broadcast('stream', 'two') + broadcast('stream', 'three') + }.to have_broadcasted_to('stream').exactly(:thrice) + end + + it "passes with at_least count when sent messages are over limit" do + expect { + broadcast('stream', 'one') + broadcast('stream', 'two') + }.to have_broadcasted_to('stream').at_least(:once) + end + + it "passes with at_most count when sent messages are under limit" do + expect { + broadcast('stream', 'hello') + }.to have_broadcasted_to('stream').at_most(:once) + end + + it "generates failure message with at least hint" do + expect { + expect { }.to have_broadcasted_to('stream').at_least(:once) + }.to raise_error(/expected to broadcast at least 1 messages to stream, but broadcast 0/) + end + + it "generates failure message with at most hint" do + expect { + expect { + broadcast('stream', 'hello') + broadcast('stream', 'hello') + }.to have_broadcasted_to('stream').at_most(:once) + }.to raise_error(/expected to broadcast at most 1 messages to stream, but broadcast 2/) + end + + it "passes with provided data" do + expect { + broadcast('stream', id: 42, name: "David") + }.to have_broadcasted_to('stream').with(id: 42, name: "David") + end + + it "passes with provided data matchers" do + expect { + broadcast('stream', id: 42, name: "David", message_id: 123) + }.to have_broadcasted_to('stream').with(a_hash_including(name: "David", id: 42)) + end + + it "generates failure message when data not match" do + expect { + expect { + broadcast('stream', id: 42, name: "David", message_id: 123) + }.to have_broadcasted_to('stream').with(a_hash_including(name: "John", id: 42)) + }.to raise_error(/expected to broadcast exactly 1 messages to stream with a hash including/) + end + + it "throws descriptive error when no test adapter set" do + require "action_cable/subscription_adapter/inline" + ActionCable.server.instance_variable_set(:@pubsub, ActionCable::SubscriptionAdapter::Inline) + expect { + expect { broadcast('stream', 'hello') }.to have_broadcasted_to('stream') + }.to raise_error("To use ActionCable matchers set `adapter: test` in your cable.yml") + end + + it "fails with with block with incorrect data" do + expect { + expect { + broadcast('stream', "asdf") + }.to have_broadcasted_to('stream').with { |data| + expect(data).to eq("zxcv") + } + }.to raise_error { |e| + expect(e.message).to match(/expected: "zxcv"/) + expect(e.message).to match(/got: "asdf"/) + } + end + + + context "when object is passed as first argument" do + let(:model) { CableGlobalIdModel.new(42) } + + context "when channel is present" do + it "passes" do + expect { + channel.broadcast_to(model, text: 'Hi') + }.to have_broadcasted_to(model).from_channel(channel) + end + end + + context "when channel can't be inferred" do + it "raises exception" do + expect { + expect { + channel.broadcast_to(model, text: 'Hi') + }.to have_broadcasted_to(model) + }.to raise_error(ArgumentError) + end + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index d38d35c503..6d0b0001f8 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -3,6 +3,12 @@ module RSpecRails class Application < ::Rails::Application self.config.secret_key_base = 'ASecretString' if config.respond_to? :secret_key_base + + if defined?(ActionCable) + ActionCable.server.config.cable = { "adapter" => "test" } + ActionCable.server.config.logger = + ActiveSupport::TaggedLogging.new ActiveSupport::Logger.new(StringIO.new) + end end end I18n.enforce_available_locales = true if I18n.respond_to?(:enforce_available_locales)