From a764600c0a336f109e5b63d30354fd786d72a2b5 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Sun, 13 Sep 2026 12:58:53 +0200 Subject: [PATCH] fix: recover TomSelect helper when the search yields no options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The helper typed member.full_name into TomSelect, but full_name includes pronouns — "Jane Doe (she/her)" — while /admin/members/search only matches CONCAT(name, ' ', surname) and email. Queries containing the pronoun suffix matched nothing server-side, and TomSelect caches loads per query (loadedSearches), so an empty or failed fetch poisoned that query string permanently: options never appeared and the 15s wait could not recover. Type a pronoun-free query, wait briefly for the 3-character results, and if none appear retype the full name as a fresh query (new cache key, new fetch). Adds a regression scenario that aborts the first search request via Playwright routing to exercise the retry deterministically. --- .../managing_meeting_invitations_spec.rb | 28 +++++++++++ spec/support/select_from_tom_select.rb | 47 ++++++++++++------- 2 files changed, 59 insertions(+), 16 deletions(-) diff --git a/spec/features/admin/managing_meeting_invitations_spec.rb b/spec/features/admin/managing_meeting_invitations_spec.rb index 2c26f61a0..6f83541b6 100644 --- a/spec/features/admin/managing_meeting_invitations_spec.rb +++ b/spec/features/admin/managing_meeting_invitations_spec.rb @@ -35,6 +35,34 @@ expect(page).to have_text("#{attending_member.full_name} is already on the list!") end + + scenario 'selects a member when the first search request fails', :js do + member = Fabricate(:member) + Fabricate(:attending_meeting_invitation, meeting:) + + visit admin_meeting_path(meeting) + + # TomSelect caches search results per query, so one failed fetch poisons + # that query permanently. Abort the first search request to simulate the + # CI flake, then verify the helper recovers by searching again. + aborted_first_search = false + search_handler = proc do |route, _request| + if aborted_first_search + route.continue + else + aborted_first_search = true + route.abort + end + end + page.driver.with_playwright_page do |pw_page| + pw_page.route('**/admin/members/search*', search_handler) + end + + select_from_tom_select(member.full_name, from: 'meeting_invitations_member') + click_on 'Add' + + expect(page).to have_text("#{member.full_name} has been successfully added and notified via email") + end end scenario 'Updating the attendance of an invitation' do diff --git a/spec/support/select_from_tom_select.rb b/spec/support/select_from_tom_select.rb index 622845267..ee0bfec48 100644 --- a/spec/support/select_from_tom_select.rb +++ b/spec/support/select_from_tom_select.rb @@ -3,10 +3,25 @@ # Helper for interacting with TomSelect dropdowns in Capybara feature tests # Similar to select_from_chosen but for TomSelect remote data loading module SelectFromTomSelect + # Search query for a display name. full_name includes pronouns, e.g. + # "Jane Doe (she/her)", but /admin/members/search only matches + # CONCAT(name, ' ', surname) and email, so the parenthetical must go. + def tom_select_search_query(item_text) + item_text.sub(/\s*\([^)]*\)\z/, '') + end + + def type_into_tom_select(input, text) + page.execute_script( + "arguments[0].value = arguments[1]; arguments[0].dispatchEvent(new Event('input', { bubbles: true }));", + input.native, text + ) + end + # Select an item from a TomSelect dropdown # @param item_text [String] The text to select # @param from [String, Symbol] The original select element ID def select_from_tom_select(item_text, from:) + search_query = tom_select_search_query(item_text) # 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 @@ -28,25 +43,25 @@ def select_from_tom_select(item_text, from:) # Use JS to set the value and dispatch an input event directly, instead of # send_keys (which can race with TomSelect's debounce timer in headless CI # when multiple parallel processes contend for CPU). - page.execute_script( - "arguments[0].value = arguments[1]; arguments[0].dispatchEvent(new Event('input', { bubbles: true }));", - input.native, item_text[0, 3] - ) - - # 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(wrapper).to have_css('.ts-dropdown .option', wait: 15) + type_into_tom_select(input, search_query[0, 3]) - # Type the rest if item_text is longer than 3 characters - if item_text.length > 3 - page.execute_script( - "arguments[0].value = arguments[1]; arguments[0].dispatchEvent(new Event('input', { bubbles: true }));", - input.native, item_text[3..] - ) + # Wait briefly for the initial 3-character search results after the + # debounce and AJAX. TomSelect caches loads per query (loadedSearches), so + # a failed or empty fetch poisons that query forever and options never + # appear no matter how long we wait. Retype the full name as a fresh query + # (new cache key, new fetch) instead of waiting it out. + if wrapper.has_css?('.ts-dropdown .option', wait: 5) + # Refine the search to the rest of the name if the query is longer + # than 3 characters + type_into_tom_select(input, search_query[3..]) if search_query.length > 3 + else + # A 3-char query is identical to the failed query, so the retry must + # differ to get a fresh cache key (ILIKE search is case-insensitive) + retry_query = search_query.length > 3 ? search_query : search_query.upcase + type_into_tom_select(input, retry_query) end - # Wait for updated results after the refined search + # Wait for the matching option after the refined or retried search expect(wrapper).to have_css('.ts-dropdown .option', text: item_text, wait: 10) # Click the matching option