From 32f5fdec5720a3651ef2a700302b704d39fb905c Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 31 Mar 2026 15:48:41 +0200 Subject: [PATCH 1/3] Apply more flaky fixes --- .../project_description_widget_spec.rb | 1 + spec/features/projects/lists/filters_spec.rb | 8 ++++---- spec/support/shared/cuprite_helpers.rb | 18 ++++++++++++++++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/modules/overviews/spec/features/project_description_widget_spec.rb b/modules/overviews/spec/features/project_description_widget_spec.rb index 6ceba57a247..908bc9b87bd 100644 --- a/modules/overviews/spec/features/project_description_widget_spec.rb +++ b/modules/overviews/spec/features/project_description_widget_spec.rb @@ -79,6 +79,7 @@ RSpec.describe "Project description widget", :js do # Activate the field for editing wait_for_turbo_stream { description_field.open_field } + wait_for_ckeditor # Set a new description new_description = "This is a **test** project description with markdown formatting." diff --git a/spec/features/projects/lists/filters_spec.rb b/spec/features/projects/lists/filters_spec.rb index 3c7904856a1..6d7a928628b 100644 --- a/spec/features/projects/lists/filters_spec.rb +++ b/spec/features/projects/lists/filters_spec.rb @@ -939,7 +939,7 @@ RSpec.describe "Projects list filters", :js, with_settings: { login_required?: f projects_page.expect_projects_not_listed(development_project, project) projects_page.expect_projects_in_order(public_project) - projects_page.remove_filter("project_finish_gate_#{gate.definition_id}") + wait_for_turbo_stream { projects_page.remove_filter("project_finish_gate_#{gate.definition_id}") } projects_page.expect_projects_in_order(development_project, project, public_project) @@ -950,7 +950,7 @@ RSpec.describe "Projects list filters", :js, with_settings: { login_required?: f projects_page.expect_projects_not_listed(development_project, project) projects_page.expect_projects_in_order(public_project) - projects_page.remove_filter("project_finish_gate_#{gate.definition_id}") + wait_for_turbo_stream { projects_page.remove_filter("project_finish_gate_#{gate.definition_id}") } projects_page.expect_projects_in_order(development_project, project, public_project) @@ -962,7 +962,7 @@ RSpec.describe "Projects list filters", :js, with_settings: { login_required?: f projects_page.expect_projects_not_listed(development_project, project) projects_page.expect_projects_in_order(public_project) - projects_page.remove_filter("project_finish_gate_#{gate.definition_id}") + wait_for_turbo_stream { projects_page.remove_filter("project_finish_gate_#{gate.definition_id}") } projects_page.expect_projects_in_order(development_project, project, public_project) @@ -973,7 +973,7 @@ RSpec.describe "Projects list filters", :js, with_settings: { login_required?: f projects_page.expect_projects_not_listed(development_project, project) projects_page.expect_projects_in_order(public_project) - projects_page.remove_filter("project_finish_gate_#{gate.definition_id}") + wait_for_turbo_stream { projects_page.remove_filter("project_finish_gate_#{gate.definition_id}") } projects_page.expect_projects_in_order(development_project, project, public_project) diff --git a/spec/support/shared/cuprite_helpers.rb b/spec/support/shared/cuprite_helpers.rb index 7decad5f7e9..f8af71d743c 100644 --- a/spec/support/shared/cuprite_helpers.rb +++ b/spec/support/shared/cuprite_helpers.rb @@ -191,6 +191,24 @@ def wait_for_turbo_frame(timeout: 10, &block) raise result["error"] if result.is_a?(Hash) && !result["success"] end +# Waits for CKEditor to be fully initialized. +# +# CKEditor is an Angular component (`opce-ckeditor-augmented-textarea`) +# that initializes asynchronously after its container is inserted into the DOM +# (e.g. via a Turbo Stream). The `.ck-content` element only appears once the +# editor instance is fully created, so waiting for it is a reliable readiness signal. +# +# Uses a generous timeout because Angular bootstrap + CKEditor init can be slow on CI. +# +# @example +# wait_for_turbo_stream { description_field.open_field } +# wait_for_ckeditor +# wait_for_turbo_stream { description_field.fill_and_submit_value(...) } +# +def wait_for_ckeditor(timeout: 20) + expect(page).to have_css(".ck-content", wait: timeout) +end + def using_cuprite? Capybara.javascript_driver == :better_cuprite_en end From 8bce0cae00e37e8defcbcc7bbbc26d26f184237d Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Tue, 31 Mar 2026 17:50:36 +0200 Subject: [PATCH 2/3] Put a retry block on the flaky WP Table spec --- .../features/my/work_package_table_spec.rb | 166 +++++++++--------- 1 file changed, 85 insertions(+), 81 deletions(-) diff --git a/modules/my_page/spec/features/my/work_package_table_spec.rb b/modules/my_page/spec/features/my/work_package_table_spec.rb index dc31a726c59..1d12e054b9f 100644 --- a/modules/my_page/spec/features/my/work_package_table_spec.rb +++ b/modules/my_page/spec/features/my/work_package_table_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -77,103 +79,105 @@ RSpec.describe "Arbitrary WorkPackage query table widget on my page", context "with the permission to save queries" do it "can add the widget and see the work packages of the filtered for types" do - # This one always exists by default. - # Using it here as a safeguard to govern speed. - created_by_me_area = Components::Grids::GridArea.new(".grid--area.-widgeted:nth-of-type(2)") - expect(created_by_me_area.area) - .to have_css(".subject", text: type_work_package.subject) + retry_block do + # This one always exists by default. + # Using it here as a safeguard to govern speed. + created_by_me_area = Components::Grids::GridArea.new(".grid--area.-widgeted:nth-of-type(2)") + expect(created_by_me_area.area) + .to have_css(".subject", text: type_work_package.subject) - my_page.add_widget(1, 2, :column, "Work packages table") + my_page.add_widget(1, 2, :column, "Work packages table") - # Actually there are two success messages displayed currently. One for the grid getting updated and one - # for the query assigned to the new widget being created. A user will not notice it but the automated - # browser can get confused. Therefore we wait. - sleep(2) + # Actually there are two success messages displayed currently. One for the grid getting updated and one + # for the query assigned to the new widget being created. A user will not notice it but the automated + # browser can get confused. Therefore we wait. + sleep(2) - my_page.expect_and_dismiss_toaster message: I18n.t("js.notice_successful_update") + my_page.expect_and_dismiss_toaster message: I18n.t("js.notice_successful_update") - filter_area = Components::Grids::GridArea.new(".grid--area.-widgeted:nth-of-type(3)") - filter_area.expect_to_span(1, 3, 2, 4) + filter_area = Components::Grids::GridArea.new(".grid--area.-widgeted:nth-of-type(3)") + filter_area.expect_to_span(1, 3, 2, 4) - # At the beginning, the default query is displayed - expect(filter_area.area) - .to have_css(".subject", text: type_work_package.subject) + # At the beginning, the default query is displayed + expect(filter_area.area) + .to have_css(".subject", text: type_work_package.subject) - expect(filter_area.area) - .to have_css(".subject", text: other_type_work_package.subject) + expect(filter_area.area) + .to have_css(".subject", text: other_type_work_package.subject) - # User has the ability to modify the query + # User has the ability to modify the query - filter_area.configure_wp_table - modal.switch_to("Filters") - filters.expect_filter_count(3) - filters.add_filter_by("Type", "is (OR)", type.name) - modal.save + filter_area.configure_wp_table + modal.switch_to("Filters") + filters.expect_filter_count(3) + filters.add_filter_by("Type", "is (OR)", type.name) + modal.save! - # Wait for the filter save to complete before opening the column configuration, - # otherwise the two saves can race and only one change gets persisted. - wait_for_network_idle + # Wait for the filter save to complete before opening the column configuration, + # otherwise the two saves can race and only one change gets persisted. + wait_for_network_idle - filter_area.configure_wp_table - modal.switch_to("Columns") - columns.assume_opened - columns.remove "Subject" + filter_area.configure_wp_table + modal.switch_to("Columns") + columns.assume_opened + columns.remove "Subject" - expect(filter_area.area) - .to have_css(".id", text: type_work_package.id) + expect(filter_area.area) + .to have_css(".id", text: type_work_package.id) - # as the Subject column is disabled - expect(filter_area.area) - .to have_no_css(".subject", text: type_work_package.subject) + # as the Subject column is disabled + expect(filter_area.area) + .to have_no_css(".subject", text: type_work_package.subject) - # As other_type is filtered out - expect(filter_area.area) - .to have_no_css(".id", text: other_type_work_package.id) + # As other_type is filtered out + expect(filter_area.area) + .to have_no_css(".id", text: other_type_work_package.id) - # Wait for the column save PATCH to complete after the DOM has confirmed the - # Angular state update. Without this ordering, wait_for_network_idle can fire - # during the gap before the async PATCH request is initiated. - wait_for_network_idle + # Wait for the column save PATCH to complete after the DOM has confirmed the + # Angular state update. Without this ordering, wait_for_network_idle can fire + # during the gap before the async PATCH request is initiated. + wait_for_network_idle - scroll_to_element(filter_area.area) - within filter_area.area do - input = find(".editable-toolbar-title--input") - input.set("My WP Filter") - input.native.send_keys(:return) + scroll_to_element(filter_area.area) + within filter_area.area do + input = find(".editable-toolbar-title--input") + input.set("My WP Filter") + input.native.send_keys(:return) + end + + my_page.expect_and_dismiss_toaster message: I18n.t("js.notice_successful_update") + + wait_for_network_idle + + # The whole of the configuration survives a reload + # as it is persisted in the grid + + visit root_path + my_page.visit! + wait_for_network_idle + + filter_area = Components::Grids::GridArea.new(".grid--area.-widgeted:nth-of-type(3)") + + # Wait for the widget to load from its persisted state before asserting. + # The title comes from the grid API; once visible, the widget is initialized. + # A second wait_for_network_idle then catches the subsequent query + results fetches. + within filter_area.area do + expect(page).to have_field("editable-toolbar-title", with: "My WP Filter", wait: 10) + end + + wait_for_network_idle + + expect(filter_area.area) + .to have_css(".id", text: type_work_package.id) + + # as the Subject column is disabled + expect(filter_area.area) + .to have_no_css(".subject", text: type_work_package.subject) + + # As other_type is filtered out + expect(filter_area.area) + .to have_no_css(".id", text: other_type_work_package.id) end - - my_page.expect_and_dismiss_toaster message: I18n.t("js.notice_successful_update") - - wait_for_network_idle - - # The whole of the configuration survives a reload - # as it is persisted in the grid - - visit root_path - my_page.visit! - wait_for_network_idle - - filter_area = Components::Grids::GridArea.new(".grid--area.-widgeted:nth-of-type(3)") - - # Wait for the widget to load from its persisted state before asserting. - # The title comes from the grid API; once visible, the widget is initialized. - # A second wait_for_network_idle then catches the subsequent query + results fetches. - within filter_area.area do - expect(page).to have_field("editable-toolbar-title", with: "My WP Filter", wait: 10) - end - - wait_for_network_idle - - expect(filter_area.area) - .to have_css(".id", text: type_work_package.id) - - # as the Subject column is disabled - expect(filter_area.area) - .to have_no_css(".subject", text: type_work_package.subject) - - # As other_type is filtered out - expect(filter_area.area) - .to have_no_css(".id", text: other_type_work_package.id) end end From dc0ca9f3fbb0839ac198ab3a95a65384de86cce0 Mon Sep 17 00:00:00 2001 From: Klaus Zanders Date: Wed, 1 Apr 2026 09:44:05 +0200 Subject: [PATCH 3/3] Add retry blocks for the relevant test parts of WP Table --- .../features/my/work_package_table_spec.rb | 122 +++++++++--------- 1 file changed, 63 insertions(+), 59 deletions(-) diff --git a/modules/my_page/spec/features/my/work_package_table_spec.rb b/modules/my_page/spec/features/my/work_package_table_spec.rb index 1d12e054b9f..e3973e5c0a9 100644 --- a/modules/my_page/spec/features/my/work_package_table_spec.rb +++ b/modules/my_page/spec/features/my/work_package_table_spec.rb @@ -79,49 +79,49 @@ RSpec.describe "Arbitrary WorkPackage query table widget on my page", context "with the permission to save queries" do it "can add the widget and see the work packages of the filtered for types" do + # This one always exists by default. + # Using it here as a safeguard to govern speed. + created_by_me_area = Components::Grids::GridArea.new(".grid--area.-widgeted:nth-of-type(2)") + expect(created_by_me_area.area) + .to have_css(".subject", text: type_work_package.subject) + + my_page.add_widget(1, 2, :column, "Work packages table") + + # Actually there are two success messages displayed currently. One for the grid getting updated and one + # for the query assigned to the new widget being created. A user will not notice it but the automated + # browser can get confused. Therefore we wait. + sleep(2) + + my_page.expect_and_dismiss_toaster message: I18n.t("js.notice_successful_update") + + filter_area = Components::Grids::GridArea.new(".grid--area.-widgeted:nth-of-type(3)") + filter_area.expect_to_span(1, 3, 2, 4) + + # At the beginning, the default query is displayed + expect(filter_area.area) + .to have_css(".subject", text: type_work_package.subject) + + expect(filter_area.area) + .to have_css(".subject", text: other_type_work_package.subject) + + # User has the ability to modify the query + + filter_area.configure_wp_table + modal.switch_to("Filters") + filters.expect_filter_count(3) + filters.add_filter_by("Type", "is (OR)", type.name) + modal.save + + # Wait for the filter save to complete before opening the column configuration, + # otherwise the two saves can race and only one change gets persisted. + wait_for_network_idle + + filter_area.configure_wp_table + modal.switch_to("Columns") + columns.assume_opened + columns.remove "Subject" + retry_block do - # This one always exists by default. - # Using it here as a safeguard to govern speed. - created_by_me_area = Components::Grids::GridArea.new(".grid--area.-widgeted:nth-of-type(2)") - expect(created_by_me_area.area) - .to have_css(".subject", text: type_work_package.subject) - - my_page.add_widget(1, 2, :column, "Work packages table") - - # Actually there are two success messages displayed currently. One for the grid getting updated and one - # for the query assigned to the new widget being created. A user will not notice it but the automated - # browser can get confused. Therefore we wait. - sleep(2) - - my_page.expect_and_dismiss_toaster message: I18n.t("js.notice_successful_update") - - filter_area = Components::Grids::GridArea.new(".grid--area.-widgeted:nth-of-type(3)") - filter_area.expect_to_span(1, 3, 2, 4) - - # At the beginning, the default query is displayed - expect(filter_area.area) - .to have_css(".subject", text: type_work_package.subject) - - expect(filter_area.area) - .to have_css(".subject", text: other_type_work_package.subject) - - # User has the ability to modify the query - - filter_area.configure_wp_table - modal.switch_to("Filters") - filters.expect_filter_count(3) - filters.add_filter_by("Type", "is (OR)", type.name) - modal.save! - - # Wait for the filter save to complete before opening the column configuration, - # otherwise the two saves can race and only one change gets persisted. - wait_for_network_idle - - filter_area.configure_wp_table - modal.switch_to("Columns") - columns.assume_opened - columns.remove "Subject" - expect(filter_area.area) .to have_css(".id", text: type_work_package.id) @@ -132,32 +132,36 @@ RSpec.describe "Arbitrary WorkPackage query table widget on my page", # As other_type is filtered out expect(filter_area.area) .to have_no_css(".id", text: other_type_work_package.id) + end - # Wait for the column save PATCH to complete after the DOM has confirmed the - # Angular state update. Without this ordering, wait_for_network_idle can fire - # during the gap before the async PATCH request is initiated. - wait_for_network_idle + # Wait for the column save PATCH to complete after the DOM has confirmed the + # Angular state update. Without this ordering, wait_for_network_idle can fire + # during the gap before the async PATCH request is initiated. + wait_for_network_idle - scroll_to_element(filter_area.area) - within filter_area.area do - input = find(".editable-toolbar-title--input") - input.set("My WP Filter") - input.native.send_keys(:return) - end + scroll_to_element(filter_area.area) + within filter_area.area do + input = find(".editable-toolbar-title--input") + input.set("My WP Filter") + input.native.send_keys(:return) + end + retry_block do my_page.expect_and_dismiss_toaster message: I18n.t("js.notice_successful_update") + end - wait_for_network_idle + wait_for_network_idle - # The whole of the configuration survives a reload - # as it is persisted in the grid + # The whole of the configuration survives a reload + # as it is persisted in the grid - visit root_path - my_page.visit! - wait_for_network_idle + visit root_path + my_page.visit! + wait_for_network_idle - filter_area = Components::Grids::GridArea.new(".grid--area.-widgeted:nth-of-type(3)") + filter_area = Components::Grids::GridArea.new(".grid--area.-widgeted:nth-of-type(3)") + retry_block do # Wait for the widget to load from its persisted state before asserting. # The title comes from the grid API; once visible, the widget is initialized. # A second wait_for_network_idle then catches the subsequent query + results fetches.