From c0a754519dfac2c1ee934615d38cc7314fcc01b0 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Tue, 28 Jul 2026 08:44:38 +0200 Subject: [PATCH] test: Stabilise TomSelect helper against focus races Fix a flaky failure where Capybara clicks did not reliably focus the TomSelect input in headless CI, so the search AJAX was never triggered and the dropdown options never appeared. Changes: - Scope selectors to the specific TomSelect via `from`. - Add an explicit JavaScript focus call after clicking. - Require `from:` in both helpers for explicit scoping. --- spec/features/admin/meeting_spec.rb | 2 +- spec/support/select_from_tom_select.rb | 43 ++++++++++++++++---------- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/spec/features/admin/meeting_spec.rb b/spec/features/admin/meeting_spec.rb index 99cfb6519..5ed4e1473 100644 --- a/spec/features/admin/meeting_spec.rb +++ b/spec/features/admin/meeting_spec.rb @@ -53,7 +53,7 @@ visit edit_admin_meeting_path(meeting) fill_in 'Name', with: 'March Meeting' - remove_from_tom_select(permissions.members.first.full_name) + remove_from_tom_select(permissions.members.first.full_name, from: 'meeting_organisers') click_on 'Save' diff --git a/spec/support/select_from_tom_select.rb b/spec/support/select_from_tom_select.rb index 3956fe885..bb48a8e39 100644 --- a/spec/support/select_from_tom_select.rb +++ b/spec/support/select_from_tom_select.rb @@ -5,18 +5,24 @@ module SelectFromTomSelect # Select an item from a TomSelect dropdown # @param item_text [String] The text to select - # @param from [String, Symbol] The field ID (for documentation purposes) - def select_from_tom_select(item_text, from: nil) - # Wait for TomSelect to initialize - the real initialization runs via the - # jQuery DOMContentLoaded handler in application.js, which fires after the - # CDN script (loaded in the page head) defines the TomSelect global. - expect(page).to have_css('.ts-wrapper', wait: 15) + # @param from [String, Symbol] The original select element ID + def select_from_tom_select(item_text, from:) + # Wait for the specific TomSelect to initialize - the real initialization + # runs via the jQuery DOMContentLoaded handler in application.js, which + # fires after the CDN script (loaded in the page head) defines the TomSelect + # global. + select = find("##{from}", visible: false) + wrapper = select.find(:xpath, '..') + expect(wrapper).to have_css('.ts-control', wait: 15) # Open dropdown and focus the input. The click on .ts-control opens the - # dropdown; a second click on the input guarantees focus before typing. - find('.ts-control').click - input = find('.ts-control input') + # dropdown; a second click on the input focuses it, and the explicit JS + # focus call guards against headless CI drivers where Capybara's click alone + # does not reliably focus the field before keys are sent. + wrapper.find('.ts-control').click + input = wrapper.find('.ts-control input') input.click + page.execute_script('arguments[0].focus();', input.native) # Type first 3 characters to trigger search (shouldLoad requires >= 3) input.send_keys(item_text[0, 3]) @@ -24,28 +30,31 @@ def select_from_tom_select(item_text, from: nil) # Wait for the initial search results to load after the debounce and AJAX. # Uses Capybara's adaptive wait instead of a blind sleep so slow CI environments # get enough time while fast environments don't waste a fixed wait. - expect(page).to have_css('.ts-dropdown .option', wait: 15) + expect(wrapper).to have_css('.ts-dropdown .option', wait: 15) # Type the rest if item_text is longer than 3 characters input.send_keys(item_text[3..]) if item_text.length > 3 # Wait for updated results after the refined search - expect(page).to have_css('.ts-dropdown .option', text: item_text, wait: 10) + expect(wrapper).to have_css('.ts-dropdown .option', text: item_text, wait: 10) # Click the matching option # Use JavaScript click to avoid element interception issues - option = find('.ts-dropdown .option', text: item_text, match: :prefer_exact) + option = wrapper.find('.ts-dropdown .option', text: item_text, match: :prefer_exact) page.execute_script("arguments[0].click();", option.native) end # Remove an item from a TomSelect multi-select # @param item_text [String] The text of the item to remove (must match exactly) - def remove_from_tom_select(item_text) - # Wait for TomSelect to initialize and items to be present - expect(page).to have_css('.ts-wrapper', wait: 15) - expect(page).to have_css('.ts-wrapper .item', text: item_text, wait: 5) + # @param from [String, Symbol] The original select element ID + def remove_from_tom_select(item_text, from:) + # Wait for the specific TomSelect to initialize and items to be present + select = find("##{from}", visible: false) + wrapper = select.find(:xpath, '..') + expect(wrapper).to have_css('.ts-wrapper', wait: 15) + expect(wrapper).to have_css('.ts-wrapper .item', text: item_text, wait: 5) - within '.ts-wrapper' do + within wrapper do find('.item', text: item_text, match: :prefer_exact).find('.remove').click end end