Skip to content

Commit 0275fbd

Browse files
[py] Implement high level APIs for script (#17371)
This is finishing the python part of #13992. It adds DOM Mutation and sets the CDP version to be deprecated as part of the longer term move towards Webdriver-Bidi.
1 parent 97ec921 commit 0275fbd

6 files changed

Lines changed: 303 additions & 2 deletions

File tree

javascript/bidi-support/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package(default_visibility = [
66
"//javascript:__subpackages__",
77
"//javascript/selenium-webdriver:__pkg__",
88
"//javascript/selenium-webdriver/lib/atoms:__subpackages__",
9+
"//py:__pkg__",
910
])
1011

1112
exports_files([

py/BUILD.bazel

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,12 @@ copy_file(
140140
out = "selenium/webdriver/common/mutation-listener.js",
141141
)
142142

143+
copy_file(
144+
name = "bidi-mutation-listener",
145+
src = "//javascript/bidi-support:bidi-mutation-listener.js",
146+
out = "selenium/webdriver/common/bidi-mutation-listener.js",
147+
)
148+
143149
copy_file(
144150
name = "firefox-driver-prefs",
145151
src = "//third_party/js/selenium:webdriver_json",
@@ -264,6 +270,7 @@ py_library(
264270
name = "bidi",
265271
srcs = [":create-bidi-src"],
266272
data = [
273+
":bidi-mutation-listener",
267274
":mutation-listener",
268275
],
269276
imports = ["."],

py/private/bidi_enhancements_manifest.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,27 @@ class SetNetworkConditionsParameters:
620620
],
621621
},
622622
"script": {
623+
"extra_dataclasses": [
624+
'''@dataclass
625+
class DomMutation:
626+
"""Represents a DOM attribute mutation event from add_dom_mutation_handler.
627+
628+
Attributes:
629+
element_id: The ``data-__webdriver_id`` attribute value set on the
630+
mutated element by the MutationObserver. Use this to locate the
631+
element from the main thread if needed.
632+
attribute_name: The name of the changed attribute.
633+
current_value: The attribute value after the mutation (may be ``None``
634+
if the attribute was removed).
635+
old_value: The attribute value before the mutation.
636+
"""
637+
638+
element_id: str | None = None
639+
attribute_name: str | None = None
640+
current_value: str | None = None
641+
old_value: str | None = None
642+
''',
643+
],
623644
"extra_methods": [
624645
''' def execute(self, function_declaration: str, *args, context_id: str | None = None) -> Any:
625646
"""Execute a function declaration in the browser context.
@@ -992,6 +1013,158 @@ def from_json(self2, p):
9921013
''' def remove_javascript_error_handler(self, callback_id: int) -> None:
9931014
"""Remove a JavaScript error handler by callback ID."""
9941015
self._unsubscribe_log_entry(callback_id)''',
1016+
''' def _subscribe_mutation_handler(self, callback):
1017+
"""Subscribe to DOM mutation events using a BiDi preload script and script.message channel.
1018+
1019+
Loads bidi-mutation-listener.js as a preload script with a channel argument,
1020+
then subscribes to script.message events from that channel to detect
1021+
DOM attribute mutations.
1022+
"""
1023+
import json as _json
1024+
import pkgutil as _pkgutil
1025+
import threading as _threading
1026+
from selenium.webdriver.common.bidi.session import Session as _Session
1027+
1028+
bidi_event = "script.message"
1029+
1030+
if not hasattr(self, "_mutation_subscriptions"):
1031+
self._mutation_subscriptions = {}
1032+
self._mutation_lock = _threading.Lock()
1033+
1034+
# Load bidi-mutation-listener.js only once (cache it on the instance)
1035+
if not hasattr(self, "_bidi_mutation_listener_js"):
1036+
_pkg = "selenium.webdriver.common"
1037+
_js_bytes = _pkgutil.get_data(_pkg, "bidi-mutation-listener.js")
1038+
if _js_bytes is None:
1039+
raise ValueError("Failed to load bidi-mutation-listener.js")
1040+
self._bidi_mutation_listener_js = _js_bytes.decode("utf8").strip()
1041+
1042+
# Use a stable, namespaced channel to avoid collisions with user scripts.
1043+
if not hasattr(self, "_mutation_channel_name"):
1044+
import uuid as _uuid
1045+
self._mutation_channel_name = f"selenium.domMutation.{_uuid.uuid4().hex}"
1046+
_channel_name = self._mutation_channel_name
1047+
_channel_arg = {"type": "channel", "value": {"channel": _channel_name}}
1048+
1049+
def _on_message(message):
1050+
# Filter to only our channel
1051+
channel = message.get("channel") if isinstance(message, dict) else None
1052+
if channel != _channel_name:
1053+
return
1054+
data = message.get("data", {}) if isinstance(message, dict) else {}
1055+
value = data.get("value") if isinstance(data, dict) else None
1056+
if value is None:
1057+
return
1058+
try:
1059+
payload = _json.loads(value)
1060+
except (ValueError, TypeError):
1061+
return
1062+
target_id = payload.get("target")
1063+
if not target_id and target_id != 0:
1064+
return
1065+
from selenium.webdriver.common.bidi.script import DomMutation as _DomMutation
1066+
event = _DomMutation(
1067+
element_id=str(target_id),
1068+
attribute_name=payload.get("name"),
1069+
current_value=payload.get("value"),
1070+
old_value=payload.get("oldValue"),
1071+
)
1072+
callback(event)
1073+
1074+
class _BidiRef:
1075+
event_class = bidi_event
1076+
1077+
def from_json(self2, p):
1078+
return p
1079+
1080+
with self._mutation_lock:
1081+
# Register the preload script only once per Script instance to avoid
1082+
# accumulating duplicate MutationObservers across handler registrations.
1083+
if not hasattr(self, "_mutation_preload_script_id"):
1084+
self._mutation_preload_script_id = self._add_preload_script(
1085+
self._bidi_mutation_listener_js, arguments=[_channel_arg]
1086+
)
1087+
# Also invoke immediately on the current page since the preload
1088+
# script only fires on future document creations.
1089+
if self._driver is not None:
1090+
_context = None
1091+
try:
1092+
_context = self._driver.current_window_handle
1093+
except Exception:
1094+
pass
1095+
if _context is not None:
1096+
self.call_function(
1097+
function_declaration=self._bidi_mutation_listener_js,
1098+
target={"context": _context},
1099+
await_promise=False,
1100+
arguments=[_channel_arg],
1101+
)
1102+
if bidi_event not in self._mutation_subscriptions:
1103+
session = _Session(self._conn)
1104+
result = session.subscribe([bidi_event])
1105+
sub_id = (
1106+
result.get("subscription") if isinstance(result, dict) else None
1107+
)
1108+
self._mutation_subscriptions[bidi_event] = {
1109+
"callbacks": [],
1110+
"subscription_id": sub_id,
1111+
}
1112+
# Register the callback AFTER setup to avoid leaking it if setup fails.
1113+
_wrapper = _BidiRef()
1114+
callback_id = self._conn.add_callback(_wrapper, _on_message)
1115+
self._mutation_subscriptions[bidi_event]["callbacks"].append(callback_id)
1116+
return callback_id''',
1117+
''' def _unsubscribe_mutation_handler(self, callback_id):
1118+
"""Unsubscribe a DOM mutation handler by callback ID."""
1119+
from selenium.webdriver.common.bidi.session import Session as _Session
1120+
1121+
bidi_event = "script.message"
1122+
if not hasattr(self, "_mutation_subscriptions"):
1123+
return
1124+
1125+
class _BidiRef:
1126+
event_class = bidi_event
1127+
1128+
def from_json(self2, p):
1129+
return p
1130+
1131+
_wrapper = _BidiRef()
1132+
self._conn.remove_callback(_wrapper, callback_id)
1133+
with self._mutation_lock:
1134+
entry = self._mutation_subscriptions.get(bidi_event)
1135+
if entry and callback_id in entry["callbacks"]:
1136+
entry["callbacks"].remove(callback_id)
1137+
if entry is not None and not entry["callbacks"]:
1138+
session = _Session(self._conn)
1139+
sub_id = entry.get("subscription_id")
1140+
if sub_id:
1141+
session.unsubscribe(subscriptions=[sub_id])
1142+
else:
1143+
session.unsubscribe(events=[bidi_event])
1144+
del self._mutation_subscriptions[bidi_event]
1145+
if hasattr(self, "_mutation_preload_script_id"):
1146+
preload_script_id = self._mutation_preload_script_id
1147+
try:
1148+
self._remove_preload_script(preload_script_id)
1149+
finally:
1150+
del self._mutation_preload_script_id''',
1151+
''' def add_dom_mutation_handler(self, callback: Callable) -> int:
1152+
"""Add a handler for DOM attribute mutation events.
1153+
1154+
Uses a BiDi preload script and channel to observe DOM attribute mutations
1155+
on the page. When an attribute changes, the callback is invoked with a
1156+
``DomMutation`` object describing the element and attribute change.
1157+
1158+
Args:
1159+
callback: Function called with a ``DomMutation`` on each attribute mutation.
1160+
1161+
Returns:
1162+
callback_id for use with remove_dom_mutation_handler.
1163+
"""
1164+
return self._subscribe_mutation_handler(callback)''',
1165+
''' def remove_dom_mutation_handler(self, callback_id: int) -> None:
1166+
"""Remove a DOM mutation handler by callback ID."""
1167+
self._unsubscribe_mutation_handler(callback_id)''',
9951168
],
9961169
},
9971170
"network": {

py/selenium/webdriver/common/log.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import json
1919
import pkgutil
20+
import warnings
2021
from collections.abc import AsyncGenerator
2122
from contextlib import asynccontextmanager
2223
from importlib import import_module
@@ -56,6 +57,10 @@ def __init__(self, driver, bidi_session) -> None:
5657
async def mutation_events(self) -> AsyncGenerator[dict[str, Any], None]:
5758
"""Listen for mutation events and emit them as they are found.
5859
60+
.. deprecated::
61+
Use ``driver.script.add_dom_mutation_handler()`` instead,
62+
which uses the WebDriver BiDi protocol.
63+
5964
Example:
6065
async with driver.log.mutation_events() as event:
6166
pages.load("dynamic.html")
@@ -67,16 +72,25 @@ async def mutation_events(self) -> AsyncGenerator[dict[str, Any], None]:
6772
assert event["current_value"] == ""
6873
assert event["old_value"] == "display:none;"
6974
"""
75+
warnings.warn(
76+
"mutation_events is deprecated, use driver.script.add_dom_mutation_handler() instead",
77+
DeprecationWarning,
78+
stacklevel=2,
79+
)
7080
page = self.cdp.get_session_context("page.enable")
7181
await page.execute(self.devtools.page.enable())
7282
runtime = self.cdp.get_session_context("runtime.enable")
7383
await runtime.execute(self.devtools.runtime.enable())
7484
await runtime.execute(self.devtools.runtime.add_binding("__webdriver_attribute"))
75-
self.driver.pin_script(self._mutation_listener_js)
85+
with warnings.catch_warnings():
86+
warnings.simplefilter("ignore", DeprecationWarning)
87+
self.driver.pin_script(self._mutation_listener_js)
7688
script_key = await page.execute(
7789
self.devtools.page.add_script_to_evaluate_on_new_document(self._mutation_listener_js)
7890
)
79-
self.driver.pin_script(self._mutation_listener_js, script_key)
91+
with warnings.catch_warnings():
92+
warnings.simplefilter("ignore", DeprecationWarning)
93+
self.driver.pin_script(self._mutation_listener_js, script_key)
8094
self.driver.execute_script(f"return {self._mutation_listener_js}")
8195

8296
event: dict[str, Any] = {}
@@ -96,12 +110,21 @@ async def mutation_events(self) -> AsyncGenerator[dict[str, Any], None]:
96110
async def add_js_error_listener(self) -> AsyncGenerator[dict[str, Any], None]:
97111
"""Listen for JS errors and check if they occurred when the context manager exits.
98112
113+
.. deprecated::
114+
Use ``driver.script.add_javascript_error_handler()`` instead,
115+
which uses the WebDriver BiDi protocol.
116+
99117
Example:
100118
async with driver.log.add_js_error_listener() as error:
101119
driver.find_element(By.ID, "throwing-mouseover").click()
102120
assert bool(error)
103121
assert error.exception_details.stack_trace.call_frames[0].function_name == "onmouseover"
104122
"""
123+
warnings.warn(
124+
"add_js_error_listener is deprecated, use driver.script.add_javascript_error_handler() instead",
125+
DeprecationWarning,
126+
stacklevel=2,
127+
)
105128
session = self.cdp.get_session_context("page.enable")
106129
await session.execute(self.devtools.page.enable())
107130
session = self.cdp.get_session_context("runtime.enable")
@@ -116,6 +139,10 @@ async def add_js_error_listener(self) -> AsyncGenerator[dict[str, Any], None]:
116139
async def add_listener(self, event_type) -> AsyncGenerator[dict[str, Any], None]:
117140
"""Listen for certain events that are passed in.
118141
142+
.. deprecated::
143+
Use ``driver.script.add_console_message_handler()`` instead,
144+
which uses the WebDriver BiDi protocol.
145+
119146
Args:
120147
event_type: The type of event that we want to look at.
121148
@@ -124,6 +151,11 @@ async def add_listener(self, event_type) -> AsyncGenerator[dict[str, Any], None]
124151
driver.execute_script("console.log('I like cheese')")
125152
assert messages["message"] == "I love cheese"
126153
"""
154+
warnings.warn(
155+
"add_listener is deprecated, use driver.script.add_console_message_handler() instead",
156+
DeprecationWarning,
157+
stacklevel=2,
158+
)
127159
from selenium.webdriver.common.bidi.console import Console
128160

129161
session = self.cdp.get_session_context("page.enable")

py/selenium/webdriver/remote/webdriver.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,19 +502,35 @@ def title(self) -> str:
502502
def pin_script(self, script: str, script_key=None) -> ScriptKey:
503503
"""Store a JavaScript script by a unique hashable ID for later execution.
504504
505+
.. deprecated::
506+
Use ``driver.script.pin()`` instead, which uses the WebDriver BiDi protocol.
507+
505508
Example:
506509
`script = "return document.getElementById('foo').value"`
507510
"""
511+
warnings.warn(
512+
"pin_script is deprecated, use driver.script.pin() instead",
513+
DeprecationWarning,
514+
stacklevel=2,
515+
)
508516
script_key_instance = ScriptKey(script_key)
509517
self.pinned_scripts[script_key_instance.id] = script
510518
return script_key_instance
511519

512520
def unpin(self, script_key: ScriptKey) -> None:
513521
"""Remove a pinned script from storage.
514522
523+
.. deprecated::
524+
Use ``driver.script.unpin()`` instead, which uses the WebDriver BiDi protocol.
525+
515526
Example:
516527
`driver.unpin(script_key)`
517528
"""
529+
warnings.warn(
530+
"unpin is deprecated, use driver.script.unpin() instead",
531+
DeprecationWarning,
532+
stacklevel=2,
533+
)
518534
try:
519535
self.pinned_scripts.pop(script_key.id)
520536
except KeyError:
@@ -523,9 +539,17 @@ def unpin(self, script_key: ScriptKey) -> None:
523539
def get_pinned_scripts(self) -> list[str]:
524540
"""Return a list of all pinned scripts.
525541
542+
.. deprecated::
543+
Use ``driver.script.pin()`` to manage preload scripts via the WebDriver BiDi protocol.
544+
526545
Example:
527546
`pinned_scripts = driver.get_pinned_scripts()`
528547
"""
548+
warnings.warn(
549+
"get_pinned_scripts is deprecated, use driver.script.pin() to manage preload scripts instead",
550+
DeprecationWarning,
551+
stacklevel=2,
552+
)
529553
return list(self.pinned_scripts)
530554

531555
def execute_script(self, script: str, *args) -> Any:

0 commit comments

Comments
 (0)