From 3068e8328338740fb74d03a7dd0dce2a9b54e0eb Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Wed, 19 Aug 2026 01:15:21 +0530 Subject: [PATCH 1/5] test(selenium-devtools-py): pin the selenium BiDi surface, and install it in CI --- .github/workflows/python.yml | 7 ++ .../tests/test_bidi_preload.py | 36 +----- .../tests/test_selenium_surface.py | 105 ++++++++++++++++++ 3 files changed, 114 insertions(+), 34 deletions(-) create mode 100644 packages/selenium-devtools-py/tests/test_selenium_surface.py diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 9d3102e9..4dddf79c 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -38,6 +38,13 @@ jobs: - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: ${{ matrix.python-version }} + # selenium is the adapter's only runtime dependency, and the tests that + # pin its BiDi surface (tests/test_selenium_surface.py) skip without it — + # so a bump that moved those internals used to pass CI green and degrade + # at runtime. Installing the package is what makes those guards real. + - name: Install the adapter and its runtime dependency + run: pip install -e . + - name: Contract is in sync with shared run: | python scripts/gen_contract.py diff --git a/packages/selenium-devtools-py/tests/test_bidi_preload.py b/packages/selenium-devtools-py/tests/test_bidi_preload.py index e6471ae6..a35a90a3 100644 --- a/packages/selenium-devtools-py/tests/test_bidi_preload.py +++ b/packages/selenium-devtools-py/tests/test_bidi_preload.py @@ -6,7 +6,6 @@ removes the whole class of "when do we re-inject / who owns this DOM" races. """ -import importlib.util import unittest from unittest import mock @@ -143,39 +142,8 @@ def execute(script, *_args): self.assertEqual([s for s in seen if "createElement" in s], []) -#: selenium is the adapter's only runtime requirement, but the package is -#: deliberately importable and unit-testable without it — the CI job installs -#: nothing and runs `PYTHONPATH=src python -m unittest`. Mirrors the -#: `resolve_script_path()` guard in test_snapshot.py on the same principle. -_HAS_SELENIUM = importlib.util.find_spec("selenium") is not None - - -@unittest.skipUnless(_HAS_SELENIUM, "selenium is not installed") -class TestTheSeleniumSurfaceWeDependOn(unittest.TestCase): - """`pin()` is public, but its docstring says "current browsing context" while - we depend on it registering GLOBALLY so documents created later are covered. - That reliance is undocumented, so it is pinned here: if selenium ever scopes - `pin` to one context, this fails instead of the preload silently covering - only the first document.""" - - def test_pin_registers_without_a_browsing_context(self): - from selenium.webdriver.common.bidi.script import Script - - seen = {} - - def fake_add(self, function_declaration, *args, **kwargs): - seen["args"] = args - seen["kwargs"] = kwargs - return "id" - - # A bare instance: constructing a real Script needs a live driver, and - # only the dispatch from pin() to _add_preload_script is under test. - script = Script.__new__(Script) - with mock.patch.object(Script, "_add_preload_script", fake_add): - script.pin("async () => {}") - - self.assertEqual(seen["args"], ()) - self.assertIsNone(seen["kwargs"].get("contexts")) +# The `pin()` global-registration guard this depends on lives with the rest of +# the selenium surface, in test_selenium_surface.py. if __name__ == "__main__": diff --git a/packages/selenium-devtools-py/tests/test_selenium_surface.py b/packages/selenium-devtools-py/tests/test_selenium_surface.py new file mode 100644 index 00000000..a342dec2 --- /dev/null +++ b/packages/selenium-devtools-py/tests/test_selenium_surface.py @@ -0,0 +1,105 @@ +"""The selenium surface this adapter reaches past the public API for. + +`tests/test_bidi.py` runs entirely on fakes, which is right for the mapping +logic but means a selenium upgrade that moves any of these attributes passes CI +green and degrades at runtime — console and network capture simply stop. Selenium +PR 17761 regenerates the BiDi layer from a shared schema, so the move is likely +rather than hypothetical. + +These assert against the INSTALLED selenium, so they fail loudly on a bump. They +check shape only — no browser, no session — because the point is to detect a +rename or relocation, not to test selenium's behaviour. + +Skipped when selenium is absent. That is not free: the CI job must install the +adapter's own runtime dependency or these never run where they are meant to +protect. See the `pip install -e .` step in `.github/workflows/python.yml`. +""" + +import importlib.util +import inspect +import unittest + +_HAS_SELENIUM = importlib.util.find_spec("selenium") is not None + + +@unittest.skipUnless(_HAS_SELENIUM, "selenium is not installed") +class TestTheBiDiInternalsTheAdapterUses(unittest.TestCase): + """`bidi.py` reaches through `driver.network.conn` to subscribe WITHOUT + interception — selenium's high-level `add_request_handler` pauses requests, + which would stall a user's page loads. That is a deliberate trade of public + API for not breaking the page, and it is what these pin.""" + + def test_the_driver_exposes_the_bidi_channels(self): + from selenium.webdriver.remote.webdriver import WebDriver + + # Properties on the class, so this needs no live session. + self.assertIsInstance(getattr(WebDriver, "script", None), property) + self.assertIsInstance(getattr(WebDriver, "network", None), property) + + def test_the_network_channel_still_carries_the_low_level_connection(self): + from selenium.webdriver.common.bidi.network import Network + + # `driver.network.conn` is the whole reason this module reaches inside. + self.assertIn("conn", inspect.signature(Network.__init__).parameters) + self.assertIn("self.conn", inspect.getsource(Network.__init__)) + + def test_the_event_and_session_types_are_where_the_adapter_imports_them(self): + from selenium.webdriver.common.bidi.network import NetworkEvent + from selenium.webdriver.common.bidi.session import Session + + # Constructed as `NetworkEvent(name)` and `Session(conn).subscribe(...)`. + self.assertTrue(callable(NetworkEvent)) + self.assertTrue(hasattr(Session, "subscribe")) + + def test_the_console_channel_keeps_its_handler_api(self): + from selenium.webdriver.common.bidi.script import Script + + for method in ("add_console_message_handler", "add_javascript_error_handler"): + self.assertTrue(hasattr(Script, method), method) + + def test_pin_registers_a_preload_without_a_browsing_context(self): + """The document-start preload depends on `pin()` registering GLOBALLY so + documents created later are covered. Its docstring says "current + browsing context", but it forwards no `contexts` and BiDi reads that as + every context. Undocumented, so pinned: if selenium ever scopes `pin`, + this fails instead of the preload silently covering only the first + document.""" + from unittest import mock + + from selenium.webdriver.common.bidi.script import Script + + seen = {} + + def fake_add(self, function_declaration, *args, **kwargs): + seen["args"] = args + seen["kwargs"] = kwargs + return "id" + + # A bare instance: constructing a real Script needs a live driver, and + # only the dispatch from pin() to _add_preload_script is under test. + script = Script.__new__(Script) + with mock.patch.object(Script, "_add_preload_script", fake_add): + script.pin("async () => {}") + + self.assertEqual(seen["args"], ()) + self.assertIsNone(seen["kwargs"].get("contexts")) + + +@unittest.skipUnless(_HAS_SELENIUM, "selenium is not installed") +class TestTheCapabilityTheAdapterInjects(unittest.TestCase): + def test_the_bidi_capability_name_matches_selenium(self): + # `_enable_bidi_capability` writes this into newSession capabilities, and + # `options.web_socket_url = True` is how a user sets it. Both must mean + # the same key or BiDi silently never opens. + from selenium.webdriver.common.options import ArgOptions + + from selenium_devtools.constants import BIDI_CAPABILITY + + options = ArgOptions() + options.web_socket_url = True + + self.assertIn(BIDI_CAPABILITY, options.to_capabilities()) + + +if __name__ == "__main__": + unittest.main() From 97f2c02ccb807e4b758d81b427206cc71988a24e Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Wed, 19 Aug 2026 02:12:54 +0530 Subject: [PATCH 2/5] =?UTF-8?q?selenium=20is=20an=20optional=20extra=20of?= =?UTF-8?q?=20this=20package,=20so=20the=20job=20selects=20it=20explicitly?= =?UTF-8?q?:=20Obtaining=20file:///Users/vishnu.p%40browserstack.com/Docum?= =?UTF-8?q?ents/devtools.=20A=20plain=20editable=20install=20pulls=20nothi?= =?UTF-8?q?ng=20and=20every=20guard=20silently=20skips=20=E2=80=94=20verif?= =?UTF-8?q?ied=20in=20a=20clean=20venv,=20False=20vs=20True.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Network.conn guard asserts the runtime contract () rather than the literal text of selenium's constructor, so a compatible rewrite of that assignment does not fail CI. --- .github/workflows/python.yml | 2 +- .../tests/test_selenium_surface.py | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 4dddf79c..0eaa6444 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -43,7 +43,7 @@ jobs: # so a bump that moved those internals used to pass CI green and degrade # at runtime. Installing the package is what makes those guards real. - name: Install the adapter and its runtime dependency - run: pip install -e . + run: pip install -e '.[selenium]' - name: Contract is in sync with shared run: | diff --git a/packages/selenium-devtools-py/tests/test_selenium_surface.py b/packages/selenium-devtools-py/tests/test_selenium_surface.py index a342dec2..b0b539fe 100644 --- a/packages/selenium-devtools-py/tests/test_selenium_surface.py +++ b/packages/selenium-devtools-py/tests/test_selenium_surface.py @@ -12,7 +12,9 @@ Skipped when selenium is absent. That is not free: the CI job must install the adapter's own runtime dependency or these never run where they are meant to -protect. See the `pip install -e .` step in `.github/workflows/python.yml`. +protect. selenium is an OPTIONAL extra of this package, so the job must select it — +`pip install -e '.[selenium]'` in `.github/workflows/python.yml`. A plain +`pip install -e .` installs nothing and every guard here silently skips. """ import importlib.util @@ -40,8 +42,15 @@ def test_the_network_channel_still_carries_the_low_level_connection(self): from selenium.webdriver.common.bidi.network import Network # `driver.network.conn` is the whole reason this module reaches inside. + # Asserted against the constructed object rather than the source text: + # what the adapter depends on is that `.conn` is reachable and is the + # connection it was given, not how selenium happens to write the + # assignment. A sentinel stands in for the connection — no session is + # needed to answer the question. self.assertIn("conn", inspect.signature(Network.__init__).parameters) - self.assertIn("self.conn", inspect.getsource(Network.__init__)) + + sentinel = object() + self.assertIs(Network(sentinel).conn, sentinel) def test_the_event_and_session_types_are_where_the_adapter_imports_them(self): from selenium.webdriver.common.bidi.network import NetworkEvent From 7f5e972aadc6ca29893f35a96b97cdd0483e3001 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Wed, 19 Aug 2026 03:00:24 +0530 Subject: [PATCH 3/5] fix(selenium-devtools-py): Capped selenium below 4.44 --- packages/selenium-devtools-py/pyproject.toml | 10 +- .../src/selenium_devtools/bidi.py | 9 ++ .../tests/test_selenium_surface.py | 93 +++++++++++++++++-- 3 files changed, 100 insertions(+), 12 deletions(-) diff --git a/packages/selenium-devtools-py/pyproject.toml b/packages/selenium-devtools-py/pyproject.toml index 8b78481b..d0ce0a50 100644 --- a/packages/selenium-devtools-py/pyproject.toml +++ b/packages/selenium-devtools-py/pyproject.toml @@ -16,9 +16,15 @@ keywords = ["selenium", "webdriver", "devtools", "pytest", "debugging"] # only patch it when present, so it's not a hard requirement to import. dependencies = [] +# Capped below 4.44: that release regenerated the BiDi layer from a schema and +# took `NetworkEvent` out of `bidi.network` while renaming `Network.conn` to +# `_conn` — the two internals `bidi.py` subscribes to network events through. +# Console capture and the document-start preload are unaffected. The cap states +# a breakage that already exists rather than causing one; lifting it needs the +# port to the new `_event_manager` surface (issue #293). [project.optional-dependencies] -selenium = ["selenium>=4.6"] -test = ["pytest>=7", "selenium>=4.6"] +selenium = ["selenium>=4.6,<4.44"] +test = ["pytest>=7", "selenium>=4.6,<4.44"] # Auto-discovered by pytest; inert unless DEVTOOLS_ENABLE / DEVTOOLS_PORT is set. [project.entry-points.pytest11] diff --git a/packages/selenium-devtools-py/src/selenium_devtools/bidi.py b/packages/selenium-devtools-py/src/selenium_devtools/bidi.py index fecf22f9..492ff56b 100644 --- a/packages/selenium-devtools-py/src/selenium_devtools/bidi.py +++ b/packages/selenium-devtools-py/src/selenium_devtools/bidi.py @@ -15,6 +15,15 @@ * selenium's high-level ``network.add_request_handler`` *intercepts* (pauses) requests. We deliberately avoid it: we subscribe to the network events via the low-level connection so requests are observed but never stalled. + +The network half of that has a ceiling: selenium 4.44 regenerated the BiDi layer +from a schema, dropping ``NetworkEvent`` and renaming ``Network.conn`` to +``_conn``, so ``_attach_network`` degrades to a warning there and the Network tab +stays empty. ``pyproject.toml`` caps the extra below it. The observe-only +replacement is ``Network._event_manager.add_event_handler``, whose callback +receives a deserialized event rather than one carrying ``.params`` — a port, not +a rename, tracked as issue #293. Console capture and the preload are unaffected; +``tests/test_selenium_surface.py`` guards all of it against the installed version. """ from __future__ import annotations diff --git a/packages/selenium-devtools-py/tests/test_selenium_surface.py b/packages/selenium-devtools-py/tests/test_selenium_surface.py index b0b539fe..3102069c 100644 --- a/packages/selenium-devtools-py/tests/test_selenium_surface.py +++ b/packages/selenium-devtools-py/tests/test_selenium_surface.py @@ -10,6 +10,15 @@ check shape only — no browser, no session — because the point is to detect a rename or relocation, not to test selenium's behaviour. +That prediction was already history when these first ran: selenium 4.44 shipped +the regenerated layer, and CI (which resolves a newer selenium than a local 3.9 +can install) failed on the first run. `NetworkEvent` is gone from `bidi.network` +and `Network.conn` is now `_conn`, so network capture has been silently dead on +4.44+ — no error, just an empty Network tab behind one warning. The observe-only +replacement is `Network._event_manager.add_event_handler`, whose callback takes a +deserialized event object rather than `.params`, so adopting it is a port and not +a rename (issue #293). Until that lands the extra is capped below 4.44. + Skipped when selenium is absent. That is not free: the CI job must install the adapter's own runtime dependency or these never run where they are meant to protect. selenium is an OPTIONAL extra of this package, so the job must select it — @@ -17,26 +26,76 @@ `pip install -e .` installs nothing and every guard here silently skips. """ +import importlib.metadata import importlib.util import inspect import unittest _HAS_SELENIUM = importlib.util.find_spec("selenium") is not None +# selenium 4.44 regenerated the BiDi layer from a schema: `NetworkEvent` left +# `bidi.network` and `Network.conn` became `_conn`. `pyproject.toml` caps the +# extra below it for that reason; this is the same fact in the place a failure +# is read, so a run against a newer selenium says WHY rather than raising an +# ImportError and an AttributeError from two unrelated-looking tests. +FIRST_UNSUPPORTED_SELENIUM = (4, 44) + + +def _installed_selenium() -> tuple: + """(major, minor) of the installed selenium, (0, 0) if unreadable.""" + try: + raw = importlib.metadata.version("selenium") + except importlib.metadata.PackageNotFoundError: + return (0, 0) + parts = [] + for chunk in raw.split(".")[:2]: + digits = "".join(ch for ch in chunk if ch.isdigit()) + parts.append(int(digits) if digits else 0) + return tuple(parts) if len(parts) == 2 else (0, 0) + + +_NETWORK_SURFACE_MOVED = _installed_selenium() >= FIRST_UNSUPPORTED_SELENIUM + @unittest.skipUnless(_HAS_SELENIUM, "selenium is not installed") -class TestTheBiDiInternalsTheAdapterUses(unittest.TestCase): - """`bidi.py` reaches through `driver.network.conn` to subscribe WITHOUT - interception — selenium's high-level `add_request_handler` pauses requests, - which would stall a user's page loads. That is a deliberate trade of public - API for not breaking the page, and it is what these pin.""" +class TestTheSupportedSeleniumRange(unittest.TestCase): + def test_the_installed_selenium_still_carries_the_network_surface(self): + """One legible failure for the whole network breakage. + + The two guards below are skipped past the cap so this is the only thing + that reports it — a reader gets the version and the reason, not two + stack traces about a missing name and a missing attribute.""" + installed = _installed_selenium() + + self.assertLess( + installed, + FIRST_UNSUPPORTED_SELENIUM, + f"selenium {'.'.join(str(p) for p in installed)} is at or past " + f"{'.'.join(str(p) for p in FIRST_UNSUPPORTED_SELENIUM)}, which " + "regenerated the BiDi layer: bidi.py subscribes to network events " + "through NetworkEvent and Network.conn, and neither exists any " + "more, so network capture silently degrades to nothing. Console " + "capture and the preload are unaffected. Porting to the new " + "_event_manager surface is issue #293; until then pyproject caps " + "the selenium extra below this release.", + ) - def test_the_driver_exposes_the_bidi_channels(self): - from selenium.webdriver.remote.webdriver import WebDriver - # Properties on the class, so this needs no live session. - self.assertIsInstance(getattr(WebDriver, "script", None), property) - self.assertIsInstance(getattr(WebDriver, "network", None), property) +@unittest.skipUnless(_HAS_SELENIUM, "selenium is not installed") +@unittest.skipIf( + _NETWORK_SURFACE_MOVED, + "selenium is past the supported range — TestTheSupportedSeleniumRange reports it", +) +class TestTheNetworkInternalsTheAdapterUses(unittest.TestCase): + """`bidi.py` reaches through `driver.network.conn` to subscribe WITHOUT + interception — selenium's `add_request_handler` registers an intercept even + in its high-level style, which pauses each request until selenium continues + it. That is a deliberate trade of public API for not stalling a user's page + loads, and it is what these pin. + + Only this class is gated on the cap: the console, preload and driver-channel + guards below still hold on newer selenium, and skipping them there would + drop the coverage exactly where a bump is most likely to move something.""" def test_the_network_channel_still_carries_the_low_level_connection(self): from selenium.webdriver.common.bidi.network import Network @@ -60,6 +119,20 @@ def test_the_event_and_session_types_are_where_the_adapter_imports_them(self): self.assertTrue(callable(NetworkEvent)) self.assertTrue(hasattr(Session, "subscribe")) + +@unittest.skipUnless(_HAS_SELENIUM, "selenium is not installed") +class TestTheChannelsThatSurvivedTheBiDiRegeneration(unittest.TestCase): + """Console capture and the document-start preload go through `driver.script`, + which 4.44's regeneration left alone. Deliberately NOT gated on the cap, so + these keep guarding on whatever selenium is installed.""" + + def test_the_driver_exposes_the_bidi_channels(self): + from selenium.webdriver.remote.webdriver import WebDriver + + # Properties on the class, so this needs no live session. + self.assertIsInstance(getattr(WebDriver, "script", None), property) + self.assertIsInstance(getattr(WebDriver, "network", None), property) + def test_the_console_channel_keeps_its_handler_api(self): from selenium.webdriver.common.bidi.script import Script From 2bbd3034f6b1f07ed908bf2c6c32554a8b560473 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Wed, 19 Aug 2026 03:27:16 +0530 Subject: [PATCH 4/5] fix(selenium-devtools-py): say why network capture is off on selenium 4.44+ --- .../src/selenium_devtools/bidi.py | 28 +++++++++++++- .../src/selenium_devtools/constants.py | 6 +++ .../src/selenium_devtools/utils.py | 19 ++++++++++ .../selenium-devtools-py/tests/test_bidi.py | 37 +++++++++++++++++++ 4 files changed, 88 insertions(+), 2 deletions(-) diff --git a/packages/selenium-devtools-py/src/selenium_devtools/bidi.py b/packages/selenium-devtools-py/src/selenium_devtools/bidi.py index 492ff56b..0b966a53 100644 --- a/packages/selenium-devtools-py/src/selenium_devtools/bidi.py +++ b/packages/selenium-devtools-py/src/selenium_devtools/bidi.py @@ -38,8 +38,9 @@ BIDI_NET_BEFORE_REQUEST, BIDI_NET_RESPONSE_COMPLETED, LOGGER_NAME, + SELENIUM_NETWORK_SURFACE_MOVED_AT, ) -from .utils import now_ms +from .utils import now_ms, selenium_version _log = logging.getLogger(f"{LOGGER_NAME}.bidi") @@ -351,6 +352,29 @@ def on_js_error(entry: Any) -> None: return False +def network_unavailable_reason(exc: Exception) -> str: + """Why network capture is off, naming the selenium version when that is why. + + The bare exception is unreadable as a cause: on selenium 4.44+ it surfaces as + ``cannot import name 'NetworkEvent'``, which reads like a broken install + rather than a version the adapter has not caught up with. Console capture and + the DOM preload keep working, so this warning is the only signal the user + gets that the Network tab will stay empty. + """ + version = selenium_version() + if version >= SELENIUM_NETWORK_SURFACE_MOVED_AT: + installed = ".".join(str(part) for part in version) + moved_at = ".".join(str(p) for p in SELENIUM_NETWORK_SURFACE_MOVED_AT) + return ( + f"network capture is unavailable on selenium {installed}: selenium " + f"{moved_at} regenerated the BiDi layer and moved the internals this " + "subscribes through. Console, DOM and command capture are " + f"unaffected, and selenium < {moved_at} captures network. Tracked at " + "https://github.com/webdriverio/devtools/issues/293" + ) + return f"network channel unavailable: {exc}" + + def _attach_network(driver: Any, capturer: SessionCapturer) -> bool: """Subscribe to network events WITHOUT interception (see module docstring). @@ -362,7 +386,7 @@ def _attach_network(driver: Any, capturer: SessionCapturer) -> bool: from selenium.webdriver.common.bidi.network import NetworkEvent # lazy from selenium.webdriver.common.bidi.session import Session # lazy except Exception as exc: # noqa: BLE001 - _warn(f"network channel unavailable: {exc}") + _warn(network_unavailable_reason(exc)) return False pending: Dict[str, Dict[str, Any]] = {} diff --git a/packages/selenium-devtools-py/src/selenium_devtools/constants.py b/packages/selenium-devtools-py/src/selenium_devtools/constants.py index 66b2467b..fa9e3e30 100644 --- a/packages/selenium-devtools-py/src/selenium_devtools/constants.py +++ b/packages/selenium-devtools-py/src/selenium_devtools/constants.py @@ -100,6 +100,12 @@ # would stall the user's page loads if a callback failed to continue them). BIDI_NET_BEFORE_REQUEST = "network.beforeRequestSent" BIDI_NET_RESPONSE_COMPLETED = "network.responseCompleted" +# selenium 4.44 regenerated the BiDi layer from a schema: ``NetworkEvent`` left +# ``bidi.network`` and ``Network.conn`` became ``_conn``, so the subscribe above +# cannot be built there and network capture degrades to nothing. Console capture +# and the preload are unaffected. One source of truth for that version, read by +# bidi.py (to explain the degradation at runtime) and by the surface guards. +SELENIUM_NETWORK_SURFACE_MOVED_AT = (4, 44) # selenium's BiDi log entries already carry lowercase levels; this normalizes # the stragglers to the shared LogLevel union. Unmapped levels fall back to log. BIDI_LEVEL_MAP = { diff --git a/packages/selenium-devtools-py/src/selenium_devtools/utils.py b/packages/selenium-devtools-py/src/selenium_devtools/utils.py index b9cfdbe2..ea51ac93 100644 --- a/packages/selenium-devtools-py/src/selenium_devtools/utils.py +++ b/packages/selenium-devtools-py/src/selenium_devtools/utils.py @@ -19,6 +19,25 @@ def iso(ms: int) -> str: return f"{base}.{ms % 1000:03d}Z" +def selenium_version() -> tuple[int, int]: + """(major, minor) of the installed selenium, (0, 0) when unreadable. + + Read from package metadata rather than ``selenium.__version__`` so it costs + no import of selenium itself, and answers for a caller that never imports it. + """ + try: + import importlib.metadata + + raw = importlib.metadata.version("selenium") + except Exception: # noqa: BLE001 — absent, or metadata unreadable + return (0, 0) + parts = [] + for chunk in raw.split(".")[:2]: + digits = "".join(ch for ch in chunk if ch.isdigit()) + parts.append(int(digits) if digits else 0) + return (parts[0], parts[1]) if len(parts) == 2 else (0, 0) + + def to_jsonable(value: Any, _depth: int = 0) -> Any: """Coerce an arbitrary value into something json.dumps can handle. diff --git a/packages/selenium-devtools-py/tests/test_bidi.py b/packages/selenium-devtools-py/tests/test_bidi.py index 894619d3..0cca117f 100644 --- a/packages/selenium-devtools-py/tests/test_bidi.py +++ b/packages/selenium-devtools-py/tests/test_bidi.py @@ -1,4 +1,5 @@ import unittest +from unittest import mock from selenium_devtools import bidi from selenium_devtools.capturer import SessionCapturer @@ -370,5 +371,41 @@ def network(self): self.assertTrue(bidi.attach(Driver(), cap)) +class TestWhyNetworkCaptureIsOff(unittest.TestCase): + """The user-facing extra is uncapped, so a user CAN be on a selenium whose + BiDi layer moved. This warning is then the only signal that the Network tab + will stay empty, so it has to name the cause rather than echo an ImportError + that reads like a broken install.""" + + def test_a_moved_surface_is_reported_as_a_version_gap(self): + major, minor = bidi.SELENIUM_NETWORK_SURFACE_MOVED_AT + with mock.patch.object( + bidi, "selenium_version", return_value=(major, minor + 1) + ): + reason = bidi.network_unavailable_reason( + ImportError("cannot import name 'NetworkEvent'") + ) + + # BOTH versions, and they are different things: what the user has, and + # where the surface moved. Asserted on the ATTRIBUTION, not on the + # version appearing somewhere — the moved-at version is also named in + # the "selenium < X captures network" advice, so a bare assertIn passes + # even when the sentence blames the installed version for the move. + self.assertIn(f"on selenium {major}.{minor + 1}", reason) + self.assertIn(f"selenium {major}.{minor} regenerated", reason) + self.assertIn("293", reason) # where the fix is tracked + # The half that still works must be said, or this reads as total loss. + self.assertIn("Console", reason) + + def test_an_ordinary_failure_still_reports_the_exception(self): + # Below the moved version the cause is NOT the selenium release, so + # blaming it would send the reader somewhere with no answer. + with mock.patch.object(bidi, "selenium_version", return_value=(4, 36)): + reason = bidi.network_unavailable_reason(RuntimeError("no bidi socket")) + + self.assertIn("no bidi socket", reason) + self.assertNotIn("293", reason) + + if __name__ == "__main__": unittest.main() From b70ed6b6d42821dab04208894688b9850681ed3d Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Wed, 19 Aug 2026 03:27:28 +0530 Subject: [PATCH 5/5] test(selenium-devtools-py): run the surface guards on a supported selenium --- .github/workflows/python.yml | 7 ++- packages/selenium-devtools-py/pyproject.toml | 17 +++--- .../tests/test_selenium_surface.py | 56 ++++++++----------- 3 files changed, 38 insertions(+), 42 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 0eaa6444..0fe8f3cd 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -42,8 +42,13 @@ jobs: # pin its BiDi surface (tests/test_selenium_surface.py) skip without it — # so a bump that moved those internals used to pass CI green and degrade # at runtime. Installing the package is what makes those guards real. + # + # `[test]`, not `[selenium]`: the test extra is the capped one. The user- + # facing extra is uncapped on purpose (see pyproject), so installing it + # here would resolve a selenium the adapter cannot fully capture on and + # fail the guards for a reason CI cannot fix. - name: Install the adapter and its runtime dependency - run: pip install -e '.[selenium]' + run: pip install -e '.[test]' - name: Contract is in sync with shared run: | diff --git a/packages/selenium-devtools-py/pyproject.toml b/packages/selenium-devtools-py/pyproject.toml index d0ce0a50..0268bf5d 100644 --- a/packages/selenium-devtools-py/pyproject.toml +++ b/packages/selenium-devtools-py/pyproject.toml @@ -16,14 +16,17 @@ keywords = ["selenium", "webdriver", "devtools", "pytest", "debugging"] # only patch it when present, so it's not a hard requirement to import. dependencies = [] -# Capped below 4.44: that release regenerated the BiDi layer from a schema and -# took `NetworkEvent` out of `bidi.network` while renaming `Network.conn` to -# `_conn` — the two internals `bidi.py` subscribes to network events through. -# Console capture and the document-start preload are unaffected. The cap states -# a breakage that already exists rather than causing one; lifting it needs the -# port to the new `_event_manager` surface (issue #293). [project.optional-dependencies] -selenium = ["selenium>=4.6,<4.44"] +# Deliberately UNCAPPED, even though network capture needs selenium < 4.44 (that +# release regenerated the BiDi layer, moving the internals `bidi.py` subscribes +# through; issue #293). A cap here would downgrade anyone already on a newer +# selenium, and hard-fail the install for a project that requires one — a cost +# paid by every user to describe a partial degradation that only affects the +# Network tab. `bidi.py` reports it at runtime instead, naming the version. +selenium = ["selenium>=4.6"] +# The TEST environment is capped, because the surface guards assert against +# whatever is installed and only mean something on a version the adapter fully +# supports. Constraining the dev environment costs no user anything. test = ["pytest>=7", "selenium>=4.6,<4.44"] # Auto-discovered by pytest; inert unless DEVTOOLS_ENABLE / DEVTOOLS_PORT is set. diff --git a/packages/selenium-devtools-py/tests/test_selenium_surface.py b/packages/selenium-devtools-py/tests/test_selenium_surface.py index 3102069c..25d5b086 100644 --- a/packages/selenium-devtools-py/tests/test_selenium_surface.py +++ b/packages/selenium-devtools-py/tests/test_selenium_surface.py @@ -26,35 +26,18 @@ `pip install -e .` installs nothing and every guard here silently skips. """ -import importlib.metadata import importlib.util import inspect import unittest -_HAS_SELENIUM = importlib.util.find_spec("selenium") is not None - -# selenium 4.44 regenerated the BiDi layer from a schema: `NetworkEvent` left -# `bidi.network` and `Network.conn` became `_conn`. `pyproject.toml` caps the -# extra below it for that reason; this is the same fact in the place a failure -# is read, so a run against a newer selenium says WHY rather than raising an -# ImportError and an AttributeError from two unrelated-looking tests. -FIRST_UNSUPPORTED_SELENIUM = (4, 44) - - -def _installed_selenium() -> tuple: - """(major, minor) of the installed selenium, (0, 0) if unreadable.""" - try: - raw = importlib.metadata.version("selenium") - except importlib.metadata.PackageNotFoundError: - return (0, 0) - parts = [] - for chunk in raw.split(".")[:2]: - digits = "".join(ch for ch in chunk if ch.isdigit()) - parts.append(int(digits) if digits else 0) - return tuple(parts) if len(parts) == 2 else (0, 0) +from selenium_devtools.constants import SELENIUM_NETWORK_SURFACE_MOVED_AT +from selenium_devtools.utils import selenium_version +_HAS_SELENIUM = importlib.util.find_spec("selenium") is not None -_NETWORK_SURFACE_MOVED = _installed_selenium() >= FIRST_UNSUPPORTED_SELENIUM +# The version fact itself lives in constants.py, because bidi.py needs it too — +# it is what turns the runtime degradation into a warning naming the version. +_NETWORK_SURFACE_MOVED = selenium_version() >= SELENIUM_NETWORK_SURFACE_MOVED_AT @unittest.skipUnless(_HAS_SELENIUM, "selenium is not installed") @@ -62,22 +45,27 @@ class TestTheSupportedSeleniumRange(unittest.TestCase): def test_the_installed_selenium_still_carries_the_network_surface(self): """One legible failure for the whole network breakage. - The two guards below are skipped past the cap so this is the only thing - that reports it — a reader gets the version and the reason, not two - stack traces about a missing name and a missing attribute.""" - installed = _installed_selenium() + The two guards below are skipped past that version so this is the only + thing that reports it — a reader gets the version and the reason, not two + stack traces about a missing name and a missing attribute. + + This failing is NOT a broken install: the user-facing extra is uncapped, + so a developer can legitimately have a newer selenium here. It means the + adapter has not caught up, and `bidi.py` says the same thing at runtime + to the user who is actually losing the Network tab.""" + installed = selenium_version() self.assertLess( installed, - FIRST_UNSUPPORTED_SELENIUM, + SELENIUM_NETWORK_SURFACE_MOVED_AT, f"selenium {'.'.join(str(p) for p in installed)} is at or past " - f"{'.'.join(str(p) for p in FIRST_UNSUPPORTED_SELENIUM)}, which " - "regenerated the BiDi layer: bidi.py subscribes to network events " - "through NetworkEvent and Network.conn, and neither exists any " - "more, so network capture silently degrades to nothing. Console " + f"{'.'.join(str(p) for p in SELENIUM_NETWORK_SURFACE_MOVED_AT)}, " + "which regenerated the BiDi layer: bidi.py subscribes to network " + "events through NetworkEvent and Network.conn, and neither exists " + "any more, so network capture silently degrades to nothing. Console " "capture and the preload are unaffected. Porting to the new " - "_event_manager surface is issue #293; until then pyproject caps " - "the selenium extra below this release.", + "_event_manager surface is issue #293; the `test` extra pins below " + "this release so CI runs the guards on a supported selenium.", )