Skip to content

Commit

Permalink
Introduce Turbo::SystemTestHelper
Browse files Browse the repository at this point in the history
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 `<turbo-cable-stream-source>` 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
  • Loading branch information
seanpdoyle committed Feb 16, 2024
1 parent 07d3488 commit 203775f
Show file tree
Hide file tree
Showing 6 changed files with 265 additions and 23 deletions.
65 changes: 62 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,15 @@ 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
layout :custom_layout

def custom_layout
return "turbo_rails/frame" if turbo_frame_request?

# ... your custom layout logic
```

Expand All @@ -81,7 +81,7 @@ layout :custom_layout

def custom_layout
return "turbo_rails/frame" if turbo_frame_request?

"some_static_layout"
```

Expand All @@ -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 `<turbo-cable-stream-source>` 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
`<turbo-cable-stream-source>` elements rendered by the call to `click_link "All
Messages"`.

To wait for any disconnected `<turbo-cable-stream-source>` 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 `<turbo-cable-stream-source>` 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
Expand Down Expand Up @@ -139,6 +197,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

Expand Down
20 changes: 20 additions & 0 deletions lib/turbo/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.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
128 changes: 128 additions & 0 deletions lib/turbo/system_test_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
module Turbo::SystemTestHelper
# Delay until every `<turbo-cable-stream-source>` 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 `<turbo-cable-stream-source>`
# 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 `<turbo-cable-stream-source>` element is present in the
# document
#
# ==== Arguments
#
# * <tt>locator</tt> optional locator to determine the element's
# `[signed-stream-name]` attribute. Can be of any type that is a valid
# argument to <tt>Turbo::Streams::StreamName#signed_stream_name</tt>.
#
# ==== Options
#
# * <tt>:connected</tt> matches the `[connected]` attribute
# * <tt>:channel</tt> matches the `[channel]` attribute. Can be a Class,
# String, Symbol, or Regexp
# * <tt>:signed_stream_name</tt> matches the element's `[signed-stream-name]`
# attribute. Can be of any type that is a valid
# argument to <tt>Turbo::Streams::StreamName#signed_stream_name</tt>.
#
# 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 `<turbo-cable-stream-source>` element is absent from the
# document
#
# ==== Arguments
#
# * <tt>locator</tt> optional locator to determine the element's
# `[signed-stream-name]` attribute. Can be of any type that is a valid
# argument to <tt>Turbo::Streams::StreamName#signed_stream_name</tt>.
#
# ==== Options
#
# * <tt>:connected</tt> matches the `[connected]` attribute
# * <tt>:channel</tt> matches the `[channel]` attribute. Can be a Class,
# String, Symbol, or Regexp
# * <tt>:signed_stream_name</tt> matches the element's `[signed-stream-name]`
# attribute. Can be of any type that is a valid
# argument to <tt>Turbo::Streams::StreamName#signed_stream_name</tt>.
#
# 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
3 changes: 3 additions & 0 deletions test/dummy/app/views/messages/show.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<h1>Message #<%= @message.id %></h1>

<%= turbo_stream_from @message %>
48 changes: 48 additions & 0 deletions test/system/assertions_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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 do |element|
element.assert_matches_selector :turbo_cable_stream_source, message
element.assert_matches_selector :turbo_cable_stream_source, [message]
element.assert_matches_selector :turbo_cable_stream_source, Turbo::StreamsChannel.signed_stream_name(message)
end
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 count: 1 do |element|
element.assert_matches_selector :turbo_cable_stream_source
element.assert_matches_selector :turbo_cable_stream_source, connected: true
element.assert_matches_selector :turbo_cable_stream_source, channel: true
element.assert_matches_selector :turbo_cable_stream_source, channel: Turbo::StreamsChannel
element.assert_matches_selector :turbo_cable_stream_source, channel: "Turbo::StreamsChannel"
element.assert_matches_selector :turbo_cable_stream_source, signed_stream_name: true
element.assert_matches_selector :turbo_cable_stream_source, signed_stream_name: "messages"
element.assert_matches_selector :turbo_cable_stream_source, signed_stream_name: ["messages"]
element.assert_matches_selector :turbo_cable_stream_source, signed_stream_name: Turbo::StreamsChannel.signed_stream_name("messages")
end
assert_no_turbo_cable_stream_source signed_stream_name: "junk"
assert_no_turbo_cable_stream_source signed_stream_name: nil
assert_no_turbo_cable_stream_source signed_stream_name: false
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 count: 1 do |element|
element.assert_matches_selector :turbo_cable_stream_source, signed_stream_name: message
element.assert_matches_selector :turbo_cable_stream_source, signed_stream_name: [message]
element.assert_matches_selector :turbo_cable_stream_source, signed_stream_name: Turbo::StreamsChannel.signed_stream_name(message)
end
assert_no_turbo_cable_stream_source signed_stream_name: [message, :junk]
end
end
Loading

0 comments on commit 203775f

Please sign in to comment.