Skip to content

[py] improve how tests are run against the grid server - #16851

Merged
titusfortner merged 3 commits into
trunkfrom
py_grid_tests
Jan 8, 2026
Merged

[py] improve how tests are run against the grid server#16851
titusfortner merged 3 commits into
trunkfrom
py_grid_tests

Conversation

@titusfortner

@titusfortner titusfortner commented Jan 5, 2026

Copy link
Copy Markdown
Member

User description

🔗 Related Issues

Splitting this out from #16814 so we can manage it independently

💥 What does this PR do?

  • Changes running remote as a specific driver to an arg that works for any browser
  • Now have chrome-remote and firefox-remote targets with tags that can be filtered on
  • Use bazel java by default (fallback to which java)
  • Use bazel built grid by default (fallback to selenium manager download)

🔧 Implementation Notes

  • This starts a new grid for each parallel process because I couldn't get it to reuse one without causing more problems. At least in CI we aren't running in parallel, but local execution might suffer.

💡 Additional Considerations

  • remote_connection_tests.py are failing now because they were pointing to the wrong server, and I'm not sure they are testing what we think they are testing. Needs to be fixed, but outside the scope of this PR

🔄 Types of changes

  • New feature (non-breaking change which adds functionality and tests!)

PR Type

Enhancement


Description

  • Refactored remote testing to use --remote flag instead of driver selection

  • Added support for browser-specific remote test targets (chrome-remote, firefox-remote)

  • Integrated Bazel-built Java and Grid server with fallback to system/Selenium Manager

  • Enhanced Server class with configurable java_path and startup_timeout parameters

  • Marked Firefox-specific tests as xfail_remote to skip when running remotely


Diagram Walkthrough

flowchart LR
  A["Test Configuration"] -->|"--remote flag"| B["Remote Mode Detection"]
  B -->|"is_remote property"| C["Driver Initialization"]
  C -->|"command_executor"| D["Grid Server"]
  E["Bazel Java Runtime"] -->|"java_path"| D
  F["Bazel Built JAR"] -->|"jar_path"| D
  G["System Java/Selenium Manager"] -->|"fallback"| D
  D -->|"status_url"| H["Remote WebDriver"]
Loading

File Walkthrough

Relevant files
Enhancement
2 files
conftest.py
Refactored remote testing to use --remote flag                     
+84/-56 
server.py
Added java_path and startup_timeout configuration               
+34/-3   
Configuration changes
2 files
pytest.bzl
Added data attribute to py_test rule                                         
+1/-0     
BUILD.bazel
Created browser-specific remote test targets with Bazel integration
+50/-27 
Tests
6 files
chrome_network_emulation_tests.py
Marked test as xfail_remote                                                           
+1/-0     
ff_installs_addons_tests.py
Marked module as xfail_remote for Firefox-specific APIs   
+2/-0     
ff_takes_full_page_screenshots_tests.py
Marked module as xfail_remote for Firefox-specific APIs   
+5/-0     
remote_connection_tests.py
Updated tests to use Grid server and marked as xfail         
+13/-14 
remote_custom_locator_tests.py
Updated fixture to use Grid server command_executor           
+5/-2     
remote_firefox_profile_tests.py
Updated test to use Grid server command_executor                 
+3/-2     
Additional files
2 files
driver_finder_tests.py [link]   
selenium_manager_tests.py [link]   

@selenium-ci selenium-ci added C-py Python Bindings B-build Includes scripting, bazel and CI integrations labels Jan 5, 2026
@qodo-code-review

qodo-code-review Bot commented Jan 5, 2026

Copy link
Copy Markdown
Contributor

PR Compliance Guide 🔍

Below is a summary of compliance checks for this PR:

Security Compliance
Untrusted executable path

Description: The remote Grid server startup may execute an attacker-controlled java binary if
SE_BAZEL_JAVA_LOCATION (and its referenced java-location.txt content) can be influenced in
the test environment, leading to arbitrary code execution when Server.start() invokes
subprocess.Popen. conftest.py [442-465]

Referred Code
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":
    # There are issues with window size/position when running Firefox
    # under Wayland, so we use XWayland instead.
    remote_env["MOZ_ENABLE_WAYLAND"] = "0"

server = Server(env=remote_env, startup_timeout=60)


 ... (clipped 3 lines)
Ticket Compliance
🟡
🎫 #1234
🟡
🎫 #5678
Codebase Duplication Compliance
Codebase context is not defined

Follow the guide to enable codebase context checks.

Custom Compliance
🟢
Generic: Comprehensive Audit Trails

Objective: To create a detailed and reliable record of critical system actions for security analysis
and compliance.

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Meaningful Naming and Self-Documenting Code

Objective: Ensure all identifiers clearly express their purpose and intent, making code
self-documenting

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Secure Error Handling

Objective: To prevent the leakage of sensitive system information through error messages while
providing sufficient detail for internal debugging.

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Security-First Input Validation and Data Handling

Objective: Ensure all data inputs are validated, sanitized, and handled securely to prevent
vulnerabilities

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

🔴
Generic: Robust Error Handling and Edge Case Management

Objective: Ensure comprehensive error handling that provides meaningful context and graceful
degradation

Status:
None path handling: The remote server fixture may raise a runtime exception because java_path can be None (set
in the exception handler) but is still passed to Path(java_path).exists() without
validation.

Referred Code
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":
    # There are issues with window size/position when running Firefox
    # 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():
    server.path = jar_path

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Secure Logging Practices

Objective: To ensure logs are useful for debugging and auditing without exposing sensitive
information like PII, PHI, or cardholder data.

Status:
Unstructured stdout logs: The server startup/shutdown flow writes operational messages to stdout via print(...),
which may be unsuitable for environments requiring structured logging and centralized log
redaction controls.

Referred Code
try:
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
        sock.connect((host, self.port))
    raise ConnectionError(f"Selenium server is already running, or something else is using port {self.port}")
except ConnectionRefusedError:
    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(timeout=self.startup_timeout):
        raise TimeoutError(f"Timed out waiting for Selenium server at {self.status_url}")
    print("Selenium server is ready")

Learn more about managing compliance generic rules or creating your own custom rules

  • Update
Compliance status legend 🟢 - Fully Compliant
🟡 - Partial Compliant
🔴 - Not Compliant
⚪ - Requires Further Human Verification
🏷️ - Compliance label

@qodo-code-review

qodo-code-review Bot commented Jan 5, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
High-level
Address performance of parallel test execution

The current approach of starting a new Grid server for each parallel test worker
degrades performance. Implement a sharing mechanism, like a file-based lock, to
ensure only one server instance is created and used by all workers.

Examples:

py/conftest.py [436-471]
def server(request):
    is_remote = request.config.getoption("remote")
    if not is_remote:
        yield None
        return

    r = Runfiles.Create()

    java_location_txt = r.Rlocation("_main/" + os.environ.get("SE_BAZEL_JAVA_LOCATION"))
    try:

 ... (clipped 26 lines)

Solution Walkthrough:

Before:

# in conftest.py

@pytest.fixture(autouse=True, scope="session")
def server(request):
    is_remote = request.config.getoption("remote")
    if not is_remote:
        yield None
        return

    # ... setup ...

    # This block is executed by each parallel worker
    server = Server(...)
    server.port = free_port() # Each worker gets a new port
    server.start() # Each worker starts a new Grid server

    yield server
    server.stop()

After:

# in conftest.py (conceptual, using pytest-xdist)

@pytest.fixture(autouse=True, scope="session")
def server(request, worker_id): # worker_id is provided by pytest-xdist
    if not request.config.getoption("remote"):
        yield None
        return

    # Use a file for coordination across processes
    info_file = Path("server_info.txt")

    if worker_id == "gw0": # The main worker
        # This worker starts the server
        server = Server(...)
        server.start()
        info_file.write_text(server.status_url) # Share server URL
        yield server
        server.stop()
        info_file.unlink()
    else: # Other workers
        # Wait for the main worker to start the server
        while not info_file.exists():
            time.sleep(0.1)
        status_url = info_file.read_text()
        # Yield a representation of the shared server
        yield SharedServer(status_url)
Suggestion importance[1-10]: 9

__

Why: This suggestion correctly identifies a major performance issue for local development, which the PR author also acknowledged, and proposes a valid architectural solution to fix it.

High
General
Use correct Bazel location macro

In py/BUILD.bazel, replace $(rootpath :java-location) with $(location
:java-location) to correctly pass the filesystem path of java-location.txt to
the test environment.

py/BUILD.bazel [535-537]

 env = {
-    "SE_BAZEL_JAVA_LOCATION": "$(rootpath :java-location)",
+    "SE_BAZEL_JAVA_LOCATION": "$(location :java-location)",
 },
  • Apply / Chat
Suggestion importance[1-10]: 9

__

Why: The suggestion correctly identifies that $(rootpath) is the wrong macro for getting a filesystem path at execution time and proposes using $(location), which is critical for the feature to work correctly.

High
Improve error handling for Java path discovery

Refactor the Java path discovery logic by first checking if the
SE_BAZEL_JAVA_LOCATION environment variable is set and replacing the broad
except Exception with more specific exception handling.

py/conftest.py [442-451]

 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
+java_location_file = os.environ.get("SE_BAZEL_JAVA_LOCATION")
+java_path = None
+if java_location_file:
+    try:
+        java_location_txt = r.Rlocation("_main/" + java_location_file)
+        with open(java_location_txt, encoding="utf-8") as handle:
+            read = handle.read().strip()
+            if read:
+                rel_path = read[len("external/") :] if read.startswith("external/") else read
+                java_path = r.Rlocation(rel_path)
+    except (IOError, TypeError) as e:
+        print(f"Could not get java path from runfiles, falling back to system PATH. Error: {e}", file=sys.stderr)
+        java_path = None
  • Apply / Chat
Suggestion importance[1-10]: 6

__

Why: The suggestion correctly points out that a broad except Exception can hide errors and proposes more robust error handling, which improves code quality and debuggability.

Low
Possible issue
Re-initialize driver object for test isolation

To ensure test isolation, unconditionally re-initialize the global
selenium_driver object in the driver fixture for each test, removing the if
selenium_driver is None: check.

py/conftest.py [325-333]

 @pytest.fixture
 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)
+    selenium_driver = Driver(driver_class, request)
     if server:
         selenium_driver._server = server
  • Apply / Chat
Suggestion importance[1-10]: 8

__

Why: The suggestion correctly identifies a significant test isolation issue where the global selenium_driver object is not re-initialized, leading to state leakage between tests, and provides a correct fix.

Medium
Provide command executor to remote WebDriver

In test_browser_specific_method, correctly initialize webdriver.Remote by
passing the command_executor argument derived from the server fixture.

py/test/selenium/webdriver/remote/remote_connection_tests.py [29-40]

 pytestmark = pytest.mark.xfail(reason="Tests not working as intended")
 
 
 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"{webserver}/simpleTest.html")
+    command_executor = server.status_url[: -len("/status")]
+    with webdriver.Remote(command_executor=command_executor, options=firefox_options) as driver:
+        driver.get(f"{webserver.where_is('simpleTest.html')}")
         screenshot = driver.execute("FULL_PAGE_SCREENSHOT")["value"]
         result = base64.b64decode(screenshot)
         kind = filetype.guess(result)
         assert kind is not None
         assert kind.mime == "image/png"
  • Apply / Chat
Suggestion importance[1-10]: 8

__

Why: The suggestion correctly identifies a bug where the webdriver.Remote is missing the command_executor, causing the test to fail, and provides the correct fix using the server fixture.

Medium
Learned
best practice
Cleanup process on startup failure

If startup times out (or another exception occurs), terminate the spawned
process in a try/finally to avoid leaving an orphaned Grid server running.

py/selenium/webdriver/remote/server.py [208-213]

 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(timeout=self.startup_timeout):
-    raise TimeoutError(f"Timed out waiting for Selenium server at {self.status_url}")
-print("Selenium server is ready")
+try:
+    print(f"Selenium server running as process: {self.process.pid}")
+    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")
+except Exception:
+    if self.process and self.process.poll() is None:
+        self.process.terminate()
+        self.process.wait()
+    self.process = None
+    raise
  • Apply / Chat
Suggestion importance[1-10]: 5

__

Why:
Relevant best practice - Ensure external resources/processes are cleaned up in a finally block so failures cannot leak resources.

Low
  • Update

@cgoldberg cgoldberg left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM besides the tiny change I suggested. Thanks.

Comment thread py/test/selenium/webdriver/remote/remote_connection_tests.py Outdated
Comment thread py/test/selenium/webdriver/remote/remote_custom_locator_tests.py Outdated
Comment thread py/test/selenium/webdriver/remote/remote_firefox_profile_tests.py Outdated
Comment thread py/conftest.py Outdated
@titusfortner
titusfortner merged commit b1904f8 into trunk Jan 8, 2026
33 checks passed
@titusfortner
titusfortner deleted the py_grid_tests branch January 8, 2026 17:14
AutomatedTester added a commit that referenced this pull request Sep 7, 2026
* [py] run BiDi tests against the Grid server

Add test-<browser>-remote-bidi targets for chrome and firefox, combining the
BiDi test sources with the grid data/env wiring the other remote suites use.
BIDI_TESTS was excluded from every remote suite glob, so the Python BiDi tests
had never run through Grid's websocket proxy.

No client change was needed: --remote and --bidi are independent code paths,
LocalNode already rewrites webSocketUrl to /session/<id>/se/bidi, and
ProxyNodeWebsockets tunnels it to the node.

Kept out of the test-<browser>-remote aggregate for now, since this is new
coverage. No xfail_remote markers were added: the suite passes except for two
tests that fail identically without --remote (a headless-Chrome
document.hasFocus() artifact, and a pre-existing firefox wheel-scroll timing
flake that fails 3/3 runs locally).

* [py] wire the remote BiDi suite into CI and document it

Add a test-remote-bidi aggregate test_suite alongside test-remote, and a
separate remote-bidi-tests job in ci-python.yml that queries it. Keeping it as
its own job means a red BiDi run cannot redden the established remote job while
this coverage is new.

Document the remote targets and the --remote / --bidi --remote pytest flags in
py/TESTING.md, including the Grid jar prerequisite.

* [py] set target_compatible_with on the remote test suites

The bidi and per-browser suites pass BROWSERS[browser]["target_compatible_with"]
through; the remote ones did not. It is a no-op today because the value is empty
for chrome and firefox, but the divergence would silently drop the platform
constraint the first time a remote suite is added for a browser that has one
(ie, safari, webkitgtk).

* [py] collapse the duplicated xfail_remote and remote-directory guards

The "skip unless --remote" guard was written out three times (driver,
firefox_options, chromium_options) and the xfail_<driver>/xfail_remote marker
dance twice (driver, clean_driver). Extract _skip_unless_remote and
_apply_xfail_markers.

The two marker blocks had drifted apart, so unifying them fixes two latent bugs
in the clean_driver path:

- it did not support the condition kwarg, and passed it straight to
  pytest.xfail(), which does not accept it (TypeError)
- neither block popped run before calling pytest.xfail(), so an explicit
  run=True raised TypeError as well

The `yield`/`return` after pytest.skip() in both blocks was dead code;
pytest.skip() raises.

Verified against alerts_tests, which exercises all three marker paths
(condition, run=False, plain xfail): 20 passed, 1 skipped, 2 xfailed both
before and after.

* [py] give the remote test suites the COMMON_TAGS their siblings carry

The remote suites set tags = ["no-sandbox", "remote", "<browser>-remote"],
omitting BROWSERS[browser]["tags"] (COMMON_TAGS + the bare browser name) that
every non-remote suite carries. Compose from the sibling expression instead and
keep the two remote markers on top.

browser-test and requires-network are inert here - nothing in .bazelrc*,
.github/, scripts/ or the Rakefile filters on either, and requires-network is
moot anyway because no-sandbox already disables the sandbox. The tag that
matters is the bare browser name: py/TESTING.md documents

    bazel test //py/... --test_tag_filters=chrome

and that silently matched no remote target. It now matches all 26 per-browser
remote-bidi targets. Ruby already does this and says why in a comment
(rb/spec/tests.bzl), so this aligns Python with the reference implementation.

The omission was incidental rather than deliberate: the per-browser remote
targets in b1904f8 (#16851) inherited the minimal tag set of the single
test-remote target they replaced, and no rationale for the tag choice is
recorded.

* [py] make the focus and wheel-scroll bidi tests work headed and headless

Both tests read an asynchronous result exactly once, so they raced it.

test_activate_browsing_context asserted that creating a second window takes
focus off the first. Focus is handed over asynchronously, so poll for it. That
alone is not enough: headless Chromium has no window manager and never hands
focus over at all, confirmed by a 5s wait timing out 5/5 runs. So skip in that
case only - gated on the new `headless` fixture, and only after the wait has
actually expired. Headless Firefox does hand focus over and keeps the full
assertions; headed runs still fail loudly if the handover regresses, because the
skip cannot trigger outside headless.

test_wheel_scroll_with_duration spreads a 100px scroll over 500ms and then
asserted pageYOffset == 100 immediately; Firefox was reliably one pixel short.
Wait for the scroll to settle before asserting, keeping the exact assertion
rather than widening it to a tolerance.

Verified per browser and per mode:
- test_activate: chrome headless skips (was failing), chrome headed passes with
  full assertions, firefox headless passes with full assertions and 0 skips
- test_wheel_scroll_with_duration: failed 3/3 runs at HEAD, 0/3 after

Both tests exist twice (bidi/ and _bidi/); both copies are updated.

Note: py/test/selenium/webdriver/common/_bidi/input_tests.py has further
pre-existing firefox flakiness not addressed here - test_key_input_with_value_attribute,
test_key_input_special_characters and test_combined_input_actions each failed in
some runs, a different one each time, both before and after this change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

B-build Includes scripting, bazel and CI integrations C-py Python Bindings Review effort 4/5

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants