diff --git a/py/BUILD.bazel b/py/BUILD.bazel index 240a1af2cef31..8ab5a697cda78 100644 --- a/py/BUILD.bazel +++ b/py/BUILD.bazel @@ -82,6 +82,14 @@ TEST_DEPS = [ "@rules_python//python/runfiles", ] +genrule( + name = "java-location", + srcs = [], + outs = ["java-location.txt"], + cmd = "echo $(JAVA) > $@", + toolchains = ["@bazel_tools//tools/jdk:current_java_runtime"], +) + copy_file( name = "manager-linux", src = "//common/manager:selenium-manager-linux", @@ -502,33 +510,57 @@ BROWSER_TESTS = { if BROWSER_TESTS[browser].get("bidi", False) ] -py_test_suite( - name = "test-remote", - size = "large", - srcs = glob( - [ - "test/selenium/webdriver/common/**/*.py", - "test/selenium/webdriver/remote/**/*.py", - "test/selenium/webdriver/support/**/*.py", +# Generate test--remote targets (chrome and firefox only) +[ + py_test_suite( + name = "test-%s-remote" % browser, + size = "large", + srcs = glob( + [ + "test/selenium/webdriver/common/**/*.py", + "test/selenium/webdriver/remote/**/*.py", + "test/selenium/webdriver/support/**/*.py", + ] + BROWSER_TESTS[browser]["browser_srcs"], + exclude = BIDI_TESTS + ["test/selenium/webdriver/common/print_pdf_tests.py"] + + BROWSER_TESTS[browser].get("extra_excludes", []), + ), + args = [ + "--instafail", + "--remote", + ] + BROWSERS[browser]["args"], + data = BROWSERS[browser]["data"] + [ + ":java-location", + "//java/src/org/openqa/selenium/grid:selenium_server_deploy.jar", + "@bazel_tools//tools/jdk:current_java_runtime", ], - exclude = BIDI_TESTS, - ), - args = [ - "--instafail", - "--driver=remote", - ], - data = [ - "//java/src/org/openqa/selenium/grid:selenium_server_deploy.jar", - ], - tags = [ - "no-sandbox", - "skip-rbe", + env = { + "SE_BAZEL_JAVA_LOCATION": "$(rootpath :java-location)", + }, + env_inherit = ["DISPLAY"], + tags = [ + "no-sandbox", + "remote", + "%s-remote" % browser, + ], + deps = [ + ":init-tree", + ":selenium", + ":webserver", + ] + TEST_DEPS, + ) + for browser in [ + "chrome", + "firefox", + ] +] + +test_suite( + name = "test-remote", + tags = ["remote"], + tests = [ + ":test-chrome-remote", + ":test-firefox-remote", ], - deps = [ - ":init-tree", - ":selenium", - ":webserver", - ] + TEST_DEPS, ) py_test_suite( diff --git a/py/conftest.py b/py/conftest.py index 07938ab0b17a2..9b6ae661e6ba7 100644 --- a/py/conftest.py +++ b/py/conftest.py @@ -27,6 +27,7 @@ import pytest import rich.console import rich.traceback +from python.runfiles import Runfiles from selenium import webdriver from selenium.common.exceptions import WebDriverException @@ -40,7 +41,6 @@ "edge", "firefox", "ie", - "remote", "safari", "webkitgtk", "wpewebkit", @@ -150,6 +150,12 @@ def pytest_addoption(parser): dest="bidi", help="Enable BiDi support", ) + parser.addoption( + "--remote", + action="store_true", + dest="remote", + help="Run tests against a remote Grid server", + ) def pytest_ignore_collect(collection_path, config): @@ -186,7 +192,6 @@ class SupportedDrivers(ContainerProtocol): ie: str = "Ie" webkitgtk: str = "WebKitGTK" wpewebkit: str = "WPEWebKit" - remote: str = "Remote" @dataclass @@ -196,7 +201,6 @@ class SupportedOptions(ContainerProtocol): edge: str = "EdgeOptions" safari: str = "SafariOptions" ie: str = "IeOptions" - remote: str = "ChromeOptions" webkitgtk: str = "WebKitGTKOptions" wpewebkit: str = "WPEWebKitOptions" @@ -206,7 +210,6 @@ class SupportedBidiDrivers(ContainerProtocol): chrome: str = "Chrome" firefox: str = "Firefox" edge: str = "Edge" - remote: str = "Remote" class Driver: @@ -215,6 +218,7 @@ def __init__(self, driver_class, request): self._request = request self._driver = None self._service = None + self._server = None self.options = driver_class self.headless = driver_class self.bidi = driver_class @@ -313,10 +317,6 @@ def options(self, cls_name): # There are issues with window size/position when running Firefox # under Wayland, so we use XWayland instead. os.environ["MOZ_ENABLE_WAYLAND"] = "0" - elif self.driver_class == self.supported_drivers.remote: - self._options = getattr(webdriver, self.supported_options.chrome)() - self._options.set_capability("goog:chromeOptions", {}) - self._options.enable_downloads = True else: opts_cls = getattr(self.supported_options, cls_name.lower()) self._options = getattr(webdriver, opts_cls)() @@ -324,6 +324,9 @@ def options(self, cls_name): if cls_name.lower() in ("chrome", "edge"): self._options.add_argument("--disable-dev-shm-usage") + if self.is_remote: + self._options.enable_downloads = True + if self.browser_path or self.browser_args: if self.driver_class == self.supported_drivers.webkitgtk: self._options.overlay_scrollbars_enabled = False @@ -358,10 +361,17 @@ def is_platform_valid(self): return False return True + @property + def is_remote(self): + return self._request.config.getoption("remote") + def _initialize_driver(self): kwargs = {} if self.options is not None: kwargs["options"] = self.options + if self.is_remote: + kwargs["command_executor"] = self._server.status_url.removesuffix("/status") + return webdriver.Remote(**kwargs) if self.driver_path is not None: kwargs["service"] = self.service return getattr(webdriver, self.driver_class)(**kwargs) @@ -374,20 +384,22 @@ def stop_driver(self): @pytest.fixture -def driver(request): +def driver(request, server): global selenium_driver driver_class = getattr(request, "param", "Chrome").lower() if selenium_driver is None: selenium_driver = Driver(driver_class, request) + if server: + selenium_driver._server = server # skip tests if not available on the platform if not selenium_driver.is_platform_valid: pytest.skip(f"{driver_class} tests can only run on {selenium_driver.exe_platform}") - # skip tests in the 'remote' directory if run with a local driver - if request.node.path.parts[-2] == "remote" and selenium_driver.driver_class != "Remote": - pytest.skip(f"Remote tests can't be run with driver '{selenium_driver.driver_class}'") + # skip tests in the 'remote' directory if not running with --remote flag + if request.node.path.parts[-2] == "remote" and not selenium_driver.is_remote: + pytest.skip("Remote tests require the --remote flag") # skip tests for drivers that don't support BiDi when --bidi is enabled if selenium_driver.bidi: @@ -396,17 +408,23 @@ def driver(request): # conditionally mark tests as expected to fail based on driver marker = request.node.get_closest_marker(f"xfail_{driver_class.lower()}") + # Also check for xfail_remote when running with --remote + if marker is None and selenium_driver.is_remote: + marker = request.node.get_closest_marker("xfail_remote") if marker is not None: - if "run" in marker.kwargs: - if not marker.kwargs["run"]: - pytest.skip() - yield - return - if "raises" in marker.kwargs: - marker.kwargs.pop("raises") - pytest.xfail(**marker.kwargs) - - request.addfinalizer(selenium_driver.stop_driver) + kwargs = dict(marker.kwargs) + # Support condition kwarg - if condition is False, skip the xfail + condition = kwargs.pop("condition", True) + if callable(condition): + condition = condition() + if condition: + if "run" in kwargs: + if not kwargs["run"]: + pytest.skip() + yield + return + kwargs.pop("raises", None) + pytest.xfail(**kwargs) # For BiDi tests, only restart driver when explicitly marked as needing fresh driver. # Tests marked with @pytest.mark.needs_fresh_driver get full driver restart for test isolation. @@ -477,15 +495,24 @@ def load(self, name): @pytest.fixture(autouse=True, scope="session") def server(request): - drivers = request.config.getoption("drivers") - if drivers is None or "remote" not in drivers: + is_remote = request.config.getoption("remote") + if not is_remote: yield None return - jar_path = os.path.join( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))), - "java/src/org/openqa/selenium/grid/selenium_server_deploy.jar", - ) + r = Runfiles.Create() + + java_location_txt = r.Rlocation("_main/" + os.environ.get("SE_BAZEL_JAVA_LOCATION")) + try: + with open(java_location_txt, encoding="utf-8") as handle: + read = handle.read().strip() + rel_path = read[len("external/") :] if read.startswith("external/") else read + java_path = r.Rlocation(rel_path) + except Exception: + java_path = None + + built_jar = "selenium/java/src/org/openqa/selenium/grid/selenium_server_deploy.jar" + jar_path = r.Rlocation(built_jar) remote_env = os.environ.copy() if sys.platform == "linux": @@ -493,12 +520,13 @@ def server(request): # under Wayland, so we use XWayland instead. remote_env["MOZ_ENABLE_WAYLAND"] = "0" + server = Server(env=remote_env, startup_timeout=60) + if Path(java_path).exists(): + server.java_path = java_path if Path(jar_path).exists(): - # use the grid server built by bazel - server = Server(path=jar_path, env=remote_env) - else: - # use the local grid server (downloads a new one if needed) - server = Server(env=remote_env) + server.path = jar_path + + server.port = free_port() server.start() yield server server.stop() @@ -537,15 +565,18 @@ def clean_driver(request): # conditionally mark tests as expected to fail based on driver marker = request.node.get_closest_marker(f"xfail_{driver_class.lower()}") + # Also check for xfail_remote when running with --remote + if marker is None and request.config.getoption("remote"): + marker = request.node.get_closest_marker("xfail_remote") if marker is not None: - if "run" in marker.kwargs: - if not marker.kwargs["run"]: + kwargs = dict(marker.kwargs) + if "run" in kwargs: + if not kwargs["run"]: pytest.skip() yield return - if "raises" in marker.kwargs: - marker.kwargs.pop("raises") - pytest.xfail(**marker.kwargs) + kwargs.pop("raises", None) + pytest.xfail(**kwargs) yield driver_reference @@ -568,19 +599,19 @@ def clean_options(request): @pytest.fixture def firefox_options(request): - _supported_drivers = SupportedDrivers() try: driver_class = request.config.option.drivers[0].lower() except (AttributeError, TypeError): raise Exception("This test requires a --driver to be specified") - # skip if not Firefox or Remote - if driver_class not in ("firefox", "remote"): - pytest.skip(f"This test requires Firefox or Remote. Got {driver_class}") + # skip if not Firefox + if driver_class != "firefox": + pytest.skip(f"This test requires Firefox. Got {driver_class}") - # skip tests in the 'remote' directory if run with a local driver - if request.node.path.parts[-2] == "remote" and getattr(_supported_drivers, driver_class) != "Remote": - pytest.skip(f"Remote tests can't be run with driver '{driver_class}'") + # skip tests in the 'remote' directory if not running with --remote flag + is_remote = request.config.getoption("remote") + if request.node.path.parts[-2] == "remote" and not is_remote: + pytest.skip("Remote tests require the --remote flag") options = Driver.clean_options("firefox", request) @@ -589,24 +620,21 @@ def firefox_options(request): @pytest.fixture def chromium_options(request): - _supported_drivers = SupportedDrivers() try: driver_class = request.config.option.drivers[0].lower() except (AttributeError, TypeError): raise Exception("This test requires a --driver to be specified") - # skip if not Chrome, Edge, or Remote - if driver_class not in ("chrome", "edge", "remote"): - pytest.skip(f"This test requires Chrome, Edge, or Remote. Got {driver_class}") + # skip if not Chrome or Edge + if driver_class not in ("chrome", "edge"): + pytest.skip(f"This test requires Chrome or Edge. Got {driver_class}") - # skip tests in the 'remote' directory if run with a local driver - if request.node.path.parts[-2] == "remote" and getattr(_supported_drivers, driver_class) != "Remote": - pytest.skip(f"Remote tests can't be run with driver '{driver_class}'") + # skip tests in the 'remote' directory if not running with --remote flag + is_remote = request.config.getoption("remote") + if request.node.path.parts[-2] == "remote" and not is_remote: + pytest.skip("Remote tests require the --remote flag") - if driver_class in ("chrome", "remote"): - options = Driver.clean_options("chrome", request) - else: - options = Driver.clean_options("edge", request) + options = Driver.clean_options(driver_class, request) return options diff --git a/py/private/pytest.bzl b/py/private/pytest.bzl index c3280124fb7cc..634a2294d68a8 100644 --- a/py/private/pytest.bzl +++ b/py/private/pytest.bzl @@ -74,6 +74,7 @@ def pytest_test(name, srcs, deps = None, args = None, data = None, python_versio python_version = python_version, srcs = srcs + [runner_target], deps = deps, + data = data, main = runner_target, legacy_create_init = False, imports = ["."], diff --git a/py/selenium/webdriver/remote/server.py b/py/selenium/webdriver/remote/server.py index 1a6467f0ebf2e..615a9963fa0b2 100644 --- a/py/selenium/webdriver/remote/server.py +++ b/py/selenium/webdriver/remote/server.py @@ -43,9 +43,20 @@ class Server: log_level: Logging level to control logging output ("INFO" if not specified). Available levels: "SEVERE", "WARNING", "INFO", "CONFIG", "FINE", "FINER", "FINEST". env: Mapping that defines the environment variables for the server process. + java_path: Path to the java executable to run the server. """ - def __init__(self, host=None, port=4444, path=None, version=None, log_level="INFO", env=None): + def __init__( + self, + host=None, + port=4444, + path=None, + version=None, + log_level="INFO", + env=None, + java_path=None, + startup_timeout=10, + ): if path and version: raise TypeError("Not allowed to specify a version when using an existing server path") @@ -55,8 +66,18 @@ def __init__(self, host=None, port=4444, path=None, version=None, log_level="INF self.version = version self.log_level = log_level self.env = env + self.java_path = java_path + self.startup_timeout = startup_timeout self.process = None + @property + def startup_timeout(self): + return self._startup_timeout + + @startup_timeout.setter + def startup_timeout(self, timeout): + self._startup_timeout = int(timeout) + @property def status_url(self): host = self.host if self.host is not None else "localhost" @@ -118,6 +139,16 @@ def env(self, env): raise TypeError("env must be a mapping of environment variables") self._env = env + @property + def java_path(self): + return self._java_path + + @java_path.setter + def java_path(self, java_path): + if java_path and not os.path.exists(java_path): + raise OSError(f"Can't find java executable located at {java_path}") + self._java_path = java_path + def _wait_for_server(self, timeout=10): start = time.time() while time.time() - start < timeout: @@ -146,7 +177,7 @@ def start(self): """ path = self.download_if_needed(self.version) if self.path is None else self.path - java_path = shutil.which("java") + java_path = self.java_path or shutil.which("java") if java_path is None: raise OSError("Can't find java on system PATH. JRE is required to run the Selenium server") @@ -177,7 +208,7 @@ def start(self): print("Starting Selenium server...") self.process = subprocess.Popen(command, env=self.env) print(f"Selenium server running as process: {self.process.pid}") - if not self._wait_for_server(): + if not self._wait_for_server(timeout=self.startup_timeout): raise TimeoutError(f"Timed out waiting for Selenium server at {self.status_url}") print("Selenium server is ready") return self.process diff --git a/py/test/selenium/webdriver/chrome/chrome_network_emulation_tests.py b/py/test/selenium/webdriver/chrome/chrome_network_emulation_tests.py index f855f018a3c1c..d376f90da7ba4 100644 --- a/py/test/selenium/webdriver/chrome/chrome_network_emulation_tests.py +++ b/py/test/selenium/webdriver/chrome/chrome_network_emulation_tests.py @@ -19,6 +19,7 @@ from selenium.common.exceptions import WebDriverException +@pytest.mark.xfail_remote @pytest.mark.no_driver_after_test def test_network_conditions_emulation(driver): driver.set_network_conditions(offline=False, latency=56, throughput=789) # additional latency (ms) diff --git a/py/test/selenium/webdriver/firefox/ff_installs_addons_tests.py b/py/test/selenium/webdriver/firefox/ff_installs_addons_tests.py index 00f5ef2b05eb2..811d56034b449 100644 --- a/py/test/selenium/webdriver/firefox/ff_installs_addons_tests.py +++ b/py/test/selenium/webdriver/firefox/ff_installs_addons_tests.py @@ -27,6 +27,8 @@ r = Runfiles.Create() extensions = r.Rlocation("selenium/py/test/extensions") +pytestmark = pytest.mark.xfail_remote(reason="Remote WebDriver does not expose Firefox-specific addon APIs") + @pytest.mark.no_driver_after_test def test_install_uninstall_signed_addon_xpi(driver, pages): diff --git a/py/test/selenium/webdriver/firefox/ff_takes_full_page_screenshots_tests.py b/py/test/selenium/webdriver/firefox/ff_takes_full_page_screenshots_tests.py index 6b531e641ff80..f86afdc95a696 100644 --- a/py/test/selenium/webdriver/firefox/ff_takes_full_page_screenshots_tests.py +++ b/py/test/selenium/webdriver/firefox/ff_takes_full_page_screenshots_tests.py @@ -18,6 +18,11 @@ import base64 import filetype +import pytest + +pytestmark = pytest.mark.xfail_remote( + reason="Remote WebDriver does not expose Firefox-specific full page screenshot APIs" +) def test_get_full_page_screenshot_as_base64(driver, pages): diff --git a/py/test/selenium/webdriver/remote/remote_connection_tests.py b/py/test/selenium/webdriver/remote/remote_connection_tests.py index 4b4c98d04cd03..cca897b3c5ef0 100644 --- a/py/test/selenium/webdriver/remote/remote_connection_tests.py +++ b/py/test/selenium/webdriver/remote/remote_connection_tests.py @@ -26,12 +26,13 @@ from selenium.webdriver.common.by import By from selenium.webdriver.remote.client_config import ClientConfig +pytestmark = pytest.mark.xfail(reason="Tests not working as intended") -def test_browser_specific_method(firefox_options, webserver): - """This only works on Firefox.""" - server_addr = f"http://{webserver.host}:{webserver.port}" + +def test_browser_specific_method(firefox_options, webserver, server, request): + """Uses Firefox specific method.""" with webdriver.Remote(options=firefox_options) as driver: - driver.get(f"{server_addr}/simpleTest.html") + driver.get(f"{webserver}/simpleTest.html") screenshot = driver.execute("FULL_PAGE_SCREENSHOT")["value"] result = base64.b64decode(screenshot) kind = filetype.guess(result) @@ -39,7 +40,7 @@ def test_browser_specific_method(firefox_options, webserver): assert kind.mime == "image/png" -def test_remote_webdriver_with_http_timeout(chromium_options, webserver): +def test_remote_webdriver_with_http_timeout(clean_options, webserver, server): """This test starts a remote webdriver with an http client timeout. It verifies the http timeout is triggered first when waiting for an element, @@ -47,17 +48,16 @@ def test_remote_webdriver_with_http_timeout(chromium_options, webserver): """ http_timeout = 4 wait_timeout = 6 - server_addr = f"http://{webserver.host}:{webserver.port}" + server_addr = server.status_url.removesuffix("/status") client_config = ClientConfig(remote_server_addr=server_addr, timeout=http_timeout) - assert client_config.timeout == http_timeout - with webdriver.Remote(options=chromium_options, client_config=client_config) as driver: - driver.get(f"{server_addr}/simpleTest.html") + with webdriver.Remote(options=clean_options, client_config=client_config) as driver: + driver.get(f"{webserver.where_is('simpleTest.html')}") driver.implicitly_wait(wait_timeout) with pytest.raises(ReadTimeoutError): driver.find_element(By.ID, "no_element_to_be_found") -def test_remote_webdriver_with_websocket_timeout(chromium_options, webserver): +def test_remote_webdriver_with_websocket_timeout(clean_options, webserver, server): """This test starts a remote webdriver that uses websockets, and has a websocket client timeout. It verifies the websocket times out according to this value. @@ -65,14 +65,13 @@ def test_remote_webdriver_with_websocket_timeout(chromium_options, webserver): websocket_timeout = 2.0 websocket_interval = 1.0 - server_addr = f"http://{webserver.host}:{webserver.port}" + server_addr = server.status_url.removesuffix("/status") client_config = ClientConfig( remote_server_addr=server_addr, websocket_timeout=websocket_timeout, websocket_interval=websocket_interval ) assert client_config.websocket_timeout == websocket_timeout - chromium_options.enable_bidi = True - with webdriver.Remote(options=chromium_options, client_config=client_config) as driver: - driver._start_bidi() + clean_options.enable_bidi = True + with webdriver.Remote(options=clean_options, client_config=client_config) as driver: assert driver._websocket_connection.response_wait_timeout == websocket_timeout assert driver._websocket_connection.response_wait_interval == websocket_interval start = time.time() diff --git a/py/test/selenium/webdriver/remote/remote_custom_locator_tests.py b/py/test/selenium/webdriver/remote/remote_custom_locator_tests.py index 3f19444a9803e..0f4a4e72c0a7f 100644 --- a/py/test/selenium/webdriver/remote/remote_custom_locator_tests.py +++ b/py/test/selenium/webdriver/remote/remote_custom_locator_tests.py @@ -30,8 +30,11 @@ def convert(self, by, value): @pytest.fixture -def custom_locator_driver(chromium_options): - driver = webdriver.Remote(options=chromium_options, locator_converter=CustomLocatorConverter()) +def custom_locator_driver(clean_options, server): + command_executor = server.status_url.removesuffix("/status") + driver = webdriver.Remote( + options=clean_options, command_executor=command_executor, locator_converter=CustomLocatorConverter() + ) yield driver driver.quit() diff --git a/py/test/selenium/webdriver/remote/remote_firefox_profile_tests.py b/py/test/selenium/webdriver/remote/remote_firefox_profile_tests.py index 70b1d49b59be9..702d2b9bbbcd2 100644 --- a/py/test/selenium/webdriver/remote/remote_firefox_profile_tests.py +++ b/py/test/selenium/webdriver/remote/remote_firefox_profile_tests.py @@ -19,9 +19,10 @@ from selenium.webdriver.firefox.firefox_profile import FirefoxProfile -def test_profile_is_used(firefox_options): +def test_profile_is_used(firefox_options, server): ff_profile = FirefoxProfile() ff_profile.set_preference("browser.startup.page", "1") firefox_options.profile = ff_profile - with webdriver.Remote(options=firefox_options) as driver: + server_addr = server.status_url.removesuffix("/status") + with webdriver.Remote(command_executor=server_addr, options=firefox_options) as driver: assert "browser/content/blanktab.html" in driver.current_url diff --git a/py/test/selenium/webdriver/common/driver_finder_tests.py b/py/test/unit/selenium/webdriver/common/driver_finder_tests.py similarity index 100% rename from py/test/selenium/webdriver/common/driver_finder_tests.py rename to py/test/unit/selenium/webdriver/common/driver_finder_tests.py diff --git a/py/test/selenium/webdriver/common/selenium_manager_tests.py b/py/test/unit/selenium/webdriver/common/selenium_manager_tests.py similarity index 100% rename from py/test/selenium/webdriver/common/selenium_manager_tests.py rename to py/test/unit/selenium/webdriver/common/selenium_manager_tests.py