From f9c07f760f84eb454531da4deb6b1b0f65431b1e Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Sun, 11 Feb 2024 10:26:11 -0500 Subject: [PATCH] Introduce `Turbo::SystemTestHelper` 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 `#wait_for_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). [ActionDispatch::SystemTestCase]: https://edgeapi.rubyonrails.org/classes/ActionDispatch/SystemTestCase.html [ActionText::SystemTestHelper]: https://edgeapi.rubyonrails.org/classes/ActionText/SystemTestHelper.html --- README.md | 67 +++++++++++++++++++++++++++++++-- lib/turbo/engine.rb | 20 ++++++++++ lib/turbo/system_test_helper.rb | 36 ++++++++++++++++++ test/system/broadcasts_test.rb | 24 ++---------- 4 files changed, 124 insertions(+), 23 deletions(-) create mode 100644 lib/turbo/system_test_helper.rb diff --git a/README.md b/README.md index c943988a..84b01cc9 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ When the user clicks on the `Edit this todo` link, as a direct response to this ### A note on custom layouts -In order to render turbo frame requests without the application layout, Turbo registers a custom [layout method](https://api.rubyonrails.org/classes/ActionView/Layouts/ClassMethods.html#method-i-layout). +In order to render turbo frame requests without the application layout, Turbo registers a custom [layout method](https://api.rubyonrails.org/classes/ActionView/Layouts/ClassMethods.html#method-i-layout). If your application uses custom layout resolution, you have to make sure to return `"turbo_rails/frame"` (or `false` for TurboRails < 1.4.0) for turbo frame requests: ```ruby @@ -64,7 +64,7 @@ layout :custom_layout def custom_layout return "turbo_rails/frame" if turbo_frame_request? - + # ... your custom layout logic ``` @@ -81,7 +81,7 @@ layout :custom_layout def custom_layout return "turbo_rails/frame" if turbo_frame_request? - + "some_static_layout" ``` @@ -100,6 +100,66 @@ 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" + # … execute server-side code to broadcast a Message + message.save! + + 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 [`#wait_for_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" ++ wait_for_turbo_cable_stream_sources + # … execute server-side code to broadcast a Message + message.save! + + 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.waiting_capybara_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.waiting_capybara_actions << :click_link +``` + +To disable automatic waiting, set the configuration to `[]`: + +```ruby +# config/environments/test.rb + +config.turbo.waiting_capybara_actions = [] +``` + [See documentation](https://turbo.hotwired.dev/handbook/streams). ## Installation @@ -139,6 +199,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 cfdac27f..bfd68b33 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.waiting_capybara_actions = %i[visit] config.autoload_once_paths = %W( #{root}/app/channels #{root}/app/controllers @@ -111,5 +112,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.waiting_capybara_actions.map do |method| + class_eval <<~RUBY, __FILE__, __LINE__ + 1 + def #{method}(...) # def visit(...) + super.tap { wait_for_turbo_cable_stream_sources } # super.tap { wait_for_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..c6feacad --- /dev/null +++ b/lib/turbo/system_test_helper.rb @@ -0,0 +1,36 @@ +module Turbo::SystemTestHelper + extend ActiveSupport::Concern + + # 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" + # # … execute server-side code to broadcast a Message + # message.save! + # + # 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.waiting_capybara_actions`. For example, to wait after calls to + # `#click_link`, add the following to `config/environments/test.rb`: + # + # # config/environments/test.rb + # config.turbo.waiting_capybara_actions << :click_link + # + # To disable automatic waiting, set the configuration to `[]`: + # + # # config/environments/test.rb + # config.turbo.waiting_capybara_actions = [] + # + def wait_for_turbo_cable_stream_sources(**options) + all(:css, "turbo-cable-stream-source:not([connected])", wait: 0).each do |element| + element.assert_matches_selector(:css, "[connected]", visible: :all, **options) + end + 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 }