From 712fdbceba244eccaef1dff295d79ee67c93caf1 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Wed, 15 Apr 2026 20:12:19 +0100 Subject: [PATCH] Harden feature test support helpers Stabilize shared ng-select interactions for Cuprite and reuse open dropdowns safely in modal-based flows. Also keep filter clearing safe when no value is selected and refresh the My Page drop target after drag-induced DOM updates. --- .../ng_select_autocomplete_helpers.rb | 53 ++++++++++++++----- spec/support/components/grids/grid_area.rb | 11 ++-- .../components/work_packages/filters.rb | 2 +- spec/support/edit_fields/edit_field.rb | 12 ++++- 4 files changed, 57 insertions(+), 21 deletions(-) diff --git a/spec/support/components/autocompleter/ng_select_autocomplete_helpers.rb b/spec/support/components/autocompleter/ng_select_autocomplete_helpers.rb index fa9087d6297..6969bb5c57a 100644 --- a/spec/support/components/autocompleter/ng_select_autocomplete_helpers.rb +++ b/spec/support/components/autocompleter/ng_select_autocomplete_helpers.rb @@ -4,10 +4,10 @@ module Components::Autocompleter module NgSelectAutocompleteHelpers def search_autocomplete(element, query:, results_selector: "body", wait_dropdown_open: true, wait_for_fetched_options: true) SeleniumHubWaiter.wait unless using_cuprite? - ng_click_autocompleter(element) # Wait for dropdown to open - ng_find_dropdown(element, results_selector:) if wait_dropdown_open + dropdown_open = ng_dropdown_open?(element, results_selector:) if wait_dropdown_open + ng_click_autocompleter(element) unless dropdown_open # Wait for autocompleter options to be loaded (data fetching is debounced by 250ms after creation or typing) wait_for_network_idle if using_cuprite? && wait_for_fetched_options @@ -33,27 +33,49 @@ module Components::Autocompleter end def ng_click_autocompleter(target) - target.click + input = ng_select_input(target) + + scroll_to_element(input, block: :nearest) + input.click end - def ng_find_dropdown(element, results_selector: "body") + def ng_find_dropdown(element, results_selector: "body", raise_on_missing: true) retry_block do if results_selector results_selector = "#{results_selector} .ng-dropdown-panel" if results_selector == "body" within_window(current_window) do - page.find(results_selector, wait: 5) + page.find(results_selector, wait: raise_on_missing ? 5 : 0) end else within(element) do - page.find("ng-select .ng-dropdown-panel", wait: 5) + page.find("ng-select .ng-dropdown-panel", wait: raise_on_missing ? 5 : 0) end end - rescue StandardError => e - ng_select_input(element)&.click + rescue Capybara::ElementNotFound => e + return nil unless raise_on_missing + + ng_click_autocompleter(element) raise e end end + def ng_dropdown_open?(element, results_selector: "body") + if results_selector + within_window(current_window) do + page.has_css?(ng_panel_selector(results_selector), wait: 0) + end + else + element.has_css?("ng-select .ng-dropdown-panel", wait: 0) + end + end + + def ng_panel_selector(results_selector) + return "body .ng-dropdown-panel" if results_selector == "body" + return results_selector if results_selector.include?(".ng-dropdown-panel") + + "#{results_selector} .ng-dropdown-panel" + end + def expect_ng_option(element, option, grouping: nil, results_selector: "body", present: true) within(ng_find_dropdown(element, results_selector:)) do if grouping && present @@ -133,7 +155,10 @@ module Components::Autocompleter # clear the ng select field def ng_select_clear(from_element, raise_on_missing: true) if raise_on_missing || from_element.has_css?(".ng-clear-wrapper", visible: :all, wait: 1) - from_element.find(".ng-clear-wrapper", visible: :all).click + clear_button = from_element.find(".ng-clear-wrapper", visible: :all) + + scroll_to_element(clear_button, block: :nearest) + clear_button.click end end @@ -143,11 +168,11 @@ module Components::Autocompleter results_selector: "body", wait_dropdown_open: true, wait_for_fetched_options: true) - target_dropdown = search_autocomplete(element, - query:, - results_selector:, - wait_dropdown_open:, - wait_for_fetched_options:) + search_autocomplete(element, + query:, + results_selector:, + wait_dropdown_open:, + wait_for_fetched_options:) ## # If a specific select_text is given, use that to locate the match, diff --git a/spec/support/components/grids/grid_area.rb b/spec/support/components/grids/grid_area.rb index 53c3af700bf..0c31fb8771d 100644 --- a/spec/support/components/grids/grid_area.rb +++ b/spec/support/components/grids/grid_area.rb @@ -79,7 +79,7 @@ module Components def drag_to(row, column) handle = drag_handle - drop_area = self.class.of(row * 2, column * 2).area + target = self.class.of(row * 2, column * 2) scroll_to_element(handle) @@ -87,13 +87,14 @@ module Components action.click_and_hold(handle.native) end - scroll_to_element(drop_area) - drop_area.hover + scroll_to_element(target.area) + target.area.hover sleep(1) - # Re-find drop_area to get a fresh native reference after CDK drag has modified the DOM - move_to(self.class.of(row * 2, column * 2).area, &:release) + # `target.area` calls page.find on each access, so this re-queries the DOM + # to get a fresh native reference after CDK drag has updated it. + move_to(target.area, &:release) end def expect_to_exist diff --git a/spec/support/components/work_packages/filters.rb b/spec/support/components/work_packages/filters.rb index 9a822c87078..bcf7f3fc86c 100644 --- a/spec/support/components/work_packages/filters.rb +++ b/spec/support/components/work_packages/filters.rb @@ -224,7 +224,7 @@ module Components end def clear_filter_value(field) - ng_select_clear(page.find("#filter_#{field} ng-select")) + ng_select_clear(page.find("#filter_#{field} ng-select"), raise_on_missing: false) end def open_autocompleter(id) diff --git a/spec/support/edit_fields/edit_field.rb b/spec/support/edit_fields/edit_field.rb index ba4bada1007..0fd11b4d7a5 100644 --- a/spec/support/edit_fields/edit_field.rb +++ b/spec/support/edit_fields/edit_field.rb @@ -227,7 +227,17 @@ class EditField if autocompleter_field? if multi - page.find(".ng-value-label", visible: :all, text: content).sibling(".ng-value-icon").click + remove_icon = field_container + .find(".ng-value-label", visible: :all, text: content) + .sibling(".ng-value-icon") + + begin + scroll_to_element(remove_icon, block: :nearest) + remove_icon.click + rescue Capybara::Cuprite::MouseEventFailed + # Cuprite-only: bypass Chrome's overlap check when the chip icon is obscured. + remove_icon.trigger("click") + end else ng_select_clear(field_container) end