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 Jul 13, 2024
1 parent 41e8561 commit 6cf1d3a
Show file tree
Hide file tree
Showing 7 changed files with 342 additions and 20 deletions.
59 changes: 59 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 @@ -126,5 +127,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
94 changes: 94 additions & 0 deletions test/capybara_selectors_test.rb
Original file line number Diff line number Diff line change
@@ -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
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 %>
34 changes: 34 additions & 0 deletions test/system/assertions_test.rb
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 6cf1d3a

Please sign in to comment.