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

Play with idea of plugins to support actions with common UI libraries #1980

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 8 additions & 0 deletions lib/capybara.rb
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,10 @@ def register_server(name, &block)
servers[name.to_sym] = block
end

def register_plugin(name, plugin)
plugins[name.to_sym] = plugin
end

##
#
# Add a new selector to Capybara. Selectors can be used by various methods in Capybara
Expand Down Expand Up @@ -203,6 +207,10 @@ def servers
@servers ||= {}
end

def plugins
@plugins ||= {}
end

# Wraps the given string, which should contain an HTML document or fragment
# in a {Capybara::Node::Simple} which exposes all {Capybara::Node::Matchers},
# {Capybara::Node::Finders} and {Capybara::Node::DocumentMatchers}. This allows you to query
Expand Down
4 changes: 4 additions & 0 deletions lib/capybara/node/actions.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true

require 'capybara/node/pluginify'

module Capybara
module Node
module Actions
Expand Down Expand Up @@ -299,6 +301,8 @@ def attach_file(locator = nil, paths, make_visible: nil, **options) # rubocop:di
end
end

prepend ::Capybara::Node::Pluginify

private

def find_select_or_datalist_input(from, options)
Expand Down
22 changes: 22 additions & 0 deletions lib/capybara/node/pluginify.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

module Capybara
module Node
module Pluginify
def self.prepended(mod)
mod.public_instance_methods.each do |method_name|
define_method method_name do |*args, **options|
plugin_names = Array(options.delete(:using) { |_using| session_options.default_plugin[method_name] })
plugin_names.reduce(false) do |memo, plugin_name|
plugin = Capybara.plugins[plugin_name]
raise ArgumentError, "Plugin not loaded: #{plugin_name}" unless plugin
raise NoMethodError, "Action not implemented in plugin: #{plugin_name}:#{method_name}" unless plugin.respond_to?(method_name)

memo || plugin.send(method_name, self, *args, **options)
end || super(*args, **options)
end
end
end
end
end
end
52 changes: 52 additions & 0 deletions lib/capybara/plugins/react_select.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# frozen_string_literal: true

module Capybara
module Plugins
class ReactSelect
def select(scope, value, **options)
sel = find_react_select(scope, value, options)
sel.click.assert_matches_selector(:css, '.select__control--is-focused')
scope.find(:react_select_option, value).click
end

def unselect(scope, value, **options)
select = find_react_select(scope, value, options)
raise Capybara::UnselectNotAllowed, 'Cannot unselect option from single select box.' unless select.has_css?('.select__value-container--is-multi')

scope.find(:css, '.select__multi-value', text: value).find(:css, '.select__multi-value__remove').click
end

private

def find_react_select(scope, value, from: nil, **options)
if from
scope.find(:react_select, from, options.merge(visible: false))
else
select = scope.find(:option, value, options).ancestor(:css, 'select', visible: false)
select.find(:xpath, XPath.next_sibling(:span)[XPath.attr(:class).contains_word('react-select')][XPath.attr(:class).contains_word('react-select-container')])
end
end
end
end
end

Capybara.add_selector(:react_select) do
xpath do |locator, **_options|
XPath.css('.select__control')[
XPath.following_sibling(:input)[XPath.attr(:name) == locator].or(
XPath.following_sibling(:div)[XPath.child(:input)[XPath.attr(:name) == locator]]
)
]
end
end

Capybara.add_selector(:react_select_option) do
xpath do |locator|
xpath = XPath.anywhere(:div)[XPath.attr(:class).contains_word('select__menu')]
xpath = xpath.descendant(:div)[XPath.attr(:class).contains_word('select__option')]
xpath = xpath[XPath.string.n.is(locator.to_s)] unless locator.nil?
xpath
end
end

Capybara.register_plugin(:react_select, Capybara::Plugins::ReactSelect.new)
53 changes: 53 additions & 0 deletions lib/capybara/plugins/select2.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# frozen_string_literal: true

module Capybara
module Plugins
class Select2
def select(scope, value, **options)
select2 = find_select2(scope, value, options).click
option = scope.find(:select2_option, value)
option[:"aria-selected"] != 'true' ? option.click : select2.click
end

def unselect(scope, value, **options)
select2 = find_select2(scope, value, options)
raise Capybara::UnselectNotAllowed, 'Cannot unselect option from single select box.' unless select2.has_css?('.select2-selection--multiple')

select2.click
option = scope.find(:select2_option, value)
option[:"aria-selected"] == 'true' ? option.click : select2.click
end

private

def find_select2(scope, value, from: nil, **options)
if from
scope.find(:select2, from, options.merge(visible: false))
else
select = scope.find(:option, value, options).ancestor(:css, 'select', visible: false)
select.find(:xpath, XPath.next_sibling(:span)[XPath.attr(:class).contains_word('select2')][XPath.attr(:class).contains_word('select2-container')])
end
end
end
end
end

Capybara.add_selector(:select2) do
xpath do |locator, **options|
xpath = XPath.descendant(:select)
xpath = locate_field(xpath, locator, options)
xpath = xpath.next_sibling(:span)[XPath.attr(:class).contains_word('select2')][XPath.attr(:class).contains_word('select2-container')]
xpath
end
end

Capybara.add_selector(:select2_option) do
xpath do |locator|
xpath = XPath.anywhere(:ul)[XPath.attr(:class).contains_word('select2-results__options')][XPath.attr(:id)]
xpath = xpath.descendant(:li)[XPath.attr(:role) == 'treeitem']
xpath = xpath[XPath.string.n.is(locator.to_s)] unless locator.nil?
xpath
end
end

Capybara.register_plugin(:select2, Capybara::Plugins::Select2.new)
7 changes: 6 additions & 1 deletion lib/capybara/session/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ class SessionConfig
automatic_reload match exact exact_text raise_server_errors visible_text_only
automatic_label_click enable_aria_label save_path asset_host default_host app_host
server_host server_port server_errors default_set_options disable_animation test_id
predicates_wait default_normalize_ws].freeze
predicates_wait default_normalize_ws default_plugin].freeze

attr_accessor(*OPTIONS)

Expand Down Expand Up @@ -102,6 +102,11 @@ def test_id=(id)
@test_id = id&.to_sym
end

remove_method :default_plugin
def default_plugin
@default_plugin ||= {}
end

def initialize_copy(other)
super
@server_errors = @server_errors.dup
Expand Down
127 changes: 127 additions & 0 deletions lib/capybara/spec/session/plugin_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# frozen_string_literal: true

require 'capybara/plugins/select2'
require 'capybara/plugins/react_select'

Capybara::SpecHelper.spec 'Plugin', requires: [:js], focus_: true do
before do
@session.visit('https://select2.org/appearance')
end

after do
Capybara.default_plugin = nil
end

it 'should raise if wrong plugin specified' do
expect do
@session.select 'Florida', from: 'Click this to focus the single select element', using: :select3
end.to raise_error(ArgumentError, /Plugin not loaded/)
end

it 'should raise if non-implemented action is called' do
expect do
@session.click_on('blah', using: :select2)
end.to raise_error(NoMethodError, /Action not implemented/)
end

it 'should select an option' do
@session.select 'Florida', from: 'Click this to focus the single select element', using: :select2
expect(@session).to have_field(type: 'select', with: 'FL', visible: false)
end

it 'should remain selected if called twice on a single select' do
@session.select 'Florida', from: 'Click this to focus the single select element', using: :select2
@session.select 'Florida', from: 'Click this to focus the single select element', using: :select2
expect(@session).to have_field(type: 'select', with: 'FL', visible: false)
end

it 'should work with multiple select' do
@session.select 'Pennsylvania', from: 'Click this to focus the multiple select element', using: :select2
@session.select 'California', from: 'Click this to focus the multiple select element', using: :select2
expect(@session).to have_select(multiple: true, selected: %w[Pennsylvania California], visible: false)
@session.unselect 'Pennsylvania', from: 'Click this to focus the multiple select element', using: :select2
expect(@session).to have_select(multiple: true, selected: %w[California], visible: false)
@session.unselect 'California', from: 'Click this to focus the multiple select element', using: :select2
expect(@session).to have_select(multiple: true, selected: %w[], visible: false)
end

it 'should not reselect if already selected' do
@session.select 'Pennsylvania', from: 'Click this to focus the multiple select element', using: :select2
@session.select 'Pennsylvania', from: 'Click this to focus the multiple select element', using: :select2
expect(@session).to have_select(multiple: true, selected: %w[Pennsylvania], visible: false)
@session.unselect 'Pennsylvania', from: 'Click this to focus the multiple select element', using: :select2
@session.unselect 'Pennsylvania', from: 'Click this to focus the multiple select element', using: :select2
expect(@session).to have_select(multiple: true, selected: %w[], visible: false)
end

it 'should work with id' do
@session.select 'Florida', from: 'id_label_single', using: :select2
expect(@session).to have_field(type: 'select', with: 'FL', visible: false)
end

it 'works without :from' do
@session.within(:css, 'div.s2-example:nth-of-type(2) p:first-child') do
@session.select 'Florida', using: :select2
expect(@session).to have_field(type: 'select', with: 'FL', visible: false)
end
end

it 'works when called on the select box' do
el = @session.find(:css, 'select#id_label_single', visible: false)
el.select 'Florida', using: :select2
expect(@session).to have_field(type: 'select', with: 'FL', visible: false)
end

it 'can set a default plugin to use' do
Capybara.default_plugin[:select] = :select2
@session.select 'Florida', from: 'Click this to focus the single select element'
expect(@session).to have_field(type: 'select', with: 'FL', visible: false)
end

it 'can override a default plugin' do
@session.visit('/form')
Capybara.default_plugin[:select] = :select2
@session.select 'Miss', from: 'Title', using: nil
expect(@session.find_field('Title').value).to eq('Miss')
end

context 'with react select 2' do
before do
@session.visit('https://react-select.com/home')
end

it 'should select an option' do
@session.select 'Red', from: 'color', using: :react_select
expect(@session).to have_field('color', type: 'hidden', with: 'red')
end

it 'should remain selected if called twice on a single select' do
@session.select 'Blue', from: 'color', using: :react_select
@session.select 'Blue', from: 'color', using: :react_select
expect(@session).to have_field('color', type: 'hidden', with: 'blue')
end

it 'should work with multiple select' do
@session.within @session.first(:css, 'div.basic-multi-select') do
@session.select 'Green', from: 'colors', using: :react_select
@session.select 'Silver', from: 'colors', using: :react_select
expect(@session).to have_field('colors', with: 'green', type: 'hidden')
expect(@session).to have_field('colors', with: 'silver', type: 'hidden')
end
end

it 'should unselect an option' do
@session.within @session.first(:css, 'div.basic-multi-select') do
@session.select 'Green', from: 'colors', using: :react_select
expect(@session).to have_field('colors', with: 'green', type: 'hidden')
@session.unselect 'Green', from: 'colors', using: :react_select
expect(@session).to have_no_field('colors', with: 'green', type: 'hidden')
end
end

it 'should work with name' do
@session.select 'Purple', from: 'color', using: :react_select
expect(@session).to have_css('input[type=hidden][name=color]', visible: false) { |el| el.value == 'purple' }
end
end
end
7 changes: 7 additions & 0 deletions lib/capybara/spec/session/select_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@
expect(extract_results(@session)['locale']).to eq('sv')
end

it 'should select an option when called on the select box' do
el = @session.find(:css, 'select#form_locale')
el.select('Swedish')
@session.click_button('awesome')
expect(extract_results(@session)['locale']).to eq('sv')
end

it 'should escape quotes' do
@session.select("John's made-up language", from: 'Locale')
@session.click_button('awesome')
Expand Down