diff --git a/lib/capybara.rb b/lib/capybara.rb index 1c8dd74e4..148119b4f 100644 --- a/lib/capybara.rb +++ b/lib/capybara.rb @@ -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 @@ -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 diff --git a/lib/capybara/node/actions.rb b/lib/capybara/node/actions.rb index a66eeecc7..44538a668 100644 --- a/lib/capybara/node/actions.rb +++ b/lib/capybara/node/actions.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'capybara/node/pluginify' + module Capybara module Node module Actions @@ -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) diff --git a/lib/capybara/node/pluginify.rb b/lib/capybara/node/pluginify.rb new file mode 100644 index 000000000..0e1b9a23c --- /dev/null +++ b/lib/capybara/node/pluginify.rb @@ -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 diff --git a/lib/capybara/plugins/react_select.rb b/lib/capybara/plugins/react_select.rb new file mode 100644 index 000000000..1a5104c04 --- /dev/null +++ b/lib/capybara/plugins/react_select.rb @@ -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) diff --git a/lib/capybara/plugins/select2.rb b/lib/capybara/plugins/select2.rb new file mode 100644 index 000000000..c49786dd4 --- /dev/null +++ b/lib/capybara/plugins/select2.rb @@ -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) diff --git a/lib/capybara/session/config.rb b/lib/capybara/session/config.rb index 8a751883d..a74084918 100644 --- a/lib/capybara/session/config.rb +++ b/lib/capybara/session/config.rb @@ -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) @@ -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 diff --git a/lib/capybara/spec/session/plugin_spec.rb b/lib/capybara/spec/session/plugin_spec.rb new file mode 100644 index 000000000..27f231e3d --- /dev/null +++ b/lib/capybara/spec/session/plugin_spec.rb @@ -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 diff --git a/lib/capybara/spec/session/select_spec.rb b/lib/capybara/spec/session/select_spec.rb index c9d65bd3d..48498c2c6 100644 --- a/lib/capybara/spec/session/select_spec.rb +++ b/lib/capybara/spec/session/select_spec.rb @@ -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')