@@ -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" : {
0 commit comments