From 9f5c846009ca17fcce16e9735bc14b0d6699cfa4 Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Tue, 17 Sep 2024 05:06:39 -0400 Subject: [PATCH] Introduce `Turbo::SystemTestHelper` (#577) Introduce the `Turbo::SystemTestHelper` module to be included into [ActionDispatch::SystemTestCase][] when it's available. The module is named to mimic [ActionText::SystemTestHelper][]. The module defines a `#connect_turbo_cable_stream_sources` helper method extracted from this project's System Test suite. It aims to synchronize the test harness with Turbo's Action Cable-powered broadcast support. The method will find all `` elements that are present but not yet `[connected]` (returning the results immediately with Capybara's `:wait`), then wait for them to connect (using whatever Capybara's configured wait value). In addition to the `connect_turbo_cable_stream_sources`, also introduce a `:turbo_cable_stream_source` Capybara selector, along with `assert_turbo_cable_stream_source` and `assert_no_turbo_cable_stream_source` helper methods. [ActionDispatch::SystemTestCase]: https://edgeapi.rubyonrails.org/classes/ActionDispatch/SystemTestCase.html [ActionText::SystemTestHelper]: https://edgeapi.rubyonrails.org/classes/ActionText/SystemTestHelper.html --- README.md | 59 +++++++++ lib/turbo/engine.rb | 20 +++ lib/turbo/system_test_helper.rb | 128 ++++++++++++++++++++ test/capybara_selectors_test.rb | 94 ++++++++++++++ test/dummy/app/views/messages/show.html.erb | 3 + test/system/assertions_test.rb | 34 ++++++ test/system/broadcasts_test.rb | 24 +--- 7 files changed, 342 insertions(+), 20 deletions(-) create mode 100644 lib/turbo/system_test_helper.rb create mode 100644 test/capybara_selectors_test.rb create mode 100644 test/dummy/app/views/messages/show.html.erb create mode 100644 test/system/assertions_test.rb diff --git a/README.md b/README.md index c8b208f3..8ef4ad47 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,64 @@ This gem provides a `turbo_stream_from` helper to create a turbo stream. <%# Rest of show here %> ``` +### Testing Turbo Stream Broadcasts + +Receiving server-generated Turbo Broadcasts requires a connected Web Socket. +Views that render `` elements with the +`#turbo_stream_from` view helper incur a slight delay before they're ready to +receive broadcasts. In System Tests, that delay can disrupt Capybara's built-in +synchronization mechanisms that wait for or assert on content that's broadcast +over Web Sockets. For example, consider a test that navigates to a page and then +immediately asserts that broadcast content is present: + +```ruby +test "renders broadcasted Messages" do + message = Message.new content: "Hello, from Action Cable" + + visit "/" + click_link "All Messages" + message.save! # execute server-side code to broadcast a Message + + assert_text message.content +end +``` + +If the call to `Message#save!` executes quickly enough, it might beat-out any +`` elements rendered by the call to `click_link "All +Messages"`. + +To wait for any disconnected `` elements to connect, +call [`#connect_turbo_cable_stream_sources`](turbo-rails/blob/wait-for-cable-stream-sourceshttps://github.com/hotwired/turbo-rails/blob/main/lib/turbo/system_test_helper.rb): + +```diff + test "renders broadcasted Messages" do + message = Message.new content: "Hello, from Action Cable" + + visit "/" + click_link "All Messages" ++ connect_turbo_cable_stream_sources + message.save! # execute server-side code to broadcast a Message + + assert_text message.content + end +``` + +By default, calls to [`#visit`](https://rubydoc.info/github/teamcapybara/capybara/master/Capybara/Session:visit) will wait for all `` elements to connect. You can control this by modifying the `config.turbo.test_connect_after_actions`. For example, to wait after calls to [`#click_link`](https://rubydoc.info/github/teamcapybara/capybara/master/Capybara/Node/Actions:click_link), add the following to `config/environments/test.rb`: + +```ruby +# config/environments/test.rb + +config.turbo.test_connect_after_actions << :click_link +``` + +To disable automatic connecting, set the configuration to `[]`: + +```ruby +# config/environments/test.rb + +config.turbo.test_connect_after_actions = [] +``` + [See documentation](https://turbo.hotwired.dev/handbook/streams). ## Installation @@ -140,6 +198,7 @@ Note that this documentation is updated automatically from the main branch, so i - [Turbo Test Assertions](https://rubydoc.info/github/hotwired/turbo-rails/main/Turbo/TestAssertions) - [Turbo Integration Test Assertions](https://rubydoc.info/github/hotwired/turbo-rails/main/Turbo/TestAssertions/IntegrationTestAssertions) - [Turbo Broadcastable Test Helper](https://rubydoc.info/github/hotwired/turbo-rails/main/Turbo/Broadcastable/TestHelper) +- [Turbo System Test Helper](https://rubydoc.info/github/hotwired/turbo-rails/main/Turbo/SystemTestHelper) ## Compatibility with Rails UJS diff --git a/lib/turbo/engine.rb b/lib/turbo/engine.rb index ca9b860f..b7efc3ba 100644 --- a/lib/turbo/engine.rb +++ b/lib/turbo/engine.rb @@ -5,6 +5,7 @@ class Engine < Rails::Engine isolate_namespace Turbo config.eager_load_namespaces << Turbo config.turbo = ActiveSupport::OrderedOptions.new + config.turbo.test_connect_after_actions = %i[visit] config.autoload_once_paths = %W( #{root}/app/channels #{root}/app/controllers @@ -151,5 +152,24 @@ class TurboStreamEncoder < IdentityEncoder end end end + + initializer "turbo.system_test_helper" do + ActiveSupport.on_load(:action_dispatch_system_test_case) do + require "turbo/system_test_helper" + include Turbo::SystemTestHelper + end + end + + config.after_initialize do |app| + ActiveSupport.on_load(:action_dispatch_system_test_case) do + app.config.turbo.test_connect_after_actions.map do |method| + class_eval <<~RUBY, __FILE__, __LINE__ + 1 + def #{method}(...) # def visit(...) + super.tap { connect_turbo_cable_stream_sources } # super.tap { connect_turbo_cable_stream_sources } + end # end + RUBY + end + end + end end end diff --git a/lib/turbo/system_test_helper.rb b/lib/turbo/system_test_helper.rb new file mode 100644 index 00000000..4e81a1e1 --- /dev/null +++ b/lib/turbo/system_test_helper.rb @@ -0,0 +1,128 @@ +module Turbo::SystemTestHelper + # Delay until every `` element present in the page + # is ready to receive broadcasts + # + # test "renders broadcasted Messages" do + # message = Message.new content: "Hello, from Action Cable" + # + # visit "/" + # click_link "All Messages" + # message.save! # execute server-side code to broadcast a Message + # + # assert_text message.content + # end + # + # By default, calls to `#visit` will wait for all `` + # elements to connect. You can control this by modifying the + # `config.turbo.test_connect_after_actions`. For example, to wait after calls to + # `#click_link`, add the following to `config/environments/test.rb`: + # + # # config/environments/test.rb + # config.turbo.test_connect_after_actions << :click_link + # + # To disable automatic connecting, set the configuration to `[]`: + # + # # config/environments/test.rb + # config.turbo.test_connect_after_actions = [] + # + def connect_turbo_cable_stream_sources(**options, &block) + all(:turbo_cable_stream_source, **options, connected: false, wait: 0).each do |element| + element.assert_matches_selector(:turbo_cable_stream_source, **options, connected: true, &block) + end + end + + # Asserts that a `` element is present in the + # document + # + # ==== Arguments + # + # * locator optional locator to determine the element's + # `[signed-stream-name]` attribute. Can be of any type that is a valid + # argument to Turbo::Streams::StreamName#signed_stream_name. + # + # ==== Options + # + # * :connected matches the `[connected]` attribute + # * :channel matches the `[channel]` attribute. Can be a Class, + # String, Symbol, or Regexp + # * :signed_stream_name matches the element's `[signed-stream-name]` + # attribute. Can be of any type that is a valid + # argument to Turbo::Streams::StreamName#signed_stream_name. + # + # In addition to the filters listed above, accepts any valid Capybara global + # filter option. + def assert_turbo_cable_stream_source(...) + assert_selector(:turbo_cable_stream_source, ...) + end + + # Asserts that a `` element is absent from the + # document + # + # ==== Arguments + # + # * locator optional locator to determine the element's + # `[signed-stream-name]` attribute. Can be of any type that is a valid + # argument to Turbo::Streams::StreamName#signed_stream_name. + # + # ==== Options + # + # * :connected matches the `[connected]` attribute + # * :channel matches the `[channel]` attribute. Can be a Class, + # String, Symbol, or Regexp + # * :signed_stream_name matches the element's `[signed-stream-name]` + # attribute. Can be of any type that is a valid + # argument to Turbo::Streams::StreamName#signed_stream_name. + # + # In addition to the filters listed above, accepts any valid Capybara global + # filter option. + def assert_no_turbo_cable_stream_source(...) + assert_no_selector(:turbo_cable_stream_source, ...) + end + + Capybara.add_selector :turbo_cable_stream_source do + xpath do |locator| + xpath = XPath.descendant.where(XPath.local_name == "turbo-cable-stream-source") + xpath.where(SignedStreamNameConditions.new(locator).reduce(:|)) + end + + expression_filter :connected do |xpath, value| + builder(xpath).add_attribute_conditions(connected: value) + end + + expression_filter :channel do |xpath, value| + builder(xpath).add_attribute_conditions(channel: value.try(:name) || value) + end + + expression_filter :signed_stream_name do |xpath, value| + case value + when TrueClass, FalseClass, NilClass, Regexp + builder(xpath).add_attribute_conditions("signed-stream-name": value) + else + xpath.where(SignedStreamNameConditions.new(value).reduce(:|)) + end + end + end + + class SignedStreamNameConditions # :nodoc: + include Turbo::Streams::StreamName, Enumerable + + def initialize(value) + @value = value + end + + def attribute + XPath.attr(:"signed-stream-name") + end + + def each + if @value.is_a?(String) + yield attribute == @value + yield attribute == signed_stream_name(@value) + elsif @value.is_a?(Array) || @value.respond_to?(:to_key) + yield attribute == signed_stream_name(@value) + elsif @value.present? + yield attribute == @value + end + end + end +end diff --git a/test/capybara_selectors_test.rb b/test/capybara_selectors_test.rb new file mode 100644 index 00000000..421a6913 --- /dev/null +++ b/test/capybara_selectors_test.rb @@ -0,0 +1,94 @@ +require "test_helper" +require "turbo/system_test_helper" +require "capybara/minitest" + +class Turbo::CapybaraSelectorTestCase < ActionView::TestCase + include Capybara::Minitest::Assertions + + attr_accessor :page + + def render_html(html, **local_assigns) + render(inline: html, locals: local_assigns) + + self.page = Capybara.string(rendered.to_s) + end +end + +class Turbo::TurboCableStreamSourceSelectorTest < Turbo::CapybaraSelectorTestCase + test ":turbo_cable_stream_source matches signed-stream-name as a locator" do + message = Message.new(id: 1) + + render_html <<~ERB, message: message + <%= turbo_stream_from message %> + ERB + + assert_selector :turbo_cable_stream_source, count: 1 + assert_selector :turbo_cable_stream_source, message, count: 1 + assert_selector :turbo_cable_stream_source, [message], count: 1 + assert_selector :turbo_cable_stream_source, Turbo::StreamsChannel.signed_stream_name(message), count: 1 + end + + test ":turbo_cable_stream_source matches signed-stream-name with :signed_stream_name filter" do + message = Message.new(id: 1) + + render_html <<~ERB, message: message + <%= turbo_stream_from message %> + ERB + + assert_selector :turbo_cable_stream_source, count: 1 + assert_selector :turbo_cable_stream_source, signed_stream_name: message, count: 1 + assert_selector :turbo_cable_stream_source, signed_stream_name: [message], count: 1 + assert_selector :turbo_cable_stream_source, signed_stream_name: Turbo::StreamsChannel.signed_stream_name(message), count: 1 + end + + test ":turbo_cable_stream_source matches channel with :channel filter" do + message = Message.new(id: 1) + + render_html <<~ERB, message: message + <%= turbo_stream_from message %> + ERB + + assert_selector :turbo_cable_stream_source, count: 1 + assert_selector :turbo_cable_stream_source, channel: true + assert_selector :turbo_cable_stream_source, channel: Turbo::StreamsChannel + assert_selector :turbo_cable_stream_source, channel: "Turbo::StreamsChannel" + end + + test ":turbo_cable_stream_source does not match signed-stream-name as a locator" do + message = Message.new(id: 1) + + render_html <<~ERB, message: Message.new(id: 2) + <%= turbo_stream_from message %> + ERB + + assert_no_selector :turbo_cable_stream_source, "junk", count: 1 + assert_no_selector :turbo_cable_stream_source, message, count: 1 + assert_no_selector :turbo_cable_stream_source, [message], count: 1 + assert_no_selector :turbo_cable_stream_source, Turbo::StreamsChannel.signed_stream_name(message), count: 1 + end + + test ":turbo_cable_stream_source does not match signed-stream-name with :signed_stream_name filter" do + message = Message.new(id: 1) + + render_html <<~ERB, message: Message.new(id: 2) + <%= turbo_stream_from message %> + ERB + + assert_no_selector :turbo_cable_stream_source, signed_stream_name: "junk", count: 1 + assert_no_selector :turbo_cable_stream_source, signed_stream_name: message, count: 1 + assert_no_selector :turbo_cable_stream_source, signed_stream_name: [message], count: 1 + assert_no_selector :turbo_cable_stream_source, signed_stream_name: Turbo::StreamsChannel.signed_stream_name(message), count: 1 + end + + test ":turbo_cable_stream_source does not match channel with :channel filter" do + message = Message.new(id: 1) + + render_html <<~ERB, message: message + <%= turbo_stream_from message %> + ERB + + assert_no_selector :turbo_cable_stream_source, channel: false + assert_no_selector :turbo_cable_stream_source, channel: Object + assert_no_selector :turbo_cable_stream_source, channel: "Object" + end +end diff --git a/test/dummy/app/views/messages/show.html.erb b/test/dummy/app/views/messages/show.html.erb new file mode 100644 index 00000000..665404f0 --- /dev/null +++ b/test/dummy/app/views/messages/show.html.erb @@ -0,0 +1,3 @@ +

Message #<%= @message.id %>

+ +<%= turbo_stream_from @message %> diff --git a/test/system/assertions_test.rb b/test/system/assertions_test.rb new file mode 100644 index 00000000..c9c02506 --- /dev/null +++ b/test/system/assertions_test.rb @@ -0,0 +1,34 @@ +require "application_system_test_case" + +class AssertionsTest < ApplicationSystemTestCase + test "#assert_turbo_cable_stream_source treats the locator as :signed_stream_name filter" do + message = Message.new(id: 1) + + visit message_path(message) + + assert_turbo_cable_stream_source message, count: 1 + assert_no_turbo_cable_stream_source "junk" + end + + test "#assert_turbo_cable_stream_source supports String collection filters" do + visit messages_path + + assert_turbo_cable_stream_source connected: true, count: 1 + assert_turbo_cable_stream_source channel: Turbo::StreamsChannel, count: 1 + assert_turbo_cable_stream_source signed_stream_name: Turbo::StreamsChannel.signed_stream_name("messages"), count: 1 + assert_no_turbo_cable_stream_source connected: false + assert_no_turbo_cable_stream_source channel: "junk" + assert_no_turbo_cable_stream_source signed_stream_name: Turbo::StreamsChannel.signed_stream_name("junk") + end + + test "#assert_turbo_cable_stream_source supports record filters" do + message = Message.new(id: 1) + + visit message_path(message) + + assert_turbo_cable_stream_source signed_stream_name: message + assert_turbo_cable_stream_source signed_stream_name: [message] + assert_turbo_cable_stream_source signed_stream_name: Turbo::StreamsChannel.signed_stream_name(message) + assert_no_turbo_cable_stream_source signed_stream_name: [message, :junk] + end +end diff --git a/test/system/broadcasts_test.rb b/test/system/broadcasts_test.rb index 2f324003..69663641 100644 --- a/test/system/broadcasts_test.rb +++ b/test/system/broadcasts_test.rb @@ -5,7 +5,6 @@ class BroadcastsTest < ApplicationSystemTestCase test "Message broadcasts Turbo Streams" do visit messages_path - wait_for_stream_to_be_connected assert_broadcasts_text "Message 1", to: :messages do |text, target| Message.create(content: text).broadcast_append_to(target) @@ -14,7 +13,6 @@ class BroadcastsTest < ApplicationSystemTestCase test "Message broadcasts with html: render option" do visit messages_path - wait_for_stream_to_be_connected assert_broadcasts_text "Hello, with html: option", to: :messages do |text, target| Message.create(content: "Ignored").broadcast_append_to(target, html: text) @@ -23,25 +21,22 @@ class BroadcastsTest < ApplicationSystemTestCase test "Message broadcasts with renderable: render option" do visit messages_path - wait_for_stream_to_be_connected - + assert_broadcasts_text "Test message", to: :messages do |text, target| Message.create(content: "Ignored").broadcast_append_to(target, renderable: MessageComponent.new(text)) end end - + test "Does not render the layout twice when passed a component" do visit messages_path - wait_for_stream_to_be_connected - + Message.create(content: "Ignored").broadcast_append_to(:messages, renderable: MessageComponent.new("test")) - + assert_selector("title", count: 1, visible: false, text: "Dummy") end test "Message broadcasts with extra attributes to turbo stream tag" do visit messages_path - wait_for_stream_to_be_connected assert_broadcasts_text "Message 1", to: :messages do |text, target| Message.create(content: text).broadcast_action_to(target, action: :append, attributes: { "data-foo": "bar" }) @@ -50,7 +45,6 @@ class BroadcastsTest < ApplicationSystemTestCase test "Message broadcasts with correct extra attributes to turbo stream tag" do visit messages_path - wait_for_stream_to_be_connected assert_forwards_turbo_stream_tag_attribute attr_key: "data-foo", attr_value: "bar", to: :messages do |attr_key, attr_value, target| Message.create(content: text).broadcast_action_to(target, action: :test, attributes: { attr_key => attr_value }) @@ -59,7 +53,6 @@ class BroadcastsTest < ApplicationSystemTestCase test "Message broadcasts with no rendering" do visit messages_path - wait_for_stream_to_be_connected assert_forwards_turbo_stream_tag_attribute attr_key: "data-foo", attr_value: "bar", to: :messages do |attr_key, attr_value, target| Message.create(content: text).broadcast_action_to(target, action: :test, render: false, partial: "non_existant", attributes: { attr_key => attr_value }) @@ -68,7 +61,6 @@ class BroadcastsTest < ApplicationSystemTestCase test "Message broadcasts later with extra attributes to turbo stream tag" do visit messages_path - wait_for_stream_to_be_connected perform_enqueued_jobs do assert_broadcasts_text "Message 1", to: :messages do |text, target| @@ -80,7 +72,6 @@ class BroadcastsTest < ApplicationSystemTestCase test "Message broadcasts later with correct extra attributes to turbo stream tag" do visit messages_path - wait_for_stream_to_be_connected perform_enqueued_jobs do assert_forwards_turbo_stream_tag_attribute attr_key: "data-foo", attr_value: "bar", to: :messages do |attr_key, attr_value, target| @@ -91,7 +82,6 @@ class BroadcastsTest < ApplicationSystemTestCase test "Message broadcasts later with no rendering" do visit messages_path - wait_for_stream_to_be_connected perform_enqueued_jobs do assert_forwards_turbo_stream_tag_attribute attr_key: "data-foo", attr_value: "bar", to: :messages do |attr_key, attr_value, target| @@ -102,7 +92,6 @@ class BroadcastsTest < ApplicationSystemTestCase test "Users::Profile broadcasts Turbo Streams" do visit users_profiles_path - wait_for_stream_to_be_connected assert_broadcasts_text "Profile 1", to: :users_profiles do |text, target| Users::Profile.new(id: 1, name: text).broadcast_append_to(target) @@ -111,7 +100,6 @@ class BroadcastsTest < ApplicationSystemTestCase test "passing extra parameters to channel" do visit section_messages_path - wait_for_stream_to_be_connected assert_broadcasts_text "In a section", to: :messages do |text| Message.create(content: text).broadcast_append_to(:important_messages) @@ -120,10 +108,6 @@ class BroadcastsTest < ApplicationSystemTestCase private - def wait_for_stream_to_be_connected - assert_selector "turbo-cable-stream-source[connected]", visible: false - end - def assert_broadcasts_text(text, to:, &block) within(:element, id: to) { assert_no_text text }