From dccab49baa8fe34a4be28cfb126b09b11a34b96a Mon Sep 17 00:00:00 2001 From: John Chilton Date: Thu, 12 Mar 2026 08:16:41 -0400 Subject: [PATCH 01/11] Add cat1 (catWrapper) to test tool conf for workflow editor tests Symlink tools/filters/catWrapper.xml into test/functional/tools/for_workflows/ and register it in sample_tool_conf.xml. Co-Authored-By: Claude Opus 4.6 --- test/functional/tools/for_workflows/catWrapper.xml | 1 + test/functional/tools/sample_tool_conf.xml | 1 + 2 files changed, 2 insertions(+) create mode 120000 test/functional/tools/for_workflows/catWrapper.xml diff --git a/test/functional/tools/for_workflows/catWrapper.xml b/test/functional/tools/for_workflows/catWrapper.xml new file mode 120000 index 00000000000..395663ac5e2 --- /dev/null +++ b/test/functional/tools/for_workflows/catWrapper.xml @@ -0,0 +1 @@ +../../../../tools/filters/catWrapper.xml \ No newline at end of file diff --git a/test/functional/tools/sample_tool_conf.xml b/test/functional/tools/sample_tool_conf.xml index 78bcb42ae6f..5033f0778b6 100644 --- a/test/functional/tools/sample_tool_conf.xml +++ b/test/functional/tools/sample_tool_conf.xml @@ -288,6 +288,7 @@ + From 3424766ff6d8e1df5a02079039447b547ef7105a Mon Sep 17 00:00:00 2001 From: John Chilton Date: Wed, 11 Mar 2026 20:38:59 -0400 Subject: [PATCH 02/11] Progress on porting workflow editor to playwright. Co-Authored-By: Claude Opus 4.6 --- .../components/Workflow/Editor/NodeOutput.vue | 2 +- lib/galaxy/selenium/has_driver.py | 4 + lib/galaxy/selenium/has_driver_protocol.py | 5 + lib/galaxy/selenium/has_driver_proxy.py | 4 + lib/galaxy/selenium/has_playwright_driver.py | 40 ++++--- lib/galaxy/selenium/navigates_galaxy.py | 36 ++++-- lib/galaxy/selenium/playwright_element.py | 75 +++++++++--- .../selenium/test_workflow_editor.py | 108 ++++++++++-------- 8 files changed, 183 insertions(+), 91 deletions(-) diff --git a/client/src/components/Workflow/Editor/NodeOutput.vue b/client/src/components/Workflow/Editor/NodeOutput.vue index 88a824ce09d..f52e617af72 100644 --- a/client/src/components/Workflow/Editor/NodeOutput.vue +++ b/client/src/components/Workflow/Editor/NodeOutput.vue @@ -406,7 +406,7 @@ const removeTagsAction = computed(() => { None: + """Reload the current page.""" + self.driver.refresh() + def re_get_with_query_params(self, params_str: str): driver = self.driver new_url = driver.current_url diff --git a/lib/galaxy/selenium/has_driver_protocol.py b/lib/galaxy/selenium/has_driver_protocol.py index 9286cb7cba7..0c168ff7451 100644 --- a/lib/galaxy/selenium/has_driver_protocol.py +++ b/lib/galaxy/selenium/has_driver_protocol.py @@ -117,6 +117,11 @@ class HasDriverProtocol(Protocol, Generic[WaitTypeT]): """Navigate to the specified URL.""" ... + @abstractmethod + def refresh(self) -> None: + """Reload the current page.""" + ... + @abstractmethod def re_get_with_query_params(self, params_str: str): """Navigate to current URL with additional query parameters.""" diff --git a/lib/galaxy/selenium/has_driver_proxy.py b/lib/galaxy/selenium/has_driver_proxy.py index fb6f9ab9670..37fc278d6f2 100644 --- a/lib/galaxy/selenium/has_driver_proxy.py +++ b/lib/galaxy/selenium/has_driver_proxy.py @@ -108,6 +108,10 @@ class HasDriverProxy(ABC, Generic[WaitTypeT]): """Navigate to the specified URL.""" self._driver_impl.navigate_to(url) + def refresh(self) -> None: + """Reload the current page.""" + self._driver_impl.refresh() + def re_get_with_query_params(self, params_str: str): """Navigate to current URL with additional query parameters.""" return self._driver_impl.re_get_with_query_params(params_str) diff --git a/lib/galaxy/selenium/has_playwright_driver.py b/lib/galaxy/selenium/has_playwright_driver.py index 45f94effa43..d2cf3aa6aea 100644 --- a/lib/galaxy/selenium/has_playwright_driver.py +++ b/lib/galaxy/selenium/has_playwright_driver.py @@ -315,6 +315,10 @@ class HasPlaywrightDriver(TimeoutMessageMixin, WaitMethodsMixin, Generic[WaitTyp """ self.page.goto(url) + def refresh(self) -> None: + """Reload the current page.""" + self.page.reload() + def re_get_with_query_params(self, params_str: str): """Add query parameters to current URL and reload.""" current_url = self.page.url @@ -773,7 +777,10 @@ class HasPlaywrightDriver(TimeoutMessageMixin, WaitMethodsMixin, Generic[WaitTyp def _hover(self, element: ElementHandle) -> None: """Internal implementation of hover.""" - element.hover() + # force=True bypasses actionability checks that fail when overlapping + # UI elements (e.g. delete-terminal-button) intercept pointer events. + # Hover is non-destructive so this is safe. + element.hover(force=True) def move_to_and_click(self, element: WebElementProtocol) -> None: """ @@ -788,8 +795,8 @@ class HasPlaywrightDriver(TimeoutMessageMixin, WaitMethodsMixin, Generic[WaitTyp def _move_to_and_click(self, element: ElementHandle) -> None: """Internal implementation of move_to_and_click.""" - element.hover() - element.click() + element.hover(force=True) + element.click(force=True) def drag_and_drop(self, source: WebElementProtocol, target: WebElementProtocol) -> None: """ @@ -807,23 +814,18 @@ class HasPlaywrightDriver(TimeoutMessageMixin, WaitMethodsMixin, Generic[WaitTyp """ Internal implementation of drag and drop. - Uses JavaScript to simulate drag and drop events. + Creates a real DataTransfer via evaluate_handle so setData/getData + work across the full drag event sequence (unlike synthetic DragEvents + where Chrome restricts getData to return empty). """ - self.page.evaluate( - """ - (elements) => { - const [source, target] = elements; - const dataTransfer = new DataTransfer(); - const dragstart = new DragEvent('dragstart', { dataTransfer, bubbles: true }); - const dragover = new DragEvent('dragover', { dataTransfer, bubbles: true }); - const drop = new DragEvent('drop', { dataTransfer, bubbles: true }); - source.dispatchEvent(dragstart); - target.dispatchEvent(dragover); - target.dispatchEvent(drop); - } - """, - [source, target], - ) + dt = self.page.evaluate_handle("() => new DataTransfer()") + source.dispatch_event("pointerdown") + source.dispatch_event("dragstart", {"dataTransfer": dt}) + target.dispatch_event("dragenter", {"dataTransfer": dt}) + target.dispatch_event("dragover", {"dataTransfer": dt}) + target.dispatch_event("drop", {"dataTransfer": dt}) + source.dispatch_event("dragend", {"dataTransfer": dt}) + source.dispatch_event("pointerup") def action_chains(self): """ diff --git a/lib/galaxy/selenium/navigates_galaxy.py b/lib/galaxy/selenium/navigates_galaxy.py index 627bd6f6abe..25be4601ee7 100644 --- a/lib/galaxy/selenium/navigates_galaxy.py +++ b/lib/galaxy/selenium/navigates_galaxy.py @@ -34,6 +34,8 @@ from selenium.webdriver.common.keys import Keys if TYPE_CHECKING: from selenium.webdriver.remote.webdriver import WebDriver + from .has_playwright_driver import HasPlaywrightDriver + from galaxy.navigation.components import ( Component, HasText, @@ -281,7 +283,7 @@ class NavigatesGalaxy(HasDriverProxy[WaitType]): NotImplementedError: If using Selenium backend """ if self._driver_impl.backend_type == "playwright": - return self._driver_impl.page # type: ignore[attr-defined] + return cast("HasPlaywrightDriver", self._driver_impl).page else: raise NotImplementedError("Functionality cannot be run with Selenium yet.") @@ -1415,13 +1417,33 @@ class NavigatesGalaxy(HasDriverProxy[WaitType]): source_id, sink_id = self.workflow_editor_source_sink_terminal_ids(source, sink) source_element = self.find_element_by_selector(f"#{source_id}") sink_element = self.find_element_by_selector(f"#{sink_id}") - ac = self.action_chains() - ac = ac.move_to_element(source_element).click_and_hold() + if screenshot_partial: - ac = ac.move_by_offset(10, 10) - ac.perform() - self.sleep_for(self.wait_types.UX_RENDER) - self.screenshot(screenshot_partial) + if self._driver_impl.backend_type == "playwright": + pw_driver = cast("HasPlaywrightDriver", self._driver_impl) + page = pw_driver.page + source_handle = pw_driver._unwrap_element(source_element) + source_box = source_handle.bounding_box() + assert source_box is not None + page.mouse.move( + source_box["x"] + source_box["width"] / 2, + source_box["y"] + source_box["height"] / 2, + ) + page.mouse.down() + page.mouse.move( + source_box["x"] + source_box["width"] / 2 + 10, + source_box["y"] + source_box["height"] / 2 + 10, + ) + self.sleep_for(self.wait_types.UX_RENDER) + self.screenshot(screenshot_partial) + page.mouse.up() + else: + ac = self.action_chains() + ac = ac.move_to_element(source_element).click_and_hold() + ac = ac.move_by_offset(10, 10) + ac.perform() + self.sleep_for(self.wait_types.UX_RENDER) + self.screenshot(screenshot_partial) self.drag_and_drop(source_element, sink_element) def workflow_editor_source_sink_terminal_ids(self, source, sink): diff --git a/lib/galaxy/selenium/playwright_element.py b/lib/galaxy/selenium/playwright_element.py index b7224936ad2..b44d67f6332 100644 --- a/lib/galaxy/selenium/playwright_element.py +++ b/lib/galaxy/selenium/playwright_element.py @@ -14,6 +14,33 @@ from playwright.sync_api import ( ElementHandle, JSHandle, ) +from selenium.webdriver.common.keys import Keys + +# Map Selenium Key unicode constants to Playwright key names +_SELENIUM_KEY_TO_PLAYWRIGHT = { + Keys.CONTROL: "Control", + Keys.COMMAND: "Meta", + Keys.META: "Meta", + Keys.SHIFT: "Shift", + Keys.ALT: "Alt", + Keys.ENTER: "Enter", + Keys.RETURN: "Enter", + Keys.ESCAPE: "Escape", + Keys.BACKSPACE: "Backspace", + Keys.DELETE: "Delete", + Keys.TAB: "Tab", + Keys.SPACE: " ", + Keys.ARROW_DOWN: "ArrowDown", + Keys.ARROW_UP: "ArrowUp", + Keys.ARROW_LEFT: "ArrowLeft", + Keys.ARROW_RIGHT: "ArrowRight", + Keys.HOME: "Home", + Keys.END: "End", + Keys.PAGE_UP: "PageUp", + Keys.PAGE_DOWN: "PageDown", +} + +_SELENIUM_MODIFIERS = {Keys.CONTROL, Keys.COMMAND, Keys.META, Keys.SHIFT, Keys.ALT} if TYPE_CHECKING: from .has_playwright_driver import HasPlaywrightDriver @@ -69,23 +96,43 @@ class PlaywrightElement: """ Send keys to the element (type text). - Uses focus() + cursor-to-end to match Selenium's send_keys behavior - of appending text. Playwright's click() positions cursor at click - point (center of element), which would insert text mid-content. + Translates Selenium Keys constants to Playwright keyboard actions. + Modifier keys (Control, Command, etc.) combine with the next key + as a keyboard shortcut (e.g. Keys.CONTROL, "a" -> "Control+a"). """ - text = "".join(str(v) for v in value) self._element.focus() - # setSelectionRange is not supported on email, number, date, etc. inputs - # per the HTML spec. For those types, use the End key to move cursor to end. - input_type = self._element.evaluate("el => (el.type || '').toLowerCase()") - no_selection_range_types = {"email", "number", "date", "month", "week", "time", "datetime-local"} - if input_type in no_selection_range_types: - self._element.press("End") + # Flatten all args into a single character stream + all_chars = "".join(str(v) for v in value) + has_special = any(c in _SELENIUM_KEY_TO_PLAYWRIGHT for c in all_chars) + if not has_special: + # setSelectionRange is not supported on email, number, date, etc. inputs + # per the HTML spec. For those types, use the End key to move cursor to end. + input_type = self._element.evaluate("el => (el.type || '').toLowerCase()") + no_selection_range_types = {"email", "number", "date", "month", "week", "time", "datetime-local"} + if input_type in no_selection_range_types: + self._element.press("End") + else: + self._element.evaluate( + "el => { if (el.setSelectionRange) el.setSelectionRange(el.value.length, el.value.length) }" + ) + self._element.type(all_chars) else: - self._element.evaluate( - "el => { if (el.setSelectionRange) el.setSelectionRange(el.value.length, el.value.length) }" - ) - self._element.type(text) + modifiers: list[str] = [] + for char in all_chars: + pw_key = _SELENIUM_KEY_TO_PLAYWRIGHT.get(char) + if pw_key and char in _SELENIUM_MODIFIERS: + modifiers.append(pw_key) + elif pw_key: + combo = "+".join(modifiers + [pw_key]) + self._element.press(combo) + modifiers.clear() + else: + if modifiers: + combo = "+".join(modifiers + [char]) + self._element.press(combo) + modifiers.clear() + else: + self._element.type(char) def clear(self) -> None: """ diff --git a/lib/galaxy_test/selenium/test_workflow_editor.py b/lib/galaxy_test/selenium/test_workflow_editor.py index c93a7f834c7..407d8a9f3aa 100644 --- a/lib/galaxy_test/selenium/test_workflow_editor.py +++ b/lib/galaxy_test/selenium/test_workflow_editor.py @@ -2,8 +2,12 @@ import json from typing import ( cast, Optional, + TYPE_CHECKING, ) +if TYPE_CHECKING: + from galaxy.selenium.has_playwright_driver import HasPlaywrightDriver + import yaml from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.by import By @@ -58,7 +62,6 @@ CHIPSEQ_COLUMNS = [ class TestWorkflowEditor(SeleniumTestCase, RunsWorkflows, UsesWorkflowAssertions): ensure_registered = True - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_basics(self): editor = self.components.workflow_editor @@ -81,7 +84,6 @@ class TestWorkflowEditor(SeleniumTestCase, RunsWorkflows, UsesWorkflowAssertions self.screenshot("workflow_editor_center_pane_maximized") - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_edit_annotation(self): editor = self.components.workflow_editor @@ -98,7 +100,6 @@ class TestWorkflowEditor(SeleniumTestCase, RunsWorkflows, UsesWorkflowAssertions self.workflow_index_open_with_name(name) self.assert_wf_annotation_is(new_annotation) - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_edit_name(self): name = self.create_and_wait_for_new_workflow_in_editor() @@ -110,7 +111,6 @@ class TestWorkflowEditor(SeleniumTestCase, RunsWorkflows, UsesWorkflowAssertions self.workflow_index_open_with_name(new_name) self.assert_wf_name_is(name) - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_edit_license(self): editor = self.components.workflow_editor @@ -124,7 +124,6 @@ class TestWorkflowEditor(SeleniumTestCase, RunsWorkflows, UsesWorkflowAssertions self.workflow_index_open_with_name(name) assert "MIT" in self.workflow_editor_license_text() - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_parameter_regex_validation(self): editor = self.components.workflow_editor @@ -154,7 +153,6 @@ class TestWorkflowEditor(SeleniumTestCase, RunsWorkflows, UsesWorkflowAssertions element = workflow_run.run_error.wait_for_present() assert "input must start with moocow" in element.text - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_int_parameter_minimum_validation(self): editor = self.components.workflow_editor @@ -181,7 +179,6 @@ class TestWorkflowEditor(SeleniumTestCase, RunsWorkflows, UsesWorkflowAssertions # in parameter validators assert "Value ('3') must fulfill (4 <= value <= +infinity)" in element.text, element.text - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_float_parameter_maximum_validation(self): editor = self.components.workflow_editor @@ -206,7 +203,6 @@ class TestWorkflowEditor(SeleniumTestCase, RunsWorkflows, UsesWorkflowAssertions # friendly. assert "Value ('3.2') must fulfill (-infinity <= value <= 3.14)" in element.text, element.text - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_optional_select_data_field(self): editor = self.components.workflow_editor @@ -235,7 +231,6 @@ class TestWorkflowEditor(SeleniumTestCase, RunsWorkflows, UsesWorkflowAssertions tool_state = json.loads(workflow["steps"]["0"]["tool_state"]) assert tool_state["select_single"] == "" - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_data_input(self): editor = self.components.workflow_editor @@ -260,7 +255,6 @@ class TestWorkflowEditor(SeleniumTestCase, RunsWorkflows, UsesWorkflowAssertions data_input_node.wait_for_absent() self.screenshot("workflow_editor_data_input_deleted") - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_collection_input(self): editor = self.components.workflow_editor @@ -326,7 +320,6 @@ class TestWorkflowEditor(SeleniumTestCase, RunsWorkflows, UsesWorkflowAssertions assert control["type"] == "element_identifier" assert control["optional"] is True - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_data_column_input_editing(self): self.open_in_workflow_editor(""" @@ -351,12 +344,11 @@ steps: self.set_text_element(columns, "4\n5\n6") self.sleep_for(self.wait_types.UX_RENDER) self.assert_workflow_has_changes_and_save() - self.driver.refresh() + self.refresh() node.title.wait_for_and_click() textarea_columns = columns.wait_for_visible() assert textarea_columns.get_attribute("value") == "4\n5\n6" - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_integer_input(self): editor = self.components.workflow_editor @@ -382,7 +374,6 @@ steps: self.sleep_for(self.wait_types.UX_RENDER) self.screenshot("workflow_editor_parameter_input_deleted") - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_non_data_connections(self): self.open_in_workflow_editor(""" @@ -436,7 +427,6 @@ steps: ) self.assert_connected("input_int#output", "tool_exec#inttest") - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_non_data_map_over_carried_through(self): # Use auto_layout=false, which prevents placing any @@ -466,7 +456,6 @@ steps: self.workflow_editor_connect("text_input_step#out_file1", "collection_input#input1") self.assert_connected("text_input_step#out_file1", "collection_input#input1") - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_connecting_display_in_upload_false_connections(self): self.open_in_workflow_editor(""" @@ -481,7 +470,6 @@ steps: self.workflow_editor_connect("step1#qname_input_sorted_bam_output", "step2#input5") self.assert_connected("step1#qname_input_sorted_bam_output", "step2#input5") - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_existing_connections(self): self.open_in_workflow_editor(WORKFLOW_SIMPLE_CAT_TWICE) @@ -504,7 +492,6 @@ steps: ) self.assert_connected("input1#output", "first_cat#input1") - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_reconnecting_nodes(self): name = self.open_in_workflow_editor(WORKFLOW_SIMPLE_CAT_TWICE) @@ -518,14 +505,12 @@ steps: self.workflow_index_open_with_name(name) self.assert_connected("input1#output", "first_cat#input1") - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_rendering_output_collection_connections(self): self.open_in_workflow_editor(WORKFLOW_WITH_OUTPUT_COLLECTION) self.workflow_editor_maximize_center_pane() self.screenshot("workflow_editor_output_collections") - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_simple_mapping_connections(self): self.open_in_workflow_editor(WORKFLOW_SIMPLE_MAPPING) @@ -539,14 +524,12 @@ steps: self.workflow_editor_connect("input1#output", "cat#input1") self.assert_input_mapped("cat#input1") - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_rendering_simple_nested_workflow(self): self.open_in_workflow_editor(WORKFLOW_NESTED_SIMPLE) self.workflow_editor_maximize_center_pane() self.screenshot("workflow_editor_simple_nested") - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_best_practices_input_label(self): editor = self.components.workflow_editor @@ -596,7 +579,6 @@ steps: self.assert_connected(rule_output, random_lines_input) self.assert_input_mapped(random_lines_input) - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_rendering_rules_workflow_2(self): self.open_in_workflow_editor(WORKFLOW_WITH_RULES_2) @@ -645,7 +627,6 @@ steps: # to a list:list, so there should be no mapping anymore even after connected. self.assert_input_not_mapped(copy_list_input) - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_save_as(self): name = self.workflow_upload_yaml_with_random_name(WORKFLOW_SIMPLE_CAT_TWICE) @@ -656,7 +637,6 @@ steps: self.components.workflow_editor.save_as_activity.wait_for_and_click() - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_editor_tool_upgrade(self): workflow_populator = self.workflow_populator @@ -690,7 +670,6 @@ steps: workflow = self.workflow_populator.download_workflow(workflow_id) assert workflow["steps"]["0"]["tool_version"] == "0.1+galaxy6" - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_editor_tool_upgrade_all_tools(self): editor = self.components.workflow_editor @@ -707,7 +686,6 @@ steps: version = node.get_attribute("data-version") assert version == "0.2" - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_editor_tool_upgrade_message(self): workflow_populator = self.workflow_populator @@ -719,7 +697,6 @@ steps: self.components.workflow_editor.modal_button_continue.wait_for_and_click() self.assert_workflow_has_changes_and_save() - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_editor_subworkflow_tool_upgrade_message(self): workflow_populator = self.workflow_populator @@ -755,7 +732,6 @@ steps: element.wait_for_and_send_keys(Keys.BACKSPACE) element.wait_for_and_send_keys(value) - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_change_datatype(self): self.open_in_workflow_editor(""" @@ -791,7 +767,6 @@ steps: # Assert connection is valid self.assert_connected("create_2#out_file1", "checksum#input") - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_change_datatype_post_job_action_lost_regression(self): self.open_in_workflow_editor(""" @@ -814,7 +789,6 @@ steps: node.wait_for_and_click() self.assert_connected("create_2#out_file1", "metadata_bam#input_bam") - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_change_datatype_in_subworkflow(self): self.open_in_workflow_editor(""" @@ -870,7 +844,6 @@ steps: node = editor.node._(label="create_2") node.wait_for_and_click() - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_editor_duplicate_node(self): workflow_id = self.workflow_populator.upload_yaml_workflow(WORKFLOW_SIMPLE_CAT_TWICE) @@ -913,7 +886,6 @@ steps: assert len(source_step["post_job_actions"]) == len(cloned_step["post_job_actions"]) == 4 assert source_step["post_job_actions"] == cloned_step["post_job_actions"] - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_editor_embed_workflow(self): self.setup_subworkflow() @@ -954,7 +926,6 @@ steps: assert subworkflow_step["input_connections"]["input1"]["input_subworkflow_step_id"] == 0 return child_workflow_name - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_editor_insert_steps(self): steps_to_insert = self.workflow_upload_yaml_with_random_name(WORKFLOW_SIMPLE_CAT_TWICE) @@ -971,7 +942,7 @@ steps: def _download_current_workflow(self): self.sleep_for(self.wait_types.DATABASE_OPERATION) - workflow_id = self.driver.current_url.split("id=")[1] + workflow_id = self.current_url.split("id=")[1] workflow = self.workflow_populator.download_workflow(workflow_id) return workflow @@ -1005,13 +976,13 @@ steps: # Assert no when input before making step conditional conditional_node.input_terminal(name="when").wait_for_absent() conditional_toggle = editor.step_when.wait_for_present() - self.action_chains().move_to_element(conditional_toggle).click().perform() + self.move_to_and_click(conditional_toggle) # Toggling conditional should cause when input to appear conditional_node.input_terminal(name="when").wait_for_present() - self.action_chains().move_to_element(conditional_toggle).click().perform() + self.move_to_and_click(conditional_toggle) # Toggling conditional should cause when input to disappear conditional_node.input_terminal(name="when").wait_for_absent() - self.action_chains().move_to_element(conditional_toggle).click().perform() + self.move_to_and_click(conditional_toggle) conditional_node.input_terminal(name="when").wait_for_present() # Output connection should be invalid, as output from conditional step is potentially null self.assert_connection_invalid("conditional_step#out_file1", "downstream_step#input1") @@ -1057,11 +1028,18 @@ steps: self.assert_workflow_has_changes_and_save() def switch_param_type(self, element, param_type): - self.action_chains().move_to_element(element).click().pause(1).send_keys(param_type).pause(1).send_keys( - Keys.ENTER - ).perform() + if self.backend_type == "playwright": + pw_driver = cast("HasPlaywrightDriver", self._driver_impl) + element.click() + self.sleep_for(self.wait_types.UX_RENDER) + pw_driver.page.keyboard.type(param_type) + self.sleep_for(self.wait_types.UX_RENDER) + pw_driver.page.keyboard.press("Enter") + else: + self.action_chains().move_to_element(element).click().pause(1).send_keys(param_type).pause(1).send_keys( + Keys.ENTER + ).perform() - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_editor_invalid_tool_state(self): workflow_populator = self.workflow_populator @@ -1072,7 +1050,6 @@ steps: self.assert_modal_has_text("Using default: '1'") self.screenshot("workflow_editor_invalid_state") - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_missing_tools(self): workflow_populator = self.workflow_populator @@ -1146,7 +1123,6 @@ steps: output_connector.send_keys(Keys.SPACE) assert self.driver.switch_to.active_element.text == "No compatible input found in workflow" - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_insert_input_handling(self): self.open_in_workflow_editor("""class: GalaxyWorkflow @@ -1165,7 +1141,6 @@ steps: node.input_terminal(name="datasets_1|input").wait_for_present() self.assert_workflow_has_changes_and_save() - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_workflow_output_handling(self): self.open_in_workflow_editor( @@ -1581,7 +1556,14 @@ steps: def assert_connected(self, source, sink): source_id, sink_id = self.workflow_editor_source_sink_terminal_ids(source, sink) - self.components.workflow_editor.connector_for(source_id=source_id, sink_id=sink_id).wait_for_visible() + # SVG elements are considered "hidden" by Playwright even when + # rendered, so use wait_for_present instead of wait_for_visible + # with the Playwright backend. + connector = self.components.workflow_editor.connector_for(source_id=source_id, sink_id=sink_id) + if self._driver_impl.backend_type == "playwright": + connector.wait_for_present() + else: + connector.wait_for_visible() def assert_connection_invalid(self, source, sink): source_id, sink_id = self.workflow_editor_source_sink_terminal_ids(source, sink) @@ -1595,11 +1577,25 @@ steps: name = self.workflow_upload_yaml_with_random_name(yaml_content) self.workflow_index_open() self.workflow_index_open_with_name(name) + self.workflow_editor_dismiss_state_upgrade_modal() if auto_layout: self.components.workflow_editor.tool_bar.auto_layout.wait_for_and_click() self.sleep_for(self.wait_types.UX_RENDER) return name + def workflow_editor_dismiss_state_upgrade_modal(self): + """Dismiss the StateUpgradeModal if it appears when opening a workflow. + + This Bootstrap Vue modal intercepts pointer events and blocks clicks + on the workflow editor canvas. Wait briefly for it to potentially + appear, then dismiss if present. + """ + editor = self.components.workflow_editor + self.sleep_for(self.wait_types.UX_RENDER) + if not editor.state_modal_body.is_absent: + editor.modal_button_continue.wait_for_and_click() + editor.state_modal_body.wait_for_absent() + def workflow_editor_destroy_connection(self, sink): editor = self.components.workflow_editor @@ -1637,7 +1633,19 @@ steps: def move_center_of_canvas(self, xoffset=0, yoffset=0): _canvas = self.find_element_by_id("canvas-container") - assert self.backend_type == "selenium" - canvas = cast(WebElement, _canvas) - chains = ActionChains(self.driver) - chains.click_and_hold(canvas).move_by_offset(xoffset=xoffset, yoffset=yoffset).release().perform() + if self.backend_type == "playwright": + pw_driver = cast("HasPlaywrightDriver", self._driver_impl) + page = pw_driver.page + handle = pw_driver._unwrap_element(_canvas) + box = handle.bounding_box() + assert box is not None + cx = box["x"] + box["width"] / 2 + cy = box["y"] + box["height"] / 2 + page.mouse.move(cx, cy) + page.mouse.down() + page.mouse.move(cx + xoffset, cy + yoffset) + page.mouse.up() + else: + canvas = cast(WebElement, _canvas) + chains = ActionChains(self.driver) + chains.click_and_hold(canvas).move_by_offset(xoffset=xoffset, yoffset=yoffset).release().perform() From e3ca01c4b2930634dd5d1dd2b37a888f13be340e Mon Sep 17 00:00:00 2001 From: John Chilton Date: Sun, 15 Mar 2026 09:22:03 -0400 Subject: [PATCH 03/11] Selenium testing for new unified history export New test_history_export.py tests wizard-based export (requires Celery+STS). Rename old integration test to test_history_export_legacy.py for non-Celery path. Uses existing history_export_tasks + last_export_record selectors. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../selenium/test_history_export.py | 75 +++++++++++++++++++ ...xport.py => test_history_export_legacy.py} | 11 ++- 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 lib/galaxy_test/selenium/test_history_export.py rename test/integration_selenium/{test_history_export.py => test_history_export_legacy.py} (79%) diff --git a/lib/galaxy_test/selenium/test_history_export.py b/lib/galaxy_test/selenium/test_history_export.py new file mode 100644 index 00000000000..1f64efa7257 --- /dev/null +++ b/lib/galaxy_test/selenium/test_history_export.py @@ -0,0 +1,75 @@ +from .framework import ( + managed_history, + selenium_test, + SeleniumTestCase, +) + + +class TestHistoryExport(SeleniumTestCase): + """Test history export wizard (requires Celery + STS enabled, the default).""" + + ensure_registered = True + + @selenium_test + @managed_history + def test_history_native_export_to_file(self): + self.perform_upload_of_pasted_content("my cool content") + self.history_panel_wait_for_hid_ok(1) + + self.home() + self.click_history_option_export_to_file() + history_export_tasks = self.components.history_export_tasks + last_export_record = self.components.last_export_record + + self.screenshot("history_export_formats") + # Step 1: Select export format (tar.gz) + history_export_tasks.select_format(format="tar.gz").wait_for_and_click() + history_export_tasks.next_button.wait_for_and_click() + + # Step 2: Select download destination + history_export_tasks.select_destination(destination="download").wait_for_and_click() + self.screenshot("history_export_native_destinations") + history_export_tasks.next_button.wait_for_and_click() + + # Step 3: Complete the export + self.screenshot("history_export_native_download_options") + history_export_tasks.export_button.wait_for_and_click() + + # Wait for export to complete + last_export_record.preparing_export_badge.wait_for_visible() + self.screenshot("history_export_native_preparing_download") + last_export_record.preparing_export_badge.wait_for_absent(wait_type=self.wait_types.DATABASE_OPERATION) + last_export_record.download_btn.wait_for_visible() + self.screenshot("history_export_native_download_ready") + + @selenium_test + @managed_history + def test_history_rocrate_export_to_file(self): + self.perform_upload_of_pasted_content("my cool content") + self.history_panel_wait_for_hid_ok(1) + + self.home() + self.click_history_option_export_to_file() + history_export_tasks = self.components.history_export_tasks + last_export_record = self.components.last_export_record + + self.screenshot("history_export_formats") + # Step 1: Select export format (rocrate) + history_export_tasks.select_format(format="rocrate.zip").wait_for_and_click() + history_export_tasks.next_button.wait_for_and_click() + + # Step 2: Select download destination + history_export_tasks.select_destination(destination="download").wait_for_and_click() + self.screenshot("history_export_rocrate_destinations") + history_export_tasks.next_button.wait_for_and_click() + + # Step 3: Complete the export + self.screenshot("history_export_rocrate_download_options") + history_export_tasks.export_button.wait_for_and_click() + + # Wait for export to complete + last_export_record.preparing_export_badge.wait_for_visible() + self.screenshot("history_export_rocrate_preparing_download") + last_export_record.preparing_export_badge.wait_for_absent(wait_type=self.wait_types.DATABASE_OPERATION) + last_export_record.download_btn.wait_for_visible() + self.screenshot("history_export_rocrate_download_ready") diff --git a/test/integration_selenium/test_history_export.py b/test/integration_selenium/test_history_export_legacy.py similarity index 79% rename from test/integration_selenium/test_history_export.py rename to test/integration_selenium/test_history_export_legacy.py index 9675f85b620..94b0fc8cfa3 100644 --- a/test/integration_selenium/test_history_export.py +++ b/test/integration_selenium/test_history_export_legacy.py @@ -4,7 +4,16 @@ from .framework import ( ) -class TestHistoryExport(SeleniumIntegrationTestCase): +class TestLegacyHistoryExport(SeleniumIntegrationTestCase): + """Test legacy history export for when celery is disabled. + + If Celery is enabled, a wizard will be setup and STS will serve downloads, + this is tested in test_history_export.py in the main selenium test suite. + + This test needs to disable celery in order to work so it is an integration + test and we disable celery in handle_galaxy_config_kwds. + """ + ensure_registered = True @classmethod From a64f04ef8f9c7284d2d21c32f94844a52fcb07c9 Mon Sep 17 00:00:00 2001 From: John Chilton Date: Mon, 16 Mar 2026 13:57:27 -0400 Subject: [PATCH 04/11] Port test_editor_place_comments, test_editor_snapping, test_editor_selection to Playwright Add mouse_drag, shift_click, send_keys_to_page Playwright impls to navigates_galaxy.py. Replace action_chains usage in all three tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/galaxy/selenium/navigates_galaxy.py | 94 +++++++++++++++++-- .../selenium/test_workflow_editor.py | 29 +++--- 2 files changed, 98 insertions(+), 25 deletions(-) diff --git a/lib/galaxy/selenium/navigates_galaxy.py b/lib/galaxy/selenium/navigates_galaxy.py index 25be4601ee7..96f65e60d0c 100644 --- a/lib/galaxy/selenium/navigates_galaxy.py +++ b/lib/galaxy/selenium/navigates_galaxy.py @@ -3042,6 +3042,49 @@ class NavigatesGalaxy(HasDriverProxy[WaitType]): self.wait_for_and_click_selector(search_selector) self.wait_for_selector_visible("#gtn-screen") + def shift_click(self, element: WebElementProtocol) -> None: + """Shift-click an element. Works with both Selenium and Playwright.""" + if self._driver_impl.backend_type == "playwright": + pw_driver = cast("HasPlaywrightDriver", self._driver_impl) + pw_driver._unwrap_element(element).click(modifiers=["Shift"]) + else: + self.action_chains().move_to_element(element).key_down(Keys.SHIFT).click().key_up(Keys.SHIFT).perform() + + def send_keys_to_page(self, *value: str) -> None: + """Send keys to the currently focused element / page. + + Replaces action_chains().send_keys(...).perform() with a backend-agnostic impl. + Accepts Selenium Keys constants and plain text, matching PlaywrightElement.send_keys semantics. + """ + if self._driver_impl.backend_type == "playwright": + from galaxy.selenium.playwright_element import _SELENIUM_KEY_TO_PLAYWRIGHT, _SELENIUM_MODIFIERS + + pw_driver = cast("HasPlaywrightDriver", self._driver_impl) + page = pw_driver.page + all_chars = "".join(str(v) for v in value) + has_special = any(c in _SELENIUM_KEY_TO_PLAYWRIGHT for c in all_chars) + if not has_special: + page.keyboard.type(all_chars) + else: + modifiers: list[str] = [] + for char in all_chars: + pw_key = _SELENIUM_KEY_TO_PLAYWRIGHT.get(char) + if pw_key and char in _SELENIUM_MODIFIERS: + modifiers.append(pw_key) + elif pw_key: + combo = "+".join(modifiers + [pw_key]) + page.keyboard.press(combo) + modifiers.clear() + else: + if modifiers: + combo = "+".join(modifiers + [char]) + page.keyboard.press(combo) + modifiers.clear() + else: + page.keyboard.type(char) + else: + self.action_chains().send_keys(*value).perform() + def mouse_drag( self, from_element: WebElementProtocol, @@ -3050,18 +3093,51 @@ class NavigatesGalaxy(HasDriverProxy[WaitType]): to_offset=(0, 0), via_offsets: Optional[list[tuple[int, int]]] = None, ): - chain = self.action_chains().move_to_element(from_element).move_by_offset(*from_offset) - chain = chain.click_and_hold().pause(self.wait_length(self.wait_types.UX_RENDER)) + if self._driver_impl.backend_type == "playwright": + pw_driver = cast("HasPlaywrightDriver", self._driver_impl) + page = pw_driver.page + pause_ms = int(self.wait_length(self.wait_types.UX_RENDER) * 1000) - if via_offsets is not None: - for offset in via_offsets: - chain = chain.move_by_offset(*offset).pause(self.wait_length(self.wait_types.UX_RENDER)) + from_box = pw_driver._unwrap_element(from_element).bounding_box() + assert from_box is not None + cx = from_box["x"] + from_box["width"] / 2 + from_offset[0] + cy = from_box["y"] + from_box["height"] / 2 + from_offset[1] - if to_element is not None: - chain = chain.move_to_element(to_element) + page.mouse.move(cx, cy) + page.mouse.down() + page.wait_for_timeout(pause_ms) - chain = chain.move_by_offset(*to_offset).pause(self.wait_length(self.wait_types.UX_RENDER)).release() - chain.perform() + if via_offsets is not None: + for offset in via_offsets: + cx += offset[0] + cy += offset[1] + page.mouse.move(cx, cy) + page.wait_for_timeout(pause_ms) + + if to_element is not None: + to_box = pw_driver._unwrap_element(to_element).bounding_box() + assert to_box is not None + cx = to_box["x"] + to_box["width"] / 2 + cy = to_box["y"] + to_box["height"] / 2 + + cx += to_offset[0] + cy += to_offset[1] + page.mouse.move(cx, cy) + page.wait_for_timeout(pause_ms) + page.mouse.up() + else: + chain = self.action_chains().move_to_element(from_element).move_by_offset(*from_offset) + chain = chain.click_and_hold().pause(self.wait_length(self.wait_types.UX_RENDER)) + + if via_offsets is not None: + for offset in via_offsets: + chain = chain.move_by_offset(*offset).pause(self.wait_length(self.wait_types.UX_RENDER)) + + if to_element is not None: + chain = chain.move_to_element(to_element) + + chain = chain.move_by_offset(*to_offset).pause(self.wait_length(self.wait_types.UX_RENDER)).release() + chain.perform() class NotLoggedInException(SeleniumTimeoutException): diff --git a/lib/galaxy_test/selenium/test_workflow_editor.py b/lib/galaxy_test/selenium/test_workflow_editor.py index 407d8a9f3aa..6d19f5bdc44 100644 --- a/lib/galaxy_test/selenium/test_workflow_editor.py +++ b/lib/galaxy_test/selenium/test_workflow_editor.py @@ -1236,7 +1236,6 @@ steps: self.workflow_editor_destroy_connection("filter#how|filter_source") self.assert_node_output_is("filter#output_filtered", "list") - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_editor_place_comments(self): editor = self.components.workflow_editor @@ -1252,12 +1251,12 @@ steps: editor.tool_bar.toggle_italic.wait_for_and_click() editor.tool_bar.color(color="pink").wait_for_and_click() editor.tool_bar.font_size.wait_for_and_click() - self.action_chains().send_keys(Keys.LEFT * 5).send_keys(Keys.RIGHT).perform() + self.send_keys_to_page(Keys.LEFT * 5 + Keys.RIGHT) # place text comment self.mouse_drag(from_element=canvas, from_offset=(-200, -200), to_offset=(400, 110)) - self.action_chains().send_keys("Hello World").perform() + self.send_keys_to_page("Hello World") # check if all options were applied comment_content: WebElementProtocol = editor.comment.text_inner.wait_for_visible() @@ -1281,7 +1280,7 @@ steps: editor.tool_bar.tool(tool="markdown_comment").wait_for_and_click() editor.tool_bar.color(color="lime").wait_for_and_click() self.mouse_drag(from_element=canvas, from_offset=(-100, -100), to_offset=(200, 220)) - self.action_chains().send_keys("# Hello World").perform() + self.send_keys_to_page("# Hello World") editor.tool_bar.tool(tool="pointer").wait_for_and_click() @@ -1302,7 +1301,7 @@ steps: editor.tool_bar.tool(tool="frame_comment").wait_for_and_click() editor.tool_bar.color(color="blue").wait_for_and_click() self.mouse_drag(from_element=canvas, from_offset=(-200, -150), to_offset=(400, 300)) - self.action_chains().send_keys("My Frame").perform() + self.send_keys_to_page("My Frame") title: WebElementProtocol = editor.comment.frame_title.wait_for_visible() assert title.text == "My Frame" @@ -1320,10 +1319,10 @@ steps: editor.tool_bar.tool(tool="freehand_pen").wait_for_and_click() editor.tool_bar.color(color="green").wait_for_and_click() editor.tool_bar.line_thickness.wait_for_and_click() - self.action_chains().send_keys(Keys.RIGHT * 20).perform() + self.send_keys_to_page(Keys.RIGHT * 20) editor.tool_bar.smoothing.wait_for_and_click() - self.action_chains().send_keys(Keys.RIGHT * 10).perform() + self.send_keys_to_page(Keys.RIGHT * 10) self.mouse_drag(from_element=canvas, from_offset=(-100, -100), to_offset=(200, 200)) @@ -1331,7 +1330,7 @@ steps: editor.tool_bar.color(color="black").wait_for_and_click() editor.tool_bar.line_thickness.wait_for_and_click() - self.action_chains().send_keys(Keys.LEFT * 20).perform() + self.send_keys_to_page(Keys.LEFT * 20) self.mouse_drag(from_element=canvas, from_offset=(-100, -100), via_offsets=[(100, 200)], to_offset=(-200, 30)) # test bulk remove freehand @@ -1340,7 +1339,7 @@ steps: # place another freehand comment and test eraser editor.tool_bar.line_thickness.wait_for_and_click() - self.action_chains().send_keys(Keys.RIGHT * 20).perform() + self.send_keys_to_page(Keys.RIGHT * 20) editor.tool_bar.color(color="orange").wait_for_and_click() self.mouse_drag(from_element=canvas, from_offset=(-100, -100), to_offset=(200, 200)) @@ -1349,7 +1348,7 @@ steps: # delete by clicking editor.tool_bar.tool(tool="freehand_eraser").wait_for_and_click() - self.action_chains().move_to_element(freehand_comment_a).click().perform() + self.move_to_and_click(freehand_comment_a) editor.comment.freehand_comment.wait_for_absent() @@ -1368,7 +1367,6 @@ steps: editor.comment.freehand_comment.wait_for_absent() - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_editor_snapping(self): editor = self.components.workflow_editor @@ -1382,11 +1380,11 @@ steps: # activate snapping and set it to max (200) editor.tool_bar.tool(tool="toggle_snap").wait_for_and_click() editor.tool_bar.snapping_distance.wait_for_and_click() - self.action_chains().send_keys(Keys.RIGHT * 10).perform() + self.send_keys_to_page(Keys.RIGHT * 10) # move the node a bit tool_node = editor.node._(label="tool_node").wait_for_present() - self.action_chains().move_to_element(tool_node).click_and_hold().move_by_offset(12, 3).release().perform() + self.mouse_drag(from_element=tool_node, to_offset=(12, 3)) # check if editor position is snapped top, left = self.get_node_position("tool_node") @@ -1396,7 +1394,7 @@ steps: # move the node a bit more tool_node = editor.node._(label="tool_node").wait_for_present() - self.action_chains().move_to_element(tool_node).click_and_hold().move_by_offset(207, -181).release().perform() + self.mouse_drag(from_element=tool_node, to_offset=(207, -181)) # check if editor position is snapped top, left = self.get_node_position("tool_node") @@ -1404,7 +1402,6 @@ steps: assert top % 200 == 0 assert left % 200 == 0 - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_editor_selection(self): editor = self.components.workflow_editor @@ -1422,7 +1419,7 @@ steps: # select the node editor.node_inspector_close.wait_for_and_click() - self.action_chains().move_to_element(tool_node).key_down(Keys.SHIFT).click().key_up(Keys.SHIFT).perform() + self.shift_click(tool_node) self.sleep_for(self.wait_types.UX_RENDER) assert editor.tool_bar.selection_count.wait_for_visible().text.find("1 step") != -1 From 41fc502ef70887e859003f3d9c76ddeb5bca51ed Mon Sep 17 00:00:00 2001 From: John Chilton Date: Mon, 16 Mar 2026 14:16:00 -0400 Subject: [PATCH 05/11] Drop selenium_only from test_map_over_output_indicator Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/galaxy_test/selenium/test_workflow_editor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/galaxy_test/selenium/test_workflow_editor.py b/lib/galaxy_test/selenium/test_workflow_editor.py index 6d19f5bdc44..03088a79280 100644 --- a/lib/galaxy_test/selenium/test_workflow_editor.py +++ b/lib/galaxy_test/selenium/test_workflow_editor.py @@ -1212,7 +1212,6 @@ steps: # should not show error editor.duplicate_label_error(output="out_file1").wait_for_absent() - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_map_over_output_indicator(self): self.open_in_workflow_editor(""" From fbffe73adbe522ca8eba97485fb8fbdcd311c0e2 Mon Sep 17 00:00:00 2001 From: John Chilton Date: Mon, 16 Mar 2026 15:10:46 -0400 Subject: [PATCH 06/11] Drop selenium_only from test_editor_create_conditional_step Close node inspector before destroy_connection to prevent panel from intercepting pointer events on the when terminal. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/galaxy_test/selenium/test_workflow_editor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/galaxy_test/selenium/test_workflow_editor.py b/lib/galaxy_test/selenium/test_workflow_editor.py index 03088a79280..81e650bdcba 100644 --- a/lib/galaxy_test/selenium/test_workflow_editor.py +++ b/lib/galaxy_test/selenium/test_workflow_editor.py @@ -946,7 +946,6 @@ steps: workflow = self.workflow_populator.download_workflow(workflow_id) return workflow - @selenium_only("Not yet migrated to support Playwright backend") @selenium_test def test_editor_create_conditional_step(self): editor = self.components.workflow_editor @@ -997,6 +996,7 @@ steps: param_type_element = editor.param_type_form.wait_for_present() self.switch_param_type(param_type_element, "Text") self.assert_connection_invalid("param_input#output", "conditional_step#when") + editor.node_inspector_close.wait_for_and_click() self.workflow_editor_destroy_connection("conditional_step#when") # Make sure the when input is still shown conditional_node.input_terminal(name="when").wait_for_present() From 722cb679a6ceadfd64daf7419124b82803b5a0d4 Mon Sep 17 00:00:00 2001 From: John Chilton Date: Mon, 16 Mar 2026 21:01:18 -0400 Subject: [PATCH 07/11] Format fix. --- lib/galaxy/selenium/navigates_galaxy.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/galaxy/selenium/navigates_galaxy.py b/lib/galaxy/selenium/navigates_galaxy.py index 96f65e60d0c..221da860373 100644 --- a/lib/galaxy/selenium/navigates_galaxy.py +++ b/lib/galaxy/selenium/navigates_galaxy.py @@ -3057,7 +3057,10 @@ class NavigatesGalaxy(HasDriverProxy[WaitType]): Accepts Selenium Keys constants and plain text, matching PlaywrightElement.send_keys semantics. """ if self._driver_impl.backend_type == "playwright": - from galaxy.selenium.playwright_element import _SELENIUM_KEY_TO_PLAYWRIGHT, _SELENIUM_MODIFIERS + from galaxy.selenium.playwright_element import ( + _SELENIUM_KEY_TO_PLAYWRIGHT, + _SELENIUM_MODIFIERS, + ) pw_driver = cast("HasPlaywrightDriver", self._driver_impl) page = pw_driver.page From 23d930dad13fe062bd723d1b04df06aa6fcadf0b Mon Sep 17 00:00:00 2001 From: John Chilton Date: Tue, 17 Mar 2026 05:19:26 -0400 Subject: [PATCH 08/11] Does this fix it? --- lib/galaxy/selenium/navigates_galaxy.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/galaxy/selenium/navigates_galaxy.py b/lib/galaxy/selenium/navigates_galaxy.py index 221da860373..c1d2f2dd40c 100644 --- a/lib/galaxy/selenium/navigates_galaxy.py +++ b/lib/galaxy/selenium/navigates_galaxy.py @@ -1445,6 +1445,10 @@ class NavigatesGalaxy(HasDriverProxy[WaitType]): self.sleep_for(self.wait_types.UX_RENDER) self.screenshot(screenshot_partial) self.drag_and_drop(source_element, sink_element) + if self._driver_impl.backend_type == "playwright": + # dispatch_event is synchronous but Vue reactivity (store updates, + # terminal type recalculation) runs in microtasks — wait for it. + self.sleep_for(self.wait_types.UX_RENDER) def workflow_editor_source_sink_terminal_ids(self, source, sink): editor = self.components.workflow_editor From 7fd66124f7eed94a788a5491cd44aae128096dcd Mon Sep 17 00:00:00 2001 From: John Chilton Date: Tue, 17 Mar 2026 06:48:37 -0400 Subject: [PATCH 09/11] Fix flaky assert_node_output_is with retry instead of sleep Add @retry_assertion_during_transitions + dismiss stale tooltip before hover so retries get fresh text. Remove UX_RENDER sleep hacks from workflow_editor_connect. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/galaxy/selenium/navigates_galaxy.py | 4 ---- lib/galaxy_test/selenium/test_workflow_editor.py | 3 +++ 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/galaxy/selenium/navigates_galaxy.py b/lib/galaxy/selenium/navigates_galaxy.py index c1d2f2dd40c..221da860373 100644 --- a/lib/galaxy/selenium/navigates_galaxy.py +++ b/lib/galaxy/selenium/navigates_galaxy.py @@ -1445,10 +1445,6 @@ class NavigatesGalaxy(HasDriverProxy[WaitType]): self.sleep_for(self.wait_types.UX_RENDER) self.screenshot(screenshot_partial) self.drag_and_drop(source_element, sink_element) - if self._driver_impl.backend_type == "playwright": - # dispatch_event is synchronous but Vue reactivity (store updates, - # terminal type recalculation) runs in microtasks — wait for it. - self.sleep_for(self.wait_types.UX_RENDER) def workflow_editor_source_sink_terminal_ids(self, source, sink): editor = self.components.workflow_editor diff --git a/lib/galaxy_test/selenium/test_workflow_editor.py b/lib/galaxy_test/selenium/test_workflow_editor.py index 81e650bdcba..0c791d161cb 100644 --- a/lib/galaxy_test/selenium/test_workflow_editor.py +++ b/lib/galaxy_test/selenium/test_workflow_editor.py @@ -1515,11 +1515,14 @@ steps: return (int(width_stripped), int(height_stripped)) + @retry_assertion_during_transitions def assert_node_output_is(self, label: str, output_type: str, subcollection_type: Optional[str] = None): editor = self.components.workflow_editor node_label, output_name = label.split("#") node = editor.node._(label=node_label) node.wait_for_present() + # Dismiss any stale tooltip before hovering so retry gets fresh text + self.click_center() output_element = node.output_terminal(name=output_name).wait_for_visible() self.hover_over(output_element) element = self.components._.tooltip_inner.wait_for_present() From f3480e03fe1ff0cdac58bcc68ec96876641c63dd Mon Sep 17 00:00:00 2001 From: John Chilton Date: Tue, 17 Mar 2026 13:02:04 -0400 Subject: [PATCH 10/11] Move playwright_element imports to top of navigates_galaxy.py Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/galaxy/selenium/navigates_galaxy.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/galaxy/selenium/navigates_galaxy.py b/lib/galaxy/selenium/navigates_galaxy.py index 221da860373..0f67cea82b2 100644 --- a/lib/galaxy/selenium/navigates_galaxy.py +++ b/lib/galaxy/selenium/navigates_galaxy.py @@ -31,6 +31,11 @@ import yaml from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys +from .playwright_element import ( + _SELENIUM_KEY_TO_PLAYWRIGHT, + _SELENIUM_MODIFIERS, +) + if TYPE_CHECKING: from selenium.webdriver.remote.webdriver import WebDriver @@ -3057,11 +3062,6 @@ class NavigatesGalaxy(HasDriverProxy[WaitType]): Accepts Selenium Keys constants and plain text, matching PlaywrightElement.send_keys semantics. """ if self._driver_impl.backend_type == "playwright": - from galaxy.selenium.playwright_element import ( - _SELENIUM_KEY_TO_PLAYWRIGHT, - _SELENIUM_MODIFIERS, - ) - pw_driver = cast("HasPlaywrightDriver", self._driver_impl) page = pw_driver.page all_chars = "".join(str(v) for v in value) From 2825bb09e421b2708c4175d4cb07db80aba1da02 Mon Sep 17 00:00:00 2001 From: John Chilton Date: Sun, 22 Mar 2026 16:00:34 -0400 Subject: [PATCH 11/11] Try to fix transiently failing test? --- lib/galaxy/selenium/navigates_galaxy.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/lib/galaxy/selenium/navigates_galaxy.py b/lib/galaxy/selenium/navigates_galaxy.py index 0f67cea82b2..7965893224b 100644 --- a/lib/galaxy/selenium/navigates_galaxy.py +++ b/lib/galaxy/selenium/navigates_galaxy.py @@ -122,6 +122,15 @@ class NullTourCallback: pass +def _exception_indicates_playwright_timeout(e): + try: + from playwright._impl._errors import TimeoutError as PlaywrightTimeoutError + + return isinstance(e, PlaywrightTimeoutError) + except ImportError: + return False + + def exception_seems_to_indicate_transition(e): """True if exception seems to indicate the page state is transitioning. @@ -132,14 +141,16 @@ def exception_seems_to_indicate_transition(e): cause of the exception. The methods that follow use it to allow retrying actions during transitions. - Currently the two kinds of exceptions that we say may indicate a transition are - StaleElement exceptions (a DOM element grabbed at one step is no longer available) - and "not clickable" exceptions (so perhaps a popup modal is blocking a click). + Currently the kinds of exceptions that we say may indicate a transition are + StaleElement exceptions (a DOM element grabbed at one step is no longer available), + "not clickable" exceptions (so perhaps a popup modal is blocking a click), and + Playwright TimeoutErrors (element not yet present/visible during a transition). """ return ( exception_indicates_stale_element(e) or exception_indicates_not_clickable(e) or exception_indicates_click_intercepted(e) + or _exception_indicates_playwright_timeout(e) )