mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-10-31 02:28:09 +08:00
- Extract FavoritesManager (lib/galaxy/managers/favorites.py): owns
normalize / resolve / persist / order logic; controllers in users.py
are now 2 lines each, and trans.sa_session.commit moves out of the
controller layer. Drops the redundant re-normalize on every write
and replaces FavoriteObjectType.X.value == .value defensive dispatch
with enum-identity comparison. Renames the set_favorite_order
endpoint summary away from the ambiguous "top-level" wording.
- Move curated_tool_tags_by_id off the toolbox into ToolsService;
the controller now routes through the service instead of poking
trans.app.toolbox directly. Raw aggregates (curated_tool_tags,
tool_edam_operations, tool_edam_topics) remain on the toolbox since
they're derived from per-tool data.
- Rename /api/tools/tags -> /api/tags/tool_tags (operation_id
tags__tool_tags) to disambiguate from /api/tools/{id}; update the
client store, tests, and the hand-edited OpenAPI schema entry.
(Run `make update-client-api-schema` before pushing to regenerate
the schema in its canonical alphabetical position.)
- Promote inline imports to module-level (ontology_data.py logging,
app/__init__.py configure_tool_tag_mapping); replace
getattr(self.config, "tool_tag_mappings_file", None) with direct
attribute access now that the option is registered in the schema.
- Convert ontology_data's import-time bundled-mapping reads to
lru_cache so the YAML / TSV files load lazily; fix the
Tool._setup_id comment to point at the actual lowercasing site
(Tool.parse around `self_ids = [self.id.lower()]`).
- doc/source/admin/tool_panel.rst: 26.0 -> 26.1.
- scripts/extract_tool_sections_from_api.py: annotate the public
helpers the test suite imports.
- test_to_dict_cache_drops_when_tool_removed now asserts observable
to_dict() output before/after remove, not private cache membership.
- Split test_tool_discovery_landing into focused tests; extract a
helper that opens the discovery view and types the standard filter.
- Move the Playwright drag helper from test_tool_panel_search.py into
TestWithSeleniumMixin.playwright_drag_item_above so future tests
reuse it.
- Client tests: drop the full-string Whoosh equality in
utilities.test.ts; hard-code expected Whoosh strings in
ToolsList.test.ts (drop createWhooshQuery as both sides of the
assertion) and remove the redundant injectTestRouter mount option
(ToolsList.vue only uses the useRouter composable); drop the
FontAwesome data-icon/iconName assertions in ToolPanel.test.ts in
favor of icon-existence checks; replace three near-identical
add/remove favorite tests in ToolsListCard.test.ts with an it.each;
export quoteToolTagValue from Panels/utilities so
filterConversion.test.js imports the real helper instead of
reconstructing it inline; delete the spy-only "loads the curated
tag mapping" test in ToolBoxSearch.test.ts (the gate is covered by
ToolPanel.test.ts's negative case and the favorite-tag rendering
tests cover the positive case end-to-end).
1566 lines
63 KiB
Python
1566 lines
63 KiB
Python
"""Basis for Selenium test framework."""
|
|
|
|
import datetime
|
|
import errno
|
|
import json
|
|
import logging
|
|
import os
|
|
import traceback
|
|
import unittest
|
|
from functools import (
|
|
partial,
|
|
wraps,
|
|
)
|
|
from typing import (
|
|
Any,
|
|
cast,
|
|
Optional,
|
|
TYPE_CHECKING,
|
|
)
|
|
|
|
import requests
|
|
import yaml
|
|
from requests.models import Response
|
|
from selenium.common.exceptions import NoSuchElementException
|
|
from selenium.webdriver.common.by import By
|
|
|
|
from galaxy.selenium import driver_factory
|
|
from galaxy.selenium.axe_results import assert_baseline_accessible
|
|
from galaxy.selenium.context import GalaxySeleniumContext
|
|
from galaxy.selenium.has_driver import (
|
|
DEFAULT_AXE_SCRIPT_URL,
|
|
SeleniumTimeoutException,
|
|
)
|
|
from galaxy.selenium.has_driver_protocol import (
|
|
BackendType,
|
|
HasDriverProtocol,
|
|
)
|
|
from galaxy.selenium.navigates_galaxy import (
|
|
exception_seems_to_indicate_transition,
|
|
galaxy_timeout_handler,
|
|
NavigatesGalaxy,
|
|
retry_during_transitions,
|
|
)
|
|
from galaxy.tool_util.verify import (
|
|
verify,
|
|
verify_job_metadata,
|
|
)
|
|
from galaxy.tool_util.verify.interactor import prepare_request_params
|
|
from galaxy.util import (
|
|
asbool,
|
|
classproperty,
|
|
DEFAULT_SOCKET_TIMEOUT,
|
|
)
|
|
from galaxy.util.unittest_utils import skip_if_github_down
|
|
from galaxy_test.base import populators
|
|
from galaxy_test.base.api import (
|
|
UsesApiTestCaseMixin,
|
|
UsesCeleryTasks,
|
|
)
|
|
from galaxy_test.base.api_util import get_admin_api_key
|
|
from galaxy_test.base.decorators import (
|
|
requires_new_history,
|
|
using_requirement,
|
|
)
|
|
from galaxy_test.base.env import (
|
|
DEFAULT_WEB_HOST,
|
|
get_ip_address,
|
|
)
|
|
from galaxy_test.base.populators import (
|
|
load_data_dict,
|
|
stage_inputs,
|
|
)
|
|
from galaxy_test.base.testcase import FunctionalTestCase
|
|
|
|
try:
|
|
from galaxy_test.driver.driver_util import GalaxyTestDriver
|
|
except ImportError:
|
|
GalaxyTestDriver = None # type: ignore[assignment, misc, unused-ignore]
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _load_config_file() -> None:
|
|
"""
|
|
Load test configuration from YAML file if GALAXY_TEST_END_TO_END_CONFIG is set.
|
|
|
|
This matches the configuration format used for Jupyter-based testing.
|
|
Explicit environment variables take precedence over config file values.
|
|
"""
|
|
config_file = os.environ.get("GALAXY_TEST_END_TO_END_CONFIG")
|
|
if not config_file:
|
|
return
|
|
|
|
# Expand user paths like ~/config.yml
|
|
config_file = os.path.expanduser(config_file)
|
|
|
|
if not os.path.exists(config_file):
|
|
raise FileNotFoundError(
|
|
f"GALAXY_TEST_END_TO_END_CONFIG is set to '{config_file}' but file does not exist. "
|
|
f"Please check the path or unset GALAXY_TEST_END_TO_END_CONFIG."
|
|
)
|
|
|
|
try:
|
|
with open(config_file) as f:
|
|
config = yaml.safe_load(f)
|
|
except Exception as e:
|
|
raise ValueError(f"Failed to load GALAXY_TEST_END_TO_END_CONFIG '{config_file}': {e}") from e
|
|
|
|
if not isinstance(config, dict):
|
|
raise ValueError(
|
|
f"GALAXY_TEST_END_TO_END_CONFIG '{config_file}' must contain a YAML dictionary, got {type(config)}"
|
|
)
|
|
|
|
# Map config keys to environment variables
|
|
# Only set if not already present (explicit env vars take precedence)
|
|
key_mapping = {
|
|
"local_galaxy_url": "GALAXY_TEST_EXTERNAL",
|
|
"login_email": "GALAXY_TEST_SELENIUM_USER_EMAIL",
|
|
"login_password": "GALAXY_TEST_SELENIUM_USER_PASSWORD",
|
|
"admin_api_key": "GALAXY_TEST_SELENIUM_ADMIN_API_KEY",
|
|
"admin_email": "GALAXY_TEST_SELENIUM_ADMIN_USER_EMAIL",
|
|
"admin_password": "GALAXY_TEST_SELENIUM_ADMIN_USER_PASSWORD",
|
|
"selenium_galaxy_url": "GALAXY_TEST_EXTERNAL_FROM_SELENIUM",
|
|
}
|
|
|
|
for config_key, env_var in key_mapping.items():
|
|
if config_key in config and config[config_key] is not None:
|
|
if env_var not in os.environ:
|
|
os.environ[env_var] = str(config[config_key])
|
|
|
|
os.environ["GALAXY_TEST_ENVIRONMENT_CONFIGURED"] = "1"
|
|
|
|
|
|
# Load config file before reading environment variables
|
|
_load_config_file()
|
|
|
|
DEFAULT_TIMEOUT_MULTIPLIER = 1
|
|
DEFAULT_TEST_ERRORS_DIRECTORY = os.path.abspath("database/test_errors")
|
|
DEFAULT_SELENIUM_HEADLESS = "auto"
|
|
DEFAULT_ADMIN_USER = "test@bx.psu.edu"
|
|
DEFAULT_ADMIN_PASSWORD = "testpass"
|
|
DEFAULT_DOWNLOAD_PATH = driver_factory.DEFAULT_DOWNLOAD_PATH
|
|
|
|
|
|
TIMEOUT_MULTIPLIER = float(os.environ.get("GALAXY_TEST_TIMEOUT_MULTIPLIER", DEFAULT_TIMEOUT_MULTIPLIER))
|
|
GALAXY_TEST_ERRORS_DIRECTORY = os.environ.get("GALAXY_TEST_ERRORS_DIRECTORY", DEFAULT_TEST_ERRORS_DIRECTORY)
|
|
GALAXY_TEST_SCREENSHOTS_DIRECTORY = os.environ.get("GALAXY_TEST_SCREENSHOTS_DIRECTORY", None)
|
|
# Driver backend can be ["selenium", "playwright"]
|
|
GALAXY_TEST_DRIVER_BACKEND = os.environ.get("GALAXY_TEST_DRIVER_BACKEND", "selenium")
|
|
# Test browser can be ["CHROME", "FIREFOX"]
|
|
GALAXY_TEST_SELENIUM_BROWSER = os.environ.get("GALAXY_TEST_SELENIUM_BROWSER", driver_factory.DEFAULT_SELENIUM_BROWSER)
|
|
GALAXY_TEST_SELENIUM_REMOTE = os.environ.get("GALAXY_TEST_SELENIUM_REMOTE", driver_factory.DEFAULT_SELENIUM_REMOTE)
|
|
GALAXY_TEST_SELENIUM_REMOTE_PORT = os.environ.get(
|
|
"GALAXY_TEST_SELENIUM_REMOTE_PORT", driver_factory.DEFAULT_SELENIUM_REMOTE_PORT
|
|
)
|
|
GALAXY_TEST_SELENIUM_REMOTE_HOST = os.environ.get(
|
|
"GALAXY_TEST_SELENIUM_REMOTE_HOST", driver_factory.DEFAULT_SELENIUM_REMOTE_HOST
|
|
)
|
|
GALAXY_TEST_SELENIUM_HEADLESS = os.environ.get("GALAXY_TEST_SELENIUM_HEADLESS", DEFAULT_SELENIUM_HEADLESS)
|
|
GALAXY_TEST_EXTERNAL_FROM_SELENIUM = os.environ.get("GALAXY_TEST_EXTERNAL_FROM_SELENIUM", None)
|
|
# Auto-retry selenium tests this many times.
|
|
GALAXY_TEST_SELENIUM_RETRIES = int(os.environ.get("GALAXY_TEST_SELENIUM_RETRIES", "0"))
|
|
|
|
GALAXY_TEST_SELENIUM_USER_EMAIL = os.environ.get("GALAXY_TEST_SELENIUM_USER_EMAIL", None)
|
|
GALAXY_TEST_SELENIUM_USER_PASSWORD = os.environ.get("GALAXY_TEST_SELENIUM_USER_PASSWORD", None)
|
|
GALAXY_TEST_SELENIUM_ADMIN_USER_EMAIL = os.environ.get("GALAXY_TEST_SELENIUM_ADMIN_USER_EMAIL", DEFAULT_ADMIN_USER)
|
|
GALAXY_TEST_SELENIUM_ADMIN_USER_PASSWORD = os.environ.get(
|
|
"GALAXY_TEST_SELENIUM_ADMIN_USER_PASSWORD", DEFAULT_ADMIN_PASSWORD
|
|
)
|
|
GALAXY_TEST_AXE_SCRIPT_URL = os.environ.get("GALAXY_TEST_AXE_SCRIPT_URL", DEFAULT_AXE_SCRIPT_URL)
|
|
GALAXY_TEST_SKIP_AXE = os.environ.get("GALAXY_TEST_SKIP_AXE", "0") == "1"
|
|
|
|
# JS code to execute in Galaxy JS console to setup localStorage of session for logging and
|
|
# logging "flatten" messages because it seems Selenium (with Chrome at least) only grabs
|
|
# the first argument to console.XXX when recovering the browser log.
|
|
SETUP_LOGGING_JS = """
|
|
window.localStorage && window.localStorage.setItem("galaxy:debug", true);
|
|
window.localStorage && window.localStorage.setItem("galaxy:debug:flatten", true);
|
|
"""
|
|
|
|
|
|
def selenium_only(reason: str = "Test requires Selenium-specific functionality"):
|
|
"""Mark test as Selenium-only, skip if running with Playwright backend.
|
|
|
|
Args:
|
|
reason: Explanation for why this test requires Selenium
|
|
|
|
Usage:
|
|
@selenium_only("Uses Selenium Select class which requires tag_name attribute")
|
|
def test_custom_select_element(self):
|
|
...
|
|
"""
|
|
|
|
def decorator(f):
|
|
@wraps(f)
|
|
def wrapper(*args, **kwargs):
|
|
backend = os.environ.get("GALAXY_TEST_DRIVER_BACKEND", "selenium")
|
|
if backend == "playwright":
|
|
raise unittest.SkipTest(f"Selenium-only test: {reason}")
|
|
return f(*args, **kwargs)
|
|
|
|
return wrapper
|
|
|
|
return decorator
|
|
|
|
|
|
def playwright_only(reason: str = "Test requires Playwright-specific functionality"):
|
|
"""Mark test as Playwright-only, skip if running with Selenium backend.
|
|
|
|
Args:
|
|
reason: Explanation for why this test requires Playwright
|
|
|
|
Usage:
|
|
@playwright_only("Uses Playwright-specific network interception")
|
|
def test_network_request_logging(self):
|
|
...
|
|
"""
|
|
|
|
def decorator(f):
|
|
@wraps(f)
|
|
def wrapper(*args, **kwargs):
|
|
backend = os.environ.get("GALAXY_TEST_DRIVER_BACKEND", "selenium")
|
|
if backend == "selenium":
|
|
raise unittest.SkipTest(f"Playwright-only test: {reason}")
|
|
return f(*args, **kwargs)
|
|
|
|
return wrapper
|
|
|
|
return decorator
|
|
|
|
|
|
def managed_history(f):
|
|
"""Ensure a Selenium test has a distinct, named history.
|
|
|
|
Cleanup the history after the job is complete as well unless
|
|
GALAXY_TEST_NO_CLEANUP is set in the environment.
|
|
"""
|
|
f = requires_new_history(f)
|
|
|
|
@wraps(f)
|
|
def func_wrapper(self, *args, **kwds):
|
|
self.home()
|
|
history_name = f.__name__ + datetime.datetime.now().strftime("%Y%m%d%H%M%s")
|
|
self.history_panel_create_new_with_name(history_name)
|
|
try:
|
|
f(self, *args, **kwds)
|
|
finally:
|
|
if "GALAXY_TEST_NO_CLEANUP" not in os.environ:
|
|
try:
|
|
current_history_id = self.current_history_id()
|
|
self.dataset_populator.cancel_history_jobs(current_history_id)
|
|
self.api_delete(f"histories/{current_history_id}")
|
|
except Exception:
|
|
print("Faild to cleanup managed history, selenium connection corrupted somehow?")
|
|
|
|
return func_wrapper
|
|
|
|
|
|
def dump_test_information(self, name_prefix):
|
|
if GALAXY_TEST_ERRORS_DIRECTORY and GALAXY_TEST_ERRORS_DIRECTORY != "0":
|
|
if not os.path.exists(GALAXY_TEST_ERRORS_DIRECTORY):
|
|
os.makedirs(GALAXY_TEST_ERRORS_DIRECTORY)
|
|
result_name = name_prefix + datetime.datetime.now().strftime("%Y%m%d%H%M%s")
|
|
target_directory = os.path.join(GALAXY_TEST_ERRORS_DIRECTORY, result_name)
|
|
|
|
def write_file(name, content, raw=False):
|
|
with open(os.path.join(target_directory, name), "wb") as buf:
|
|
buf.write(content.encode("utf-8") if not raw else content)
|
|
|
|
os.makedirs(target_directory)
|
|
write_file("stacktrace.txt", traceback.format_exc())
|
|
for snapshot in getattr(self, "snapshots", []):
|
|
snapshot.write_to_error_directory(write_file)
|
|
|
|
# Try to use the Selenium driver to write a final summary of the accessibility
|
|
# information for the test.
|
|
try:
|
|
self.axe_eval(write_to=os.path.join(target_directory, "last.a11y.json"))
|
|
except Exception as e:
|
|
print(e)
|
|
print("Failed to use test driver to print accessibility information")
|
|
|
|
# Try to use the Selenium driver to recover more debug information, but don't
|
|
# throw an exception if the connection is broken in some way.
|
|
try:
|
|
self.save_screenshot(os.path.join(target_directory, "last.png"))
|
|
write_file("page_source.txt", self.page_source)
|
|
write_file("DOM.txt", self.execute_script("return document.documentElement.outerHTML"))
|
|
except Exception:
|
|
formatted_exception = traceback.format_exc()
|
|
print(f"Failed to use test driver to recover debug information from Selenium: {formatted_exception}")
|
|
write_file("selenium_exception.txt", formatted_exception)
|
|
|
|
for log_type in ["browser", "driver"]:
|
|
try:
|
|
full_log = self.driver.get_log(log_type)
|
|
trimmed_log = [entry for entry in full_log if entry["level"] not in ["DEBUG", "INFO"]]
|
|
write_file(f"{log_type}.log.json", json.dumps(trimmed_log, indent=True))
|
|
write_file(f"{log_type}.log.verbose.json", json.dumps(full_log, indent=True))
|
|
except Exception:
|
|
continue
|
|
|
|
try_symlink(target_directory, os.path.join(GALAXY_TEST_ERRORS_DIRECTORY, "latest"))
|
|
|
|
|
|
def try_symlink(file1, file2):
|
|
try:
|
|
try:
|
|
os.symlink(file1, file2)
|
|
except OSError as e:
|
|
if e.errno == errno.EEXIST:
|
|
os.remove(file2)
|
|
os.symlink(file1, file2)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def selenium_test(f):
|
|
test_name = f.__name__
|
|
|
|
@wraps(f)
|
|
def func_wrapper(self, *args, **kwds):
|
|
retry_attempts = 0
|
|
while True:
|
|
if retry_attempts > 0:
|
|
self.reset_driver_and_session()
|
|
try:
|
|
rval = f(self, *args, **kwds)
|
|
self.assert_baseline_accessibility()
|
|
return rval
|
|
except unittest.SkipTest:
|
|
dump_test_information(self, test_name)
|
|
# Don't retry if we have purposely decided to skip the test.
|
|
raise
|
|
except Exception:
|
|
dump_test_information(self, test_name)
|
|
if retry_attempts < GALAXY_TEST_SELENIUM_RETRIES:
|
|
retry_attempts += 1
|
|
print(
|
|
f"Test function [{test_name}] threw an exception, retrying. Failed attempts - {retry_attempts}."
|
|
)
|
|
else:
|
|
raise
|
|
|
|
return func_wrapper
|
|
|
|
|
|
def exception_is_assertion_or_transition(e: Exception) -> bool:
|
|
"""Drive the retry_assertion_during_transitions decorator.
|
|
|
|
Reuse logic for checking for transition exceptions but also retry
|
|
if there is an assertion error.
|
|
"""
|
|
return exception_seems_to_indicate_transition(e) or isinstance(e, AssertionError)
|
|
|
|
|
|
retry_assertion_during_transitions = partial(
|
|
retry_during_transitions, exception_check=exception_is_assertion_or_transition
|
|
)
|
|
|
|
|
|
class TestSnapshot:
|
|
__test__ = False # Prevent pytest from discovering this class (issue #12071)
|
|
|
|
def __init__(self, has_driver: HasDriverProtocol, index: int, description: str):
|
|
self.screenshot_binary = has_driver.get_screenshot_as_png()
|
|
self.description = description
|
|
self.index = index
|
|
self.exc = traceback.format_exc()
|
|
self.stack = traceback.format_stack()
|
|
|
|
def write_to_error_directory(self, write_file_func):
|
|
prefix = f"{self.index}-{self.description}"
|
|
write_file_func(f"{prefix}-screenshot.png", self.screenshot_binary, raw=True)
|
|
write_file_func(f"{prefix}-traceback.txt", self.exc)
|
|
write_file_func(f"{prefix}-stack.txt", str(self.stack))
|
|
|
|
|
|
class GalaxyTestSeleniumContext(GalaxySeleniumContext):
|
|
"""Extend GalaxySeleniumContext with Selenium-aware galaxy_test.base.populators."""
|
|
|
|
@property
|
|
def dataset_populator(self) -> "SeleniumSessionDatasetPopulator":
|
|
"""A dataset populator connected to the Galaxy session described by Selenium context."""
|
|
return SeleniumSessionDatasetPopulator(self)
|
|
|
|
@property
|
|
def dataset_collection_populator(self) -> populators.BaseDatasetCollectionPopulator:
|
|
"""A dataset collection populator connected to the Galaxy session described by Selenium context."""
|
|
return SeleniumSessionDatasetCollectionPopulator(self)
|
|
|
|
@property
|
|
def workflow_populator(self) -> populators.BaseWorkflowPopulator:
|
|
"""A workflow populator connected to the Galaxy session described by Selenium context."""
|
|
return SeleniumSessionWorkflowPopulator(self)
|
|
|
|
|
|
class TestWithSeleniumMixin(GalaxyTestSeleniumContext, UsesApiTestCaseMixin, UsesCeleryTasks):
|
|
# If run one-off via pytest, the next line ensures test
|
|
# tools and datatypes are used instead of configured tools.
|
|
framework_tool_and_types = True
|
|
|
|
# Override this in subclasses to ensure a user is logged in
|
|
# before each test. If GALAXY_TEST_SELENIUM_USER_EMAIL and
|
|
# GALAXY_TEST_SELENIUM_USER_PASSWORD are set these values
|
|
# will be used to login.
|
|
ensure_registered = False
|
|
|
|
# Override this in subclasses to annotate that an admin user
|
|
# is required for the test to run properly. Override admin user
|
|
# login info with GALAXY_TEST_SELENIUM_ADMIN_USER_EMAIL /
|
|
# GALAXY_TEST_SELENIUM_ADMIN_USER_PASSWORD
|
|
run_as_admin = False
|
|
|
|
# where we pull axe from if enabled
|
|
axe_script_url = GALAXY_TEST_AXE_SCRIPT_URL
|
|
|
|
# boolean used to skip axe testing, might be useful to speed up
|
|
# tests or may be required if you have no external internet access
|
|
axe_skip = GALAXY_TEST_SKIP_AXE
|
|
|
|
def assert_baseline_accessibility(self):
|
|
axe_results = self.axe_eval()
|
|
assert_baseline_accessible(axe_results)
|
|
|
|
def playwright_drag_item_above(self, source_locator, target_locator):
|
|
"""Drag ``source_locator`` so it is dropped immediately above ``target_locator``.
|
|
|
|
Playwright's ``drag_to`` jumps the cursor and is fooled by drop zones
|
|
that activate only after a sustained mouse-move. Hand-rolled mouse
|
|
moves (with intermediate steps and a small initial nudge) are reliable
|
|
for HTML5-DnD targets used across the tool-panel UIs.
|
|
"""
|
|
source_locator.scroll_into_view_if_needed()
|
|
target_locator.scroll_into_view_if_needed()
|
|
source_box = source_locator.bounding_box()
|
|
target_box = target_locator.bounding_box()
|
|
assert source_box is not None, "source element has no bounding box (off-screen?)"
|
|
assert target_box is not None, "target element has no bounding box (off-screen?)"
|
|
|
|
source_x = source_box["x"] + (source_box["width"] / 2)
|
|
source_y = source_box["y"] + (source_box["height"] / 2)
|
|
target_x = target_box["x"] + (target_box["width"] / 2)
|
|
target_y = target_box["y"] + min(8, target_box["height"] / 4)
|
|
|
|
self.page.mouse.move(source_x, source_y)
|
|
self.page.mouse.down()
|
|
self.page.mouse.move(source_x, source_y + 12, steps=4)
|
|
self.page.mouse.move(target_x, target_y, steps=20)
|
|
self.page.mouse.up()
|
|
|
|
def _target_url_from_selenium(self):
|
|
# Deal with the case when Galaxy has a different URL when being accessed by Selenium
|
|
# then when being accessed by local API calls.
|
|
if GALAXY_TEST_EXTERNAL_FROM_SELENIUM is not None:
|
|
target_url_from_selenium = GALAXY_TEST_EXTERNAL_FROM_SELENIUM
|
|
else:
|
|
target_url_from_selenium = self.url
|
|
return target_url_from_selenium
|
|
|
|
def setup_selenium(self):
|
|
self.target_url_from_selenium = self._target_url_from_selenium()
|
|
self.snapshots = []
|
|
self.setup_driver_and_session()
|
|
# Once the driver is allocated, any subsequent failure must still
|
|
# tear it down: pytest does not call tearDown when setUp raises, so
|
|
# without this the Playwright asyncio loop would stay registered as
|
|
# "running" on the main thread and cascade every subsequent test's
|
|
# setUp with "Sync API inside the asyncio loop".
|
|
try:
|
|
if self.run_as_admin and GALAXY_TEST_SELENIUM_ADMIN_USER_EMAIL == DEFAULT_ADMIN_USER:
|
|
self._setup_interactor()
|
|
self._setup_user(GALAXY_TEST_SELENIUM_ADMIN_USER_EMAIL)
|
|
self._try_setup_with_driver()
|
|
except Exception:
|
|
try:
|
|
self.tear_down_driver()
|
|
except Exception:
|
|
logger.exception("Error tearing down driver after setup_selenium failure")
|
|
raise
|
|
|
|
def _try_setup_with_driver(self):
|
|
try:
|
|
self.setup_with_driver()
|
|
except Exception:
|
|
dump_test_information(self, f"{self.__class__.__name__}_setup")
|
|
raise
|
|
|
|
def setup_with_driver(self):
|
|
"""Override point that allows setting up data using self.driver and Selenium connection.
|
|
|
|
Overriding this instead of setUp will ensure debug data such as screenshots and stack traces
|
|
are dumped if there are problems with the setup and it will be re-ran on test retries.
|
|
"""
|
|
if self.ensure_registered:
|
|
self.login()
|
|
|
|
def tear_down_selenium(self):
|
|
self.tear_down_driver()
|
|
|
|
def snapshot(self, description: str):
|
|
"""Create a debug snapshot (DOM, screenshot, etc...) that is written out on tool failure.
|
|
|
|
This information will be automatically written to a per-test directory created for all
|
|
failed tests.
|
|
"""
|
|
self.snapshots.append(TestSnapshot(self, len(self.snapshots), description))
|
|
|
|
def get_download_path(self):
|
|
"""Returns default download path"""
|
|
return DEFAULT_DOWNLOAD_PATH
|
|
|
|
@property
|
|
def anonymous_galaxy_interactor(self):
|
|
api_key = self.get_api_key(force=False)
|
|
interactor = self._get_interactor(api_key=api_key, allow_anonymous=True)
|
|
return interactor
|
|
|
|
def api_interactor_for_logged_in_user(self):
|
|
api_key = self.get_api_key(force=True)
|
|
interactor = self._get_interactor(api_key=api_key)
|
|
return interactor
|
|
|
|
def write_screenshot_directory_file(self, label, content):
|
|
target = self._screenshot_path(label, ".txt")
|
|
if target is None:
|
|
return
|
|
|
|
with open(target, "w") as f:
|
|
f.write(content)
|
|
|
|
def _screenshot_path(self, label, extension=".png"):
|
|
if GALAXY_TEST_SCREENSHOTS_DIRECTORY is None:
|
|
return
|
|
if not os.path.exists(GALAXY_TEST_SCREENSHOTS_DIRECTORY):
|
|
os.makedirs(GALAXY_TEST_SCREENSHOTS_DIRECTORY)
|
|
target = os.path.join(GALAXY_TEST_SCREENSHOTS_DIRECTORY, label + extension)
|
|
copy = 1
|
|
while os.path.exists(target):
|
|
# Maybe previously a test re-run - keep the original.
|
|
target = os.path.join(GALAXY_TEST_SCREENSHOTS_DIRECTORY, f"{label}-{copy}{extension}")
|
|
copy += 1
|
|
|
|
return target
|
|
|
|
def reset_driver_and_session(self):
|
|
self.tear_down_driver()
|
|
self.target_url_from_selenium = self._target_url_from_selenium()
|
|
self.setup_driver_and_session()
|
|
self._try_setup_with_driver()
|
|
|
|
def setup_driver_and_session(self):
|
|
virtual_display_enabled = use_virtual_display()
|
|
self.display = driver_factory.virtual_display_if_enabled(virtual_display_enabled)
|
|
self.configured_driver = get_configured_driver()
|
|
self._setup_galaxy_logging()
|
|
|
|
def _setup_galaxy_logging(self):
|
|
self.home()
|
|
self.execute_script(SETUP_LOGGING_JS)
|
|
|
|
def login(self):
|
|
if GALAXY_TEST_SELENIUM_USER_EMAIL:
|
|
assert (
|
|
GALAXY_TEST_SELENIUM_USER_PASSWORD
|
|
), "If GALAXY_TEST_SELENIUM_USER_EMAIL is set, a password must be set also with GALAXY_TEST_SELENIUM_USER_PASSWORD"
|
|
self.home()
|
|
self.submit_login(
|
|
email=GALAXY_TEST_SELENIUM_USER_EMAIL,
|
|
password=GALAXY_TEST_SELENIUM_USER_PASSWORD,
|
|
assert_valid=True,
|
|
)
|
|
else:
|
|
self.register()
|
|
|
|
def tear_down_driver(self):
|
|
exception = None
|
|
if self.backend_type == "playwright":
|
|
try:
|
|
self.quit()
|
|
except Exception as e:
|
|
exception = e
|
|
else:
|
|
try:
|
|
self.close()
|
|
except Exception as e:
|
|
if "cannot kill Chrome" in str(e):
|
|
print(f"Ignoring likely harmless error in Selenium shutdown {e}")
|
|
else:
|
|
exception = e
|
|
|
|
try:
|
|
self.display.stop()
|
|
except Exception as e:
|
|
exception = e
|
|
|
|
if exception is not None:
|
|
raise exception
|
|
|
|
@classproperty
|
|
def default_web_host(cls):
|
|
return default_web_host_for_selenium_tests()
|
|
|
|
def assert_initial_history_panel_state_correct(self):
|
|
# Move into a TestsHistoryPanel mixin
|
|
unnamed_name = self.components.history_panel.new_name.text
|
|
name_element = self.history_panel_name_element()
|
|
|
|
assert name_element.is_displayed()
|
|
assert unnamed_name in name_element.text
|
|
|
|
self.components.history_panel.empty_message.wait_for_visible()
|
|
|
|
def admin_login(self):
|
|
using_requirement("admin")
|
|
self.home()
|
|
self.submit_login(GALAXY_TEST_SELENIUM_ADMIN_USER_EMAIL, GALAXY_TEST_SELENIUM_ADMIN_USER_PASSWORD)
|
|
with self.main_panel():
|
|
self.assert_no_error_message()
|
|
return GALAXY_TEST_SELENIUM_ADMIN_USER_EMAIL
|
|
|
|
@retry_assertion_during_transitions
|
|
def assert_workflow_has_changes_and_save(self):
|
|
save_button = self.components.workflow_editor.save_button
|
|
save_button.wait_for_visible()
|
|
assert not save_button.has_class("disabled")
|
|
save_button.wait_for_and_click()
|
|
self.sleep_for(self.wait_types.UX_RENDER)
|
|
|
|
@retry_assertion_during_transitions
|
|
def assert_modal_has_text(self, expected_text):
|
|
modal_element = self.components.workflow_editor.state_upgrade_modal.wait_for_visible()
|
|
text = modal_element.text
|
|
assert expected_text in text, f"Failed to find expected text [{expected_text}] in modal text [{text}]"
|
|
|
|
def ensure_visualization_available(self, hid, visualization_name):
|
|
"""Skip or fail a test if visualization for file doesn't appear.
|
|
|
|
Precondition: viz menu has been opened with history_panel_item_click_visualization_menu.
|
|
"""
|
|
visualization_names = self.history_panel_item_available_visualizations(hid)
|
|
if visualization_name not in visualization_names:
|
|
raise unittest.SkipTest(
|
|
f"Skipping test, visualization [{visualization_name}] doesn't appear to be configured."
|
|
)
|
|
|
|
|
|
class SeleniumTestCase(FunctionalTestCase, TestWithSeleniumMixin):
|
|
galaxy_driver_class = GalaxyTestDriver
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.setup_selenium()
|
|
self.admin_api_key = get_admin_api_key()
|
|
|
|
def tearDown(self):
|
|
exception = None
|
|
try:
|
|
self.tear_down_selenium()
|
|
except Exception as e:
|
|
exception = e
|
|
|
|
try:
|
|
super().tearDown()
|
|
except Exception as e:
|
|
exception = e
|
|
|
|
if exception is not None:
|
|
raise exception
|
|
|
|
|
|
class SharedStateSeleniumTestCase(SeleniumTestCase):
|
|
"""This describes a class Selenium tests that setup class state for all tests.
|
|
|
|
This is a bit hacky because we are simulating class level initialization
|
|
with instance level methods. The problem is that super.setUp() works at
|
|
instance level. It might be worth considering having two variants of
|
|
SeleniumTestCase - one that initializes with the class and the other that
|
|
initializes with the instance but all the helpers are instance helpers.
|
|
"""
|
|
|
|
shared_state_initialized = False
|
|
shared_state_in_error = False
|
|
|
|
def setup_with_driver(self):
|
|
if not self.__class__.shared_state_initialized:
|
|
try:
|
|
self.setup_shared_state()
|
|
self.logout_if_needed()
|
|
except Exception:
|
|
self.__class__.shared_state_in_error = True
|
|
raise
|
|
finally:
|
|
self.__class__.shared_state_initialized = True
|
|
else:
|
|
if self.__class__.shared_state_in_error:
|
|
raise unittest.SkipTest("Skipping test, failed to initialize state previously.")
|
|
|
|
def setup_shared_state(self):
|
|
"""Override this to setup shared data for tests that gets initialized only once."""
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
NavigatesGalaxyMixin = NavigatesGalaxy
|
|
else:
|
|
NavigatesGalaxyMixin = object
|
|
|
|
|
|
class UsesLibraryAssertions(NavigatesGalaxyMixin):
|
|
@retry_assertion_during_transitions
|
|
def assert_num_displayed_items_is(self, n):
|
|
num_displayed = self.num_displayed_items()
|
|
assert n == num_displayed, f"Expected number of displayed items is {n} but actual was {num_displayed}"
|
|
|
|
def num_displayed_items(self) -> int:
|
|
return len(self.libraries_table_elements())
|
|
|
|
|
|
class UsesHistoryItemAssertions(NavigatesGalaxyMixin):
|
|
def assert_item_peek_includes(self, hid, expected):
|
|
item_body = self.history_panel_item_component(hid=hid)
|
|
peek_text = item_body.peek.wait_for_text()
|
|
assert expected in peek_text, f"Expected peek [{expected}] not found in peek [{peek_text}]"
|
|
|
|
def assert_item_info_includes(self, hid, expected):
|
|
item_body = self.history_panel_item_component(hid=hid)
|
|
info_text = item_body.info.wait_for_text()
|
|
assert expected in info_text, f"Failed to find expected info text [{expected}] in info [{info_text}]"
|
|
|
|
def assert_item_dbkey_displayed_as(self, hid, dbkey):
|
|
item_body = self.history_panel_item_component(hid=hid)
|
|
dbkey_text = item_body.dbkey.wait_for_text()
|
|
assert dbkey in dbkey_text
|
|
|
|
def assert_item_summary_includes(self, hid, expected_text):
|
|
item_body = self.history_panel_item_component(hid=hid)
|
|
summary_text = item_body.summary.wait_for_text()
|
|
assert expected_text in summary_text, f"Expected summary [{expected_text}] not found in [{summary_text}]."
|
|
|
|
def assert_item_name(self, hid, expected_name):
|
|
item_body = self.history_panel_item_component(hid=hid)
|
|
name = item_body.name.wait_for_text()
|
|
assert name == expected_name, name
|
|
|
|
def assert_item_extension(self, hid, expected_extension):
|
|
item_body = self.history_panel_item_component(hid=hid)
|
|
extension = item_body.datatype.wait_for_text()
|
|
assert extension == expected_extension, extension
|
|
|
|
def assert_item_hid_text(self, hid):
|
|
# Check the text HID matches HID returned from API. The hid span includes a colon.
|
|
item_body = self.history_panel_item_component(hid=hid)
|
|
hid_text = item_body.hid.wait_for_text()
|
|
assert hid_text == f"{hid}:", hid_text
|
|
|
|
|
|
class RunsToolTests(NavigatesGalaxyMixin):
|
|
"""Mixin that drives tool execution through the browser form using tool test definitions."""
|
|
|
|
def run_tool_test(self, tool_id: str, test_index: int = 0, galaxy_interactor=None, dataset_populator=None):
|
|
"""Top-level entry point: fetch test def, stage data, fill form, execute, verify."""
|
|
assert galaxy_interactor is not None, "galaxy_interactor is required"
|
|
assert dataset_populator is not None, "dataset_populator is required"
|
|
test_defs = galaxy_interactor.get_tool_tests(tool_id)
|
|
assert len(test_defs) > test_index, f"Tool {tool_id} has {len(test_defs)} tests, requested index {test_index}"
|
|
test_def = test_defs[test_index]
|
|
history_id = self.current_history_id()
|
|
self.home()
|
|
hid_map, required_filenames, collection_hid_map = self._stage_test_data(test_def, history_id, galaxy_interactor)
|
|
pre_job_ids = {j["id"] for j in dataset_populator.history_jobs_for_tool(history_id, tool_id)}
|
|
self.tool_open(tool_id)
|
|
self._fill_tool_test_inputs(test_def, hid_map, required_filenames, collection_hid_map)
|
|
self.tool_form_execute()
|
|
self._verify_tool_test_outputs(test_def, history_id, tool_id, pre_job_ids, dataset_populator)
|
|
|
|
# -- Data staging --
|
|
|
|
def _stage_test_data(
|
|
self, test_def: dict, history_id: str, galaxy_interactor
|
|
) -> tuple[dict[str, int], set[str], dict[str, int]]:
|
|
"""Stage test data files and collections using stage_inputs().
|
|
|
|
Returns (hid_map, required_filenames, collection_hid_map) where:
|
|
- hid_map maps filenames to HIDs for scalar data params
|
|
- required_filenames is the set of filenames from required_files
|
|
- collection_hid_map maps param keys to collection HIDs
|
|
"""
|
|
required_files = test_def.get("required_files", [])
|
|
inputs = test_def.get("inputs", {})
|
|
required_filenames = {f[0] for f in required_files}
|
|
|
|
file_meta: dict[str, dict] = {}
|
|
for entry in required_files:
|
|
fname = entry[0]
|
|
if len(entry) > 1 and isinstance(entry[1], dict):
|
|
file_meta[fname] = entry[1]
|
|
|
|
job: dict = {}
|
|
filename_by_key: dict[str, str] = {}
|
|
collection_keys: set[str] = set()
|
|
|
|
for key, value in inputs.items():
|
|
raw = value[0] if isinstance(value, list) and len(value) == 1 else value
|
|
if isinstance(raw, dict) and raw.get("model_class") == "TestCollectionDef":
|
|
job[key] = self._convert_collection_def(raw, file_meta)
|
|
collection_keys.add(key)
|
|
elif isinstance(raw, str) and raw in required_filenames:
|
|
file_entry: dict = {"class": "File", "path": raw}
|
|
ftype = file_meta.get(raw, {}).get("ftype")
|
|
if ftype:
|
|
file_entry["filetype"] = ftype
|
|
job[key] = file_entry
|
|
filename_by_key[key] = raw
|
|
|
|
if not job:
|
|
return {}, required_filenames, {}
|
|
|
|
staged_job, datasets = stage_inputs(
|
|
galaxy_interactor,
|
|
history_id,
|
|
job,
|
|
use_path_paste=False,
|
|
tool_or_workflow="tool",
|
|
)
|
|
|
|
dataset_id_to_hid = {ds["id"]: ds["hid"] for ds in datasets}
|
|
hid_map: dict[str, int] = {}
|
|
for key, ref in staged_job.items():
|
|
if key in collection_keys:
|
|
continue
|
|
filename = filename_by_key.get(key)
|
|
if filename and ref.get("id") in dataset_id_to_hid:
|
|
hid_map[filename] = dataset_id_to_hid[ref["id"]]
|
|
|
|
collection_hid_map: dict[str, int] = {}
|
|
if collection_keys:
|
|
contents = self.api_get(f"histories/{history_id}/contents?type=dataset_collection")
|
|
collection_id_to_hid = {item["id"]: item["hid"] for item in contents}
|
|
for key in collection_keys:
|
|
ref = staged_job.get(key, {})
|
|
if ref.get("id") in collection_id_to_hid:
|
|
collection_hid_map[key] = collection_id_to_hid[ref["id"]]
|
|
|
|
for hid in hid_map.values():
|
|
self.history_panel_wait_for_hid_ok(hid)
|
|
for hid in collection_hid_map.values():
|
|
self.history_panel_wait_for_hid_ok(hid)
|
|
|
|
return hid_map, required_filenames, collection_hid_map
|
|
|
|
@staticmethod
|
|
def _convert_collection_def(coll_def: dict, file_meta: dict | None = None) -> dict:
|
|
"""Convert a TestCollectionDef dict to a CWL-like collection job entry."""
|
|
file_meta = file_meta or {}
|
|
elements = []
|
|
for elem in coll_def["elements"]:
|
|
inner = elem["element_definition"]
|
|
if inner.get("model_class") == "TestCollectionDef":
|
|
converted = RunsToolTests._convert_collection_def(inner, file_meta)
|
|
converted["identifier"] = elem["element_identifier"]
|
|
elements.append(converted)
|
|
else:
|
|
file_entry: dict = {
|
|
"identifier": elem["element_identifier"],
|
|
"class": "File",
|
|
"path": inner["value"],
|
|
}
|
|
ftype = file_meta.get(inner["value"], {}).get("ftype")
|
|
if ftype:
|
|
file_entry["filetype"] = ftype
|
|
elements.append(file_entry)
|
|
result: dict = {
|
|
"class": "Collection",
|
|
"collection_type": coll_def["collection_type"],
|
|
"elements": elements,
|
|
}
|
|
if coll_def.get("name"):
|
|
result["name"] = coll_def["name"]
|
|
return result
|
|
|
|
# -- Form filling --
|
|
|
|
def _fill_tool_test_inputs(
|
|
self,
|
|
test_def: dict,
|
|
hid_map: dict,
|
|
required_filenames: set,
|
|
collection_hid_map: dict | None = None,
|
|
):
|
|
"""Fill tool form inputs from test definition.
|
|
|
|
Two-pass approach for conditionals: set shallow params first so
|
|
conditionals reveal nested params, then retry deferred ones.
|
|
Data/collection params set last.
|
|
"""
|
|
inputs = test_def.get("inputs", {})
|
|
if not inputs:
|
|
return
|
|
collection_hid_map = collection_hid_map or {}
|
|
|
|
data_params = []
|
|
collection_params = []
|
|
non_data_params = []
|
|
repeat_counts: dict[str, int] = {}
|
|
|
|
for key, value in inputs.items():
|
|
raw_value = value[0] if isinstance(value, list) and len(value) == 1 else value
|
|
|
|
repeat_match = self._parse_repeat_key(key)
|
|
if repeat_match:
|
|
repeat_name, repeat_index, _child_key = repeat_match
|
|
if repeat_name not in repeat_counts:
|
|
repeat_counts[repeat_name] = 0
|
|
repeat_counts[repeat_name] = max(repeat_counts[repeat_name], repeat_index + 1)
|
|
|
|
if isinstance(raw_value, dict) and raw_value.get("model_class") == "TestCollectionDef":
|
|
collection_params.append((key, raw_value))
|
|
elif isinstance(raw_value, str) and raw_value in required_filenames:
|
|
data_params.append((key, value))
|
|
else:
|
|
non_data_params.append((key, value))
|
|
|
|
for repeat_name, count in repeat_counts.items():
|
|
self._add_repeat_instances(repeat_name, count)
|
|
|
|
self._expand_collapsed_sections()
|
|
|
|
non_data_params.sort(key=lambda kv: kv[0].count("|"))
|
|
|
|
deferred = []
|
|
for key, value in non_data_params:
|
|
try:
|
|
self._set_tool_form_value(key, value, required_filenames)
|
|
self.sleep_for(self.wait_types.UX_RENDER)
|
|
except (NoSuchElementException, SeleniumTimeoutException, AssertionError):
|
|
deferred.append((key, value))
|
|
|
|
if deferred:
|
|
self.sleep_for(self.wait_types.UX_RENDER)
|
|
for key, value in deferred:
|
|
expanded_id = key.replace("|", "-")
|
|
if self.components.tool_form.parameter_div(parameter=expanded_id).is_absent:
|
|
continue
|
|
self._set_tool_form_value(key, value, required_filenames)
|
|
|
|
for key, value in data_params:
|
|
if isinstance(value, list) and len(value) == 1:
|
|
value = value[0]
|
|
hid = hid_map.get(value)
|
|
assert hid is not None, f"No staged file for data param {key}={value}"
|
|
is_multiple = self._is_multi_data_param(key)
|
|
if is_multiple:
|
|
self._clear_multiselect_tags(key)
|
|
self.tool_set_value(key, f"{hid}: {value}", expected_type="data", multiple=is_multiple)
|
|
|
|
for key, coll_def in collection_params:
|
|
hid = collection_hid_map.get(key)
|
|
assert hid is not None, f"No staged collection for param {key}"
|
|
coll_name = coll_def.get("name", "")
|
|
self.tool_set_value(key, f"{hid}: {coll_name}", expected_type="data")
|
|
|
|
def _is_multi_data_param(self, expanded_id: str) -> bool:
|
|
return not self.components.tool_form.parameter_form_selection(parameter=expanded_id).is_absent
|
|
|
|
def _clear_multiselect_tags(self, expanded_id: str):
|
|
tag_close = self.components.tool_form.parameter_multiselect_tag_close(parameter=expanded_id)
|
|
for _ in range(20):
|
|
close_buttons = tag_close.all()
|
|
if not close_buttons:
|
|
break
|
|
close_buttons[0].click()
|
|
self.sleep_for(self.wait_types.UX_RENDER)
|
|
|
|
@staticmethod
|
|
def _parse_repeat_key(key: str):
|
|
import re
|
|
|
|
match = re.match(r"^(.+?)_(\d+)\|(.+)$", key)
|
|
if match:
|
|
return match.group(1), int(match.group(2)), match.group(3)
|
|
return None
|
|
|
|
def _add_repeat_instances(self, repeat_name: str, count: int):
|
|
for _ in range(count):
|
|
self.components.tool_form.repeat_insert.wait_for_and_click()
|
|
self.sleep_for(self.wait_types.UX_RENDER)
|
|
|
|
def _expand_collapsed_sections(self):
|
|
self.components.tool_form.execute.wait_for_visible()
|
|
for header in self.components.tool_form.section_header.all():
|
|
header.click()
|
|
self.sleep_for(self.wait_types.UX_RENDER)
|
|
|
|
# -- Type detection and value setting --
|
|
|
|
def _detect_param_type(self, expanded_id: str) -> str:
|
|
"""Inspect DOM to determine param type.
|
|
|
|
Detection order matters:
|
|
- drilldown before checkbox_select (drilldown also has checkboxes)
|
|
- checkbox_select before boolean (both have input[type='checkbox'])
|
|
"""
|
|
tf = self.components.tool_form
|
|
|
|
param_div = tf.parameter_div(parameter=expanded_id).wait_for_visible()
|
|
if param_div.find_elements(By.CSS_SELECTOR, tf.drilldown_option.selector):
|
|
return "drilldown"
|
|
|
|
checkboxes = tf.parameter_checkbox_input(parameter=expanded_id).all()
|
|
if len(checkboxes) > 1:
|
|
return "checkbox_select"
|
|
if checkboxes:
|
|
return "boolean"
|
|
|
|
if not tf.parameter_color_input(parameter=expanded_id).is_absent:
|
|
return "color"
|
|
if not tf.parameter_select(parameter=expanded_id).is_absent:
|
|
return "select"
|
|
|
|
return "text"
|
|
|
|
def _set_tool_form_value(self, key: str, value, required_filenames: set):
|
|
if isinstance(value, list) and len(value) == 1:
|
|
value = value[0]
|
|
if isinstance(value, str) and value in required_filenames:
|
|
return
|
|
|
|
param_type = self._detect_param_type(key)
|
|
|
|
if param_type == "drilldown":
|
|
values = value if isinstance(value, list) else [value]
|
|
self._set_drilldown_value(key, values)
|
|
elif param_type == "checkbox_select":
|
|
values = value if isinstance(value, list) else [value]
|
|
self._set_checkbox_select_value(key, values)
|
|
elif param_type == "boolean":
|
|
self._set_boolean_value(key, value)
|
|
elif param_type == "color":
|
|
self._set_color_value(key, value)
|
|
elif param_type == "select":
|
|
self.tool_set_value(key, str(value), expected_type="select")
|
|
else:
|
|
self._set_text_value(key, str(value))
|
|
|
|
def _set_boolean_value(self, expanded_id: str, value):
|
|
checkbox = self.components.tool_form.parameter_checkbox_input(parameter=expanded_id).wait_for_present()
|
|
is_checked = checkbox.is_selected()
|
|
want_checked = str(value).lower() in ("true", "1", "yes")
|
|
if is_checked != want_checked:
|
|
self.execute_script("arguments[0].click();", checkbox)
|
|
|
|
def _set_checkbox_select_value(self, expanded_id: str, values: list):
|
|
all_checkboxes = self.components.tool_form.parameter_checkbox_input(parameter=expanded_id).all()
|
|
for val in values:
|
|
matched = [cb for cb in all_checkboxes if cb.get_attribute("value") == val]
|
|
assert matched, f"No checkbox with value '{val}' in param {expanded_id}"
|
|
if not matched[0].is_selected():
|
|
self.execute_script("arguments[0].click();", matched[0])
|
|
|
|
def _set_drilldown_value(self, expanded_id: str, values: list):
|
|
"""Set drill-down param by clicking option checkboxes.
|
|
|
|
TODO: DOM id is ``drilldown-option-{option.name}`` but test API returns
|
|
option *values*. Works only when name == value. If a tool has
|
|
``<option name="Label" value="key">``, the lookup will fail.
|
|
Fixing requires changing FormDrilldownOption.vue (breaks library export)
|
|
or adding a value->name mapping step here.
|
|
"""
|
|
for val in values:
|
|
checkbox = self.components.tool_form.parameter_drilldown_option(
|
|
parameter=expanded_id, value=val
|
|
).wait_for_present()
|
|
if not checkbox.is_selected():
|
|
self.execute_script("arguments[0].click();", checkbox)
|
|
|
|
def _set_color_value(self, expanded_id: str, value: str):
|
|
color_input = self.components.tool_form.parameter_color_input(parameter=expanded_id).wait_for_present()
|
|
self._set_input_value_via_js(color_input, value)
|
|
|
|
def _set_text_value(self, expanded_id: str, value: str):
|
|
input_element = self.components.tool_form.parameter_text_input(parameter=expanded_id).wait_for_present()
|
|
self._set_input_value_via_js(input_element, value)
|
|
|
|
def _set_input_value_via_js(self, element, value):
|
|
self.execute_script(
|
|
"arguments[0].value = arguments[1];"
|
|
"arguments[0].dispatchEvent(new Event('input', {bubbles: true}));"
|
|
"arguments[0].dispatchEvent(new Event('change', {bubbles: true}));",
|
|
element,
|
|
value,
|
|
)
|
|
|
|
# -- Output verification --
|
|
|
|
def _verify_tool_test_outputs(
|
|
self, test_def: dict, history_id: str, tool_id: str, pre_job_ids: set, dataset_populator
|
|
):
|
|
expect_failure = test_def.get("expect_failure", False)
|
|
outputs = test_def.get("outputs", [])
|
|
output_collections = test_def.get("output_collections", [])
|
|
expect_num_outputs = test_def.get("expect_num_outputs")
|
|
expect_exit_code = test_def.get("expect_exit_code")
|
|
stdout_assertions = test_def.get("stdout")
|
|
stderr_assertions = test_def.get("stderr")
|
|
command_assertions = test_def.get("command")
|
|
command_version_assertions = test_def.get("command_version")
|
|
has_work = (
|
|
outputs
|
|
or output_collections
|
|
or expect_failure
|
|
or expect_num_outputs is not None
|
|
or expect_exit_code is not None
|
|
or stdout_assertions
|
|
or stderr_assertions
|
|
or command_assertions
|
|
or command_version_assertions
|
|
)
|
|
if not has_work:
|
|
return
|
|
|
|
def _find_new_job(driver=None):
|
|
jobs = dataset_populator.history_jobs_for_tool(history_id, tool_id)
|
|
new_jobs = [j for j in jobs if j["id"] not in pre_job_ids]
|
|
if new_jobs:
|
|
return new_jobs[0]
|
|
return None
|
|
|
|
new_job = self._wait_on(_find_new_job, "tool job to appear in history")
|
|
assert new_job is not None
|
|
job_id = new_job["id"]
|
|
|
|
has_job_checks = (
|
|
expect_exit_code is not None
|
|
or stdout_assertions
|
|
or stderr_assertions
|
|
or command_assertions
|
|
or command_version_assertions
|
|
)
|
|
|
|
if expect_failure:
|
|
dataset_populator.wait_for_job(job_id, assert_ok=False)
|
|
job_details = dataset_populator.get_job_details(job_id, full=has_job_checks).json()
|
|
assert job_details["state"] == "error", f"Expected job to fail but state is '{job_details['state']}'"
|
|
if has_job_checks:
|
|
verify_job_metadata(
|
|
job_details,
|
|
expect_exit_code,
|
|
stdout_assertions,
|
|
stderr_assertions,
|
|
command_assertions,
|
|
command_version_assertions,
|
|
)
|
|
return
|
|
|
|
dataset_populator.wait_for_job(job_id, assert_ok=True)
|
|
if has_job_checks:
|
|
job_details = dataset_populator.get_job_details(job_id, full=True).json()
|
|
verify_job_metadata(
|
|
job_details,
|
|
expect_exit_code,
|
|
stdout_assertions,
|
|
stderr_assertions,
|
|
command_assertions,
|
|
command_version_assertions,
|
|
)
|
|
|
|
all_job_outputs = dataset_populator.job_outputs(job_id)
|
|
|
|
if expect_num_outputs is not None:
|
|
actual_count = len([o for o in all_job_outputs if "dataset" in o])
|
|
assert actual_count == int(expect_num_outputs), f"Expected {expect_num_outputs} outputs, got {actual_count}"
|
|
|
|
if outputs:
|
|
output_id_by_name = {o["name"]: o["dataset"]["id"] for o in all_job_outputs if "dataset" in o}
|
|
for output_def in outputs:
|
|
output_name = output_def.get("name")
|
|
assert (
|
|
output_name in output_id_by_name
|
|
), f"Output '{output_name}' not found in job outputs: {list(output_id_by_name.keys())}"
|
|
dataset_id = output_id_by_name[output_name]
|
|
output_content = dataset_populator.get_history_dataset_content(
|
|
history_id,
|
|
dataset_id=dataset_id,
|
|
type="bytes",
|
|
wait=False,
|
|
)
|
|
verify(output_name, output_content, output_def.get("attributes", {}))
|
|
primary_datasets = output_def.get("attributes", {}).get("primary_datasets", {})
|
|
for designation, (_primary_outfile, primary_attribs) in primary_datasets.items():
|
|
primary_key = f"__new_primary_file_{output_name}|{designation}__"
|
|
assert primary_key in output_id_by_name, (
|
|
f"Discovered dataset '{designation}' not found for output '{output_name}': "
|
|
f"{list(output_id_by_name.keys())}"
|
|
)
|
|
primary_content = dataset_populator.get_history_dataset_content(
|
|
history_id,
|
|
dataset_id=output_id_by_name[primary_key],
|
|
type="bytes",
|
|
wait=False,
|
|
)
|
|
verify(designation, primary_content, primary_attribs)
|
|
|
|
if output_collections:
|
|
collection_id_by_name = {
|
|
o["name"]: o["dataset_collection_instance"]["id"]
|
|
for o in all_job_outputs
|
|
if "dataset_collection_instance" in o
|
|
}
|
|
for oc_def in output_collections:
|
|
oc_name = oc_def["name"]
|
|
assert (
|
|
oc_name in collection_id_by_name
|
|
), f"Output collection '{oc_name}' not found in job outputs: {list(collection_id_by_name.keys())}"
|
|
self._verify_output_collection(oc_def, collection_id_by_name[oc_name], history_id, dataset_populator)
|
|
|
|
def _verify_output_collection(self, oc_def: dict, collection_id: str, history_id: str, dataset_populator):
|
|
|
|
data_collection = dataset_populator.get_history_collection_details(
|
|
history_id,
|
|
content_id=collection_id,
|
|
wait=False,
|
|
)
|
|
|
|
expected_type = oc_def.get("attributes", {}).get("type")
|
|
if expected_type:
|
|
actual_type = data_collection["collection_type"]
|
|
assert (
|
|
actual_type == expected_type
|
|
), f"Collection '{oc_def['name']}': expected type '{expected_type}', got '{actual_type}'"
|
|
|
|
expected_count = oc_def.get("attributes", {}).get("count")
|
|
if expected_count is not None:
|
|
actual_count = len(data_collection["elements"])
|
|
assert actual_count == int(
|
|
expected_count
|
|
), f"Collection '{oc_def['name']}': expected {expected_count} elements, got {actual_count}"
|
|
|
|
element_tests = oc_def.get("element_tests", {})
|
|
elements_by_id = {e["element_identifier"]: e for e in data_collection["elements"]}
|
|
for element_id, element_test in element_tests.items():
|
|
assert element_id in elements_by_id, f"Element '{element_id}' not found in collection '{oc_def['name']}'"
|
|
element = elements_by_id[element_id]
|
|
if isinstance(element_test, list):
|
|
_element_outfile, element_attrib = element_test
|
|
else:
|
|
element_attrib = element_test
|
|
|
|
if element.get("element_type") == "dataset_collection":
|
|
sub_elements = element["object"]["elements"]
|
|
sub_tests = element_attrib.get("elements", {})
|
|
sub_elements_by_id = {e["element_identifier"]: e for e in sub_elements}
|
|
for sub_id, sub_test in sub_tests.items():
|
|
assert (
|
|
sub_id in sub_elements_by_id
|
|
), f"Element '{sub_id}' not found in sub-collection '{element_id}'"
|
|
sub_elem = sub_elements_by_id[sub_id]
|
|
if isinstance(sub_test, list):
|
|
_sub_outfile, sub_attrib = sub_test
|
|
else:
|
|
sub_attrib = sub_test
|
|
if sub_elem.get("element_type") == "dataset_collection":
|
|
sub_oc_def = {
|
|
"name": f"{oc_def['name']}/{element_id}/{sub_id}",
|
|
"attributes": {},
|
|
"element_tests": sub_attrib.get("elements", {}),
|
|
}
|
|
self._verify_output_collection(
|
|
sub_oc_def, sub_elem["object"]["id"], history_id, dataset_populator
|
|
)
|
|
else:
|
|
content = dataset_populator.get_history_dataset_content(
|
|
history_id,
|
|
dataset_id=sub_elem["object"]["id"],
|
|
type="bytes",
|
|
wait=False,
|
|
)
|
|
verify(sub_id, content, sub_attrib if isinstance(sub_attrib, dict) else {})
|
|
else:
|
|
hda_id = element["object"]["id"]
|
|
content = dataset_populator.get_history_dataset_content(
|
|
history_id,
|
|
dataset_id=hda_id,
|
|
type="bytes",
|
|
wait=False,
|
|
)
|
|
verify(element_id, content, element_attrib if isinstance(element_attrib, dict) else {})
|
|
|
|
|
|
EXAMPLE_WORKFLOW_URL_1 = (
|
|
"https://raw.githubusercontent.com/galaxyproject/galaxy/release_19.09/test/base/data/test_workflow_1.ga"
|
|
)
|
|
|
|
|
|
class UsesWorkflowAssertions(NavigatesGalaxyMixin):
|
|
@retry_assertion_during_transitions
|
|
def _assert_showing_n_workflows(self, n):
|
|
if (actual_count := len(self.workflow_card_elements())) != n:
|
|
message = f"Expected {n} workflows to be displayed, based on DOM found {actual_count} workflow rows."
|
|
raise AssertionError(message)
|
|
|
|
@skip_if_github_down
|
|
def _workflow_import_from_url(self, url=EXAMPLE_WORKFLOW_URL_1):
|
|
self.workflow_index_click_import()
|
|
self.workflow_import_submit_url(url)
|
|
|
|
@retry_assertion_during_transitions
|
|
def assert_wf_annotation_is(self, expected_annotation):
|
|
edit_annotation = self.components.workflow_editor.edit_annotation
|
|
edit_annotation_element = edit_annotation.wait_for_visible()
|
|
actual_annotation = edit_annotation_element.get_attribute("value")
|
|
assert (
|
|
expected_annotation in actual_annotation
|
|
), f"'{expected_annotation}' unequal annotation '{actual_annotation}'"
|
|
|
|
|
|
class TestsGalaxyPagers(GalaxyTestSeleniumContext):
|
|
@retry_assertion_during_transitions
|
|
def _assert_current_page_is(self, component, expected_page: int):
|
|
component.pager.wait_for_visible()
|
|
page_from_pager = component.pager_page_active.wait_for_present().text
|
|
assert int(page_from_pager) == expected_page
|
|
|
|
def _next_page(self, component):
|
|
component.pager_page_next.wait_for_and_click()
|
|
|
|
def _previous_page(self, component):
|
|
component.pager_page_previous.wait_for_and_click()
|
|
|
|
def _last_page(self, component):
|
|
component.pager_page_last.wait_for_and_click()
|
|
|
|
def _first_page(self, component):
|
|
component.pager_page_first.wait_for_and_click()
|
|
|
|
|
|
class RunsWorkflows(GalaxyTestSeleniumContext):
|
|
def workflow_upload_yaml_with_random_name(self, content: str, **kwds) -> str:
|
|
name = self._get_random_name()
|
|
workflow_populator = self.workflow_populator
|
|
workflow_populator.upload_yaml_workflow(content, name=name, **kwds)
|
|
return name
|
|
|
|
def workflow_run_setup_inputs(self, content: Optional[str]) -> tuple[str, dict[str, Any]]:
|
|
history_id = self.current_history_id()
|
|
if content:
|
|
yaml_content = yaml.safe_load(content)
|
|
if "test_data" in yaml_content:
|
|
test_data = yaml_content["test_data"]
|
|
else:
|
|
test_data = yaml_content
|
|
inputs, _, _ = load_data_dict(
|
|
history_id, test_data, self.dataset_populator, self.dataset_collection_populator
|
|
)
|
|
for input_value in inputs.values():
|
|
if (
|
|
isinstance(input_value, dict)
|
|
and (src := input_value.get("src"))
|
|
and (content_id := input_value.get("id"))
|
|
):
|
|
if src == "hda":
|
|
content_item = self.dataset_populator.get_history_dataset_details(
|
|
history_id, content_id=content_id
|
|
)
|
|
input_value["hid"] = content_item["hid"]
|
|
elif src == "hdca":
|
|
content_item = self.dataset_populator.get_history_collection_details(
|
|
history_id=history_id, content_id=content_id
|
|
)
|
|
input_value["hid"] = content_item["hid"]
|
|
self.dataset_populator.wait_for_history(history_id)
|
|
else:
|
|
inputs = {}
|
|
return history_id, inputs
|
|
|
|
def workflow_run_open_workflow(self, yaml_content: str):
|
|
name = self.workflow_upload_yaml_with_random_name(yaml_content)
|
|
self.workflow_run_with_name(name)
|
|
|
|
def workflow_run_and_submit(
|
|
self,
|
|
workflow_content: str,
|
|
test_data_content: Optional[str] = None,
|
|
landing_screenshot_name=None,
|
|
inputs_specified_screenshot_name: Optional[str] = None,
|
|
ensure_expanded: bool = False,
|
|
):
|
|
history_id, inputs = self.workflow_run_setup_inputs(test_data_content)
|
|
self.workflow_run_open_workflow(workflow_content)
|
|
if ensure_expanded:
|
|
self.workflow_run_ensure_expanded()
|
|
self.screenshot_if(landing_screenshot_name)
|
|
self.workflow_run_specify_inputs(inputs)
|
|
self.screenshot_if(inputs_specified_screenshot_name)
|
|
self.workflow_run_submit()
|
|
self.sleep_for(self.wait_types.UX_TRANSITION)
|
|
return history_id
|
|
|
|
def workflow_run_wait_for_ok(self, hid: int, expand=False):
|
|
timeout = self.wait_length(self.wait_types.JOB_COMPLETION)
|
|
item = self.content_item_by_attributes(hid=hid, state="ok")
|
|
item.wait_for_present(timeout=timeout)
|
|
if expand:
|
|
item.title.wait_for_and_click()
|
|
|
|
|
|
def default_web_host_for_selenium_tests():
|
|
if asbool(GALAXY_TEST_SELENIUM_REMOTE):
|
|
try:
|
|
dev_ip = get_ip_address("docker0")
|
|
return dev_ip
|
|
except OSError:
|
|
return DEFAULT_WEB_HOST
|
|
else:
|
|
return DEFAULT_WEB_HOST
|
|
|
|
|
|
def get_configured_driver():
|
|
backend_type = GALAXY_TEST_DRIVER_BACKEND
|
|
assert backend_type in ["selenium", "playwright"], f"Unknown GALAXY_TEST_DRIVER_BACKEND [{backend_type}]"
|
|
return driver_factory.ConfiguredDriver(
|
|
galaxy_timeout_handler(TIMEOUT_MULTIPLIER),
|
|
browser=GALAXY_TEST_SELENIUM_BROWSER,
|
|
remote=asbool(GALAXY_TEST_SELENIUM_REMOTE),
|
|
remote_host=GALAXY_TEST_SELENIUM_REMOTE_HOST,
|
|
remote_port=GALAXY_TEST_SELENIUM_REMOTE_PORT,
|
|
headless=headless_selenium(),
|
|
backend_type=cast(BackendType, backend_type),
|
|
)
|
|
|
|
|
|
def headless_selenium():
|
|
if asbool(GALAXY_TEST_SELENIUM_REMOTE):
|
|
return False
|
|
|
|
if GALAXY_TEST_SELENIUM_HEADLESS == "auto":
|
|
if (
|
|
driver_factory.is_virtual_display_available()
|
|
or driver_factory.get_local_browser(GALAXY_TEST_SELENIUM_BROWSER) == "CHROME"
|
|
):
|
|
return True
|
|
else:
|
|
return False
|
|
else:
|
|
return asbool(GALAXY_TEST_SELENIUM_HEADLESS)
|
|
|
|
|
|
def use_virtual_display():
|
|
using_selenium = GALAXY_TEST_DRIVER_BACKEND == "selenium"
|
|
if asbool(GALAXY_TEST_SELENIUM_REMOTE):
|
|
return False
|
|
|
|
if GALAXY_TEST_SELENIUM_HEADLESS == "auto":
|
|
if (
|
|
using_selenium
|
|
and driver_factory.is_virtual_display_available()
|
|
and not driver_factory.get_local_browser(GALAXY_TEST_SELENIUM_BROWSER) == "CHROME"
|
|
):
|
|
return True
|
|
else:
|
|
return False
|
|
else:
|
|
return using_selenium and asbool(GALAXY_TEST_SELENIUM_HEADLESS)
|
|
|
|
|
|
class SeleniumSessionGetPostMixin:
|
|
"""Mixin for adapting Galaxy testing populators helpers to Selenium session backed bioblend."""
|
|
|
|
selenium_context: GalaxySeleniumContext
|
|
|
|
@property
|
|
def _mixin_admin_api_key(self) -> str:
|
|
return getattr(self, "admin_api_key", get_admin_api_key())
|
|
|
|
def _get(self, route, data=None, headers=None, admin=False) -> Response:
|
|
data = data or {}
|
|
full_url = self.selenium_context.build_url(f"api/{route}", for_selenium=False)
|
|
cookies = None
|
|
if admin:
|
|
full_url = f"{full_url}?key={self._mixin_admin_api_key}"
|
|
else:
|
|
cookies = self.selenium_context.selenium_to_requests_cookies()
|
|
response = requests.get(full_url, params=data, cookies=cookies, headers=headers, timeout=DEFAULT_SOCKET_TIMEOUT)
|
|
return response
|
|
|
|
def _post(
|
|
self, route, data=None, files=None, headers=None, admin=False, json: bool = False, anon: bool = False
|
|
) -> Response:
|
|
full_url = self.selenium_context.build_url(f"api/{route}", for_selenium=False)
|
|
cookies = None
|
|
if admin:
|
|
full_url = f"{full_url}?key={self._mixin_admin_api_key}"
|
|
elif not anon:
|
|
cookies = self.selenium_context.selenium_to_requests_cookies()
|
|
request_kwd = prepare_request_params(data=data, files=files, as_json=json, headers=headers, cookies=cookies)
|
|
response = requests.post(full_url, timeout=DEFAULT_SOCKET_TIMEOUT, **request_kwd)
|
|
return response
|
|
|
|
def _delete(self, route, data=None, headers=None, admin=False, json: bool = False) -> Response:
|
|
full_url = self.selenium_context.build_url(f"api/{route}", for_selenium=False)
|
|
cookies = None
|
|
if admin:
|
|
full_url = f"{full_url}?key={self._mixin_admin_api_key}"
|
|
else:
|
|
cookies = self.selenium_context.selenium_to_requests_cookies()
|
|
request_kwd = prepare_request_params(data=data, as_json=json, headers=headers, cookies=cookies)
|
|
response = requests.delete(full_url, timeout=DEFAULT_SOCKET_TIMEOUT, **request_kwd)
|
|
return response
|
|
|
|
def _put(self, route, data=None, headers=None, admin=False, json: bool = False) -> Response:
|
|
full_url = self.selenium_context.build_url(f"api/{route}", for_selenium=False)
|
|
cookies = None
|
|
if admin:
|
|
full_url = f"{full_url}?key={self._mixin_admin_api_key}"
|
|
else:
|
|
cookies = self.selenium_context.selenium_to_requests_cookies()
|
|
request_kwd = prepare_request_params(data=data, as_json=json, headers=headers, cookies=cookies)
|
|
response = requests.put(full_url, **request_kwd)
|
|
return response
|
|
|
|
|
|
class SeleniumSessionDatasetPopulator(SeleniumSessionGetPostMixin, populators.BaseDatasetPopulator):
|
|
"""Implementation of BaseDatasetPopulator backed by bioblend."""
|
|
|
|
def __init__(self, selenium_context: GalaxySeleniumContext):
|
|
"""Construct a dataset populator from a bioblend GalaxyInstance."""
|
|
self.selenium_context = selenium_context
|
|
|
|
def _summarize_history(self, history_id: str) -> None:
|
|
pass
|
|
|
|
|
|
class SeleniumSessionDatasetCollectionPopulator(SeleniumSessionGetPostMixin, populators.BaseDatasetCollectionPopulator):
|
|
"""Implementation of BaseDatasetCollectionPopulator backed by bioblend."""
|
|
|
|
def __init__(self, selenium_context: GalaxySeleniumContext):
|
|
"""Construct a dataset collection populator from a bioblend GalaxyInstance."""
|
|
self.selenium_context = selenium_context
|
|
self.dataset_populator = SeleniumSessionDatasetPopulator(selenium_context)
|
|
|
|
def _create_collection(self, payload: dict) -> Response:
|
|
create_response = self._post("dataset_collections", data=payload, json=True)
|
|
return create_response
|
|
|
|
|
|
class SeleniumSessionWorkflowPopulator(SeleniumSessionGetPostMixin, populators.BaseWorkflowPopulator):
|
|
"""Implementation of BaseWorkflowPopulator backed by bioblend."""
|
|
|
|
def __init__(self, selenium_context: GalaxySeleniumContext):
|
|
"""Construct a workflow populator from a bioblend GalaxyInstance."""
|
|
self.selenium_context = selenium_context
|
|
self.dataset_populator = SeleniumSessionDatasetPopulator(selenium_context)
|
|
self.dataset_collection_populator = SeleniumSessionDatasetCollectionPopulator(selenium_context)
|
|
|
|
def import_workflow(self, workflow: dict, **kwds) -> dict:
|
|
workflow_str = json.dumps(workflow, indent=4)
|
|
data = {
|
|
"workflow": workflow_str,
|
|
}
|
|
data.update(**kwds)
|
|
upload_response = self._post("workflows", data=data)
|
|
upload_response.raise_for_status()
|
|
return upload_response.json()
|
|
|
|
|
|
__all__ = ("retry_during_transitions",)
|