Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce Turbo::SystemTestHelper #577

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -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

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 @@ -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
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
Loading