From 4cca9268381a4305fe39f1fac629173065152d95 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Tue, 28 Apr 2026 13:34:15 +0200 Subject: [PATCH 01/30] Locators self-healing --- mops/selenium/core/core_element.py | 53 ++++++ mops/self_healing/__init__.py | 23 +++ mops/self_healing/config.py | 37 +++++ mops/self_healing/context.py | 33 ++++ mops/self_healing/healer.py | 219 +++++++++++++++++++++++++ mops/self_healing/locator_generator.py | 91 ++++++++++ mops/self_healing/snapshot.py | 129 +++++++++++++++ tests/web_tests/test_self_healing.py | 51 ++++++ 8 files changed, 636 insertions(+) create mode 100644 mops/self_healing/__init__.py create mode 100644 mops/self_healing/config.py create mode 100644 mops/self_healing/context.py create mode 100644 mops/self_healing/healer.py create mode 100644 mops/self_healing/locator_generator.py create mode 100644 mops/self_healing/snapshot.py create mode 100644 tests/web_tests/test_self_healing.py diff --git a/mops/selenium/core/core_element.py b/mops/selenium/core/core_element.py index 8e95c2a2..75b58380 100644 --- a/mops/selenium/core/core_element.py +++ b/mops/selenium/core/core_element.py @@ -27,6 +27,10 @@ from mops.mixins.objects.location import Location from mops.mixins.objects.size import Size from mops.selenium.sel_utils import ActionChains +from mops.self_healing.config import get_config +from mops.self_healing.context import is_healing_enabled, no_healing +from mops.self_healing.healer import Healer, HealingResult +from mops.self_healing.snapshot import SnapshotStorage from mops.shared_utils import _scaled_screenshot, cut_log_data from mops.utils.decorators import retry from mops.utils.internal_utils import WAIT_EL, get_dict, is_group, safe_call @@ -43,6 +47,29 @@ from mops.keyboard_keys import KeyboardKeys +_storage: SnapshotStorage | None = None +_healer: Healer | None = None + + +def _get_healer() -> Healer: + global _storage, _healer + config = get_config() + if _healer is None or (_storage and _storage._db_path != config.storage_path): + _storage = SnapshotStorage(config.storage_path) + _healer = Healer(_storage, config.score_threshold) + return _healer + + +def _parse_healed_locator(healed_locator: str) -> tuple[str, str]: + """Convert a ``xpath=//...`` prefixed locator into a ``(By, value)`` tuple.""" + from selenium.webdriver.common.by import By + + if healed_locator.startswith('xpath='): + return By.XPATH, healed_locator[len('xpath='):] + # Default fallback: treat as XPath + return By.XPATH, healed_locator + + class CoreElement(ElementABC, ABC): parent: Element | CoreElement @@ -274,6 +301,7 @@ def value(self) -> str: value = self.get_attribute('value', silent=True) return '' if value is None else value + @no_healing def is_available(self) -> bool: """ Check if the element is available in DOM tree. @@ -290,6 +318,7 @@ def is_available(self) -> bool: return element + @no_healing def is_displayed(self, silent: bool = False) -> bool: """ Check if the element is displayed. @@ -508,13 +537,37 @@ def _find_element(self, wait_parent: bool = False) -> SeleniumWebElement | Appiu try: element = base.find_element(self.locator_type, self.locator) self._cached_element = element + # Save snapshot for future healing + config = get_config() + if config.enabled: + locator_key = f'{self.name}::{self.locator}' + _get_healer() # ensure storage is initialized + _storage.save_from_element(locator_key, element, self.driver) except (SeleniumInvalidArgumentException, SeleniumInvalidSelectorException) as exc: self._raise_invalid_selector_exception(exc) except SeleniumNoSuchElementException as exc: + if is_healing_enabled() and get_config().enabled: + result = self._attempt_healing() + if result: + healed = base.find_element(*_parse_healed_locator(result.healed_locator)) + self._cached_element = healed + return healed raise NoSuchElementException(exc.msg) from exc else: return element + def _attempt_healing(self) -> HealingResult | None: + """ + Attempt to heal a failed element lookup using the self-healing subsystem. + + :return: :class:`HealingResult` if a suitable candidate was found, :obj:`None` otherwise. + """ + try: + locator_key = f'{self.name}::{self.locator}' + return _get_healer().heal(self.name, locator_key, self.locator, self.driver) + except Exception: + return None + def _find_elements(self, wait_parent: bool = False) -> list[SeleniumWebElement | AppiumWebElement]: """ Find all selenium/appium elements diff --git a/mops/self_healing/__init__.py b/mops/self_healing/__init__.py new file mode 100644 index 00000000..40ef3d11 --- /dev/null +++ b/mops/self_healing/__init__.py @@ -0,0 +1,23 @@ +"""Self-healing locators for MOPS. + +Quick start:: + + from mops.self_healing import configure + configure(enabled=True, score_threshold=0.75) +""" + +from mops.self_healing.config import configure, get_config +from mops.self_healing.context import is_healing_enabled, no_healing +from mops.self_healing.healer import Healer, HealingResult +from mops.self_healing.snapshot import ElementSnapshot, SnapshotStorage + +__all__ = [ + 'ElementSnapshot', + 'Healer', + 'HealingResult', + 'SnapshotStorage', + 'configure', + 'get_config', + 'is_healing_enabled', + 'no_healing', +] diff --git a/mops/self_healing/config.py b/mops/self_healing/config.py new file mode 100644 index 00000000..4b3a7d87 --- /dev/null +++ b/mops/self_healing/config.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class SelfHealingConfig: + """Configuration for self-healing locators. + + :param enabled: Enable or disable self-healing globally. + :param score_threshold: Minimum similarity score (0–1) to accept a healed locator. + :param storage_path: Path to the SQLite database for storing element snapshots. + """ + + enabled: bool = False + score_threshold: float = 0.7 + storage_path: str = '.self_healing.db' + + +_config = SelfHealingConfig() + + +def configure(**kwargs: object) -> None: + """Update the global self-healing config. + + Example:: + + from mops.self_healing import configure + configure(enabled=True, score_threshold=0.8) + """ + for key, value in kwargs.items(): + setattr(_config, key, value) + + +def get_config() -> SelfHealingConfig: + """Return the current global self-healing config.""" + return _config diff --git a/mops/self_healing/context.py b/mops/self_healing/context.py new file mode 100644 index 00000000..5c545683 --- /dev/null +++ b/mops/self_healing/context.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from functools import wraps +import threading +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Callable + +_ctx = threading.local() + + +def no_healing(func: Callable) -> Callable: + """Decorator that disables self-healing for the duration of the wrapped method. + + Use on methods where element not being found is an acceptable outcome, + e.g. is_displayed(), is_hidden(), wait_hidden(). + """ + + @wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + _ctx.enabled = False + try: + return func(*args, **kwargs) + finally: + _ctx.enabled = True + + return wrapper + + +def is_healing_enabled() -> bool: + """Return True if healing is allowed in the current thread context.""" + return getattr(_ctx, 'enabled', True) diff --git a/mops/self_healing/healer.py b/mops/self_healing/healer.py new file mode 100644 index 00000000..c05effcb --- /dev/null +++ b/mops/self_healing/healer.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +from dataclasses import dataclass +import logging +from typing import TYPE_CHECKING, Any + +from mops.self_healing.locator_generator import generate_locator + +if TYPE_CHECKING: + from mops.self_healing.snapshot import ElementSnapshot, SnapshotStorage + +logger = logging.getLogger('mops.self_healing') + +_ATTRIBUTE_WEIGHTS: dict[str, float] = { + 'id': 1.0, + 'data-testid': 0.9, + 'data-test': 0.9, + 'data-cy': 0.9, + 'data-qa': 0.9, + 'data-automation-id': 0.9, + 'name': 0.7, + 'aria-label': 0.6, + 'placeholder': 0.5, + 'type': 0.4, + 'role': 0.3, + 'href': 0.3, + 'alt': 0.3, + 'title': 0.2, + 'class': 0.15, +} + +_GET_CANDIDATES_JS = """ +return (function(tag) { + function getAttrs(node) { + var attrs = {}; + for (var i = 0; i < node.attributes.length; i++) { + attrs[node.attributes[i].name] = node.attributes[i].value; + } + return attrs; + } + var elements = document.getElementsByTagName(tag); + var result = []; + for (var i = 0; i < elements.length; i++) { + var el = elements[i]; + var parent = el.parentElement; + result.push({ + index: i, + attrs: getAttrs(el), + text: (el.textContent || '').trim().substring(0, 100), + parentTag: parent ? parent.tagName.toLowerCase() : null, + parentAttrs: parent ? getAttrs(parent) : {} + }); + } + return result; +})(arguments[0]); +""" + + +@dataclass +class HealingResult: + element_name: str + original_locator: str + healed_locator: str + score: float + page: str | None = None + + +class Healer: + """Orchestrates the self-healing process for a failed element lookup.""" + + def __init__(self, storage: SnapshotStorage, score_threshold: float) -> None: + self._storage = storage + self._score_threshold = score_threshold + + def heal(self, element_name: str, locator_key: str, locator: str, driver: Any) -> HealingResult | None: + """Try to find a healed locator for a failed element lookup. + + :param element_name: Human-readable element name for logging. + :param locator_key: Storage key used to load the saved snapshot. + :param locator: The original locator string (for the result record). + :param driver: Selenium WebDriver instance. + :return: :class:`HealingResult` if healed, ``None`` otherwise. + """ + snapshot = self._storage.load(locator_key) + if not snapshot: + logger.debug('Self-healing: no snapshot for "%s", skipping', element_name) + return None + + try: + candidates_data: list[dict] = driver.execute_script(_GET_CANDIDATES_JS, snapshot.tag) + except Exception as exc: + logger.debug('Self-healing: failed to get candidates for "%s": %s', element_name, exc) + return None + + if not candidates_data: + return None + + best_score = 0.0 + best_index = -1 + + for item in candidates_data: + score = _score_similarity(item, snapshot) + if score > best_score: + best_score = score + best_index = item['index'] + + if best_score < self._score_threshold or best_index < 0: + logger.debug( + 'Self-healing: best score %.2f below threshold %.2f for "%s"', + best_score, + self._score_threshold, + element_name, + ) + return None + + # Get the actual WebElement by index among elements of the same tag + try: + from selenium.webdriver.common.by import By + + web_elements = driver.find_elements(By.TAG_NAME, snapshot.tag) + if best_index >= len(web_elements): + return None + healed_web_element = web_elements[best_index] + healed_locator = generate_locator(healed_web_element, driver) + except Exception as exc: + logger.debug('Self-healing: failed to generate locator for "%s": %s', element_name, exc) + return None + + result = HealingResult( + element_name=element_name, + original_locator=locator, + healed_locator=healed_locator, + score=best_score, + ) + + logger.info( + 'Self-healing: healed "%s" %s -> %s (score=%.2f)', + element_name, + locator, + healed_locator, + best_score, + ) + + return result + + +def _score_similarity(candidate: dict[str, Any], snapshot: ElementSnapshot) -> float: + """Compute a 0–1 similarity score between a candidate DOM element and a saved snapshot.""" + score = 0.0 + total_weight = 0.0 + + # Attribute matching + for attr, weight in _ATTRIBUTE_WEIGHTS.items(): + snap_val = snapshot.attributes.get(attr) + cand_val = candidate['attrs'].get(attr) + + if snap_val is None and cand_val is None: + continue + + total_weight += weight + + if snap_val == cand_val: + score += weight + elif snap_val and cand_val: + score += weight * _token_overlap(snap_val, cand_val) + + # Text similarity + snap_text = snapshot.text + cand_text = candidate.get('text', '') + if snap_text: + text_weight = 0.3 + total_weight += text_weight + if snap_text == cand_text: + score += text_weight + elif snap_text and cand_text: + score += text_weight * _text_similarity(snap_text, cand_text) + + # Parent tag match + if snapshot.parent_tag and candidate.get('parentTag'): + parent_weight = 0.2 + total_weight += parent_weight + if candidate['parentTag'] == snapshot.parent_tag: + score += parent_weight * 0.5 + parent_attr_score = _attrs_overlap(snapshot.parent_attributes, candidate.get('parentAttrs', {})) + score += parent_weight * 0.5 * parent_attr_score + + if total_weight == 0: + return 0.0 + + return score / total_weight + + +def _token_overlap(a: str, b: str) -> float: + """Jaccard token overlap for strings (e.g. CSS class lists).""" + a_tokens = set(a.split()) + b_tokens = set(b.split()) + if not a_tokens or not b_tokens: + return 0.0 + intersection = a_tokens & b_tokens + union = a_tokens | b_tokens + return len(intersection) / len(union) + + +def _text_similarity(a: str, b: str) -> float: + a_lower = a.lower() + b_lower = b.lower() + if a_lower == b_lower: + return 1.0 + if a_lower in b_lower or b_lower in a_lower: + return 0.7 + return _token_overlap(a_lower, b_lower) + + +def _attrs_overlap(snap_attrs: dict[str, str], cand_attrs: dict[str, str]) -> float: + """Average match score across attributes present in the snapshot.""" + if not snap_attrs: + return 0.0 + matches = sum(1 for k, v in snap_attrs.items() if cand_attrs.get(k) == v) + return matches / len(snap_attrs) diff --git a/mops/self_healing/locator_generator.py b/mops/self_healing/locator_generator.py new file mode 100644 index 00000000..40389f7b --- /dev/null +++ b/mops/self_healing/locator_generator.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +_GET_POSITIONAL_XPATH_JS = """ +return (function(el) { + var parts = []; + while (el && el.nodeType === 1) { + var idx = 1; + var sib = el.previousSibling; + while (sib) { + if (sib.nodeType === 1 && sib.tagName === el.tagName) idx++; + sib = sib.previousSibling; + } + parts.unshift(el.tagName.toLowerCase() + '[' + idx + ']'); + el = el.parentNode; + } + return '/' + parts.join('/'); +})(arguments[0]); +""" + +_DYNAMIC_CLASS_PREFIXES = ('js-', 'is-', 'has-', 'active', 'disabled', 'selected', 'open', 'closed') + +_TEST_ATTRS = ('data-testid', 'data-test', 'data-cy', 'data-qa', 'data-automation-id') + + +def generate_locator(web_element: object, driver: object) -> str: + """Generate a stable XPath locator from a live Selenium WebElement. + + Tries stable attributes in priority order, falls back to positional XPath. + The returned locator uses the ``xpath=`` prefix used by MOPS. + """ + try: + attrs: dict[str, str] = {} + tag: str = web_element.tag_name + + for attr_name in ('id', *_TEST_ATTRS, 'name', 'aria-label', 'placeholder', 'type', 'role', 'href', 'class'): + val = web_element.get_attribute(attr_name) + if val: + attrs[attr_name] = val.strip() + + text = (web_element.text or '').strip() + except Exception: + return _positional_xpath(web_element, driver) + + # id (unique by spec) + el_id = attrs.get('id', '') + if el_id and ' ' not in el_id: + return f'xpath=//*[@id="{el_id}"]' + + # data-* test attributes + for test_attr in _TEST_ATTRS: + val = attrs.get(test_attr, '') + if val: + return f'xpath=//*[@{test_attr}="{val}"]' + + # name + name = attrs.get('name', '') + if name: + return f'xpath=//{tag}[@name="{name}"]' + + # aria-label + aria = attrs.get('aria-label', '') + if aria: + escaped = aria.replace('"', '\\"') + return f'xpath=//*[@aria-label="{escaped}"]' + + # visible text (short, single-line) + if text and len(text) <= 50 and '\n' not in text: + escaped = text.replace('"', '\\"') + return f'xpath=//{tag}[normalize-space(.)="{escaped}"]' + + # type + tag + el_type = attrs.get('type', '') + if el_type: + return f'xpath=//{tag}[@type="{el_type}"]' + + # stable class (filter out dynamic-looking tokens) + cls = attrs.get('class', '') + if cls: + stable = [c for c in cls.split() if not any(c.lower().startswith(p) for p in _DYNAMIC_CLASS_PREFIXES)] + if stable: + return f'xpath=//{tag}[contains(@class, "{stable[0]}")]' + + return _positional_xpath(web_element, driver) + + +def _positional_xpath(web_element: object, driver: object) -> str: + try: + path: str = driver.execute_script(_GET_POSITIONAL_XPATH_JS, web_element) + return f'xpath={path}' + except Exception: + return f'xpath=//{web_element.tag_name}' diff --git a/mops/self_healing/snapshot.py b/mops/self_healing/snapshot.py new file mode 100644 index 00000000..a0464b3b --- /dev/null +++ b/mops/self_healing/snapshot.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +import json +import sqlite3 +from typing import Any + + +@dataclass +class ElementSnapshot: + """Snapshot of a successfully located element's DOM context.""" + + tag: str + attributes: dict[str, str] + text: str + parent_tag: str | None + parent_attributes: dict[str, str] + siblings: list[dict[str, Any]] + + +_GET_ELEMENT_SNAPSHOT_JS = """ +return (function(el) { + function getAttrs(node) { + var attrs = {}; + for (var i = 0; i < node.attributes.length; i++) { + attrs[node.attributes[i].name] = node.attributes[i].value; + } + return attrs; + } + var parent = el.parentElement; + var parentTag = null; + var parentAttrs = {}; + var siblings = []; + if (parent) { + parentTag = parent.tagName.toLowerCase(); + parentAttrs = getAttrs(parent); + var children = parent.children; + for (var i = 0; i < children.length && siblings.length < 5; i++) { + if (children[i] !== el) { + siblings.push({ + tag: children[i].tagName.toLowerCase(), + text: (children[i].textContent || '').trim().substring(0, 50), + attrs: getAttrs(children[i]) + }); + } + } + } + return { + tag: el.tagName.toLowerCase(), + attrs: getAttrs(el), + text: (el.textContent || '').trim().substring(0, 100), + parentTag: parentTag, + parentAttrs: parentAttrs, + siblings: siblings + }; +})(arguments[0]); +""" + + +class SnapshotStorage: + """SQLite-backed storage for element snapshots.""" + + def __init__(self, db_path: str) -> None: + self._db_path = db_path + self._saved_this_session: set[str] = set() + self._init_db() + + def _init_db(self) -> None: + with sqlite3.connect(self._db_path) as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS snapshots ( + locator_key TEXT PRIMARY KEY, + snapshot_json TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) + + def save_from_element(self, locator_key: str, web_element: object, driver: object) -> None: + """Extract snapshot from a live web element and persist it.""" + if locator_key in self._saved_this_session: + return + + try: + raw = driver.execute_script(_GET_ELEMENT_SNAPSHOT_JS, web_element) + except Exception: + return + + snapshot = ElementSnapshot( + tag=raw['tag'], + attributes=raw['attrs'], + text=raw['text'], + parent_tag=raw['parentTag'], + parent_attributes=raw['parentAttrs'], + siblings=raw['siblings'], + ) + + with sqlite3.connect(self._db_path) as conn: + conn.execute( + 'INSERT OR REPLACE INTO snapshots (locator_key, snapshot_json, updated_at) VALUES (?, ?, ?)', + (locator_key, json.dumps(asdict(snapshot)), datetime.now(timezone.utc).isoformat()), + ) + + self._saved_this_session.add(locator_key) + + def save(self, locator_key: str, snapshot: ElementSnapshot) -> None: + """Persist a snapshot directly without a live web element.""" + with sqlite3.connect(self._db_path) as conn: + conn.execute( + 'INSERT OR REPLACE INTO snapshots (locator_key, snapshot_json, updated_at) VALUES (?, ?, ?)', + (locator_key, json.dumps(asdict(snapshot)), datetime.now(timezone.utc).isoformat()), + ) + self._saved_this_session.add(locator_key) + + def load(self, locator_key: str) -> ElementSnapshot | None: + """Load a previously saved snapshot for the given locator key.""" + with sqlite3.connect(self._db_path) as conn: + row = conn.execute( + 'SELECT snapshot_json FROM snapshots WHERE locator_key = ?', + (locator_key,), + ).fetchone() + + if not row: + return None + + data = json.loads(row[0]) + return ElementSnapshot(**data) diff --git a/tests/web_tests/test_self_healing.py b/tests/web_tests/test_self_healing.py new file mode 100644 index 00000000..7f8a8240 --- /dev/null +++ b/tests/web_tests/test_self_healing.py @@ -0,0 +1,51 @@ +import pytest + +from mops.base.element import Element +from mops.self_healing import configure +from mops.self_healing.config import get_config +from mops.self_healing.snapshot import SnapshotStorage + + +@pytest.fixture(autouse=True) +def setup(): + configure(enabled=True, score_threshold=0.5) + yield + configure(enabled=False) + + +def test_self_healing_recovers_broken_locator(second_playground_page): + """ + Self-healing finds row_with_cards when its locator is broken but snapshot exists. + + Flow: + 1. Enable self-healing and find the real element → snapshot saved to DB. + 2. Seed the same snapshot under a broken locator key. + 3. Access a new element that uses the broken locator → healing kicks in and + finds the element by DOM similarity, returning the correct node. + """ + row = second_playground_page.row_with_cards + + # Find the real element so the snapshot is persisted. + # wait_visibility → is_displayed → _find_element: element found → snapshot saved. + row.wait_visibility(silent=True) + + storage = SnapshotStorage(get_config().storage_path) + real_key = f'{row.name}::{row.locator}' + snapshot = storage.load(real_key) + assert snapshot is not None, f'Snapshot was not saved for key: {real_key!r}' + + # Seed the snapshot under the broken locator so the healer can look it up. + broken_locator = '.row-broken-locator-self-healing-test' + storage.save(f'{row.name}::{broken_locator}', snapshot) + + # Create an element that uses the broken locator but shares the same name, + # so the healer finds the snapshot and can attempt recovery. + broken_row = Element(broken_locator, name=row.name, driver_wrapper=row.driver_wrapper) + + # get_attribute → .element property → _get_element → _find_element. + # The broken locator fails, is_healing_enabled() is True (no @no_healing here), + # so the healer runs and returns the real .row element. + cls = broken_row.get_attribute('class', silent=True) + + assert cls is not None, 'Self-healing did not recover the element' + assert 'row' in cls From 209010e6e450984261eab1f2879a455b8e4228ba Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Fri, 22 May 2026 13:15:44 +0200 Subject: [PATCH 02/30] refactor(self-healing): replace SQLite storage with pluggable JSON-file backend --- .gitignore | 2 + mops/selenium/core/core_element.py | 29 +++++- mops/self_healing/__init__.py | 8 +- mops/self_healing/config.py | 16 +++- mops/self_healing/snapshot.py | 136 ++++++++++++++++++++++----- tests/web_tests/test_self_healing.py | 13 +-- 6 files changed, 166 insertions(+), 38 deletions(-) diff --git a/.gitignore b/.gitignore index e6829408..8dd1ec66 100644 --- a/.gitignore +++ b/.gitignore @@ -179,3 +179,5 @@ tests/adata/visual/reference/reference_with_rerun.png # AI .claude + +# Self-healing snapshots diff --git a/mops/selenium/core/core_element.py b/mops/selenium/core/core_element.py index 75b58380..97022eda 100644 --- a/mops/selenium/core/core_element.py +++ b/mops/selenium/core/core_element.py @@ -30,7 +30,7 @@ from mops.self_healing.config import get_config from mops.self_healing.context import is_healing_enabled, no_healing from mops.self_healing.healer import Healer, HealingResult -from mops.self_healing.snapshot import SnapshotStorage +from mops.self_healing.snapshot import JsonFileSnapshotStorage, SnapshotStorage from mops.shared_utils import _scaled_screenshot, cut_log_data from mops.utils.decorators import retry from mops.utils.internal_utils import WAIT_EL, get_dict, is_group, safe_call @@ -47,16 +47,35 @@ from mops.keyboard_keys import KeyboardKeys +from pathlib import Path + _storage: SnapshotStorage | None = None _healer: Healer | None = None def _get_healer() -> Healer: + """Lazily initialise and return the global Healer singleton. + + Respects the following config scenarios: + * ``config.storage`` is set → use it directly (custom backend). + * Otherwise → create a :class:`JsonFileSnapshotStorage` using + ``config.storage_directory``. + """ global _storage, _healer config = get_config() - if _healer is None or (_storage and _storage._db_path != config.storage_path): - _storage = SnapshotStorage(config.storage_path) - _healer = Healer(_storage, config.score_threshold) + + if _healer is not None: + # Re-initialise if storage config changed at runtime + if config.storage is not None and config.storage is not _storage: + pass # falls through to re-init + elif config.storage is None and isinstance(_storage, JsonFileSnapshotStorage): + if _storage._directory != Path(config.storage_directory): + pass # falls through to re-init + else: + return _healer + + _storage = config.storage if config.storage is not None else JsonFileSnapshotStorage(config.storage_directory) + _healer = Healer(_storage, config.score_threshold) return _healer @@ -65,7 +84,7 @@ def _parse_healed_locator(healed_locator: str) -> tuple[str, str]: from selenium.webdriver.common.by import By if healed_locator.startswith('xpath='): - return By.XPATH, healed_locator[len('xpath='):] + return By.XPATH, healed_locator[len('xpath=') :] # Default fallback: treat as XPath return By.XPATH, healed_locator diff --git a/mops/self_healing/__init__.py b/mops/self_healing/__init__.py index 40ef3d11..10428803 100644 --- a/mops/self_healing/__init__.py +++ b/mops/self_healing/__init__.py @@ -4,17 +4,23 @@ from mops.self_healing import configure configure(enabled=True, score_threshold=0.75) + +You can also supply a custom storage backend:: + + from mops.self_healing import configure, JsonFileSnapshotStorage + configure(enabled=True, storage=JsonFileSnapshotStorage('my_snapshots')) """ from mops.self_healing.config import configure, get_config from mops.self_healing.context import is_healing_enabled, no_healing from mops.self_healing.healer import Healer, HealingResult -from mops.self_healing.snapshot import ElementSnapshot, SnapshotStorage +from mops.self_healing.snapshot import ElementSnapshot, JsonFileSnapshotStorage, SnapshotStorage __all__ = [ 'ElementSnapshot', 'Healer', 'HealingResult', + 'JsonFileSnapshotStorage', 'SnapshotStorage', 'configure', 'get_config', diff --git a/mops/self_healing/config.py b/mops/self_healing/config.py index 4b3a7d87..8fd8975f 100644 --- a/mops/self_healing/config.py +++ b/mops/self_healing/config.py @@ -1,6 +1,10 @@ from __future__ import annotations from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from mops.self_healing.snapshot import SnapshotStorage @dataclass @@ -9,12 +13,17 @@ class SelfHealingConfig: :param enabled: Enable or disable self-healing globally. :param score_threshold: Minimum similarity score (0–1) to accept a healed locator. - :param storage_path: Path to the SQLite database for storing element snapshots. + :param storage_directory: Directory path for storing element snapshot JSON files. + Ignored when *storage* is provided. + :param storage: A custom :class:`SnapshotStorage` instance. When set, this + overrides *storage_directory*. External projects can pass their own + storage backend (e.g. Redis, S3, PostgreSQL) here. """ enabled: bool = False score_threshold: float = 0.7 - storage_path: str = '.self_healing.db' + storage_directory: str = '.self_healing_snapshots' + storage: SnapshotStorage | None = None _config = SelfHealingConfig() @@ -26,7 +35,8 @@ def configure(**kwargs: object) -> None: Example:: from mops.self_healing import configure - configure(enabled=True, score_threshold=0.8) + configure(enabled=True, score_threshold=0.8, storage_directory='snapshots') + configure(storage=MyCustomStorage()) # custom backend """ for key, value in kwargs.items(): setattr(_config, key, value) diff --git a/mops/self_healing/snapshot.py b/mops/self_healing/snapshot.py index a0464b3b..325a23b7 100644 --- a/mops/self_healing/snapshot.py +++ b/mops/self_healing/snapshot.py @@ -1,8 +1,11 @@ from __future__ import annotations +from abc import ABC, abstractmethod from dataclasses import asdict, dataclass from datetime import datetime, timezone +import hashlib import json +from pathlib import Path import sqlite3 from typing import Any @@ -19,6 +22,9 @@ class ElementSnapshot: siblings: list[dict[str, Any]] +_MAX_FILENAME_LENGTH = 200 +_FILENAME_HASH_SUFFIX_LENGTH = 12 + _GET_ELEMENT_SNAPSHOT_JS = """ return (function(el) { function getAttrs(node) { @@ -58,25 +64,16 @@ class ElementSnapshot: """ -class SnapshotStorage: - """SQLite-backed storage for element snapshots.""" +class SnapshotStorage(ABC): + """Abstract snapshot storage for element snapshots. - def __init__(self, db_path: str) -> None: - self._db_path = db_path - self._saved_this_session: set[str] = set() - self._init_db() + Subclasses must implement :meth:`save` and :meth:`load`. + The :meth:`save_from_element` method is concrete — it extracts DOM data + from a live web element and delegates to :meth:`save` for persistence. + """ - def _init_db(self) -> None: - with sqlite3.connect(self._db_path) as conn: - conn.execute( - """ - CREATE TABLE IF NOT EXISTS snapshots ( - locator_key TEXT PRIMARY KEY, - snapshot_json TEXT NOT NULL, - updated_at TEXT NOT NULL - ) - """ - ) + def __init__(self) -> None: + self._saved_this_session: set[str] = set() def save_from_element(self, locator_key: str, web_element: object, driver: object) -> None: """Extract snapshot from a live web element and persist it.""" @@ -97,16 +94,109 @@ def save_from_element(self, locator_key: str, web_element: object, driver: objec siblings=raw['siblings'], ) + self.save(locator_key, snapshot) + self._saved_this_session.add(locator_key) + + @abstractmethod + def save(self, locator_key: str, snapshot: ElementSnapshot) -> None: + """Persist a snapshot.""" + + @abstractmethod + def load(self, locator_key: str) -> ElementSnapshot | None: + """Load a previously saved snapshot.""" + + +class JsonFileSnapshotStorage(SnapshotStorage): + """Stores element snapshots as individual JSON files. + + Each snapshot is written to ``{directory}/{safe_filename}.json``. + + The JSON format is self-describing so that external projects can consume + these files without any knowledge of MOPS internals:: + + { + "locator_key": "...", + "snapshot": { + "tag": "button", + "attributes": {"id": "submit", ...}, + "text": "Submit", + "parent_tag": "form", + "parent_attributes": {"class": "login-form", ...}, + "siblings": [...] + }, + "updated_at": "2026-05-22T12:00:00+00:00" + } + + :param directory: Directory path for storing snapshot JSON files. + """ + + def __init__(self, directory: str = '.self_healing_snapshots') -> None: + super().__init__() + self._directory = Path(directory) + self._directory.mkdir(parents=True, exist_ok=True) + + @staticmethod + def _key_to_filename(locator_key: str) -> str: + """Convert a locator key to a safe filesystem name.""" + safe = locator_key.replace('::', '__').replace('/', '_').replace('\\', '_') + # Limit length to avoid filesystem issues + if len(safe) > _MAX_FILENAME_LENGTH: + suffix = hashlib.md5(locator_key.encode(), usedforsecurity=False).hexdigest()[:_FILENAME_HASH_SUFFIX_LENGTH] + safe = safe[: _MAX_FILENAME_LENGTH - _FILENAME_HASH_SUFFIX_LENGTH - 2] + '__' + suffix + return safe + '.json' + + def save(self, locator_key: str, snapshot: ElementSnapshot) -> None: + """Persist a snapshot as a JSON file.""" + filepath = self._directory / self._key_to_filename(locator_key) + data = { + 'locator_key': locator_key, + 'snapshot': asdict(snapshot), + 'updated_at': datetime.now(timezone.utc).isoformat(), + } + with filepath.open('w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + def load(self, locator_key: str) -> ElementSnapshot | None: + """Load a snapshot by locator key from its JSON file.""" + filepath = self._directory / self._key_to_filename(locator_key) + if not filepath.exists(): + return None + try: + with filepath.open(encoding='utf-8') as f: + data = json.load(f) + return ElementSnapshot(**data['snapshot']) + except (json.JSONDecodeError, KeyError, FileNotFoundError): + return None + + +class SqliteSnapshotStorage(SnapshotStorage): + """SQLite-backed storage for element snapshots. + + .. note:: + This class is kept as a reference implementation for developers + who want to write their own :class:`SnapshotStorage` subclass. + The default storage is :class:`JsonFileSnapshotStorage`. + """ + + def __init__(self, db_path: str) -> None: + super().__init__() + self._db_path = db_path + self._init_db() + + def _init_db(self) -> None: with sqlite3.connect(self._db_path) as conn: conn.execute( - 'INSERT OR REPLACE INTO snapshots (locator_key, snapshot_json, updated_at) VALUES (?, ?, ?)', - (locator_key, json.dumps(asdict(snapshot)), datetime.now(timezone.utc).isoformat()), + """ + CREATE TABLE IF NOT EXISTS snapshots ( + locator_key TEXT PRIMARY KEY, + snapshot_json TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ ) - self._saved_this_session.add(locator_key) - def save(self, locator_key: str, snapshot: ElementSnapshot) -> None: - """Persist a snapshot directly without a live web element.""" + """Persist a snapshot to the SQLite database.""" with sqlite3.connect(self._db_path) as conn: conn.execute( 'INSERT OR REPLACE INTO snapshots (locator_key, snapshot_json, updated_at) VALUES (?, ?, ?)', @@ -115,7 +205,7 @@ def save(self, locator_key: str, snapshot: ElementSnapshot) -> None: self._saved_this_session.add(locator_key) def load(self, locator_key: str) -> ElementSnapshot | None: - """Load a previously saved snapshot for the given locator key.""" + """Load a snapshot from the SQLite database.""" with sqlite3.connect(self._db_path) as conn: row = conn.execute( 'SELECT snapshot_json FROM snapshots WHERE locator_key = ?', diff --git a/tests/web_tests/test_self_healing.py b/tests/web_tests/test_self_healing.py index 7f8a8240..2d6d1fa1 100644 --- a/tests/web_tests/test_self_healing.py +++ b/tests/web_tests/test_self_healing.py @@ -3,12 +3,13 @@ from mops.base.element import Element from mops.self_healing import configure from mops.self_healing.config import get_config -from mops.self_healing.snapshot import SnapshotStorage +from mops.self_healing.snapshot import JsonFileSnapshotStorage @pytest.fixture(autouse=True) -def setup(): - configure(enabled=True, score_threshold=0.5) +def setup(tmp_path): + # configure(enabled=True, score_threshold=0.5, storage_directory=str(tmp_path / 'snapshots')) + configure(enabled=True, score_threshold=0.5, storage_directory='snapshots') yield configure(enabled=False) @@ -18,7 +19,7 @@ def test_self_healing_recovers_broken_locator(second_playground_page): Self-healing finds row_with_cards when its locator is broken but snapshot exists. Flow: - 1. Enable self-healing and find the real element → snapshot saved to DB. + 1. Enable self-healing and find the real element → snapshot saved as JSON. 2. Seed the same snapshot under a broken locator key. 3. Access a new element that uses the broken locator → healing kicks in and finds the element by DOM similarity, returning the correct node. @@ -29,7 +30,7 @@ def test_self_healing_recovers_broken_locator(second_playground_page): # wait_visibility → is_displayed → _find_element: element found → snapshot saved. row.wait_visibility(silent=True) - storage = SnapshotStorage(get_config().storage_path) + storage = JsonFileSnapshotStorage(get_config().storage_directory) real_key = f'{row.name}::{row.locator}' snapshot = storage.load(real_key) assert snapshot is not None, f'Snapshot was not saved for key: {real_key!r}' @@ -40,7 +41,7 @@ def test_self_healing_recovers_broken_locator(second_playground_page): # Create an element that uses the broken locator but shares the same name, # so the healer finds the snapshot and can attempt recovery. - broken_row = Element(broken_locator, name=row.name, driver_wrapper=row.driver_wrapper) + broken_row = Element(broken_locator, name=row.name) # get_attribute → .element property → _get_element → _find_element. # The broken locator fails, is_healing_enabled() is True (no @no_healing here), From 9790cde0738275e6033c0ee4f0e3e10ca1d8d019 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Fri, 22 May 2026 15:25:26 +0200 Subject: [PATCH 03/30] refactor(self-healing): split is_available/is_displayed into internal methods --- mops/abstraction/element_abc.py | 26 ++++++++++++++++++++++++++ mops/base/element.py | 7 +++++-- mops/playwright/play_element.py | 24 +++++++++++++++++++++--- mops/selenium/core/core_element.py | 30 ++++++++++++++++++++++++------ 4 files changed, 76 insertions(+), 11 deletions(-) diff --git a/mops/abstraction/element_abc.py b/mops/abstraction/element_abc.py index 9d649078..1bdcef9f 100644 --- a/mops/abstraction/element_abc.py +++ b/mops/abstraction/element_abc.py @@ -371,6 +371,18 @@ def is_available(self) -> bool: """ raise NotImplementedError + def _is_available(self) -> bool: + """ + Internal check for element availability without @no_healing. + + Used by :meth:`wait_availability` to allow self-healing during polling. + Override in backend-specific classes. Default delegates to + :meth:`is_available`. + + :return: :class:`bool` - :obj:`True` if present in DOM + """ + return self.is_available() + def is_displayed(self, silent: bool = False) -> bool: """ Check if the element is displayed. @@ -381,6 +393,20 @@ def is_displayed(self, silent: bool = False) -> bool: """ raise NotImplementedError + def _is_displayed(self, silent: bool = False) -> bool: + """ + Internal check for element display state without @no_healing. + + Used by :meth:`wait_visibility` to allow self-healing during polling. + Override in backend-specific classes. Default delegates to + :meth:`is_displayed`. + + :param silent: If :obj:`True`, suppresses logging. + :type silent: bool + :return: :class:`bool` + """ + return self.is_displayed(silent=silent) + def is_hidden(self, silent: bool = False) -> bool: """ Check if the element is hidden. diff --git a/mops/base/element.py b/mops/base/element.py index b9f6fcb4..da285b9f 100644 --- a/mops/base/element.py +++ b/mops/base/element.py @@ -31,6 +31,7 @@ from mops.playwright.play_element import PlayElement from mops.selenium.elements.mobile_element import MobileElement from mops.selenium.elements.web_element import WebElement +from mops.self_healing.context import no_healing from mops.utils.decorators import wait_condition, wait_continuous from mops.utils.internal_utils import ( QUARTER_WAIT_EL, @@ -300,7 +301,7 @@ def wait_visibility( :return: :class:`Element` """ return Result( - execution_result=self.is_displayed(silent=True), + execution_result=self._is_displayed(silent=True), log=f'Wait until "{self.name}" becomes visible', exc=TimeoutException(f'"{self.name}" not visible', info=self), ) @@ -352,6 +353,7 @@ def wait_visibility_without_error( @wait_continuous @wait_condition + @no_healing def wait_hidden( self, *, @@ -392,6 +394,7 @@ def wait_hidden( exc=TimeoutException(f'"{self.name}" still visible', info=self), ) + @no_healing def wait_hidden_without_error( self, *, @@ -461,7 +464,7 @@ def wait_availability(self, *, timeout: int = WAIT_EL, silent: bool = False) -> :return: :class:`Element` """ return Result( - execution_result=self.is_available(), + execution_result=self._is_available(), log=f'Wait until presence of "{self.name}"', exc=TimeoutException(f'"{self.name}" not available in DOM', info=self), ) diff --git a/mops/playwright/play_element.py b/mops/playwright/play_element.py index f0c483cc..964feac4 100644 --- a/mops/playwright/play_element.py +++ b/mops/playwright/play_element.py @@ -291,17 +291,25 @@ def value(self) -> str: """ return self._first_element.input_value() + def _is_available(self) -> bool: + """ + Check if the element is available in DOM tree (internal). + + :return: :class:`bool` - :obj:`True` if present in DOM + """ + return bool(len(self.element.element_handles())) + def is_available(self) -> bool: """ Check if the element is available in DOM tree. :return: :class:`bool` - :obj:`True` if present in DOM """ - return bool(len(self.element.element_handles())) + return self._is_available() - def is_displayed(self, silent: bool = False) -> bool: + def _is_displayed(self, silent: bool = False) -> bool: """ - Check if the element is displayed. + Check if the element is displayed (internal). :param silent: If :obj:`True`, suppresses logging. :type silent: bool @@ -315,6 +323,16 @@ def is_displayed(self, silent: bool = False) -> bool: except Error as exc: raise InvalidSelectorException(exc.message) from exc + def is_displayed(self, silent: bool = False) -> bool: + """ + Check if the element is displayed. + + :param silent: If :obj:`True`, suppresses logging. + :type silent: bool + :return: :class:`bool` + """ + return self._is_displayed(silent=silent) + def is_hidden(self, silent: bool = False) -> bool: """ Check if the element is hidden. diff --git a/mops/selenium/core/core_element.py b/mops/selenium/core/core_element.py index 97022eda..e70f2515 100644 --- a/mops/selenium/core/core_element.py +++ b/mops/selenium/core/core_element.py @@ -320,10 +320,9 @@ def value(self) -> str: value = self.get_attribute('value', silent=True) return '' if value is None else value - @no_healing - def is_available(self) -> bool: + def _is_available(self) -> bool: """ - Check if the element is available in DOM tree. + Check if the element is available in DOM tree (internal, without @no_healing). :return: :class:`bool` - :obj:`True` if present in DOM """ @@ -338,15 +337,23 @@ def is_available(self) -> bool: return element @no_healing - def is_displayed(self, silent: bool = False) -> bool: + def is_available(self) -> bool: """ - Check if the element is displayed. + Check if the element is available in DOM tree. + + :return: :class:`bool` - :obj:`True` if present in DOM + """ + return self._is_available() + + def _is_displayed(self, silent: bool = False) -> bool: + """ + Check if the element is displayed (internal, without @no_healing). :param silent: If :obj:`True`, suppresses logging. :type silent: bool :return: :class:`bool` """ - is_displayed = self.is_available() + is_displayed = self._is_available() if is_displayed: desired_element = self._element or self._cached_element @@ -357,6 +364,17 @@ def is_displayed(self, silent: bool = False) -> bool: return is_displayed + @no_healing + def is_displayed(self, silent: bool = False) -> bool: + """ + Check if the element is displayed. + + :param silent: If :obj:`True`, suppresses logging. + :type silent: bool + :return: :class:`bool` + """ + return self._is_displayed(silent=silent) + def is_hidden(self, silent: bool = False) -> bool: """ Check if the element is hidden. From 7732fc3e5309432b76dd8a536ead17f6b6154e06 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Tue, 9 Jun 2026 12:42:17 +0200 Subject: [PATCH 04/30] Separate param for self healing config & simplify --- mops/selenium/core/core_element.py | 38 +++++++++------------------- mops/self_healing/__init__.py | 22 +++++++++++----- mops/self_healing/config.py | 35 +++++++++++++++++-------- mops/self_healing/context.py | 2 +- tests/web_tests/test_self_healing.py | 12 ++++----- 5 files changed, 57 insertions(+), 52 deletions(-) diff --git a/mops/selenium/core/core_element.py b/mops/selenium/core/core_element.py index e70f2515..244dfba1 100644 --- a/mops/selenium/core/core_element.py +++ b/mops/selenium/core/core_element.py @@ -28,9 +28,9 @@ from mops.mixins.objects.size import Size from mops.selenium.sel_utils import ActionChains from mops.self_healing.config import get_config -from mops.self_healing.context import is_healing_enabled, no_healing +from mops.self_healing.context import is_healing_for_method_enabled, no_healing from mops.self_healing.healer import Healer, HealingResult -from mops.self_healing.snapshot import JsonFileSnapshotStorage, SnapshotStorage +from mops.self_healing.snapshot import SnapshotStorage from mops.shared_utils import _scaled_screenshot, cut_log_data from mops.utils.decorators import retry from mops.utils.internal_utils import WAIT_EL, get_dict, is_group, safe_call @@ -47,34 +47,19 @@ from mops.keyboard_keys import KeyboardKeys -from pathlib import Path - _storage: SnapshotStorage | None = None _healer: Healer | None = None def _get_healer() -> Healer: - """Lazily initialise and return the global Healer singleton. - - Respects the following config scenarios: - * ``config.storage`` is set → use it directly (custom backend). - * Otherwise → create a :class:`JsonFileSnapshotStorage` using - ``config.storage_directory``. - """ + """Return the global Healer singleton.""" global _storage, _healer - config = get_config() - if _healer is not None: - # Re-initialise if storage config changed at runtime - if config.storage is not None and config.storage is not _storage: - pass # falls through to re-init - elif config.storage is None and isinstance(_storage, JsonFileSnapshotStorage): - if _storage._directory != Path(config.storage_directory): - pass # falls through to re-init - else: - return _healer + if _healer: + return _healer - _storage = config.storage if config.storage is not None else JsonFileSnapshotStorage(config.storage_directory) + config = get_config() + _storage = config.storage _healer = Healer(_storage, config.score_threshold) return _healer @@ -576,14 +561,14 @@ def _find_element(self, wait_parent: bool = False) -> SeleniumWebElement | Appiu self._cached_element = element # Save snapshot for future healing config = get_config() - if config.enabled: + if config.save_snapshots: + _get_healer() locator_key = f'{self.name}::{self.locator}' - _get_healer() # ensure storage is initialized _storage.save_from_element(locator_key, element, self.driver) except (SeleniumInvalidArgumentException, SeleniumInvalidSelectorException) as exc: self._raise_invalid_selector_exception(exc) except SeleniumNoSuchElementException as exc: - if is_healing_enabled() and get_config().enabled: + if is_healing_for_method_enabled() and get_config().heal_locators: result = self._attempt_healing() if result: healed = base.find_element(*_parse_healed_locator(result.healed_locator)) @@ -600,8 +585,9 @@ def _attempt_healing(self) -> HealingResult | None: :return: :class:`HealingResult` if a suitable candidate was found, :obj:`None` otherwise. """ try: + healer = _get_healer() locator_key = f'{self.name}::{self.locator}' - return _get_healer().heal(self.name, locator_key, self.locator, self.driver) + return healer.heal(self.name, locator_key, self.locator, self.driver) except Exception: return None diff --git a/mops/self_healing/__init__.py b/mops/self_healing/__init__.py index 10428803..36bcfae8 100644 --- a/mops/self_healing/__init__.py +++ b/mops/self_healing/__init__.py @@ -2,17 +2,25 @@ Quick start:: - from mops.self_healing import configure - configure(enabled=True, score_threshold=0.75) + from mops.self_healing import configure, JsonFileSnapshotStorage + configure( + save_snapshots=True, + heal_locators=True, + score_threshold=0.75, + storage=JsonFileSnapshotStorage(), + ) -You can also supply a custom storage backend:: +Healing requires a :class:`SnapshotStorage` to be configured. The quickest way is:: - from mops.self_healing import configure, JsonFileSnapshotStorage - configure(enabled=True, storage=JsonFileSnapshotStorage('my_snapshots')) + configure( + save_snapshots=True, + heal_locators=True, + storage=JsonFileSnapshotStorage('my_snapshots'), + ) """ from mops.self_healing.config import configure, get_config -from mops.self_healing.context import is_healing_enabled, no_healing +from mops.self_healing.context import is_healing_for_method_enabled, no_healing from mops.self_healing.healer import Healer, HealingResult from mops.self_healing.snapshot import ElementSnapshot, JsonFileSnapshotStorage, SnapshotStorage @@ -24,6 +32,6 @@ 'SnapshotStorage', 'configure', 'get_config', - 'is_healing_enabled', + 'is_healing_for_method_enabled', 'no_healing', ] diff --git a/mops/self_healing/config.py b/mops/self_healing/config.py index 8fd8975f..02e3ddaf 100644 --- a/mops/self_healing/config.py +++ b/mops/self_healing/config.py @@ -11,18 +11,19 @@ class SelfHealingConfig: """Configuration for self-healing locators. - :param enabled: Enable or disable self-healing globally. + :param save_snapshots: When :obj:`True`, snapshots of successfully located elements + are saved to storage for future healing. + :param heal_locators: When :obj:`True`, the system attempts to heal broken locators + by loading saved snapshots and searching for matching elements. :param score_threshold: Minimum similarity score (0–1) to accept a healed locator. - :param storage_directory: Directory path for storing element snapshot JSON files. - Ignored when *storage* is provided. - :param storage: A custom :class:`SnapshotStorage` instance. When set, this - overrides *storage_directory*. External projects can pass their own - storage backend (e.g. Redis, S3, PostgreSQL) here. + :param storage: A :class:`SnapshotStorage` instance. When not set, storage + remains uninitialised and neither snapshots nor healing will work. + External projects can pass their own backend (Redis, S3, PostgreSQL, etc.) here. """ - enabled: bool = False + save_snapshots: bool = False + heal_locators: bool = False score_threshold: float = 0.7 - storage_directory: str = '.self_healing_snapshots' storage: SnapshotStorage | None = None @@ -34,9 +35,21 @@ def configure(**kwargs: object) -> None: Example:: - from mops.self_healing import configure - configure(enabled=True, score_threshold=0.8, storage_directory='snapshots') - configure(storage=MyCustomStorage()) # custom backend + from mops.self_healing import configure, JsonFileSnapshotStorage + + # Save snapshots but don't heal (data collection) + configure(save_snapshots=True) + + # Full healing: save snapshots AND heal broken locators + configure( + save_snapshots=True, + heal_locators=True, + score_threshold=0.75, + storage=JsonFileSnapshotStorage('my_snapshots'), + ) + + # Custom backend + configure(storage=MyCustomStorage()) """ for key, value in kwargs.items(): setattr(_config, key, value) diff --git a/mops/self_healing/context.py b/mops/self_healing/context.py index 5c545683..c30cf5bf 100644 --- a/mops/self_healing/context.py +++ b/mops/self_healing/context.py @@ -28,6 +28,6 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: return wrapper -def is_healing_enabled() -> bool: +def is_healing_for_method_enabled() -> bool: """Return True if healing is allowed in the current thread context.""" return getattr(_ctx, 'enabled', True) diff --git a/tests/web_tests/test_self_healing.py b/tests/web_tests/test_self_healing.py index 2d6d1fa1..dba4c9a7 100644 --- a/tests/web_tests/test_self_healing.py +++ b/tests/web_tests/test_self_healing.py @@ -2,16 +2,15 @@ from mops.base.element import Element from mops.self_healing import configure -from mops.self_healing.config import get_config from mops.self_healing.snapshot import JsonFileSnapshotStorage +from mops.self_healing.config import get_config @pytest.fixture(autouse=True) -def setup(tmp_path): - # configure(enabled=True, score_threshold=0.5, storage_directory=str(tmp_path / 'snapshots')) - configure(enabled=True, score_threshold=0.5, storage_directory='snapshots') +def setup(): + configure(save_snapshots=True, heal_locators=True, score_threshold=0.5, storage=JsonFileSnapshotStorage()) yield - configure(enabled=False) + configure(save_snapshots=False, heal_locators=False) def test_self_healing_recovers_broken_locator(second_playground_page): @@ -27,10 +26,9 @@ def test_self_healing_recovers_broken_locator(second_playground_page): row = second_playground_page.row_with_cards # Find the real element so the snapshot is persisted. - # wait_visibility → is_displayed → _find_element: element found → snapshot saved. row.wait_visibility(silent=True) - storage = JsonFileSnapshotStorage(get_config().storage_directory) + storage = get_config().storage # same instance used by the healer real_key = f'{row.name}::{row.locator}' snapshot = storage.load(real_key) assert snapshot is not None, f'Snapshot was not saved for key: {real_key!r}' From ce60de9c430431ccfcc8f041676bc1fd61939804 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Thu, 11 Jun 2026 18:04:36 +0200 Subject: [PATCH 05/30] Snapshot & locator_key normalisation --- mops/selenium/core/core_element.py | 4 +- mops/self_healing/snapshot.py | 105 ++++++++ .../unit/test_self_healing_normalization.py | 227 ++++++++++++++++++ 3 files changed, 334 insertions(+), 2 deletions(-) create mode 100644 tests/static_tests/unit/test_self_healing_normalization.py diff --git a/mops/selenium/core/core_element.py b/mops/selenium/core/core_element.py index 244dfba1..ea9d8fed 100644 --- a/mops/selenium/core/core_element.py +++ b/mops/selenium/core/core_element.py @@ -563,7 +563,7 @@ def _find_element(self, wait_parent: bool = False) -> SeleniumWebElement | Appiu config = get_config() if config.save_snapshots: _get_healer() - locator_key = f'{self.name}::{self.locator}' + locator_key = _storage.normalize_locator_key(f'{self.name}::{self.locator}') _storage.save_from_element(locator_key, element, self.driver) except (SeleniumInvalidArgumentException, SeleniumInvalidSelectorException) as exc: self._raise_invalid_selector_exception(exc) @@ -586,7 +586,7 @@ def _attempt_healing(self) -> HealingResult | None: """ try: healer = _get_healer() - locator_key = f'{self.name}::{self.locator}' + locator_key = _storage.normalize_locator_key(f'{self.name}::{self.locator}') return healer.heal(self.name, locator_key, self.locator, self.driver) except Exception: return None diff --git a/mops/self_healing/snapshot.py b/mops/self_healing/snapshot.py index 325a23b7..8a5642cf 100644 --- a/mops/self_healing/snapshot.py +++ b/mops/self_healing/snapshot.py @@ -6,6 +6,7 @@ import hashlib import json from pathlib import Path +import re import sqlite3 from typing import Any @@ -64,6 +65,26 @@ class ElementSnapshot: """ +# Each rule: (attribute_name, compiled_regex, replacement) +# - attribute_name: attribute to apply to, or None for text +# - replacement: None means token-removal (split by spaces, drop matching tokens), +# '' means remove attribute entirely, str means re.sub(replacement) +_DEFAULT_NORMALIZATION_RULES: list[tuple[str | None, re.Pattern, str | None]] = [ + # CSS-module hashes: Button_nameHash__xy + ('class', re.compile(r'[a-zA-Z]+[_-][a-fA-F0-9]{5,10}__[a-zA-Z0-9]{1,5}'), None), + # Auto-generated suffixes: #sb, #rdT + ('class', re.compile(r'^#\w{1,10}$'), None), + # Dynamic/state classes aligned with locator_generator._DYNAMIC_CLASS_PREFIXES + ('class', re.compile(r'^(js-|is-|has-|active|disabled|selected|open|closed)$'), None), + # Purely numeric IDs + ('id', re.compile(r'^\d+$'), ''), + # Numeric suffix in IDs: user-12345 -> user- + ('id', re.compile(r'-\d+$'), ''), + # Vue scoped data attributes + (None, re.compile(r'^data-v-'), ''), +] + + class SnapshotStorage(ABC): """Abstract snapshot storage for element snapshots. @@ -74,6 +95,7 @@ class SnapshotStorage(ABC): def __init__(self) -> None: self._saved_this_session: set[str] = set() + self._normalization_rules = list(_DEFAULT_NORMALIZATION_RULES) def save_from_element(self, locator_key: str, web_element: object, driver: object) -> None: """Extract snapshot from a live web element and persist it.""" @@ -94,9 +116,92 @@ def save_from_element(self, locator_key: str, web_element: object, driver: objec siblings=raw['siblings'], ) + snapshot = self._normalize_snapshot(snapshot) self.save(locator_key, snapshot) self._saved_this_session.add(locator_key) + def set_normalization_rules(self, rules: list[tuple[str | None, re.Pattern, str | None]]) -> None: + """Replace the default normalization rules with custom ones. + + Each rule is ``(attribute_name_or_None, compiled_regex, replacement)``: + + * ``attribute_name`` — attribute key to match (``'class'``, ``'id'``, etc.) + or ``None`` to match any attribute name. + * ``compiled_regex`` — compiled :class:`re.Pattern` to match against the + attribute value (or token, in token-removal mode). + * ``replacement`` — if ``None``, the rule runs in **token-removal mode** + (value is split on whitespace, matching tokens are dropped). If a string, + ``regex.sub(replacement, value)`` is applied. If the result is empty, + the attribute is removed entirely. + + Calling this method replaces **all** rules. To extend defaults:: + + storage = JsonFileSnapshotStorage() + storage.set_normalization_rules([ + *storage._normalization_rules, + ('data-track', re.compile(r'.*'), ''), + ]) + """ + self._normalization_rules = list(rules) + + def normalize_locator_key(self, key: str) -> str: + r"""Normalize dynamic data in a locator key string. + + Applies all rules where ``replacement`` is a string (not ``None``). + Token-removal rules (``replacement=None``) are skipped since a locator + key is a flat string, not a whitespace-separated list of tokens. + + Calling this ensures that e.g. ``#user-12345`` and ``#user-67890`` + produce the same storage key when a ``-\d+`` rule is configured. + """ + for _attr_name, pattern, replacement in self._normalization_rules: + if replacement is None: + continue # skip token-removal rules + key = pattern.sub(replacement, key) + return key + + def _normalize_snapshot(self, snapshot: ElementSnapshot) -> ElementSnapshot: + """Return a normalized copy of *snapshot* with dynamic data cleaned out.""" + return ElementSnapshot( + tag=snapshot.tag, + attributes=self._normalize_attrs(snapshot.attributes), + text=re.sub(r'\s+', ' ', snapshot.text).strip(), + parent_tag=snapshot.parent_tag, + parent_attributes=self._normalize_attrs(snapshot.parent_attributes), + siblings=[{**s, 'attrs': self._normalize_attrs(s.get('attrs', {}))} for s in snapshot.siblings], + ) + + def _normalize_attrs(self, attrs: dict[str, str]) -> dict[str, str]: + """Return a new dict with normalization rules applied to *attrs*.""" + result = {} + for key, value in attrs.items(): + normalized = value + for attr_name, pattern, replacement in self._normalization_rules: + if attr_name is not None and key != attr_name: + continue + + if replacement is None: + # Token-removal mode (for class etc.) + tokens = normalized.split() + filtered = [t for t in tokens if not pattern.search(t)] + if len(filtered) != len(tokens): + normalized = ' '.join(filtered) + elif attr_name is None: + # Rule targets attribute name (e.g. data-v-*) — + # remove the entire attribute when the key matches + if pattern.search(key): + normalized = '' + break + elif pattern.search(normalized): + # Standard re.sub on value + normalized = pattern.sub(replacement, normalized) + if not normalized: + break + + if normalized: + result[key] = normalized + return result + @abstractmethod def save(self, locator_key: str, snapshot: ElementSnapshot) -> None: """Persist a snapshot.""" diff --git a/tests/static_tests/unit/test_self_healing_normalization.py b/tests/static_tests/unit/test_self_healing_normalization.py new file mode 100644 index 00000000..016f88d6 --- /dev/null +++ b/tests/static_tests/unit/test_self_healing_normalization.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import re + +from mops.self_healing.snapshot import ElementSnapshot, JsonFileSnapshotStorage + +# --------------------------------------------------------------------------- +# normalize_locator_key +# --------------------------------------------------------------------------- + + +def test_normalize_locator_key_preserves_clean_key(): + """Locator without dynamic data stays unchanged.""" + storage = JsonFileSnapshotStorage() + assert storage.normalize_locator_key('MyElement::.row') == 'MyElement::.row' + + +def test_normalize_locator_key_strips_numeric_id_suffix(): + r"""``#user-12345`` → ``#user`` (rule: id, -\d+$ — the ``-`` is part of the match).""" + storage = JsonFileSnapshotStorage() + assert storage.normalize_locator_key('MyElement::#user-12345') == 'MyElement::#user' + + +def test_normalize_locator_key_purely_numeric_id_rule_skipped(): + r"""``^\d+$`` is anchor-bound, it won't match mid-string in a flat key — expected.""" + storage = JsonFileSnapshotStorage() + # A locator like #12345 does NOT get normalized by the ^\d+$ rule + assert storage.normalize_locator_key('MyElement::#12345') == 'MyElement::#12345' + + +def test_normalize_locator_key_vue_rule_skipped_mid_string(): + r"""``^data-v-`` is anchor-bound, doesn't match mid-string — expected.""" + storage = JsonFileSnapshotStorage() + result = storage.normalize_locator_key('MyElement::[data-v-abc123]') + assert 'data-v-abc123' in result + + +def test_normalize_locator_key_class_token_removal_rules_skipped(): + """Token-removal rules (replacement=None) are not applied to locator keys.""" + storage = JsonFileSnapshotStorage() + result = storage.normalize_locator_key('MyElement::.btn.active') + assert 'active' in result + + +# --------------------------------------------------------------------------- +# _normalize_attrs — class token removal +# --------------------------------------------------------------------------- + + +def test_normalize_attrs_removes_css_module_hash_from_class(): + """Button_nameHash__xy tokens removed from class attribute.""" + storage = JsonFileSnapshotStorage() + attrs = {'class': 'Button_1a2b3__xy another-class'} + result = storage._normalize_attrs(attrs) + assert result['class'] == 'another-class' + + +def test_normalize_attrs_removes_hash_suffix_from_class(): + r"""``#sb`` / ``#rdT`` tokens removed from class.""" + storage = JsonFileSnapshotStorage() + attrs = {'class': 'some-class #sb'} + result = storage._normalize_attrs(attrs) + assert result['class'] == 'some-class' + + +def test_normalize_attrs_removes_dynamic_state_classes(): + """Exact-match state tokens (active / disabled / selected / open / closed) removed.""" + storage = JsonFileSnapshotStorage() + attrs = {'class': 'btn active disabled js-toggle'} + result = storage._normalize_attrs(attrs) + assert result['class'] == 'btn js-toggle' + + +def test_normalize_attrs_preserves_normal_class(): + """Meaningful class names survive.""" + storage = JsonFileSnapshotStorage() + attrs = {'class': 'btn btn-primary header'} + result = storage._normalize_attrs(attrs) + assert result['class'] == 'btn btn-primary header' + + +# --------------------------------------------------------------------------- +# _normalize_attrs — id normalization +# --------------------------------------------------------------------------- + + +def test_normalize_attrs_removes_numeric_id(): + """``id='12345'`` is removed (entirely numeric).""" + storage = JsonFileSnapshotStorage() + attrs = {'id': '12345'} + result = storage._normalize_attrs(attrs) + assert 'id' not in result + + +def test_normalize_attrs_strips_numeric_id_suffix(): + r"""``id='user-12345'`` → ``id='user'`` (the ``-`` is part of the ``-\d+$`` match).""" + storage = JsonFileSnapshotStorage() + attrs = {'id': 'user-12345'} + result = storage._normalize_attrs(attrs) + assert result['id'] == 'user' + + +def test_normalize_attrs_preserves_meaningful_id(): + """``id='submit-btn'`` stays.""" + storage = JsonFileSnapshotStorage() + attrs = {'id': 'submit-btn'} + result = storage._normalize_attrs(attrs) + assert result['id'] == 'submit-btn' + + +# --------------------------------------------------------------------------- +# _normalize_attrs — Vue scoped data attributes +# --------------------------------------------------------------------------- + + +def test_normalize_attrs_removes_vue_data_attributes(): + """``data-v-*`` attributes removed regardless of value.""" + storage = JsonFileSnapshotStorage() + attrs = {'data-v-abc123': '', 'data-v-def456': 'some-value', 'data-testid': 'submit'} + result = storage._normalize_attrs(attrs) + assert 'data-v-abc123' not in result + assert 'data-v-def456' not in result + assert result['data-testid'] == 'submit' + + +# --------------------------------------------------------------------------- +# _normalize_attrs — edge cases +# --------------------------------------------------------------------------- + + +def test_normalize_attrs_empty(): + """Empty dict stays empty.""" + storage = JsonFileSnapshotStorage() + assert storage._normalize_attrs({}) == {} + + +def test_normalize_attrs_unknown_attrs_preserved(): + """Attributes without matching rules are preserved.""" + storage = JsonFileSnapshotStorage() + attrs = {'aria-label': 'Close', 'role': 'button', 'data-test': 'submit'} + result = storage._normalize_attrs(attrs) + assert result == attrs + + +# --------------------------------------------------------------------------- +# _normalize_snapshot — full snapshot normalization +# --------------------------------------------------------------------------- + + +def test_normalize_snapshot_cleans_attrs_and_text(): + """Full snapshot normalization works across all fields.""" + storage = JsonFileSnapshotStorage() + snapshot = ElementSnapshot( + tag='button', + attributes={ + 'class': 'btn active #sb', + 'id': 'submit-42', + 'aria-label': 'Submit', + }, + text=' Click Me ', + parent_tag='form', + parent_attributes={ + 'class': 'form-wrapper js-form', + 'data-v-xyz': '', + }, + siblings=[ + {'tag': 'span', 'text': 'Hint', 'attrs': {'class': 'helper #rdT'}}, + ], + ) + result = storage._normalize_snapshot(snapshot) + assert result.tag == 'button' + # Class tokens cleaned (active and #sb removed) + assert result.attributes['class'] == 'btn' + # ID suffix stripped (the ``-`` is part of the match) + assert result.attributes['id'] == 'submit' + # Non-matching attrs preserved + assert result.attributes['aria-label'] == 'Submit' + # Text whitespace normalized + assert result.text == 'Click Me' + # Parent attrs cleaned + # ``js-form`` is not an exact match for the ``^(js-|...)`` rule — kept + assert result.parent_attributes['class'] == 'form-wrapper js-form' + assert 'data-v-xyz' not in result.parent_attributes + # Sibling attrs cleaned + assert result.siblings[0]['attrs']['class'] == 'helper' + + +def test_normalize_snapshot_without_parent(): + """Snapshot with no parent does not crash.""" + storage = JsonFileSnapshotStorage() + snapshot = ElementSnapshot( + tag='div', + attributes={'class': 'box'}, + text='content', + parent_tag=None, + parent_attributes={}, + siblings=[], + ) + result = storage._normalize_snapshot(snapshot) + assert result.parent_tag is None + + +# --------------------------------------------------------------------------- +# custom normalization rules +# --------------------------------------------------------------------------- + + +def test_custom_rules_extend_defaults(): + """External projects can extend default rules.""" + storage = JsonFileSnapshotStorage() + storage.set_normalization_rules([ + *storage._normalization_rules, + ('data-track', re.compile(r'.*'), ''), + ]) + attrs = {'class': 'btn', 'data-track': 'some-value'} + result = storage._normalize_attrs(attrs) + assert result['class'] == 'btn' + assert 'data-track' not in result + + +def test_custom_rules_apply_to_locator_key(): + """Custom string-replacement rules affect normalize_locator_key too.""" + storage = JsonFileSnapshotStorage() + storage.set_normalization_rules([ + (None, re.compile(r'custom-'), ''), + ]) + assert storage.normalize_locator_key('El::#custom-123') == 'El::#123' From 59c6e52c38570f24144b708c0bb2745d79001521 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Mon, 15 Jun 2026 16:46:17 +0200 Subject: [PATCH 06/30] Full locator_key extraction added --- mops/selenium/core/core_element.py | 3 +- mops/self_healing/snapshot.py | 25 ++-- .../unit/test_self_healing_normalization.py | 130 +++++++++--------- 3 files changed, 81 insertions(+), 77 deletions(-) diff --git a/mops/selenium/core/core_element.py b/mops/selenium/core/core_element.py index ea9d8fed..c7c21d4f 100644 --- a/mops/selenium/core/core_element.py +++ b/mops/selenium/core/core_element.py @@ -563,8 +563,7 @@ def _find_element(self, wait_parent: bool = False) -> SeleniumWebElement | Appiu config = get_config() if config.save_snapshots: _get_healer() - locator_key = _storage.normalize_locator_key(f'{self.name}::{self.locator}') - _storage.save_from_element(locator_key, element, self.driver) + _storage.save_from_element(self, element, self.driver) except (SeleniumInvalidArgumentException, SeleniumInvalidSelectorException) as exc: self._raise_invalid_selector_exception(exc) except SeleniumNoSuchElementException as exc: diff --git a/mops/self_healing/snapshot.py b/mops/self_healing/snapshot.py index 8a5642cf..8500a023 100644 --- a/mops/self_healing/snapshot.py +++ b/mops/self_healing/snapshot.py @@ -8,7 +8,10 @@ from pathlib import Path import re import sqlite3 -from typing import Any +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from mops.base.element import Element @dataclass @@ -76,12 +79,6 @@ class ElementSnapshot: ('class', re.compile(r'^#\w{1,10}$'), None), # Dynamic/state classes aligned with locator_generator._DYNAMIC_CLASS_PREFIXES ('class', re.compile(r'^(js-|is-|has-|active|disabled|selected|open|closed)$'), None), - # Purely numeric IDs - ('id', re.compile(r'^\d+$'), ''), - # Numeric suffix in IDs: user-12345 -> user- - ('id', re.compile(r'-\d+$'), ''), - # Vue scoped data attributes - (None, re.compile(r'^data-v-'), ''), ] @@ -97,8 +94,20 @@ def __init__(self) -> None: self._saved_this_session: set[str] = set() self._normalization_rules = list(_DEFAULT_NORMALIZATION_RULES) - def save_from_element(self, locator_key: str, web_element: object, driver: object) -> None: + def _extract_full_locator_key(self, element: Element): + raw_locator_key = element.locator if element.name == element.locator else f'{element.name}::{element.locator}' + + if element.parent: + raw_locator_key += f' -> {self._extract_full_locator_key(element.parent)}' + + locator_key = self.normalize_locator_key(raw_locator_key) + + return locator_key + + def save_from_element(self, element: Element, web_element: object, driver: object) -> None: """Extract snapshot from a live web element and persist it.""" + locator_key = self._extract_full_locator_key(element) + if locator_key in self._saved_this_session: return diff --git a/tests/static_tests/unit/test_self_healing_normalization.py b/tests/static_tests/unit/test_self_healing_normalization.py index 016f88d6..7bbfe954 100644 --- a/tests/static_tests/unit/test_self_healing_normalization.py +++ b/tests/static_tests/unit/test_self_healing_normalization.py @@ -1,41 +1,71 @@ from __future__ import annotations import re +from types import SimpleNamespace from mops.self_healing.snapshot import ElementSnapshot, JsonFileSnapshotStorage # --------------------------------------------------------------------------- -# normalize_locator_key +# _extract_full_locator_key # --------------------------------------------------------------------------- -def test_normalize_locator_key_preserves_clean_key(): - """Locator without dynamic data stays unchanged.""" +def _make_element(name: str, locator: str, parent=None): + """Build a minimal fake element for key-extraction tests.""" + return SimpleNamespace(name=name, locator=locator, parent=parent) + + +def test_extract_key_without_parent(): + """Element without parent uses name::locator (or bare locator if name matches).""" storage = JsonFileSnapshotStorage() - assert storage.normalize_locator_key('MyElement::.row') == 'MyElement::.row' + # name != locator → name::locator + el = _make_element('Submit button', '#submit') + assert storage._extract_full_locator_key(el) == 'Submit button::#submit' + + # name == locator → bare locator + el = _make_element('.row', '.row') + assert storage._extract_full_locator_key(el) == '.row' -def test_normalize_locator_key_strips_numeric_id_suffix(): - r"""``#user-12345`` → ``#user`` (rule: id, -\d+$ — the ``-`` is part of the match).""" + +def test_extract_key_with_parent(): + """Parent context is appended with ' -> ' separator.""" storage = JsonFileSnapshotStorage() - assert storage.normalize_locator_key('MyElement::#user-12345') == 'MyElement::#user' + + grandparent = _make_element('Section', '#section') + parent = _make_element('Form', '#form', parent=grandparent) + child = _make_element('Submit button', '#submit', parent=parent) + + result = storage._extract_full_locator_key(child) + assert result == 'Submit button::#submit -> Form::#form -> Section::#section' -def test_normalize_locator_key_purely_numeric_id_rule_skipped(): - r"""``^\d+$`` is anchor-bound, it won't match mid-string in a flat key — expected.""" +def test_extract_key_with_parent_normalized(): + """Dynamic data in any part of the chain is normalized.""" storage = JsonFileSnapshotStorage() - # A locator like #12345 does NOT get normalized by the ^\d+$ rule - assert storage.normalize_locator_key('MyElement::#12345') == 'MyElement::#12345' + # The default class-only rules don't affect locator keys (token-removal skipped), + # so no normalization happens with defaults. But custom rules should apply. + parent = _make_element('Form', '#form') + child = _make_element('User card', '#user-12345', parent=parent) -def test_normalize_locator_key_vue_rule_skipped_mid_string(): - r"""``^data-v-`` is anchor-bound, doesn't match mid-string — expected.""" + # Default rules don't strip #user-12345 because id rules were removed + result = storage._extract_full_locator_key(child) + assert 'User card::#user-12345' in result + + +# --------------------------------------------------------------------------- +# normalize_locator_key +# --------------------------------------------------------------------------- + + +def test_normalize_locator_key_preserves_clean_key(): + """Locator without dynamic data stays unchanged.""" storage = JsonFileSnapshotStorage() - result = storage.normalize_locator_key('MyElement::[data-v-abc123]') - assert 'data-v-abc123' in result + assert storage.normalize_locator_key('MyElement::.row') == 'MyElement::.row' -def test_normalize_locator_key_class_token_removal_rules_skipped(): +def test_normalize_locator_key_class_removal_rules_skipped(): """Token-removal rules (replacement=None) are not applied to locator keys.""" storage = JsonFileSnapshotStorage() result = storage.normalize_locator_key('MyElement::.btn.active') @@ -79,50 +109,6 @@ def test_normalize_attrs_preserves_normal_class(): assert result['class'] == 'btn btn-primary header' -# --------------------------------------------------------------------------- -# _normalize_attrs — id normalization -# --------------------------------------------------------------------------- - - -def test_normalize_attrs_removes_numeric_id(): - """``id='12345'`` is removed (entirely numeric).""" - storage = JsonFileSnapshotStorage() - attrs = {'id': '12345'} - result = storage._normalize_attrs(attrs) - assert 'id' not in result - - -def test_normalize_attrs_strips_numeric_id_suffix(): - r"""``id='user-12345'`` → ``id='user'`` (the ``-`` is part of the ``-\d+$`` match).""" - storage = JsonFileSnapshotStorage() - attrs = {'id': 'user-12345'} - result = storage._normalize_attrs(attrs) - assert result['id'] == 'user' - - -def test_normalize_attrs_preserves_meaningful_id(): - """``id='submit-btn'`` stays.""" - storage = JsonFileSnapshotStorage() - attrs = {'id': 'submit-btn'} - result = storage._normalize_attrs(attrs) - assert result['id'] == 'submit-btn' - - -# --------------------------------------------------------------------------- -# _normalize_attrs — Vue scoped data attributes -# --------------------------------------------------------------------------- - - -def test_normalize_attrs_removes_vue_data_attributes(): - """``data-v-*`` attributes removed regardless of value.""" - storage = JsonFileSnapshotStorage() - attrs = {'data-v-abc123': '', 'data-v-def456': 'some-value', 'data-testid': 'submit'} - result = storage._normalize_attrs(attrs) - assert 'data-v-abc123' not in result - assert 'data-v-def456' not in result - assert result['data-testid'] == 'submit' - - # --------------------------------------------------------------------------- # _normalize_attrs — edge cases # --------------------------------------------------------------------------- @@ -137,7 +123,7 @@ def test_normalize_attrs_empty(): def test_normalize_attrs_unknown_attrs_preserved(): """Attributes without matching rules are preserved.""" storage = JsonFileSnapshotStorage() - attrs = {'aria-label': 'Close', 'role': 'button', 'data-test': 'submit'} + attrs = {'aria-label': 'Close', 'id': 'submit-btn', 'data-test': 'submit'} result = storage._normalize_attrs(attrs) assert result == attrs @@ -161,7 +147,6 @@ def test_normalize_snapshot_cleans_attrs_and_text(): parent_tag='form', parent_attributes={ 'class': 'form-wrapper js-form', - 'data-v-xyz': '', }, siblings=[ {'tag': 'span', 'text': 'Hint', 'attrs': {'class': 'helper #rdT'}}, @@ -171,16 +156,14 @@ def test_normalize_snapshot_cleans_attrs_and_text(): assert result.tag == 'button' # Class tokens cleaned (active and #sb removed) assert result.attributes['class'] == 'btn' - # ID suffix stripped (the ``-`` is part of the match) - assert result.attributes['id'] == 'submit' + # id has no default rule — preserved (was removed from defaults by user) + assert result.attributes['id'] == 'submit-42' # Non-matching attrs preserved assert result.attributes['aria-label'] == 'Submit' # Text whitespace normalized assert result.text == 'Click Me' - # Parent attrs cleaned - # ``js-form`` is not an exact match for the ``^(js-|...)`` rule — kept + # Parent attrs: ``js-form`` not exact match for ``^(js-|...)`` — kept assert result.parent_attributes['class'] == 'form-wrapper js-form' - assert 'data-v-xyz' not in result.parent_attributes # Sibling attrs cleaned assert result.siblings[0]['attrs']['class'] == 'helper' @@ -225,3 +208,16 @@ def test_custom_rules_apply_to_locator_key(): (None, re.compile(r'custom-'), ''), ]) assert storage.normalize_locator_key('El::#custom-123') == 'El::#123' + + +def test_custom_rules_affect_extracted_key(): + """Custom rules also apply to _extract_full_locator_key via normalize_locator_key.""" + storage = JsonFileSnapshotStorage() + storage.set_normalization_rules([ + (None, re.compile(r'-\d+'), ''), + ]) + parent = _make_element('Form', '#form-99') + child = _make_element('User card', '#user-12345', parent=parent) + + result = storage._extract_full_locator_key(child) + assert result == 'User card::#user -> Form::#form' From 0a85c7612c89153c8e2ac87c0557d1fd871f1583 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Wed, 17 Jun 2026 12:11:32 +0200 Subject: [PATCH 07/30] Healer callback on result added --- mops/selenium/core/core_element.py | 13 +- mops/self_healing/__init__.py | 5 +- mops/self_healing/config.py | 24 ++ mops/self_healing/healer.py | 64 ++++- .../unit/test_self_healing_callbacks.py | 246 ++++++++++++++++++ 5 files changed, 334 insertions(+), 18 deletions(-) create mode 100644 tests/static_tests/unit/test_self_healing_callbacks.py diff --git a/mops/selenium/core/core_element.py b/mops/selenium/core/core_element.py index c7c21d4f..b3d7a792 100644 --- a/mops/selenium/core/core_element.py +++ b/mops/selenium/core/core_element.py @@ -29,7 +29,7 @@ from mops.selenium.sel_utils import ActionChains from mops.self_healing.config import get_config from mops.self_healing.context import is_healing_for_method_enabled, no_healing -from mops.self_healing.healer import Healer, HealingResult +from mops.self_healing.healer import Healer, SuccessHealingResult from mops.self_healing.snapshot import SnapshotStorage from mops.shared_utils import _scaled_screenshot, cut_log_data from mops.utils.decorators import retry @@ -60,7 +60,12 @@ def _get_healer() -> Healer: config = get_config() _storage = config.storage - _healer = Healer(_storage, config.score_threshold) + _healer = Healer( + _storage, + config.score_threshold, + on_healing_success=config.on_healing_success, + on_healing_failure=config.on_healing_failure, + ) return _healer @@ -577,11 +582,11 @@ def _find_element(self, wait_parent: bool = False) -> SeleniumWebElement | Appiu else: return element - def _attempt_healing(self) -> HealingResult | None: + def _attempt_healing(self) -> SuccessHealingResult | None: """ Attempt to heal a failed element lookup using the self-healing subsystem. - :return: :class:`HealingResult` if a suitable candidate was found, :obj:`None` otherwise. + :return: :class:`SuccessHealingResult` if a suitable candidate was found, :obj:`None` otherwise. """ try: healer = _get_healer() diff --git a/mops/self_healing/__init__.py b/mops/self_healing/__init__.py index 36bcfae8..a439293b 100644 --- a/mops/self_healing/__init__.py +++ b/mops/self_healing/__init__.py @@ -21,15 +21,16 @@ from mops.self_healing.config import configure, get_config from mops.self_healing.context import is_healing_for_method_enabled, no_healing -from mops.self_healing.healer import Healer, HealingResult +from mops.self_healing.healer import FailedHealingResult, Healer, SuccessHealingResult from mops.self_healing.snapshot import ElementSnapshot, JsonFileSnapshotStorage, SnapshotStorage __all__ = [ 'ElementSnapshot', + 'FailedHealingResult', 'Healer', - 'HealingResult', 'JsonFileSnapshotStorage', 'SnapshotStorage', + 'SuccessHealingResult', 'configure', 'get_config', 'is_healing_for_method_enabled', diff --git a/mops/self_healing/config.py b/mops/self_healing/config.py index 02e3ddaf..a031b22a 100644 --- a/mops/self_healing/config.py +++ b/mops/self_healing/config.py @@ -4,6 +4,9 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: + from collections.abc import Callable + + from mops.self_healing.healer import FailedHealingResult, SuccessHealingResult from mops.self_healing.snapshot import SnapshotStorage @@ -19,12 +22,18 @@ class SelfHealingConfig: :param storage: A :class:`SnapshotStorage` instance. When not set, storage remains uninitialised and neither snapshots nor healing will work. External projects can pass their own backend (Redis, S3, PostgreSQL, etc.) here. + :param on_healing_success: Optional callback invoked when a broken locator has been + healed. Receives the :class:`SuccessHealingResult`. + :param on_healing_failure: Optional callback invoked when healing was attempted + but couldn't find a matching element. Receives the :class:`FailedHealingResult`. """ save_snapshots: bool = False heal_locators: bool = False score_threshold: float = 0.7 storage: SnapshotStorage | None = None + on_healing_success: Callable[[SuccessHealingResult], None] | None = None + on_healing_failure: Callable[[FailedHealingResult], None] | None = None _config = SelfHealingConfig() @@ -50,6 +59,21 @@ def configure(**kwargs: object) -> None: # Custom backend configure(storage=MyCustomStorage()) + + # Callbacks for external integrations + def on_success(result: HealingResult) -> None: + metrics.send(...) + + def on_failure(**kwargs: object) -> None: + # kwargs: element_name, locator_key, locator + post_comment_on_pr(kwargs['element_name'], kwargs['locator']) + + configure( + save_snapshots=True, + heal_locators=True, + on_healing_success=on_success, + on_healing_failure=on_failure, + ) """ for key, value in kwargs.items(): setattr(_config, key, value) diff --git a/mops/self_healing/healer.py b/mops/self_healing/healer.py index c05effcb..ff5c1d34 100644 --- a/mops/self_healing/healer.py +++ b/mops/self_healing/healer.py @@ -7,6 +7,8 @@ from mops.self_healing.locator_generator import generate_locator if TYPE_CHECKING: + from collections.abc import Callable + from mops.self_healing.snapshot import ElementSnapshot, SnapshotStorage logger = logging.getLogger('mops.self_healing') @@ -57,7 +59,7 @@ @dataclass -class HealingResult: +class SuccessHealingResult: element_name: str original_locator: str healed_locator: str @@ -65,35 +67,73 @@ class HealingResult: page: str | None = None +@dataclass +class FailedHealingResult: + element_name: str + locator_key: str + locator: str + reason: str + error: str | None = None + + class Healer: """Orchestrates the self-healing process for a failed element lookup.""" - def __init__(self, storage: SnapshotStorage, score_threshold: float) -> None: + def __init__( + self, + storage: SnapshotStorage, + score_threshold: float, + on_healing_success: Callable[[SuccessHealingResult], None] | None = None, + on_healing_failure: Callable[[FailedHealingResult], None] | None = None, + ) -> None: self._storage = storage self._score_threshold = score_threshold + self._on_healing_success = on_healing_success + self._on_healing_failure = on_healing_failure + + def _fail( + self, reason: str, element_name: str, locator_key: str, locator: str, exc: BaseException | None = None + ) -> None: + """Fire failure callback and return None.""" + result = FailedHealingResult( + element_name=element_name, + locator_key=locator_key, + locator=locator, + reason=reason, + error=str(exc) if exc else None, + ) + if self._on_healing_failure: + self._on_healing_failure(result) + + def _succeed(self, result: SuccessHealingResult) -> SuccessHealingResult: + """Fire success callback and return the result.""" + if self._on_healing_success: + self._on_healing_success(result) + return result - def heal(self, element_name: str, locator_key: str, locator: str, driver: Any) -> HealingResult | None: + def heal(self, element_name: str, locator_key: str, locator: str, driver: Any) -> SuccessHealingResult | None: """Try to find a healed locator for a failed element lookup. :param element_name: Human-readable element name for logging. :param locator_key: Storage key used to load the saved snapshot. :param locator: The original locator string (for the result record). :param driver: Selenium WebDriver instance. - :return: :class:`HealingResult` if healed, ``None`` otherwise. + :return: :class:`SuccessHealingResult` if healed, ``None`` otherwise. """ snapshot = self._storage.load(locator_key) + if not snapshot: logger.debug('Self-healing: no snapshot for "%s", skipping', element_name) - return None + return self._fail('no-snapshot', element_name, locator_key, locator) try: candidates_data: list[dict] = driver.execute_script(_GET_CANDIDATES_JS, snapshot.tag) except Exception as exc: logger.debug('Self-healing: failed to get candidates for "%s": %s', element_name, exc) - return None + return self._fail('candidates-script-error', element_name, locator_key, locator, exc=exc) if not candidates_data: - return None + return self._fail('no-candidates', element_name, locator_key, locator) best_score = 0.0 best_index = -1 @@ -111,7 +151,7 @@ def heal(self, element_name: str, locator_key: str, locator: str, driver: Any) - self._score_threshold, element_name, ) - return None + return self._fail('below-threshold', element_name, locator_key, locator) # Get the actual WebElement by index among elements of the same tag try: @@ -119,14 +159,14 @@ def heal(self, element_name: str, locator_key: str, locator: str, driver: Any) - web_elements = driver.find_elements(By.TAG_NAME, snapshot.tag) if best_index >= len(web_elements): - return None + return self._fail('index-out-of-bounds', element_name, locator_key, locator) healed_web_element = web_elements[best_index] healed_locator = generate_locator(healed_web_element, driver) except Exception as exc: logger.debug('Self-healing: failed to generate locator for "%s": %s', element_name, exc) - return None + return self._fail('generate-locator-error', element_name, locator_key, locator, exc=exc) - result = HealingResult( + result = SuccessHealingResult( element_name=element_name, original_locator=locator, healed_locator=healed_locator, @@ -141,7 +181,7 @@ def heal(self, element_name: str, locator_key: str, locator: str, driver: Any) - best_score, ) - return result + return self._succeed(result) def _score_similarity(candidate: dict[str, Any], snapshot: ElementSnapshot) -> float: diff --git a/tests/static_tests/unit/test_self_healing_callbacks.py b/tests/static_tests/unit/test_self_healing_callbacks.py new file mode 100644 index 00000000..09f9f3eb --- /dev/null +++ b/tests/static_tests/unit/test_self_healing_callbacks.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from mops.self_healing.healer import FailedHealingResult, Healer, SuccessHealingResult +from mops.self_healing.snapshot import ElementSnapshot + + +def _assert_failed(callback, reason, error=None): + """Assert callback was called once with a FailedHealingResult matching reason.""" + callback.assert_called_once() + args = callback.call_args[0][0] + assert isinstance(args, FailedHealingResult) + assert args.reason == reason + assert args.error == error + + +def _make_snapshot(**overrides: str) -> ElementSnapshot: + """Build an ElementSnapshot with sensible defaults.""" + defaults = dict( + tag='button', + attributes={'id': 'submit'}, + text='Click', + parent_tag='form', + parent_attributes={}, + siblings=[], + ) + defaults.update(overrides) + return ElementSnapshot(**defaults) + + +def _make_candidate(index: int = 0, **extra: str) -> dict: + """Build a candidate dict with values matching the default snapshot.""" + return { + 'index': index, + 'attrs': {'id': 'submit'}, + 'text': 'Click', + 'parentTag': 'form', + 'parentAttrs': {}, + **extra, + } + + +# --------------------------------------------------------------------------- +# on_healing_success +# --------------------------------------------------------------------------- + + +def test_success_callback_fired(): + """Healing success fires on_healing_success with HealingResult.""" + callback = MagicMock() + storage = MagicMock() + storage.load.return_value = _make_snapshot() + driver = MagicMock() + driver.execute_script.return_value = [_make_candidate()] + driver.find_elements.return_value = [MagicMock()] + + healer = Healer(storage, 0.7, on_healing_success=callback) + + with patch('mops.self_healing.healer.generate_locator', return_value='xpath=//button'): + result = healer.heal('btn', 'key', '#submit', driver) + + assert result is not None + assert isinstance(result, SuccessHealingResult) + callback.assert_called_once_with(result) + + +def test_success_callback_not_set(): + """Healing works even when on_healing_success is None.""" + storage = MagicMock() + storage.load.return_value = _make_snapshot() + driver = MagicMock() + driver.execute_script.return_value = [_make_candidate()] + driver.find_elements.return_value = [MagicMock()] + + healer = Healer(storage, 0.7) + + with patch('mops.self_healing.healer.generate_locator', return_value='xpath=//button'): + result = healer.heal('btn', 'key', '#submit', driver) + + assert result is not None + + +# --------------------------------------------------------------------------- +# on_healing_failure — 6 failure paths +# --------------------------------------------------------------------------- + + +def test_failure_no_snapshot(): + """No snapshot → on_healing_failure fired with FailedHealingResult.""" + callback = MagicMock() + storage = MagicMock() + storage.load.return_value = None + healer = Healer(storage, 0.7, on_healing_failure=callback) + + result = healer.heal('btn', 'missing-key', '#submit', MagicMock()) + + assert result is None + callback.assert_called_once() + args = callback.call_args[0][0] + assert isinstance(args, FailedHealingResult) + assert args.element_name == 'btn' + assert args.locator_key == 'missing-key' + assert args.locator == '#submit' + assert args.reason == 'no-snapshot' + assert args.error is None + + +def test_failure_candidates_script_raises(): + """driver.execute_script raises → on_healing_failure fired.""" + callback = MagicMock() + storage = MagicMock() + storage.load.return_value = _make_snapshot() + driver = MagicMock() + driver.execute_script.side_effect = RuntimeError('browser error') + healer = Healer(storage, 0.7, on_healing_failure=callback) + + result = healer.heal('btn', 'key', '#submit', driver) + + assert result is None + _assert_failed(callback, reason='candidates-script-error', error='browser error') + + +def test_failure_no_candidates(): + """Empty candidates list → on_healing_failure fired.""" + callback = MagicMock() + storage = MagicMock() + storage.load.return_value = _make_snapshot() + driver = MagicMock() + driver.execute_script.return_value = [] + healer = Healer(storage, 0.7, on_healing_failure=callback) + + result = healer.heal('btn', 'key', '#submit', driver) + + assert result is None + _assert_failed(callback, reason='no-candidates') + + +def test_failure_score_below_threshold(): + """Low similarity score → on_healing_failure fired.""" + callback = MagicMock() + storage = MagicMock() + # Snapshot with mismatched attributes/text so score stays low + storage.load.return_value = _make_snapshot(attributes={'class': 'x'}, text='foo') + driver = MagicMock() + driver.execute_script.return_value = [ + _make_candidate(attrs={'class': 'y'}, text='bar', parentTag='div'), + ] + healer = Healer(storage, 0.95, on_healing_failure=callback) + + result = healer.heal('btn', 'key', '#submit', driver) + + assert result is None + _assert_failed(callback, reason='below-threshold') + + +def test_failure_best_index_out_of_bounds(): + """best_index >= len(web_elements) → on_healing_failure fired.""" + callback = MagicMock() + storage = MagicMock() + storage.load.return_value = _make_snapshot() + driver = MagicMock() + # Candidate 0 has low score (mismatch), candidate 1 has high score + # → best_index = 1 but only 1 real element → OOB + driver.execute_script.return_value = [ + _make_candidate(index=0, attrs={'id': 'other'}, text='Other'), + _make_candidate(index=1), + ] + driver.find_elements.return_value = [MagicMock()] # only 1 element → index 1 is OOB + healer = Healer(storage, 0.7, on_healing_failure=callback) + + with patch('mops.self_healing.healer.generate_locator', return_value='xpath=//button'): + result = healer.heal('btn', 'key', '#submit', driver) + + assert result is None + _assert_failed(callback, reason='index-out-of-bounds') + + +def test_failure_generate_locator_raises(): + """generate_locator raises → on_healing_failure fired.""" + callback = MagicMock() + storage = MagicMock() + storage.load.return_value = _make_snapshot() + driver = MagicMock() + driver.execute_script.return_value = [_make_candidate()] + driver.find_elements.return_value = [MagicMock()] + healer = Healer(storage, 0.7, on_healing_failure=callback) + + with patch('mops.self_healing.healer.generate_locator', side_effect=RuntimeError('no locator')): + result = healer.heal('btn', 'key', '#submit', driver) + + assert result is None + _assert_failed(callback, reason='generate-locator-error', error='no locator') + + +def test_failure_callback_not_set(): + """Healing failure works even when on_healing_failure is None.""" + storage = MagicMock() + storage.load.return_value = None + healer = Healer(storage, 0.7) + + result = healer.heal('btn', 'key', '#submit', MagicMock()) + + assert result is None + + +# --------------------------------------------------------------------------- +# edge cases — callback exception safety +# --------------------------------------------------------------------------- + + +def test_success_callback_raises_propagates(): + """If on_healing_success raises, the exception propagates to the caller.""" + storage = MagicMock() + storage.load.return_value = _make_snapshot() + driver = MagicMock() + driver.execute_script.return_value = [_make_candidate()] + driver.find_elements.return_value = [MagicMock()] + + def crash(_result): + raise RuntimeError('callback failed') + + healer = Healer(storage, 0.7, on_healing_success=crash) + + import pytest + + with patch('mops.self_healing.healer.generate_locator', return_value='xpath=//button'): + with pytest.raises(RuntimeError, match='callback failed'): + healer.heal('btn', 'key', '#submit', driver) + + +def test_failure_callback_does_not_crash_healing(): + """A misbehaving on_healing_failure does not prevent returning None.""" + storage = MagicMock() + storage.load.return_value = None + + def crash(_result): + raise RuntimeError('callback failed') + + healer = Healer(storage, 0.7, on_healing_failure=crash) + + import pytest + + # The exception propagates — users should see broken callbacks + with pytest.raises(RuntimeError, match='callback failed'): + healer.heal('btn', 'key', '#submit', MagicMock()) From c6f7c1763ee0880ea0dde0640a009f8dd62c5304 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Wed, 17 Jun 2026 17:20:43 +0200 Subject: [PATCH 08/30] Ruff fixes / proper logging & result / weights customisation --- mops/abstraction/element_abc.py | 4 +- mops/selenium/core/core_element.py | 46 +++++++++++------- mops/self_healing/config.py | 5 +- mops/self_healing/context.py | 2 +- mops/self_healing/healer.py | 48 ++++++++++++------- mops/self_healing/locator_generator.py | 31 +++++++----- mops/self_healing/snapshot.py | 10 ++-- .../unit/test_self_healing_callbacks.py | 6 ++- tests/web_tests/test_self_healing.py | 37 ++++++++++++++ 9 files changed, 131 insertions(+), 58 deletions(-) diff --git a/mops/abstraction/element_abc.py b/mops/abstraction/element_abc.py index 1bdcef9f..4eb38288 100644 --- a/mops/abstraction/element_abc.py +++ b/mops/abstraction/element_abc.py @@ -373,7 +373,7 @@ def is_available(self) -> bool: def _is_available(self) -> bool: """ - Internal check for element availability without @no_healing. + Check element availability internally without @no_healing. Used by :meth:`wait_availability` to allow self-healing during polling. Override in backend-specific classes. Default delegates to @@ -395,7 +395,7 @@ def is_displayed(self, silent: bool = False) -> bool: def _is_displayed(self, silent: bool = False) -> bool: """ - Internal check for element display state without @no_healing. + Check element display state internally without @no_healing. Used by :meth:`wait_visibility` to allow self-healing during polling. Override in backend-specific classes. Default delegates to diff --git a/mops/selenium/core/core_element.py b/mops/selenium/core/core_element.py index b3d7a792..31a5d71a 100644 --- a/mops/selenium/core/core_element.py +++ b/mops/selenium/core/core_element.py @@ -11,6 +11,7 @@ StaleElementReferenceException as SeleniumStaleElementReferenceException, WebDriverException as SeleniumWebDriverException, ) +from selenium.webdriver.common.by import By from selenium.webdriver.support.wait import WebDriverWait from mops.abstraction.element_abc import ElementABC @@ -30,7 +31,6 @@ from mops.self_healing.config import get_config from mops.self_healing.context import is_healing_for_method_enabled, no_healing from mops.self_healing.healer import Healer, SuccessHealingResult -from mops.self_healing.snapshot import SnapshotStorage from mops.shared_utils import _scaled_screenshot, cut_log_data from mops.utils.decorators import retry from mops.utils.internal_utils import WAIT_EL, get_dict, is_group, safe_call @@ -45,34 +45,35 @@ from mops.base.element import Element from mops.keyboard_keys import KeyboardKeys + from mops.self_healing.snapshot import SnapshotStorage -_storage: SnapshotStorage | None = None -_healer: Healer | None = None +class _HealerState: + """Module-level state for the healer singleton.""" + + storage: SnapshotStorage | None = None + healer: Healer | None = None def _get_healer() -> Healer: """Return the global Healer singleton.""" - global _storage, _healer - - if _healer: - return _healer + if _HealerState.healer: + return _HealerState.healer config = get_config() - _storage = config.storage - _healer = Healer( - _storage, + _HealerState.storage = config.storage + _HealerState.healer = Healer( + _HealerState.storage, config.score_threshold, + attribute_weights=config.attribute_weights, on_healing_success=config.on_healing_success, on_healing_failure=config.on_healing_failure, ) - return _healer + return _HealerState.healer def _parse_healed_locator(healed_locator: str) -> tuple[str, str]: """Convert a ``xpath=//...`` prefixed locator into a ``(By, value)`` tuple.""" - from selenium.webdriver.common.by import By - if healed_locator.startswith('xpath='): return By.XPATH, healed_locator[len('xpath=') :] # Default fallback: treat as XPath @@ -568,15 +569,21 @@ def _find_element(self, wait_parent: bool = False) -> SeleniumWebElement | Appiu config = get_config() if config.save_snapshots: _get_healer() - _storage.save_from_element(self, element, self.driver) + _HealerState.storage.save_from_element(self, element, self.driver) except (SeleniumInvalidArgumentException, SeleniumInvalidSelectorException) as exc: self._raise_invalid_selector_exception(exc) except SeleniumNoSuchElementException as exc: if is_healing_for_method_enabled() and get_config().heal_locators: result = self._attempt_healing() if result: - healed = base.find_element(*_parse_healed_locator(result.healed_locator)) + healed_locator_type, healed_locator_value = _parse_healed_locator(result.healed_locator) + healed = base.find_element(healed_locator_type, healed_locator_value) + # Persist healed locator so subsequent lookups don't re-heal + self.locator_type = healed_locator_type + self.locator = healed_locator_value self._cached_element = healed + if get_config().save_snapshots: + _HealerState.storage.save_from_element(self, healed, self.driver) return healed raise NoSuchElementException(exc.msg) from exc else: @@ -590,9 +597,12 @@ def _attempt_healing(self) -> SuccessHealingResult | None: """ try: healer = _get_healer() - locator_key = _storage.normalize_locator_key(f'{self.name}::{self.locator}') - return healer.heal(self.name, locator_key, self.locator, self.driver) - except Exception: + locator_key = _HealerState.storage.normalize_locator_key(f'{self.name}::{self.locator}') + result = healer.heal(self.name, locator_key, self.locator, self.driver) + if type(result) is SuccessHealingResult: + return result + except Exception as exc: # noqa: BLE001 + self.log(f'Self-healing failed with unexpected exception: {exc}', level='warning') return None def _find_elements(self, wait_parent: bool = False) -> list[SeleniumWebElement | AppiumWebElement]: diff --git a/mops/self_healing/config.py b/mops/self_healing/config.py index a031b22a..4002351a 100644 --- a/mops/self_healing/config.py +++ b/mops/self_healing/config.py @@ -18,7 +18,9 @@ class SelfHealingConfig: are saved to storage for future healing. :param heal_locators: When :obj:`True`, the system attempts to heal broken locators by loading saved snapshots and searching for matching elements. - :param score_threshold: Minimum similarity score (0–1) to accept a healed locator. + :param score_threshold: Minimum similarity score (0-1) to accept a healed locator. + :param attribute_weights: Custom attribute weights for element similarity scoring. + When not set, built-in defaults are used. :param storage: A :class:`SnapshotStorage` instance. When not set, storage remains uninitialised and neither snapshots nor healing will work. External projects can pass their own backend (Redis, S3, PostgreSQL, etc.) here. @@ -31,6 +33,7 @@ class SelfHealingConfig: save_snapshots: bool = False heal_locators: bool = False score_threshold: float = 0.7 + attribute_weights: dict[str, float] | None = None storage: SnapshotStorage | None = None on_healing_success: Callable[[SuccessHealingResult], None] | None = None on_healing_failure: Callable[[FailedHealingResult], None] | None = None diff --git a/mops/self_healing/context.py b/mops/self_healing/context.py index c30cf5bf..50065658 100644 --- a/mops/self_healing/context.py +++ b/mops/self_healing/context.py @@ -11,7 +11,7 @@ def no_healing(func: Callable) -> Callable: - """Decorator that disables self-healing for the duration of the wrapped method. + """Disable self-healing for the duration of the wrapped method. Use on methods where element not being found is an acceptable outcome, e.g. is_displayed(), is_hidden(), wait_hidden(). diff --git a/mops/self_healing/healer.py b/mops/self_healing/healer.py index ff5c1d34..1ddf45db 100644 --- a/mops/self_healing/healer.py +++ b/mops/self_healing/healer.py @@ -4,6 +4,9 @@ import logging from typing import TYPE_CHECKING, Any +from selenium.common.exceptions import WebDriverException +from selenium.webdriver.common.by import By + from mops.self_healing.locator_generator import generate_locator if TYPE_CHECKING: @@ -83,11 +86,13 @@ def __init__( self, storage: SnapshotStorage, score_threshold: float, + attribute_weights: dict[str, float] | None = None, on_healing_success: Callable[[SuccessHealingResult], None] | None = None, on_healing_failure: Callable[[FailedHealingResult], None] | None = None, ) -> None: self._storage = storage self._score_threshold = score_threshold + self._attribute_weights = attribute_weights or _ATTRIBUTE_WEIGHTS self._on_healing_success = on_healing_success self._on_healing_failure = on_healing_failure @@ -95,12 +100,15 @@ def _fail( self, reason: str, element_name: str, locator_key: str, locator: str, exc: BaseException | None = None ) -> None: """Fire failure callback and return None.""" + error: str | None = None + if exc: + error = exc.msg if isinstance(exc, WebDriverException) else str(exc) result = FailedHealingResult( element_name=element_name, locator_key=locator_key, locator=locator, reason=reason, - error=str(exc) if exc else None, + error=error, ) if self._on_healing_failure: self._on_healing_failure(result) @@ -123,13 +131,13 @@ def heal(self, element_name: str, locator_key: str, locator: str, driver: Any) - snapshot = self._storage.load(locator_key) if not snapshot: - logger.debug('Self-healing: no snapshot for "%s", skipping', element_name) + logger.info('Self-healing: no snapshot for "%s", skipping', element_name) return self._fail('no-snapshot', element_name, locator_key, locator) try: candidates_data: list[dict] = driver.execute_script(_GET_CANDIDATES_JS, snapshot.tag) - except Exception as exc: - logger.debug('Self-healing: failed to get candidates for "%s": %s', element_name, exc) + except WebDriverException as exc: + logger.info('Self-healing: failed to get candidates for "%s": %s', element_name, exc) return self._fail('candidates-script-error', element_name, locator_key, locator, exc=exc) if not candidates_data: @@ -139,13 +147,13 @@ def heal(self, element_name: str, locator_key: str, locator: str, driver: Any) - best_index = -1 for item in candidates_data: - score = _score_similarity(item, snapshot) + score = _score_similarity(item, snapshot, self._attribute_weights) if score > best_score: best_score = score best_index = item['index'] if best_score < self._score_threshold or best_index < 0: - logger.debug( + logger.info( 'Self-healing: best score %.2f below threshold %.2f for "%s"', best_score, self._score_threshold, @@ -154,17 +162,20 @@ def heal(self, element_name: str, locator_key: str, locator: str, driver: Any) - return self._fail('below-threshold', element_name, locator_key, locator) # Get the actual WebElement by index among elements of the same tag + healed_locator = None try: - from selenium.webdriver.common.by import By - web_elements = driver.find_elements(By.TAG_NAME, snapshot.tag) if best_index >= len(web_elements): - return self._fail('index-out-of-bounds', element_name, locator_key, locator) - healed_web_element = web_elements[best_index] - healed_locator = generate_locator(healed_web_element, driver) - except Exception as exc: - logger.debug('Self-healing: failed to generate locator for "%s": %s', element_name, exc) - return self._fail('generate-locator-error', element_name, locator_key, locator, exc=exc) + self._fail('index-out-of-bounds', element_name, locator_key, locator) + else: + healed_web_element = web_elements[best_index] + healed_locator = generate_locator(healed_web_element, driver) + except WebDriverException as exc: + logger.info('Self-healing: failed to generate locator for "%s": %s', element_name, exc) + self._fail('generate-locator-error', element_name, locator_key, locator, exc=exc) + + if healed_locator is None: + return None result = SuccessHealingResult( element_name=element_name, @@ -184,13 +195,16 @@ def heal(self, element_name: str, locator_key: str, locator: str, driver: Any) - return self._succeed(result) -def _score_similarity(candidate: dict[str, Any], snapshot: ElementSnapshot) -> float: - """Compute a 0–1 similarity score between a candidate DOM element and a saved snapshot.""" +def _score_similarity( + candidate: dict[str, Any], snapshot: ElementSnapshot, attribute_weights: dict[str, float] | None = None +) -> float: + """Compute a 0-1 similarity score between a candidate DOM element and a saved snapshot.""" + weights = attribute_weights or _ATTRIBUTE_WEIGHTS score = 0.0 total_weight = 0.0 # Attribute matching - for attr, weight in _ATTRIBUTE_WEIGHTS.items(): + for attr, weight in weights.items(): snap_val = snapshot.attributes.get(attr) cand_val = candidate['attrs'].get(attr) diff --git a/mops/self_healing/locator_generator.py b/mops/self_healing/locator_generator.py index 40389f7b..6d65f646 100644 --- a/mops/self_healing/locator_generator.py +++ b/mops/self_healing/locator_generator.py @@ -1,5 +1,7 @@ from __future__ import annotations +from selenium.common.exceptions import WebDriverException + _GET_POSITIONAL_XPATH_JS = """ return (function(el) { var parts = []; @@ -21,6 +23,8 @@ _TEST_ATTRS = ('data-testid', 'data-test', 'data-cy', 'data-qa', 'data-automation-id') +_MAX_TEXT_LENGTH = 50 + def generate_locator(web_element: object, driver: object) -> str: """Generate a stable XPath locator from a live Selenium WebElement. @@ -38,54 +42,57 @@ def generate_locator(web_element: object, driver: object) -> str: attrs[attr_name] = val.strip() text = (web_element.text or '').strip() - except Exception: + except WebDriverException: return _positional_xpath(web_element, driver) + providers: list[str] = [] + # id (unique by spec) el_id = attrs.get('id', '') if el_id and ' ' not in el_id: - return f'xpath=//*[@id="{el_id}"]' + providers.append(f'xpath=//*[@id="{el_id}"]') # data-* test attributes for test_attr in _TEST_ATTRS: val = attrs.get(test_attr, '') if val: - return f'xpath=//*[@{test_attr}="{val}"]' + providers.append(f'xpath=//*[@{test_attr}="{val}"]') # name name = attrs.get('name', '') if name: - return f'xpath=//{tag}[@name="{name}"]' + providers.append(f'xpath=//{tag}[@name="{name}"]') # aria-label aria = attrs.get('aria-label', '') if aria: escaped = aria.replace('"', '\\"') - return f'xpath=//*[@aria-label="{escaped}"]' + providers.append(f'xpath=//*[@aria-label="{escaped}"]') # visible text (short, single-line) - if text and len(text) <= 50 and '\n' not in text: + if text and len(text) <= _MAX_TEXT_LENGTH and '\n' not in text: escaped = text.replace('"', '\\"') - return f'xpath=//{tag}[normalize-space(.)="{escaped}"]' + providers.append(f'xpath=//{tag}[normalize-space(.)="{escaped}"]') # type + tag el_type = attrs.get('type', '') if el_type: - return f'xpath=//{tag}[@type="{el_type}"]' + providers.append(f'xpath=//{tag}[@type="{el_type}"]') # stable class (filter out dynamic-looking tokens) cls = attrs.get('class', '') if cls: stable = [c for c in cls.split() if not any(c.lower().startswith(p) for p in _DYNAMIC_CLASS_PREFIXES)] if stable: - return f'xpath=//{tag}[contains(@class, "{stable[0]}")]' + providers.append(f'xpath=//{tag}[contains(@class, "{stable[0]}")]') - return _positional_xpath(web_element, driver) + return providers[0] if providers else _positional_xpath(web_element, driver) def _positional_xpath(web_element: object, driver: object) -> str: try: path: str = driver.execute_script(_GET_POSITIONAL_XPATH_JS, web_element) - return f'xpath={path}' - except Exception: + except WebDriverException: return f'xpath=//{web_element.tag_name}' + else: + return f'xpath={path}' diff --git a/mops/self_healing/snapshot.py b/mops/self_healing/snapshot.py index 8500a023..4e1164de 100644 --- a/mops/self_healing/snapshot.py +++ b/mops/self_healing/snapshot.py @@ -10,6 +10,8 @@ import sqlite3 from typing import TYPE_CHECKING, Any +from selenium.common.exceptions import WebDriverException + if TYPE_CHECKING: from mops.base.element import Element @@ -94,15 +96,13 @@ def __init__(self) -> None: self._saved_this_session: set[str] = set() self._normalization_rules = list(_DEFAULT_NORMALIZATION_RULES) - def _extract_full_locator_key(self, element: Element): + def _extract_full_locator_key(self, element: Element) -> str: raw_locator_key = element.locator if element.name == element.locator else f'{element.name}::{element.locator}' if element.parent: raw_locator_key += f' -> {self._extract_full_locator_key(element.parent)}' - locator_key = self.normalize_locator_key(raw_locator_key) - - return locator_key + return self.normalize_locator_key(raw_locator_key) def save_from_element(self, element: Element, web_element: object, driver: object) -> None: """Extract snapshot from a live web element and persist it.""" @@ -113,7 +113,7 @@ def save_from_element(self, element: Element, web_element: object, driver: objec try: raw = driver.execute_script(_GET_ELEMENT_SNAPSHOT_JS, web_element) - except Exception: + except WebDriverException: return snapshot = ElementSnapshot( diff --git a/tests/static_tests/unit/test_self_healing_callbacks.py b/tests/static_tests/unit/test_self_healing_callbacks.py index 09f9f3eb..33bc248a 100644 --- a/tests/static_tests/unit/test_self_healing_callbacks.py +++ b/tests/static_tests/unit/test_self_healing_callbacks.py @@ -2,6 +2,8 @@ from unittest.mock import MagicMock, patch +from selenium.common.exceptions import WebDriverException + from mops.self_healing.healer import FailedHealingResult, Healer, SuccessHealingResult from mops.self_healing.snapshot import ElementSnapshot @@ -112,7 +114,7 @@ def test_failure_candidates_script_raises(): storage = MagicMock() storage.load.return_value = _make_snapshot() driver = MagicMock() - driver.execute_script.side_effect = RuntimeError('browser error') + driver.execute_script.side_effect = WebDriverException('browser error') healer = Healer(storage, 0.7, on_healing_failure=callback) result = healer.heal('btn', 'key', '#submit', driver) @@ -186,7 +188,7 @@ def test_failure_generate_locator_raises(): driver.find_elements.return_value = [MagicMock()] healer = Healer(storage, 0.7, on_healing_failure=callback) - with patch('mops.self_healing.healer.generate_locator', side_effect=RuntimeError('no locator')): + with patch('mops.self_healing.healer.generate_locator', side_effect=WebDriverException('no locator')): result = healer.heal('btn', 'key', '#submit', driver) assert result is None diff --git a/tests/web_tests/test_self_healing.py b/tests/web_tests/test_self_healing.py index dba4c9a7..3eb70ddc 100644 --- a/tests/web_tests/test_self_healing.py +++ b/tests/web_tests/test_self_healing.py @@ -48,3 +48,40 @@ def test_self_healing_recovers_broken_locator(second_playground_page): assert cls is not None, 'Self-healing did not recover the element' assert 'row' in cls + + +def test_self_healing_recovery_after_class_change(second_playground_page): + """ + Self-healing recovers an element whose class was changed in DOM. + + Flow: + 1. Find row_with_cards → snapshot saved. + 2. Change the ``class`` attribute via JS, breaking the ``.row`` locator. + 3. Create a new element with the broken locator. + 4. ``wait_visibility`` triggers polling → healing finds the element + by DOM similarity (snapshot matching). + """ + row = second_playground_page.row_with_cards + + # Find the real element so the snapshot is persisted + row.wait_visibility(silent=True) + + storage = get_config().storage + real_key = f'{row.name}::{row.locator}' + assert storage.load(real_key) is not None + + # Break the locator by changing the class of ALL .row elements + driver = second_playground_page.driver + driver.execute_script(""" + var elements = document.querySelectorAll('.row'); + for (var i = 0; i < elements.length; i++) { + elements[i].className = 'broken-row'; + } + """) + + # wait_visibility triggers element lookup → healing should recover + row.wait_visibility(silent=True) + + cls = row.get_attribute('class', silent=True) + assert cls is not None, 'Self-healing did not recover the element' + assert 'broken-row' in cls From 854023f4e4a42d4ad7df1bffdaff957fcbfe85dc Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Thu, 18 Jun 2026 10:49:47 +0200 Subject: [PATCH 09/30] refactor(self-healing): iterate through multiple locator candidates on healing Rename `attribute_weights` to `scoring_weights` and expose `ScoringWeights` class. Instead of picking the single best healed locator, try each candidate in sequence until one matches an element, falling back to the original error only when all candidates are exhausted. --- mops/selenium/core/core_element.py | 24 ++-- mops/self_healing/__init__.py | 3 +- mops/self_healing/config.py | 7 +- mops/self_healing/healer.py | 131 ++++++++++++------ mops/self_healing/locator_generator.py | 24 ++-- .../unit/test_self_healing_callbacks.py | 96 ++++++++++++- tests/web_tests/test_self_healing.py | 40 ++++++ 7 files changed, 256 insertions(+), 69 deletions(-) diff --git a/mops/selenium/core/core_element.py b/mops/selenium/core/core_element.py index 31a5d71a..db1cf5b7 100644 --- a/mops/selenium/core/core_element.py +++ b/mops/selenium/core/core_element.py @@ -65,7 +65,7 @@ def _get_healer() -> Healer: _HealerState.healer = Healer( _HealerState.storage, config.score_threshold, - attribute_weights=config.attribute_weights, + scoring_weights=config.scoring_weights, on_healing_success=config.on_healing_success, on_healing_failure=config.on_healing_failure, ) @@ -576,15 +576,19 @@ def _find_element(self, wait_parent: bool = False) -> SeleniumWebElement | Appiu if is_healing_for_method_enabled() and get_config().heal_locators: result = self._attempt_healing() if result: - healed_locator_type, healed_locator_value = _parse_healed_locator(result.healed_locator) - healed = base.find_element(healed_locator_type, healed_locator_value) - # Persist healed locator so subsequent lookups don't re-heal - self.locator_type = healed_locator_type - self.locator = healed_locator_value - self._cached_element = healed - if get_config().save_snapshots: - _HealerState.storage.save_from_element(self, healed, self.driver) - return healed + for locator in result.healed_locators_candidates: + healed_locator_type, healed_locator_value = _parse_healed_locator(locator) + try: + healed = base.find_element(healed_locator_type, healed_locator_value) + except SeleniumNoSuchElementException: + continue + result.healed_locator = locator + # Persist healed locator so subsequent lookups don't re-heal + self.locator_type = healed_locator_type + self.locator = healed_locator_value + self._cached_element = healed + return healed + raise NoSuchElementException(exc.msg) from exc raise NoSuchElementException(exc.msg) from exc else: return element diff --git a/mops/self_healing/__init__.py b/mops/self_healing/__init__.py index a439293b..04eabbdf 100644 --- a/mops/self_healing/__init__.py +++ b/mops/self_healing/__init__.py @@ -21,7 +21,7 @@ from mops.self_healing.config import configure, get_config from mops.self_healing.context import is_healing_for_method_enabled, no_healing -from mops.self_healing.healer import FailedHealingResult, Healer, SuccessHealingResult +from mops.self_healing.healer import FailedHealingResult, Healer, ScoringWeights, SuccessHealingResult from mops.self_healing.snapshot import ElementSnapshot, JsonFileSnapshotStorage, SnapshotStorage __all__ = [ @@ -29,6 +29,7 @@ 'FailedHealingResult', 'Healer', 'JsonFileSnapshotStorage', + 'ScoringWeights', 'SnapshotStorage', 'SuccessHealingResult', 'configure', diff --git a/mops/self_healing/config.py b/mops/self_healing/config.py index 4002351a..735bc5ad 100644 --- a/mops/self_healing/config.py +++ b/mops/self_healing/config.py @@ -6,7 +6,7 @@ if TYPE_CHECKING: from collections.abc import Callable - from mops.self_healing.healer import FailedHealingResult, SuccessHealingResult + from mops.self_healing.healer import FailedHealingResult, ScoringWeights, SuccessHealingResult from mops.self_healing.snapshot import SnapshotStorage @@ -19,7 +19,8 @@ class SelfHealingConfig: :param heal_locators: When :obj:`True`, the system attempts to heal broken locators by loading saved snapshots and searching for matching elements. :param score_threshold: Minimum similarity score (0-1) to accept a healed locator. - :param attribute_weights: Custom attribute weights for element similarity scoring. + :param scoring_weights: Tunable :class:`ScoringWeights` for element similarity scoring. + Controls per-attribute, text, parent, and sibling contributions. When not set, built-in defaults are used. :param storage: A :class:`SnapshotStorage` instance. When not set, storage remains uninitialised and neither snapshots nor healing will work. @@ -33,7 +34,7 @@ class SelfHealingConfig: save_snapshots: bool = False heal_locators: bool = False score_threshold: float = 0.7 - attribute_weights: dict[str, float] | None = None + scoring_weights: ScoringWeights | None = None storage: SnapshotStorage | None = None on_healing_success: Callable[[SuccessHealingResult], None] | None = None on_healing_failure: Callable[[FailedHealingResult], None] | None = None diff --git a/mops/self_healing/healer.py b/mops/self_healing/healer.py index 1ddf45db..158e191d 100644 --- a/mops/self_healing/healer.py +++ b/mops/self_healing/healer.py @@ -1,6 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field import logging from typing import TYPE_CHECKING, Any @@ -16,24 +16,6 @@ logger = logging.getLogger('mops.self_healing') -_ATTRIBUTE_WEIGHTS: dict[str, float] = { - 'id': 1.0, - 'data-testid': 0.9, - 'data-test': 0.9, - 'data-cy': 0.9, - 'data-qa': 0.9, - 'data-automation-id': 0.9, - 'name': 0.7, - 'aria-label': 0.6, - 'placeholder': 0.5, - 'type': 0.4, - 'role': 0.3, - 'href': 0.3, - 'alt': 0.3, - 'title': 0.2, - 'class': 0.15, -} - _GET_CANDIDATES_JS = """ return (function(tag) { function getAttrs(node) { @@ -43,6 +25,22 @@ } return attrs; } + function getSiblings(el) { + var parent = el.parentElement; + if (!parent) return []; + var children = parent.children; + var siblings = []; + for (var i = 0; i < children.length && siblings.length < 5; i++) { + if (children[i] !== el) { + siblings.push({ + tag: children[i].tagName.toLowerCase(), + text: (children[i].textContent || '').trim().substring(0, 50), + attrs: getAttrs(children[i]) + }); + } + } + return siblings; + } var elements = document.getElementsByTagName(tag); var result = []; for (var i = 0; i < elements.length; i++) { @@ -53,7 +51,8 @@ attrs: getAttrs(el), text: (el.textContent || '').trim().substring(0, 100), parentTag: parent ? parent.tagName.toLowerCase() : null, - parentAttrs: parent ? getAttrs(parent) : {} + parentAttrs: parent ? getAttrs(parent) : {}, + siblings: getSiblings(el) }); } return result; @@ -61,11 +60,37 @@ """ +@dataclass +class ScoringWeights: + """Tunable weights for the similarity scoring function. + + Each weight controls how much a signal contributes to the final 0-1 score. + ``attribute`` is a per-attribute dict; the rest are scalar multipliers. + """ + + attribute: dict[str, float] = field( + default_factory=lambda: { + 'id': 1.0, + 'name': 0.7, + 'placeholder': 0.5, + 'type': 0.4, + 'role': 0.3, + 'href': 0.3, + 'title': 0.2, + 'class': 0.15, + } + ) + text: float = 0.3 + parent: float = 0.2 + siblings: float = 0.15 + + @dataclass class SuccessHealingResult: element_name: str original_locator: str - healed_locator: str + healed_locator: str | None + healed_locators_candidates: list[str] score: float page: str | None = None @@ -86,13 +111,13 @@ def __init__( self, storage: SnapshotStorage, score_threshold: float, - attribute_weights: dict[str, float] | None = None, + scoring_weights: ScoringWeights | None = None, on_healing_success: Callable[[SuccessHealingResult], None] | None = None, on_healing_failure: Callable[[FailedHealingResult], None] | None = None, ) -> None: self._storage = storage self._score_threshold = score_threshold - self._attribute_weights = attribute_weights or _ATTRIBUTE_WEIGHTS + self._scoring_weights = scoring_weights or ScoringWeights() self._on_healing_success = on_healing_success self._on_healing_failure = on_healing_failure @@ -147,7 +172,7 @@ def heal(self, element_name: str, locator_key: str, locator: str, driver: Any) - best_index = -1 for item in candidates_data: - score = _score_similarity(item, snapshot, self._attribute_weights) + score = _score_similarity(item, snapshot, self._scoring_weights) if score > best_score: best_score = score best_index = item['index'] @@ -162,25 +187,26 @@ def heal(self, element_name: str, locator_key: str, locator: str, driver: Any) - return self._fail('below-threshold', element_name, locator_key, locator) # Get the actual WebElement by index among elements of the same tag - healed_locator = None + healed_locators: list[str] | None = None try: web_elements = driver.find_elements(By.TAG_NAME, snapshot.tag) if best_index >= len(web_elements): self._fail('index-out-of-bounds', element_name, locator_key, locator) else: healed_web_element = web_elements[best_index] - healed_locator = generate_locator(healed_web_element, driver) + healed_locators = generate_locator(healed_web_element, driver) except WebDriverException as exc: logger.info('Self-healing: failed to generate locator for "%s": %s', element_name, exc) self._fail('generate-locator-error', element_name, locator_key, locator, exc=exc) - if healed_locator is None: + if healed_locators is None: return None result = SuccessHealingResult( element_name=element_name, original_locator=locator, - healed_locator=healed_locator, + healed_locator=None, + healed_locators_candidates=healed_locators, score=best_score, ) @@ -188,7 +214,7 @@ def heal(self, element_name: str, locator_key: str, locator: str, driver: Any) - 'Self-healing: healed "%s" %s -> %s (score=%.2f)', element_name, locator, - healed_locator, + healed_locators, best_score, ) @@ -196,15 +222,17 @@ def heal(self, element_name: str, locator_key: str, locator: str, driver: Any) - def _score_similarity( - candidate: dict[str, Any], snapshot: ElementSnapshot, attribute_weights: dict[str, float] | None = None + candidate: dict[str, Any], + snapshot: ElementSnapshot, + weights: ScoringWeights | None = None, ) -> float: """Compute a 0-1 similarity score between a candidate DOM element and a saved snapshot.""" - weights = attribute_weights or _ATTRIBUTE_WEIGHTS + w = weights or ScoringWeights() score = 0.0 total_weight = 0.0 # Attribute matching - for attr, weight in weights.items(): + for attr, weight in w.attribute.items(): snap_val = snapshot.attributes.get(attr) cand_val = candidate['attrs'].get(attr) @@ -222,21 +250,26 @@ def _score_similarity( snap_text = snapshot.text cand_text = candidate.get('text', '') if snap_text: - text_weight = 0.3 - total_weight += text_weight + total_weight += w.text if snap_text == cand_text: - score += text_weight + score += w.text elif snap_text and cand_text: - score += text_weight * _text_similarity(snap_text, cand_text) + score += w.text * _text_similarity(snap_text, cand_text) # Parent tag match if snapshot.parent_tag and candidate.get('parentTag'): - parent_weight = 0.2 - total_weight += parent_weight + total_weight += w.parent if candidate['parentTag'] == snapshot.parent_tag: - score += parent_weight * 0.5 + score += w.parent * 0.5 parent_attr_score = _attrs_overlap(snapshot.parent_attributes, candidate.get('parentAttrs', {})) - score += parent_weight * 0.5 * parent_attr_score + score += w.parent * 0.5 * parent_attr_score + + # Sibling similarity + snap_siblings = snapshot.siblings + cand_siblings = candidate.get('siblings', []) + if snap_siblings: + total_weight += w.siblings + score += w.siblings * _siblings_similarity(snap_siblings, cand_siblings) if total_weight == 0: return 0.0 @@ -271,3 +304,21 @@ def _attrs_overlap(snap_attrs: dict[str, str], cand_attrs: dict[str, str]) -> fl return 0.0 matches = sum(1 for k, v in snap_attrs.items() if cand_attrs.get(k) == v) return matches / len(snap_attrs) + + +def _siblings_similarity(snap_siblings: list[dict], cand_siblings: list[dict]) -> float: + """Compute 0-1 similarity between two sets of sibling elements.""" + if not snap_siblings: + return 0.0 + + total = 0.0 + for snap_sib in snap_siblings: + best = 0.0 + for cand_sib in cand_siblings: + tag_match = 1.0 if snap_sib.get('tag') == cand_sib.get('tag') else 0.0 + attr_score = _attrs_overlap(snap_sib.get('attrs', {}), cand_sib.get('attrs', {})) + score = tag_match * 0.3 + attr_score * 0.7 + best = max(best, score) + total += best + + return total / len(snap_siblings) diff --git a/mops/self_healing/locator_generator.py b/mops/self_healing/locator_generator.py index 6d65f646..96be21ee 100644 --- a/mops/self_healing/locator_generator.py +++ b/mops/self_healing/locator_generator.py @@ -26,11 +26,12 @@ _MAX_TEXT_LENGTH = 50 -def generate_locator(web_element: object, driver: object) -> str: - """Generate a stable XPath locator from a live Selenium WebElement. +def generate_locator(web_element: object, driver: object) -> list[str]: + """Generate all possible stable XPath locators from a live Selenium WebElement. - Tries stable attributes in priority order, falls back to positional XPath. - The returned locator uses the ``xpath=`` prefix used by MOPS. + Returns locators ordered by preference (most stable first). + The caller should try each in order until one succeeds. + Each locator uses the ``xpath=`` prefix used by MOPS. """ try: attrs: dict[str, str] = {} @@ -43,7 +44,7 @@ def generate_locator(web_element: object, driver: object) -> str: text = (web_element.text or '').strip() except WebDriverException: - return _positional_xpath(web_element, driver) + return [_positional_xpath(web_element, driver)] providers: list[str] = [] @@ -69,11 +70,6 @@ def generate_locator(web_element: object, driver: object) -> str: escaped = aria.replace('"', '\\"') providers.append(f'xpath=//*[@aria-label="{escaped}"]') - # visible text (short, single-line) - if text and len(text) <= _MAX_TEXT_LENGTH and '\n' not in text: - escaped = text.replace('"', '\\"') - providers.append(f'xpath=//{tag}[normalize-space(.)="{escaped}"]') - # type + tag el_type = attrs.get('type', '') if el_type: @@ -86,7 +82,13 @@ def generate_locator(web_element: object, driver: object) -> str: if stable: providers.append(f'xpath=//{tag}[contains(@class, "{stable[0]}")]') - return providers[0] if providers else _positional_xpath(web_element, driver) + # visible text (short, single-line) + if text and len(text) <= _MAX_TEXT_LENGTH and '\n' not in text: + escaped = text.replace('"', '\\"') + providers.append(f'xpath=//{tag}[normalize-space(.)="{escaped}"]') + + providers.append(_positional_xpath(web_element, driver)) + return providers def _positional_xpath(web_element: object, driver: object) -> str: diff --git a/tests/static_tests/unit/test_self_healing_callbacks.py b/tests/static_tests/unit/test_self_healing_callbacks.py index 33bc248a..2f083812 100644 --- a/tests/static_tests/unit/test_self_healing_callbacks.py +++ b/tests/static_tests/unit/test_self_healing_callbacks.py @@ -59,7 +59,7 @@ def test_success_callback_fired(): healer = Healer(storage, 0.7, on_healing_success=callback) - with patch('mops.self_healing.healer.generate_locator', return_value='xpath=//button'): + with patch('mops.self_healing.healer.generate_locator', return_value=['xpath=//button']): result = healer.heal('btn', 'key', '#submit', driver) assert result is not None @@ -77,7 +77,7 @@ def test_success_callback_not_set(): healer = Healer(storage, 0.7) - with patch('mops.self_healing.healer.generate_locator', return_value='xpath=//button'): + with patch('mops.self_healing.healer.generate_locator', return_value=['xpath=//button']): result = healer.heal('btn', 'key', '#submit', driver) assert result is not None @@ -171,7 +171,7 @@ def test_failure_best_index_out_of_bounds(): driver.find_elements.return_value = [MagicMock()] # only 1 element → index 1 is OOB healer = Healer(storage, 0.7, on_healing_failure=callback) - with patch('mops.self_healing.healer.generate_locator', return_value='xpath=//button'): + with patch('mops.self_healing.healer.generate_locator', return_value=['xpath=//button']): result = healer.heal('btn', 'key', '#submit', driver) assert result is None @@ -206,6 +206,94 @@ def test_failure_callback_not_set(): assert result is None +# --------------------------------------------------------------------------- +# healed_locators_candidates +# --------------------------------------------------------------------------- + + +def test_multiple_locators_stored_in_result(): + """All generated locators are stored in healed_locators_candidates.""" + storage = MagicMock() + storage.load.return_value = _make_snapshot() + driver = MagicMock() + driver.execute_script.return_value = [_make_candidate()] + driver.find_elements.return_value = [MagicMock()] + + healer = Healer(storage, 0.7) + + locators = ['xpath=//button[1]', 'xpath=//button[2]', 'xpath=//button[3]'] + with patch('mops.self_healing.healer.generate_locator', return_value=locators): + result = healer.heal('btn', 'key', '#submit', driver) + + assert result is not None + assert result.healed_locators_candidates == locators + # healed_locator is None by default — set later by _find_element + assert result.healed_locator is None + + +# --------------------------------------------------------------------------- +# siblings in _score_similarity +# --------------------------------------------------------------------------- + + +def _make_siblings_snapshot(siblings: list[dict]): + """Build an ElementSnapshot with siblings data, matching default snapshot attributes.""" + return _make_snapshot(siblings=siblings) + + +def test_siblings_matching_boosts_score(): + """Matching siblings increase the similarity score compared to no siblings.""" + storage = MagicMock() + siblings = [{'tag': 'span', 'attrs': {'class': 'helper'}, 'text': 'label'}] + storage.load.return_value = _make_siblings_snapshot(siblings) + driver = MagicMock() + candidate_with_siblings = _make_candidate( + siblings=[{'tag': 'span', 'attrs': {'class': 'helper'}, 'text': 'label'}], + ) + candidate_no_siblings = _make_candidate(siblings=[]) + driver.execute_script.return_value = [candidate_with_siblings, candidate_no_siblings] + driver.find_elements.return_value = [MagicMock()] + + healer_no_threshold = Healer(storage, 0.0) + + with patch('mops.self_healing.healer.generate_locator', return_value=['xpath=//button']): + result = healer_no_threshold.heal('btn', 'key', '#submit', driver) + + assert result is not None + assert result.score > 0 + # Both candidates are identical in attrs/text/parent, so the only difference + # is sibling matching — the one with matching siblings should be picked + # (it has the same score from attrs but added sibling contribution) + assert result.score > 0 + + +def test_mismatched_siblings_lower_score(): + """Having siblings but none matching the snapshot yields a lower score.""" + storage = MagicMock() + snap_siblings = [{'tag': 'span', 'attrs': {'class': 'helper'}, 'text': 'label'}] + storage.load.return_value = _make_siblings_snapshot(snap_siblings) + + driver = MagicMock() + candidate = _make_candidate( + attrs={'id': 'submit'}, + text='Click', + parentTag='form', + siblings=[{'tag': 'div', 'attrs': {'class': 'other'}, 'text': 'different'}], + ) + driver.execute_script.return_value = [candidate] + driver.find_elements.return_value = [MagicMock()] + + healer = Healer(storage, 0.0) + + with patch('mops.self_healing.healer.generate_locator', return_value=['xpath=//button']): + result = healer.heal('btn', 'key', '#submit', driver) + + assert result is not None + # The attrs/text/parent all match perfectly, so score starts high, + # then weighted down by sibling mismatch — verify score < 1.0 + assert result.score < 1.0 + + # --------------------------------------------------------------------------- # edge cases — callback exception safety # --------------------------------------------------------------------------- @@ -226,7 +314,7 @@ def crash(_result): import pytest - with patch('mops.self_healing.healer.generate_locator', return_value='xpath=//button'): + with patch('mops.self_healing.healer.generate_locator', return_value=['xpath=//button']): with pytest.raises(RuntimeError, match='callback failed'): healer.heal('btn', 'key', '#submit', driver) diff --git a/tests/web_tests/test_self_healing.py b/tests/web_tests/test_self_healing.py index 3eb70ddc..af28052c 100644 --- a/tests/web_tests/test_self_healing.py +++ b/tests/web_tests/test_self_healing.py @@ -85,3 +85,43 @@ def test_self_healing_recovery_after_class_change(second_playground_page): cls = row.get_attribute('class', silent=True) assert cls is not None, 'Self-healing did not recover the element' assert 'broken-row' in cls + + +def test_self_healing_falls_back_to_second_locator(second_playground_page): + """ + When the first healed locator fails to find the element, + ``_find_element`` tries subsequent locators from ``healed_locators_candidates``. + + Flow: + 1. Find row_with_cards → snapshot saved. + 2. Change the ``class`` in DOM, breaking the original ``.row`` locator. + 3. Patch ``generate_locator`` to prepend a non-existent locator so the + first attempt always fails. + 4. Healing runs → first locator misses → second (real) locator succeeds. + """ + from unittest.mock import patch + + import mops.self_healing.healer as healer_module + + row = second_playground_page.row_with_cards + row.wait_visibility(silent=True) # snapshot saved + + # Break the original locator + driver = second_playground_page.driver + driver.execute_script(""" + var elements = document.querySelectorAll('.row'); + for (var i = 0; i < elements.length; i++) { + elements[i].className = 'broken-row'; + } + """) + + original_generate = healer_module.generate_locator + + def _generate_with_bad_first(web_element, driver): + real_locators = original_generate(web_element, driver) + return ['xpath=//*[@id="definitely-not-found"]'] + real_locators + + with patch('mops.self_healing.healer.generate_locator', side_effect=_generate_with_bad_first): + cls = row.get_attribute('class', silent=True) + assert cls is not None, 'Self-healing did not recover the element' + assert 'broken-row' in cls From 943387970ec2550991485d8a3a466efe8beaf5a4 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Thu, 25 Jun 2026 15:49:35 +0200 Subject: [PATCH 10/30] Change locators healing logic --- mops/base/element.py | 6 +- mops/selenium/core/core_element.py | 70 ++++--- mops/self_healing/__init__.py | 3 - mops/self_healing/context.py | 33 ---- mops/self_healing/decorators.py | 55 ++++++ .../static_tests/unit/test_wait_condition.py | 93 +++++++++ tests/web_tests/test_self_healing.py | 180 ++++++++++++++++++ 7 files changed, 376 insertions(+), 64 deletions(-) delete mode 100644 mops/self_healing/context.py create mode 100644 mops/self_healing/decorators.py diff --git a/mops/base/element.py b/mops/base/element.py index da285b9f..dfa645d3 100644 --- a/mops/base/element.py +++ b/mops/base/element.py @@ -31,7 +31,7 @@ from mops.playwright.play_element import PlayElement from mops.selenium.elements.mobile_element import MobileElement from mops.selenium.elements.web_element import WebElement -from mops.self_healing.context import no_healing +from mops.self_healing.decorators import healing_after_wait from mops.utils.decorators import wait_condition, wait_continuous from mops.utils.internal_utils import ( QUARTER_WAIT_EL, @@ -265,6 +265,7 @@ def send_keyboard_action(self, action: str | KeyboardKeys) -> Element: # Elements waits @wait_continuous + @healing_after_wait @wait_condition def wait_visibility( self, @@ -353,7 +354,6 @@ def wait_visibility_without_error( @wait_continuous @wait_condition - @no_healing def wait_hidden( self, *, @@ -394,7 +394,6 @@ def wait_hidden( exc=TimeoutException(f'"{self.name}" still visible', info=self), ) - @no_healing def wait_hidden_without_error( self, *, @@ -440,6 +439,7 @@ def wait_hidden_without_error( self.log(f'Ignored exception: "{exception.msg}"') return self + @healing_after_wait @wait_condition def wait_availability(self, *, timeout: int = WAIT_EL, silent: bool = False) -> Element: r""" diff --git a/mops/selenium/core/core_element.py b/mops/selenium/core/core_element.py index db1cf5b7..ef7f9e55 100644 --- a/mops/selenium/core/core_element.py +++ b/mops/selenium/core/core_element.py @@ -29,7 +29,7 @@ from mops.mixins.objects.size import Size from mops.selenium.sel_utils import ActionChains from mops.self_healing.config import get_config -from mops.self_healing.context import is_healing_for_method_enabled, no_healing +from mops.self_healing.decorators import healing from mops.self_healing.healer import Healer, SuccessHealingResult from mops.shared_utils import _scaled_screenshot, cut_log_data from mops.utils.decorators import retry @@ -56,11 +56,17 @@ class _HealerState: def _get_healer() -> Healer: - """Return the global Healer singleton.""" - if _HealerState.healer: - return _HealerState.healer + """Return the global Healer singleton. + Re-initialises when ``get_config().storage`` changes (identity check) + or when the cached storage is ``None``, so that ``configure()`` calls + between tests are picked up. + """ config = get_config() + + if _HealerState.healer and _HealerState.storage is config.storage and _HealerState.storage is not None: + return _HealerState.healer + _HealerState.storage = config.storage _HealerState.healer = Healer( _HealerState.storage, @@ -327,7 +333,6 @@ def _is_available(self) -> bool: return element - @no_healing def is_available(self) -> bool: """ Check if the element is available in DOM tree. @@ -355,7 +360,6 @@ def _is_displayed(self, silent: bool = False) -> bool: return is_displayed - @no_healing def is_displayed(self, silent: bool = False) -> bool: """ Check if the element is displayed. @@ -494,6 +498,7 @@ def _action_chains(self) -> ActionChains: """ return ActionChains(self.driver) + @healing def _get_element(self, wait_strategy: bool | Callable = True, force_wait: bool = False) -> SeleniumWebElement: """ Get selenium element from driver or parent element @@ -567,28 +572,12 @@ def _find_element(self, wait_parent: bool = False) -> SeleniumWebElement | Appiu self._cached_element = element # Save snapshot for future healing config = get_config() - if config.save_snapshots: + if config.save_snapshots and config.storage: _get_healer() - _HealerState.storage.save_from_element(self, element, self.driver) + config.storage.save_from_element(self, element, self.driver) except (SeleniumInvalidArgumentException, SeleniumInvalidSelectorException) as exc: self._raise_invalid_selector_exception(exc) except SeleniumNoSuchElementException as exc: - if is_healing_for_method_enabled() and get_config().heal_locators: - result = self._attempt_healing() - if result: - for locator in result.healed_locators_candidates: - healed_locator_type, healed_locator_value = _parse_healed_locator(locator) - try: - healed = base.find_element(healed_locator_type, healed_locator_value) - except SeleniumNoSuchElementException: - continue - result.healed_locator = locator - # Persist healed locator so subsequent lookups don't re-heal - self.locator_type = healed_locator_type - self.locator = healed_locator_value - self._cached_element = healed - return healed - raise NoSuchElementException(exc.msg) from exc raise NoSuchElementException(exc.msg) from exc else: return element @@ -601,7 +590,7 @@ def _attempt_healing(self) -> SuccessHealingResult | None: """ try: healer = _get_healer() - locator_key = _HealerState.storage.normalize_locator_key(f'{self.name}::{self.locator}') + locator_key = get_config().storage.normalize_locator_key(f'{self.name}::{self.locator}') result = healer.heal(self.name, locator_key, self.locator, self.driver) if type(result) is SuccessHealingResult: return result @@ -609,6 +598,37 @@ def _attempt_healing(self) -> SuccessHealingResult | None: self.log(f'Self-healing failed with unexpected exception: {exc}', level='warning') return None + def _try_healed_locators(self, result: SuccessHealingResult) -> SeleniumWebElement: + """Try each healed locator and persist the first working one.""" + base = self._get_base(wait_strategy=False) + for locator in result.healed_locators_candidates: + healed_locator_type, healed_locator_value = _parse_healed_locator(locator) + try: + healed = base.find_element(healed_locator_type, healed_locator_value) + except SeleniumNoSuchElementException: + continue + result.healed_locator = locator + self.locator_type = healed_locator_type + self.locator = healed_locator_value + self._cached_element = healed + return healed + raise NoSuchElementException + + def _heal_after_wait(self) -> bool: + """Attempt healing after a wait condition timed out. + + Persists the first working healed locator so subsequent lookups + use it directly. Returns ``True`` if a working locator was found. + """ + result = self._attempt_healing() + if not result: + return False + try: + self._try_healed_locators(result) + except NoSuchElementException: + return False + return True + def _find_elements(self, wait_parent: bool = False) -> list[SeleniumWebElement | AppiumWebElement]: """ Find all selenium/appium elements diff --git a/mops/self_healing/__init__.py b/mops/self_healing/__init__.py index 04eabbdf..b0189e86 100644 --- a/mops/self_healing/__init__.py +++ b/mops/self_healing/__init__.py @@ -20,7 +20,6 @@ """ from mops.self_healing.config import configure, get_config -from mops.self_healing.context import is_healing_for_method_enabled, no_healing from mops.self_healing.healer import FailedHealingResult, Healer, ScoringWeights, SuccessHealingResult from mops.self_healing.snapshot import ElementSnapshot, JsonFileSnapshotStorage, SnapshotStorage @@ -34,6 +33,4 @@ 'SuccessHealingResult', 'configure', 'get_config', - 'is_healing_for_method_enabled', - 'no_healing', ] diff --git a/mops/self_healing/context.py b/mops/self_healing/context.py deleted file mode 100644 index 50065658..00000000 --- a/mops/self_healing/context.py +++ /dev/null @@ -1,33 +0,0 @@ -from __future__ import annotations - -from functools import wraps -import threading -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from collections.abc import Callable - -_ctx = threading.local() - - -def no_healing(func: Callable) -> Callable: - """Disable self-healing for the duration of the wrapped method. - - Use on methods where element not being found is an acceptable outcome, - e.g. is_displayed(), is_hidden(), wait_hidden(). - """ - - @wraps(func) - def wrapper(*args: Any, **kwargs: Any) -> Any: - _ctx.enabled = False - try: - return func(*args, **kwargs) - finally: - _ctx.enabled = True - - return wrapper - - -def is_healing_for_method_enabled() -> bool: - """Return True if healing is allowed in the current thread context.""" - return getattr(_ctx, 'enabled', True) diff --git a/mops/self_healing/decorators.py b/mops/self_healing/decorators.py new file mode 100644 index 00000000..00668a3a --- /dev/null +++ b/mops/self_healing/decorators.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from contextlib import suppress +from functools import wraps +from typing import TYPE_CHECKING, Any + +from mops.exceptions import NoSuchElementException +from mops.self_healing.config import get_config + +if TYPE_CHECKING: + from collections.abc import Callable + + +def healing(method: Callable) -> Callable: + """Attempt self-healing when a :class:`NoSuchElementException` is raised. + + Apply to element access methods (``_get_element``, ``click``, etc.) + so that direct element lookups are healed immediately. + """ + + @wraps(method) + def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: + try: + return method(self, *args, **kwargs) + except NoSuchElementException: + if not get_config().heal_locators: + raise + result = self._attempt_healing() + if not result: + raise + return self._try_healed_locators(result) + + return wrapper + + +def healing_after_wait(method: Callable) -> Callable: + """Attempt self-healing after a wait condition times out. + + If the wait times out (the exception carries a ``_timeout`` attribute), + calls ``_heal_after_wait()`` once and retries the wait on success. + """ + + @wraps(method) + def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: + try: + return method(self, *args, **kwargs) + except Exception as exc: + if getattr(exc, '_timeout', None) is not None: + heal = getattr(self, '_heal_after_wait', None) + if heal and heal(): + with suppress(Exception): + return method(self, *args, **kwargs) + raise + + return wrapper diff --git a/tests/static_tests/unit/test_wait_condition.py b/tests/static_tests/unit/test_wait_condition.py index 1fe5cbd4..b4c7e908 100644 --- a/tests/static_tests/unit/test_wait_condition.py +++ b/tests/static_tests/unit/test_wait_condition.py @@ -4,6 +4,7 @@ import pytest from mops.exceptions import TimeoutException +from mops.self_healing.decorators import healing_after_wait from mops.utils.internal_utils import WAIT_METHODS_DELAY from mops.utils.decorators import wait_condition from mops.utils.logs import autolog @@ -136,6 +137,98 @@ def test_wait_condition_mobile_delay_increasing(): assert end_time < 0.75 +# --------------------------------------------------------------------------- +# Healing-after-wait tests +# --------------------------------------------------------------------------- + + +class MockHealableNamespace(MockNamespace): + """Like MockNamespace but supports _heal_after_wait for wait_condition healing.""" + + def __init__(self, log_msg: str, call_count: int, heal_after_wait_result: bool = False, **kwargs): + super().__init__(log_msg, call_count, **kwargs) + self._heal_after_wait_result = heal_after_wait_result + self.heal_after_wait_called = False + self._post_heal_retry = False + + def get_result(self): + # After healing, the retry call should always succeed + if self._post_heal_retry: + return True + return super().get_result() + + def _heal_after_wait(self) -> bool: + self.heal_after_wait_called = True + if self._heal_after_wait_result: + self._post_heal_retry = True + return self._heal_after_wait_result + + @healing_after_wait + @wait_condition + def wait_something(self, *, timeout: Union[int, float] = 1, silent: bool = False) -> bool: # noqa + return Result( # noqa + execution_result=self.get_result(), + log=self.log_msg, + exc=TimeoutException('wait some condition failed!'), + ) + + +def test_wait_condition_heal_on_timeout_called(): + """After wait times out, _heal_after_wait is called.""" + namespace = MockHealableNamespace('wait', call_count=10, heal_after_wait_result=False) + + with pytest.raises(TimeoutException): + namespace.wait_something(timeout=0.1) + + assert namespace.heal_after_wait_called + + +def test_wait_condition_heal_success_retries(): + """_heal_after_wait returns True -> wait is retried and succeeds.""" + namespace = MockHealableNamespace('wait', call_count=10, heal_after_wait_result=True) + + result = namespace.wait_something(timeout=0.1) + + assert result is namespace + assert namespace.heal_after_wait_called + + +def test_wait_condition_heal_fails_raises(): + """_heal_after_wait returns False -> original exception raised.""" + namespace = MockHealableNamespace('wait', call_count=10, heal_after_wait_result=False) + + with pytest.raises(TimeoutException) as exc_info: + namespace.wait_something(timeout=0.1) + + assert 'wait some condition failed!' in str(exc_info.value) + assert namespace.heal_after_wait_called + + +def test_wait_condition_no_heal_method(): + """Element without _heal_after_wait -> no healing, original exception.""" + namespace = MockNamespace('wait', call_count=10) + + with pytest.raises(TimeoutException): + namespace.wait_something(timeout=0.1) + + +def test_wait_condition_heal_retry_also_times_out(): + """If the retry after healing also fails, original exception is raised.""" + namespace = MockHealableNamespace('wait', call_count=10, heal_after_wait_result=True) + # After _heal_after_wait, the retry gets result from get_result(). + # Since _post_heal_retry is True, get_result() returns True. + # To test retry failure, override the retry behavior. + original_heal = namespace._heal_after_wait + + def heal_and_no_retry(): + original_heal() + namespace._post_heal_retry = False # undo the retry-success flag + return True + + namespace._heal_after_wait = heal_and_no_retry + + with pytest.raises(TimeoutException): + namespace.wait_something(timeout=0.1) def test_wait_condition_desktop_default_delay(): """ sleep for 0.1 seconds between iterations """ diff --git a/tests/web_tests/test_self_healing.py b/tests/web_tests/test_self_healing.py index af28052c..948cb36a 100644 --- a/tests/web_tests/test_self_healing.py +++ b/tests/web_tests/test_self_healing.py @@ -125,3 +125,183 @@ def _generate_with_bad_first(web_element, driver): cls = row.get_attribute('class', silent=True) assert cls is not None, 'Self-healing did not recover the element' assert 'broken-row' in cls + + +def test_wait_hidden_does_not_heal(second_playground_page): + """ + ``wait_hidden`` must NOT trigger self-healing. + + Flow: + 1. Find row_with_cards → snapshot saved. + 2. Seed the snapshot under a broken locator key. + 3. Create a new element with the broken locator. + 4. ``wait_hidden`` succeeds immediately (element not found = hidden). + 5. Assert the locator was NOT updated — healing was never attempted. + 6. Assert the element still can't be found after ``wait_hidden``. + """ + from unittest.mock import patch + + from mops.selenium.core.core_element import CoreElement + + row = second_playground_page.row_with_cards + + # Find the real element so the snapshot is persisted. + row.wait_visibility(silent=True) + + storage = get_config().storage + real_key = f'{row.name}::{row.locator}' + snapshot = storage.load(real_key) + assert snapshot is not None + + # Seed the snapshot under a broken locator. + broken_locator = '.row-broken-locator-self-healing-test' + storage.save(f'{row.name}::{broken_locator}', snapshot) + + broken_row = Element(broken_locator, name=row.name) + + # Spy on _attempt_healing — it must NOT be called during wait_hidden. + original_attempt = CoreElement._attempt_healing + attempt_called = False + + def _spy(self, *args, **kwargs): + nonlocal attempt_called + attempt_called = True + return original_attempt(self, *args, **kwargs) + + with patch.object(CoreElement, '_attempt_healing', _spy): + broken_row.wait_hidden(silent=True) + + # wait_hidden should succeed without attempting healing + assert not attempt_called, '_attempt_healing was called during wait_hidden' + assert broken_row.locator == broken_locator, 'Locator was changed by healing' + + # Verify the element can't be found with the broken locator + assert not broken_row.is_available(), 'Broken locator should not find an element' + + +def test_is_displayed_does_not_heal(second_playground_page): + """ + ``is_displayed`` must NOT trigger self-healing. + + Flow: + 1. Find row_with_cards → snapshot saved. + 2. Seed the snapshot under a broken locator key. + 3. Create a new element with the broken locator. + 4. ``is_displayed`` returns False (element not found). + 5. Assert ``_attempt_healing`` was never called. + """ + from unittest.mock import patch + + from mops.selenium.core.core_element import CoreElement + + row = second_playground_page.row_with_cards + row.wait_visibility(silent=True) + + storage = get_config().storage + real_key = f'{row.name}::{row.locator}' + snapshot = storage.load(real_key) + + broken_locator = '.row-broken-locator-self-healing-test' + storage.save(f'{row.name}::{broken_locator}', snapshot) + + broken_row = Element(broken_locator, name=row.name) + + original_attempt = CoreElement._attempt_healing + attempt_called = False + + def _spy(self, *args, **kwargs): + nonlocal attempt_called + attempt_called = True + return original_attempt(self, *args, **kwargs) + + with patch.object(CoreElement, '_attempt_healing', _spy): + displayed = broken_row.is_displayed(silent=True) + + assert not displayed, 'Broken element should not be displayed' + assert not attempt_called, '_attempt_healing was called during is_displayed' + assert broken_row.locator == broken_locator, 'Locator was changed by healing' + + +def test_is_hidden_does_not_heal(second_playground_page): + """ + ``is_hidden`` must NOT trigger self-healing. + + Flow: + 1. Find row_with_cards → snapshot saved. + 2. Seed the snapshot under a broken locator key. + 3. Create a new element with the broken locator. + 4. ``is_hidden`` returns True (element not found = hidden). + 5. Assert ``_attempt_healing`` was never called. + """ + from unittest.mock import patch + + from mops.selenium.core.core_element import CoreElement + + row = second_playground_page.row_with_cards + row.wait_visibility(silent=True) + + storage = get_config().storage + real_key = f'{row.name}::{row.locator}' + snapshot = storage.load(real_key) + + broken_locator = '.row-broken-locator-self-healing-test' + storage.save(f'{row.name}::{broken_locator}', snapshot) + + broken_row = Element(broken_locator, name=row.name) + + original_attempt = CoreElement._attempt_healing + attempt_called = False + + def _spy(self, *args, **kwargs): + nonlocal attempt_called + attempt_called = True + return original_attempt(self, *args, **kwargs) + + with patch.object(CoreElement, '_attempt_healing', _spy): + hidden = broken_row.is_hidden(silent=True) + + assert hidden, 'Broken element should be considered hidden' + assert not attempt_called, '_attempt_healing was called during is_hidden' + assert broken_row.locator == broken_locator, 'Locator was changed by healing' + + +def test_wait_hidden_without_error_does_not_heal(second_playground_page): + """ + ``wait_hidden_without_error`` must NOT trigger self-healing. + + Flow: + 1. Find row_with_cards → snapshot saved. + 2. Seed the snapshot under a broken locator key. + 3. Create a new element with the broken locator. + 4. ``wait_hidden_without_error`` succeeds (element not found = hidden). + 5. Assert ``_attempt_healing`` was never called. + """ + from unittest.mock import patch + + from mops.selenium.core.core_element import CoreElement + + row = second_playground_page.row_with_cards + row.wait_visibility(silent=True) + + storage = get_config().storage + real_key = f'{row.name}::{row.locator}' + snapshot = storage.load(real_key) + + broken_locator = '.row-broken-locator-self-healing-test' + storage.save(f'{row.name}::{broken_locator}', snapshot) + + broken_row = Element(broken_locator, name=row.name) + + original_attempt = CoreElement._attempt_healing + attempt_called = False + + def _spy(self, *args, **kwargs): + nonlocal attempt_called + attempt_called = True + return original_attempt(self, *args, **kwargs) + + with patch.object(CoreElement, '_attempt_healing', _spy): + broken_row.wait_hidden_without_error(silent=True) + + assert not attempt_called, '_attempt_healing was called during wait_hidden_without_error' + assert broken_row.locator == broken_locator, 'Locator was changed by healing' From 342215d2fd1b26d76ae5a1fc1aab53a663878609 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Fri, 26 Jun 2026 14:50:57 +0200 Subject: [PATCH 11/30] healing_after_wait moved --- mops/base/element.py | 3 - mops/selenium/core/core_element.py | 3 +- mops/self_healing/decorators.py | 55 ------------------- mops/utils/decorators.py | 33 ++++++++++- .../static_tests/unit/test_wait_condition.py | 2 - 5 files changed, 33 insertions(+), 63 deletions(-) delete mode 100644 mops/self_healing/decorators.py diff --git a/mops/base/element.py b/mops/base/element.py index dfa645d3..ade16c4c 100644 --- a/mops/base/element.py +++ b/mops/base/element.py @@ -31,7 +31,6 @@ from mops.playwright.play_element import PlayElement from mops.selenium.elements.mobile_element import MobileElement from mops.selenium.elements.web_element import WebElement -from mops.self_healing.decorators import healing_after_wait from mops.utils.decorators import wait_condition, wait_continuous from mops.utils.internal_utils import ( QUARTER_WAIT_EL, @@ -265,7 +264,6 @@ def send_keyboard_action(self, action: str | KeyboardKeys) -> Element: # Elements waits @wait_continuous - @healing_after_wait @wait_condition def wait_visibility( self, @@ -439,7 +437,6 @@ def wait_hidden_without_error( self.log(f'Ignored exception: "{exception.msg}"') return self - @healing_after_wait @wait_condition def wait_availability(self, *, timeout: int = WAIT_EL, silent: bool = False) -> Element: r""" diff --git a/mops/selenium/core/core_element.py b/mops/selenium/core/core_element.py index ef7f9e55..592f6d81 100644 --- a/mops/selenium/core/core_element.py +++ b/mops/selenium/core/core_element.py @@ -29,10 +29,9 @@ from mops.mixins.objects.size import Size from mops.selenium.sel_utils import ActionChains from mops.self_healing.config import get_config -from mops.self_healing.decorators import healing from mops.self_healing.healer import Healer, SuccessHealingResult from mops.shared_utils import _scaled_screenshot, cut_log_data -from mops.utils.decorators import retry +from mops.utils.decorators import healing, retry from mops.utils.internal_utils import WAIT_EL, get_dict, is_group, safe_call if TYPE_CHECKING: diff --git a/mops/self_healing/decorators.py b/mops/self_healing/decorators.py deleted file mode 100644 index 00668a3a..00000000 --- a/mops/self_healing/decorators.py +++ /dev/null @@ -1,55 +0,0 @@ -from __future__ import annotations - -from contextlib import suppress -from functools import wraps -from typing import TYPE_CHECKING, Any - -from mops.exceptions import NoSuchElementException -from mops.self_healing.config import get_config - -if TYPE_CHECKING: - from collections.abc import Callable - - -def healing(method: Callable) -> Callable: - """Attempt self-healing when a :class:`NoSuchElementException` is raised. - - Apply to element access methods (``_get_element``, ``click``, etc.) - so that direct element lookups are healed immediately. - """ - - @wraps(method) - def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: - try: - return method(self, *args, **kwargs) - except NoSuchElementException: - if not get_config().heal_locators: - raise - result = self._attempt_healing() - if not result: - raise - return self._try_healed_locators(result) - - return wrapper - - -def healing_after_wait(method: Callable) -> Callable: - """Attempt self-healing after a wait condition times out. - - If the wait times out (the exception carries a ``_timeout`` attribute), - calls ``_heal_after_wait()`` once and retries the wait on success. - """ - - @wraps(method) - def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: - try: - return method(self, *args, **kwargs) - except Exception as exc: - if getattr(exc, '_timeout', None) is not None: - heal = getattr(self, '_heal_after_wait', None) - if heal and heal(): - with suppress(Exception): - return method(self, *args, **kwargs) - raise - - return wrapper diff --git a/mops/utils/decorators.py b/mops/utils/decorators.py index e60c6247..649eccd8 100644 --- a/mops/utils/decorators.py +++ b/mops/utils/decorators.py @@ -4,7 +4,8 @@ import time from typing import TYPE_CHECKING, Any -from mops.exceptions import ContinuousWaitException +from mops.exceptions import ContinuousWaitException, NoSuchElementException +from mops.self_healing.config import get_config from mops.utils.internal_utils import ( HALF_WAIT_EL, QUARTER_WAIT_EL, @@ -54,6 +55,28 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: return decorator +def healing(method: Callable) -> Callable: + """Attempt self-healing when a :class:`NoSuchElementException` is raised. + + Apply to element access methods (``_get_element``, ``click``, etc.) + so that direct element lookups are healed immediately. + """ + + @wraps(method) + def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: + try: + return method(self, *args, **kwargs) + except NoSuchElementException: + if not get_config().heal_locators: + raise + result = self._attempt_healing() + if not result: + raise + return self._try_healed_locators(result) + + return wrapper + + def wait_condition(method: Callable) -> Callable: """Wrap an element wait method with polling logic until timeout or success.""" @@ -93,6 +116,14 @@ def wrapper( delay = increase_delay(delay) result.exc._timeout = timeout + + # Attempt healing after wait timeout + heal = getattr(self, '_heal_after_wait', None) + if heal and heal(): + result = method(self, *args, **kwargs) + if result.execution_result: + return self + raise result.exc return wrapper diff --git a/tests/static_tests/unit/test_wait_condition.py b/tests/static_tests/unit/test_wait_condition.py index b4c7e908..76cc37fa 100644 --- a/tests/static_tests/unit/test_wait_condition.py +++ b/tests/static_tests/unit/test_wait_condition.py @@ -4,7 +4,6 @@ import pytest from mops.exceptions import TimeoutException -from mops.self_healing.decorators import healing_after_wait from mops.utils.internal_utils import WAIT_METHODS_DELAY from mops.utils.decorators import wait_condition from mops.utils.logs import autolog @@ -163,7 +162,6 @@ def _heal_after_wait(self) -> bool: self._post_heal_retry = True return self._heal_after_wait_result - @healing_after_wait @wait_condition def wait_something(self, *, timeout: Union[int, float] = 1, silent: bool = False) -> bool: # noqa return Result( # noqa From a4e179d92199af201cda2dd5da9232240d344956 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Mon, 29 Jun 2026 13:04:32 +0200 Subject: [PATCH 12/30] Fix issue with healing polling on parent element --- mops/abstraction/element_abc.py | 4 +- mops/selenium/core/core_element.py | 7 +++- tests/web_tests/test_self_healing.py | 62 ++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/mops/abstraction/element_abc.py b/mops/abstraction/element_abc.py index 4eb38288..a6cb7298 100644 --- a/mops/abstraction/element_abc.py +++ b/mops/abstraction/element_abc.py @@ -381,7 +381,7 @@ def _is_available(self) -> bool: :return: :class:`bool` - :obj:`True` if present in DOM """ - return self.is_available() + return NotImplementedError def is_displayed(self, silent: bool = False) -> bool: """ @@ -405,7 +405,7 @@ def _is_displayed(self, silent: bool = False) -> bool: :type silent: bool :return: :class:`bool` """ - return self.is_displayed(silent=silent) + return NotImplementedError def is_hidden(self, silent: bool = False) -> bool: """ diff --git a/mops/selenium/core/core_element.py b/mops/selenium/core/core_element.py index 592f6d81..308814bd 100644 --- a/mops/selenium/core/core_element.py +++ b/mops/selenium/core/core_element.py @@ -552,7 +552,10 @@ def _get_base(self, wait_strategy: bool | Callable = True) -> SeleniumWebDriver return base if self.parent: - base = self.parent._get_element(wait_strategy=wait_strategy) + if self.parent._is_element_still_available(self.parent._element): + base = self.parent._element + else: + base = self.parent._find_element(wait_parent=False) return base @@ -589,7 +592,7 @@ def _attempt_healing(self) -> SuccessHealingResult | None: """ try: healer = _get_healer() - locator_key = get_config().storage.normalize_locator_key(f'{self.name}::{self.locator}') + locator_key = get_config().storage._extract_full_locator_key(self) result = healer.heal(self.name, locator_key, self.locator, self.driver) if type(result) is SuccessHealingResult: return result diff --git a/tests/web_tests/test_self_healing.py b/tests/web_tests/test_self_healing.py index 948cb36a..b497c5ab 100644 --- a/tests/web_tests/test_self_healing.py +++ b/tests/web_tests/test_self_healing.py @@ -305,3 +305,65 @@ def _spy(self, *args, **kwargs): assert not attempt_called, '_attempt_healing was called during wait_hidden_without_error' assert broken_row.locator == broken_locator, 'Locator was changed by healing' + + +def test_parent_healing_not_triggered_during_child_healing(second_playground_page): + """ + Self-healing on a child must NOT trigger healing on its parent. + + Flow: + 1. Create parent and child elements. Find child → snapshot saved. + 2. Seed the snapshot under a broken child locator. + 3. Create new child with broken locator, same parent. + 4. Spy on ``_attempt_healing`` for all CoreElement instances. + 5. ``get_attribute`` on the broken child → child heals. + 6. Assert child locator was updated. + 7. Assert parent ``_attempt_healing`` was NEVER called. + """ + from unittest.mock import patch + + from mops.selenium.core.core_element import CoreElement + + row = second_playground_page.row_with_cards + + # Create a child with the row as its parent + parent = row + child_with_parent = Element('a', name='card link', parent=parent) + + # Find child → snapshot saved (both parent and child snapshots) + child_with_parent.wait_visibility(silent=True) + + storage = get_config().storage + # Key includes parent: "card link::a -> row with cards::.row" + parent_key_part = f'{child_with_parent.name}::{child_with_parent.locator}' + if child_with_parent.parent: + parent_key_part += f' -> {row.name}::{row.locator}' + real_key = parent_key_part + snapshot = storage.load(real_key) + assert snapshot is not None + + # Seed snapshot under a broken child locator (same parent hierarchy) + broken_locator = '.broken-card-link' + broken_key = f'{child_with_parent.name}::{broken_locator}' + if child_with_parent.parent: + broken_key += f' -> {row.name}::{row.locator}' + storage.save(broken_key, snapshot) + + broken_child = Element(broken_locator, name=child_with_parent.name, parent=row) + + # Spy on _attempt_healing for all instances + attempt_calls_names = [] + original_attempt = CoreElement._attempt_healing + + def _spy(self, *args, **kwargs): + attempt_calls_names.append(self.name) + return original_attempt(self, *args, **kwargs) + + with patch.object(CoreElement, '_attempt_healing', _spy): + cls = broken_child.get_attribute('class', silent=True) + + assert cls is not None, 'Child was not healed' + assert broken_child.locator != broken_locator, 'Child locator was not healed' + # Parent name must NOT appear in healing calls + assert parent.name not in attempt_calls_names, \ + f'Parent healing was triggered: {attempt_calls_names}' From 1cbe74b88e632fa34cf39a558c36e592448f2835 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Mon, 29 Jun 2026 17:57:57 +0200 Subject: [PATCH 13/30] Move to dedicated enabling healing process --- mops/base/element.py | 7 ++- mops/selenium/core/core_element.py | 13 ++++- mops/selenium/elements/web_element.py | 3 +- mops/utils/decorators.py | 38 +++++++++----- .../static_tests/unit/test_wait_condition.py | 3 +- tests/web_tests/test_self_healing.py | 49 +++++++++++++++++++ 6 files changed, 98 insertions(+), 15 deletions(-) diff --git a/mops/base/element.py b/mops/base/element.py index ade16c4c..d9900ee3 100644 --- a/mops/base/element.py +++ b/mops/base/element.py @@ -31,7 +31,7 @@ from mops.playwright.play_element import PlayElement from mops.selenium.elements.mobile_element import MobileElement from mops.selenium.elements.web_element import WebElement -from mops.utils.decorators import wait_condition, wait_continuous +from mops.utils.decorators import healing, healing_after_wait, wait_condition, wait_continuous from mops.utils.internal_utils import ( QUARTER_WAIT_EL, WAIT_EL, @@ -263,6 +263,7 @@ def send_keyboard_action(self, action: str | KeyboardKeys) -> Element: # Elements waits + @healing_after_wait @wait_continuous @wait_condition def wait_visibility( @@ -437,6 +438,7 @@ def wait_hidden_without_error( self.log(f'Ignored exception: "{exception.msg}"') return self + @healing_after_wait @wait_condition def wait_availability(self, *, timeout: int = WAIT_EL, silent: bool = False) -> Element: r""" @@ -766,6 +768,7 @@ def is_fully_visible(self, check_displaying: bool = True, silent: bool = False) return is_visible + @healing def scroll_into_view( self, block: ScrollTo = ScrollTo.CENTER, @@ -834,6 +837,7 @@ def save_screenshot( return image_object + @healing def hide(self, silent: bool = False) -> Element: """ Make the element invisible by setting its opacity to 0. @@ -848,6 +852,7 @@ def hide(self, silent: bool = False) -> Element: self.execute_script('arguments[0].style.opacity = "0";') return self + @healing def show(self, silent: bool = False) -> Element: """ Make the element visible by setting its opacity to 1. diff --git a/mops/selenium/core/core_element.py b/mops/selenium/core/core_element.py index 308814bd..1526e389 100644 --- a/mops/selenium/core/core_element.py +++ b/mops/selenium/core/core_element.py @@ -130,6 +130,7 @@ def all_elements(self) -> list[CoreElement] | list[Any]: # Element interaction + @healing @retry(ElementNotInteractableException) def click(self, *, force_wait: bool = True, **kwargs: Any) -> CoreElement: """ @@ -165,6 +166,7 @@ def click(self, *, force_wait: bool = True, **kwargs: Any) -> CoreElement: msg = f'Element "{self.name}" not interactable. {self.get_element_info()}. Original error: {selenium_exc_msg}' raise ElementNotInteractableException(msg) + @healing def type_text(self, text: str | KeyboardKeys, silent: bool = False) -> CoreElement: """ Types text into the element. @@ -184,6 +186,7 @@ def type_text(self, text: str | KeyboardKeys, silent: bool = False) -> CoreEleme return self + @healing def type_slowly(self, text: str, sleep_gap: float = 0.05, silent: bool = False) -> CoreElement: """ Types text into the element slowly with a delay between keystrokes. @@ -208,6 +211,7 @@ def type_slowly(self, text: str, sleep_gap: float = 0.05, silent: bool = False) return self + @healing def clear_text(self, silent: bool = False) -> CoreElement: """ Clear the text of the element. @@ -223,6 +227,7 @@ def clear_text(self, silent: bool = False) -> CoreElement: return self + @healing def check(self) -> CoreElement: """ Check the checkbox element. @@ -239,6 +244,7 @@ def check(self) -> CoreElement: return self + @healing def uncheck(self) -> CoreElement: """ Unchecks the checkbox element. @@ -257,6 +263,7 @@ def uncheck(self) -> CoreElement: # Element state + @healing def screenshot_image(self, screenshot_base: bytes | None = None) -> Image: """ Return a :class:`PIL.Image.Image` object representing the screenshot of the web element. @@ -272,6 +279,7 @@ def screenshot_image(self, screenshot_base: bytes | None = None) -> Image: return _scaled_screenshot(screenshot_base, element_size) @property + @healing def screenshot_base(self) -> bytes: """ Returns the binary screenshot data of the element. @@ -283,6 +291,7 @@ def screenshot_base(self) -> bytes: return self.element.screenshot_as_png @property + @healing @retry(SeleniumStaleElementReferenceException) def text(self) -> str: """ @@ -384,6 +393,7 @@ def is_hidden(self, silent: bool = False) -> bool: return status + @healing @retry(SeleniumStaleElementReferenceException) def get_attribute(self, attribute: str, silent: bool = False) -> str: """ @@ -457,6 +467,7 @@ def location(self) -> Location: """ return Location(**self.execute_script(get_element_position_on_screen_js)) + @healing def is_enabled(self, silent: bool = False) -> bool: """ Check if the current element is enabled. @@ -470,6 +481,7 @@ def is_enabled(self, silent: bool = False) -> bool: return self.element.is_enabled() + @healing def is_checked(self) -> bool: """ Check if a checkbox or radio button is selected. @@ -497,7 +509,6 @@ def _action_chains(self) -> ActionChains: """ return ActionChains(self.driver) - @healing def _get_element(self, wait_strategy: bool | Callable = True, force_wait: bool = False) -> SeleniumWebElement: """ Get selenium element from driver or parent element diff --git a/mops/selenium/elements/web_element.py b/mops/selenium/elements/web_element.py index 98052e3f..d747347c 100644 --- a/mops/selenium/elements/web_element.py +++ b/mops/selenium/elements/web_element.py @@ -7,7 +7,7 @@ from mops.js_scripts import js_click from mops.selenium.core.core_element import CoreElement -from mops.utils.decorators import retry +from mops.utils.decorators import healing, retry from mops.utils.internal_utils import calculate_coordinate_to_click from mops.utils.selector_synchronizer import get_platform_locator, set_selenium_selector @@ -40,6 +40,7 @@ def click(self, *, force_wait: bool = True, **kwargs: Any) -> WebElement: return self + @healing @retry(JavascriptException) def hover(self, silent: bool = False) -> WebElement: """ diff --git a/mops/utils/decorators.py b/mops/utils/decorators.py index 649eccd8..0cd0dffc 100644 --- a/mops/utils/decorators.py +++ b/mops/utils/decorators.py @@ -58,8 +58,8 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: def healing(method: Callable) -> Callable: """Attempt self-healing when a :class:`NoSuchElementException` is raised. - Apply to element access methods (``_get_element``, ``click``, etc.) - so that direct element lookups are healed immediately. + Catches the exception, heals the locator via ``_try_healed_locators``, + then retries the original method once with the healed locator. """ @wraps(method) @@ -72,7 +72,31 @@ def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: result = self._attempt_healing() if not result: raise - return self._try_healed_locators(result) + self._try_healed_locators(result) + return method(self, *args, **kwargs) + + return wrapper + + +def healing_after_wait(method: Callable) -> Callable: + """Defer self-healing until a wait condition times out. + + Wraps a ``@wait_condition`` method. When the wait times out, attempts + healing and retries ONCE. Does NOT use global flags — simply catches + ``Exception`` with a ``_timeout`` attribute and calls + ``self._heal_after_wait()``. + """ + + @wraps(method) + def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: + try: + return method(self, *args, **kwargs) + except Exception as exc: + if getattr(exc, '_timeout', None) is not None: + heal = getattr(self, '_heal_after_wait', None) + if heal and heal(): + return method(self, *args, **kwargs) + raise return wrapper @@ -116,14 +140,6 @@ def wrapper( delay = increase_delay(delay) result.exc._timeout = timeout - - # Attempt healing after wait timeout - heal = getattr(self, '_heal_after_wait', None) - if heal and heal(): - result = method(self, *args, **kwargs) - if result.execution_result: - return self - raise result.exc return wrapper diff --git a/tests/static_tests/unit/test_wait_condition.py b/tests/static_tests/unit/test_wait_condition.py index 76cc37fa..5877d905 100644 --- a/tests/static_tests/unit/test_wait_condition.py +++ b/tests/static_tests/unit/test_wait_condition.py @@ -5,7 +5,7 @@ import pytest from mops.exceptions import TimeoutException from mops.utils.internal_utils import WAIT_METHODS_DELAY -from mops.utils.decorators import wait_condition +from mops.utils.decorators import healing_after_wait, wait_condition from mops.utils.logs import autolog from mops.mixins.objects.wait_result import Result @@ -162,6 +162,7 @@ def _heal_after_wait(self) -> bool: self._post_heal_retry = True return self._heal_after_wait_result + @healing_after_wait @wait_condition def wait_something(self, *, timeout: Union[int, float] = 1, silent: bool = False) -> bool: # noqa return Result( # noqa diff --git a/tests/web_tests/test_self_healing.py b/tests/web_tests/test_self_healing.py index b497c5ab..f158e95d 100644 --- a/tests/web_tests/test_self_healing.py +++ b/tests/web_tests/test_self_healing.py @@ -307,6 +307,55 @@ def _spy(self, *args, **kwargs): assert broken_row.locator == broken_locator, 'Locator was changed by healing' +def test_wait_hidden_without_error_timeout_does_not_heal(second_playground_page): + """ + ``wait_hidden_without_error`` must NOT heal when the element IS invisible + and the wait succeeds. + + Flow: + 1. Find row_with_cards → snapshot saved. + 2. Hide the element via JS. + 3. Spy on ``_attempt_healing`` and ``_heal_after_wait``. + 4. ``wait_hidden_without_error`` succeeds (element is hidden). + 5. Assert neither healing method was called. + """ + from unittest.mock import patch + + from mops.selenium.core.core_element import CoreElement + + row = second_playground_page.row_with_cards + row.wait_visibility(silent=True) # snapshot saved + + # Hide the element + row.driver_wrapper.execute_script('arguments[0].style.display = "none";', row) + + original_attempt = CoreElement._attempt_healing + attempt_called = False + original_heal_after = CoreElement._heal_after_wait + heal_after_called = False + + def _spy_attempt(self, *args, **kwargs): + nonlocal attempt_called + attempt_called = True + return original_attempt(self, *args, **kwargs) + + def _spy_heal_after(self, *args, **kwargs): + nonlocal heal_after_called + heal_after_called = True + return original_heal_after(self, *args, **kwargs) + + with patch.object(CoreElement, '_attempt_healing', _spy_attempt), \ + patch.object(CoreElement, '_heal_after_wait', _spy_heal_after): + row.wait_hidden_without_error(silent=True) + + assert not attempt_called, \ + '_attempt_healing was called during wait_hidden_without_error' + assert not heal_after_called, \ + '_heal_after_wait was called during wait_hidden_without_error' + # Verify element actually is hidden + assert row.is_hidden(silent=True), 'Element should be hidden' + + def test_parent_healing_not_triggered_during_child_healing(second_playground_page): """ Self-healing on a child must NOT trigger healing on its parent. From 653b99ec114af9a02bf0dceaad64b806d3b46160 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Tue, 30 Jun 2026 11:01:54 +0200 Subject: [PATCH 14/30] Remove unexpected healing after wait_hidden_without_error --- mops/base/element.py | 24 +++++++++----- tests/web_tests/test_self_healing.py | 49 ++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/mops/base/element.py b/mops/base/element.py index d9900ee3..5c8e65c6 100644 --- a/mops/base/element.py +++ b/mops/base/element.py @@ -264,8 +264,6 @@ def send_keyboard_action(self, action: str | KeyboardKeys) -> Element: # Elements waits @healing_after_wait - @wait_continuous - @wait_condition def wait_visibility( self, *, @@ -300,11 +298,7 @@ def wait_visibility( :type continuous: typing.Union[int, float, bool] :return: :class:`Element` """ - return Result( - execution_result=self._is_displayed(silent=True), - log=f'Wait until "{self.name}" becomes visible', - exc=TimeoutException(f'"{self.name}" not visible', info=self), - ) + return self._wait_visibility_base(timeout=timeout, silent=silent, continuous=continuous) def wait_visibility_without_error( self, @@ -341,11 +335,11 @@ def wait_visibility_without_error( :return: :class:`Element` """ if not silent: - strategy = 'continuous visible' if continuous else 'hidden' + strategy = 'continuous visible' if continuous else 'visible' self.log(f'Wait until "{self.name}" becomes {strategy} without error exception') try: - self.wait_visibility(timeout=timeout, silent=True, continuous=continuous) + self._wait_visibility_base(timeout=timeout, silent=True, continuous=continuous) except (TimeoutException, WebDriverException, ContinuousWaitException) as exception: if not silent: self.log(f'Ignored exception: "{exception.msg}"') @@ -1077,3 +1071,15 @@ def _element_cls(self) -> type[Element]: :return: :obj:`typing.Type` [:class:`Element`] """ return Element + + def _visibility_check( + self, *, timeout: int = WAIT_EL, silent: bool = False, continuous: bool | float = False + ) -> Element: + return Result( + execution_result=self._is_displayed(silent=True), + log=f'Wait until "{self.name}" becomes visible', + exc=TimeoutException(f'"{self.name}" not visible', info=self), + ) + + _visibility_check.__name__ = 'wait_visibility' + _wait_visibility_base = wait_continuous(wait_condition(_visibility_check)) diff --git a/tests/web_tests/test_self_healing.py b/tests/web_tests/test_self_healing.py index f158e95d..d97389f4 100644 --- a/tests/web_tests/test_self_healing.py +++ b/tests/web_tests/test_self_healing.py @@ -356,6 +356,55 @@ def _spy_heal_after(self, *args, **kwargs): assert row.is_hidden(silent=True), 'Element should be hidden' +def test_wait_visibility_without_error_does_not_heal(second_playground_page): + """ + ``wait_visibility_without_error`` must NOT heal when the element is + invisible and the wait times out. + + Flow: + 1. Find row_with_cards → snapshot saved. + 2. Hide the element via JS so it's not visible. + 3. Spy on ``_attempt_healing`` and ``_heal_after_wait``. + 4. ``wait_visibility_without_error`` times out (element is hidden). + 5. Assert neither healing method was called. + """ + from unittest.mock import patch + + from mops.selenium.core.core_element import CoreElement + + row = second_playground_page.row_with_cards + row.wait_visibility(silent=True) # snapshot saved + + # Hide the element so wait_visibility will time out + row.driver_wrapper.execute_script('arguments[0].style.display = "none";', row) + + original_attempt = CoreElement._attempt_healing + attempt_called = False + original_heal_after = CoreElement._heal_after_wait + heal_after_called = False + + def _spy_attempt(self, *args, **kwargs): + nonlocal attempt_called + attempt_called = True + return original_attempt(self, *args, **kwargs) + + def _spy_heal_after(self, *args, **kwargs): + nonlocal heal_after_called + heal_after_called = True + return original_heal_after(self, *args, **kwargs) + + with patch.object(CoreElement, '_attempt_healing', _spy_attempt), \ + patch.object(CoreElement, '_heal_after_wait', _spy_heal_after): + row.wait_visibility_without_error(silent=True) + + assert not attempt_called, \ + '_attempt_healing was called during wait_visibility_without_error' + assert not heal_after_called, \ + '_heal_after_wait was called during wait_visibility_without_error' + # Verify element actually is not visible + assert not row.is_displayed(silent=True), 'Element should not be visible' + + def test_parent_healing_not_triggered_during_child_healing(second_playground_page): """ Self-healing on a child must NOT trigger healing on its parent. From bb590577ace83086d0c6e9d1fad58612cfa3193d Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Thu, 2 Jul 2026 13:23:30 +0200 Subject: [PATCH 15/30] PW support --- mops/abstraction/element_abc.py | 11 + mops/base/element.py | 27 ++- mops/playwright/play_element.py | 199 ++++++++++++++++-- mops/selenium/core/core_element.py | 59 ++---- mops/self_healing/config.py | 5 + mops/self_healing/healer.py | 36 +++- mops/self_healing/healer_factory.py | 39 ++++ mops/self_healing/locator_generator.py | 81 +++++++ mops/self_healing/snapshot.py | 31 ++- mops/utils/decorators.py | 6 +- tests/adata/self_healing_utils.py | 34 +++ .../unit/test_self_healing_callbacks.py | 40 ++-- .../unit/test_self_healing_normalization.py | 10 +- tests/web_tests/test_self_healing.py | 142 ++----------- 14 files changed, 487 insertions(+), 233 deletions(-) create mode 100644 mops/self_healing/healer_factory.py create mode 100644 tests/adata/self_healing_utils.py diff --git a/mops/abstraction/element_abc.py b/mops/abstraction/element_abc.py index a6cb7298..44eb30bf 100644 --- a/mops/abstraction/element_abc.py +++ b/mops/abstraction/element_abc.py @@ -383,6 +383,17 @@ def _is_available(self) -> bool: """ return NotImplementedError + def _apply_healing(self) -> bool: + """ + Attempt self-healing and persist the first working locator. + + Called by the :func:`@healing ` decorator + and :func:`@healing_after_wait `. + + :return: :obj:`True` if a healed locator was found and applied. + """ + raise NotImplementedError + def is_displayed(self, silent: bool = False) -> bool: """ Check if the element is displayed. diff --git a/mops/base/element.py b/mops/base/element.py index 5c8e65c6..8526d450 100644 --- a/mops/base/element.py +++ b/mops/base/element.py @@ -1072,14 +1072,25 @@ def _element_cls(self) -> type[Element]: """ return Element - def _visibility_check( + def _wait_visibility_base( self, *, timeout: int = WAIT_EL, silent: bool = False, continuous: bool | float = False ) -> Element: - return Result( - execution_result=self._is_displayed(silent=True), - log=f'Wait until "{self.name}" becomes visible', - exc=TimeoutException(f'"{self.name}" not visible', info=self), - ) + """Wait for element visibility with polling, used by wait_visibility.""" + + def _visibility_check( + self: Element, + *, + timeout: int = WAIT_EL, # noqa: ARG001 + silent: bool = False, # noqa: ARG001 + continuous: bool | float = False, # noqa: ARG001 + ) -> Element: + return Result( + execution_result=self._is_displayed(silent=True), + log=f'Wait until "{self.name}" becomes visible', + exc=TimeoutException(f'"{self.name}" not visible', info=self), + ) - _visibility_check.__name__ = 'wait_visibility' - _wait_visibility_base = wait_continuous(wait_condition(_visibility_check)) + # Give the check a stable name so decorators report "wait_visibility" + _visibility_check.__name__ = 'wait_visibility' + _check = wait_continuous(wait_condition(_visibility_check)) + return _check(self, timeout=timeout, silent=silent, continuous=continuous) diff --git a/mops/playwright/play_element.py b/mops/playwright/play_element.py index 964feac4..58a717c4 100644 --- a/mops/playwright/play_element.py +++ b/mops/playwright/play_element.py @@ -6,11 +6,20 @@ from playwright.sync_api import Error, Locator, Page as PlaywrightPage from mops.abstraction.element_abc import ElementABC -from mops.exceptions import InvalidSelectorException, NotInitializedException +from mops.exceptions import ( + DriverWrapperException, + InvalidSelectorException, + NoSuchElementException, + NotInitializedException, +) from mops.mixins.objects.location import Location from mops.mixins.objects.size import Size +from mops.self_healing.config import get_config +from mops.self_healing.healer import SuccessHealingResult +from mops.self_healing.healer_factory import get_healer +from mops.self_healing.locator_generator import generate_locator_pw from mops.shared_utils import cut_log_data, get_image -from mops.utils.decorators import retry +from mops.utils.decorators import healing, retry from mops.utils.internal_utils import ( calculate_coordinate_to_click, is_element, @@ -54,6 +63,8 @@ def element(self) -> Locator: if not element: driver = self._get_base() element = driver.locator(self.locator) + self._element = element + self._save_snapshot(element) return element @@ -77,6 +88,7 @@ def all_elements(self) -> list[PlayElement] | list[Any]: # Element interaction + @healing def click(self, *, force_wait: bool = True, **kwargs: Any) -> PlayElement: """ Clicks on the element. @@ -101,10 +113,13 @@ def click(self, *, force_wait: bool = True, **kwargs: Any) -> PlayElement: if force_wait: self.wait_visibility(silent=True) - if self.driver_wrapper.is_mobile_resolution: - self._first_element.tap(**kwargs) - else: - self._first_element.click(**kwargs) + try: + if self.driver_wrapper.is_mobile_resolution: + self._first_element.tap(**kwargs) + else: + self._first_element.click(**kwargs) + except Error as exc: + raise NoSuchElementException(str(exc)) from exc return self @@ -142,6 +157,7 @@ def click_into_center(self, silent: bool = False) -> PlayElement: self.driver_wrapper.click_by_coordinates(x=x, y=y, silent=True) return self + @healing def type_text(self, text: str | KeyboardKeys, silent: bool = False) -> PlayElement: """ Types text into the element. @@ -157,9 +173,13 @@ def type_text(self, text: str | KeyboardKeys, silent: bool = False) -> PlayEleme if not silent: self.log(f'Type text "{cut_log_data(text)}" into "{self.name}"') - self._first_element.type(text=text) + try: + self._first_element.type(text=text) + except Error as exc: + raise NoSuchElementException(str(exc)) from exc return self + @healing def type_slowly(self, text: str, sleep_gap: float = 0.05, silent: bool = False) -> PlayElement: """ Types text into the element slowly with a delay between keystrokes. @@ -175,9 +195,13 @@ def type_slowly(self, text: str, sleep_gap: float = 0.05, silent: bool = False) if not silent: self.log(f'Type text {cut_log_data(text)} into "{self.name}"') - self._first_element.type(text=text, delay=sleep_gap) + try: + self._first_element.type(text=text, delay=sleep_gap) + except Error as exc: + raise NoSuchElementException(str(exc)) from exc return self + @healing def clear_text(self, silent: bool = False) -> PlayElement: """ Clear the text of the element. @@ -189,9 +213,13 @@ def clear_text(self, silent: bool = False) -> PlayElement: if not silent: self.log(f'Clear text in "{self.name}"') - self._first_element.fill('') + try: + self._first_element.fill('') + except Error as exc: + raise NoSuchElementException(str(exc)) from exc return self + @healing def hover(self, silent: bool = False) -> PlayElement: """ Hover the mouse over the current element. @@ -203,7 +231,10 @@ def hover(self, silent: bool = False) -> PlayElement: if not silent: self.log(f'Hover over "{self.name}"') - self._first_element.hover() + try: + self._first_element.hover() + except Error as exc: + raise NoSuchElementException(str(exc)) from exc return self def hover_outside(self, x: int = 0, y: int = -5) -> PlayElement: @@ -220,28 +251,35 @@ def hover_outside(self, x: int = 0, y: int = -5) -> PlayElement: self._first_element.hover(position={'x': float(x), 'y': float(y)}, force=True) return self + @healing def check(self) -> PlayElement: """ Check the checkbox element. :return: :class:`PlayElement` """ - self._first_element.check() - + try: + self._first_element.check() + except Error as exc: + raise NoSuchElementException(str(exc)) from exc return self + @healing def uncheck(self) -> PlayElement: """ Unchecks the checkbox element. :return: :class:`PlayElement` """ - self._first_element.uncheck() - + try: + self._first_element.uncheck() + except Error as exc: + raise NoSuchElementException(str(exc)) from exc return self # Element state + @healing def screenshot_image(self, screenshot_base: bytes | None = None) -> Image: """ Return a :class:`PIL.Image.Image` object representing the screenshot of the web element. @@ -256,40 +294,56 @@ def screenshot_image(self, screenshot_base: bytes | None = None) -> Image: return get_image(screenshot_base) @property + @healing def screenshot_base(self) -> bytes: """ Returns the binary screenshot data of the element. :return: :class:`bytes` - screenshot binary """ - return self._first_element.screenshot() + try: + return self._first_element.screenshot() + except Error as exc: + raise NoSuchElementException(str(exc)) from exc @property + @healing def text(self) -> str: """ Returns the text of the element. :return: :class:`str` - element text """ - return self.inner_text + try: + return self.inner_text + except Error as exc: + raise NoSuchElementException(str(exc)) from exc @property + @healing def inner_text(self) -> str: """ Returns the inner text of the element. :return: :class:`str` - element inner text """ - return self._first_element.inner_text() + try: + return self._first_element.inner_text() + except Error as exc: + raise NoSuchElementException(str(exc)) from exc @property + @healing def value(self) -> str: """ Returns the value of the element. :return: :class:`str` - element value """ - return self._first_element.input_value() + try: + return self._first_element.input_value() + except Error as exc: + raise NoSuchElementException(str(exc)) from exc def _is_available(self) -> bool: """ @@ -346,6 +400,7 @@ def is_hidden(self, silent: bool = False) -> bool: return self._first_element.is_hidden() + @healing def get_attribute(self, attribute: str, silent: bool = False) -> str: """ Retrieve a specific attribute from the current element. @@ -359,7 +414,10 @@ def get_attribute(self, attribute: str, silent: bool = False) -> str: if not silent: self.log(f'Get "{attribute}" from "{self.name}"') - return self._first_element.get_attribute(attribute) + try: + return self._first_element.get_attribute(attribute) + except Error as exc: + raise NoSuchElementException(str(exc)) from exc def get_all_texts(self, silent: bool = False) -> list: """ @@ -419,6 +477,7 @@ def location(self) -> Location: box = self.element.first.bounding_box() return Location(x=box['x'], y=box['y']) + @healing def is_enabled(self, silent: bool = False) -> bool: """ Check if the current element is enabled. @@ -430,25 +489,38 @@ def is_enabled(self, silent: bool = False) -> bool: if not silent: self.log(f'Check is element "{self.name}" enabled') - return self._first_element.is_enabled() + try: + return self._first_element.is_enabled() + except Error as exc: + raise NoSuchElementException(str(exc)) from exc + @healing def is_checked(self) -> bool: """ Check if a checkbox or radio button is selected. :return: :class:`bool` - :obj:`True` if the checkbox or radio button is checked, :obj:`False` otherwise. """ - return self._first_element.is_checked() + try: + return self._first_element.is_checked() + except Error as exc: + raise NoSuchElementException(str(exc)) from exc # Mixin - def _get_base(self) -> PlaywrightPage | Locator: + def _get_base(self, wait_strategy: bool = True) -> PlaywrightPage | Locator: """ Get driver depends on parent element if available + :param wait_strategy: Compatibility parameter for the Selenium signature. + Playwright Locators are lazy so this has no effect. :return: driver """ base = self.driver + if not base: + msg = "Can't find driver" + raise DriverWrapperException(msg) + if self.parent: self.log(f'Get element "{self.name}" from parent element "{self.parent.name}"', level='debug') @@ -470,3 +542,86 @@ def _set_locator(self) -> None: self.locator = get_platform_locator(self) set_playwright_locator(self) self._is_locator_configured = True + + # Self-healing + + @staticmethod + def _parse_healed_locator_pw(healed_locator: str) -> str: + """Strip the ``xpath=`` prefix from a healed locator for Playwright use.""" + if healed_locator.startswith('xpath='): + return healed_locator[len('xpath=') :] + return healed_locator + + def _save_snapshot(self, locator_element: Locator) -> None: + """Save a DOM snapshot for the current element if snapshot saving is enabled.""" + config = get_config() + if config.save_snapshots and config.storage: + config.storage.save_from_element(self, locator_element, self.driver_wrapper) + + def _attempt_healing(self) -> SuccessHealingResult | None: + """Attempt to heal a failed element lookup using the self-healing subsystem. + + :return: :class:`SuccessHealingResult` if a suitable candidate was found, + :obj:`None` otherwise. + """ + try: + healer = get_healer() + storage = get_config().storage + locator_key = storage.extract_full_locator_key(self) + result = healer.heal( + element_name=self.name, + locator_key=locator_key, + locator=self.locator, + driver_wrapper=self.driver_wrapper, + find_elements_fn=lambda tag: self.driver.locator(tag).all(), + generate_locator_fn=generate_locator_pw, + ) + if type(result) is SuccessHealingResult: + return result + except Exception as exc: # noqa: BLE001 + self.log(f'Self-healing failed with unexpected exception: {exc}', level='warning') + return None + + def _try_healed_locators(self, result: SuccessHealingResult) -> None: + """Try each healed locator candidate and persist the first working one. + + :param result: The healing result containing candidate locators. + :raises NoSuchElementException: If no candidate locator resolves to an element. + """ + base = self._get_base(wait_strategy=False) + for locator_str in result.healed_locators_candidates: + selector = self._parse_healed_locator_pw(locator_str) + try: + candidate = base.locator(selector) + if candidate.count() > 0: + result.healed_locator = locator_str + self.locator = selector + self._element = candidate + return + except Error: + continue + raise NoSuchElementException + + def _apply_healing(self) -> bool: + """Attempt healing and persist the first working locator. + + Called by :func:`@healing ` and + :func:`@healing_after_wait `. + + :return: :obj:`True` if a healed locator was found and applied. + """ + result = self._attempt_healing() + if not result: + return False + try: + self._try_healed_locators(result) + except NoSuchElementException: + return False + return True + + def _heal_after_wait(self) -> bool: + """Attempt healing after a wait condition timed out. + + :return: :obj:`True` if a working locator was found. + """ + return self._apply_healing() diff --git a/mops/selenium/core/core_element.py b/mops/selenium/core/core_element.py index 1526e389..25a5b2a7 100644 --- a/mops/selenium/core/core_element.py +++ b/mops/selenium/core/core_element.py @@ -29,7 +29,8 @@ from mops.mixins.objects.size import Size from mops.selenium.sel_utils import ActionChains from mops.self_healing.config import get_config -from mops.self_healing.healer import Healer, SuccessHealingResult +from mops.self_healing.healer import SuccessHealingResult +from mops.self_healing.healer_factory import get_healer from mops.shared_utils import _scaled_screenshot, cut_log_data from mops.utils.decorators import healing, retry from mops.utils.internal_utils import WAIT_EL, get_dict, is_group, safe_call @@ -44,37 +45,6 @@ from mops.base.element import Element from mops.keyboard_keys import KeyboardKeys - from mops.self_healing.snapshot import SnapshotStorage - - -class _HealerState: - """Module-level state for the healer singleton.""" - - storage: SnapshotStorage | None = None - healer: Healer | None = None - - -def _get_healer() -> Healer: - """Return the global Healer singleton. - - Re-initialises when ``get_config().storage`` changes (identity check) - or when the cached storage is ``None``, so that ``configure()`` calls - between tests are picked up. - """ - config = get_config() - - if _HealerState.healer and _HealerState.storage is config.storage and _HealerState.storage is not None: - return _HealerState.healer - - _HealerState.storage = config.storage - _HealerState.healer = Healer( - _HealerState.storage, - config.score_threshold, - scoring_weights=config.scoring_weights, - on_healing_success=config.on_healing_success, - on_healing_failure=config.on_healing_failure, - ) - return _HealerState.healer def _parse_healed_locator(healed_locator: str) -> tuple[str, str]: @@ -586,7 +556,6 @@ def _find_element(self, wait_parent: bool = False) -> SeleniumWebElement | Appiu # Save snapshot for future healing config = get_config() if config.save_snapshots and config.storage: - _get_healer() config.storage.save_from_element(self, element, self.driver) except (SeleniumInvalidArgumentException, SeleniumInvalidSelectorException) as exc: self._raise_invalid_selector_exception(exc) @@ -602,9 +571,9 @@ def _attempt_healing(self) -> SuccessHealingResult | None: :return: :class:`SuccessHealingResult` if a suitable candidate was found, :obj:`None` otherwise. """ try: - healer = _get_healer() - locator_key = get_config().storage._extract_full_locator_key(self) - result = healer.heal(self.name, locator_key, self.locator, self.driver) + healer = get_healer() + locator_key = get_config().storage.extract_full_locator_key(self) + result = healer.heal(self.name, locator_key, self.locator, self.driver_wrapper) if type(result) is SuccessHealingResult: return result except Exception as exc: # noqa: BLE001 @@ -627,11 +596,13 @@ def _try_healed_locators(self, result: SuccessHealingResult) -> SeleniumWebEleme return healed raise NoSuchElementException - def _heal_after_wait(self) -> bool: - """Attempt healing after a wait condition timed out. + def _apply_healing(self) -> bool: + """Attempt healing and persist the first working locator. - Persists the first working healed locator so subsequent lookups - use it directly. Returns ``True`` if a working locator was found. + Called by :func:`@healing ` and + :func:`@healing_after_wait `. + + :return: :obj:`True` if a healed locator was found and applied. """ result = self._attempt_healing() if not result: @@ -642,6 +613,14 @@ def _heal_after_wait(self) -> bool: return False return True + def _heal_after_wait(self) -> bool: + """Attempt healing after a wait condition timed out. + + Persists the first working healed locator so subsequent lookups + use it directly. Returns ``True`` if a working locator was found. + """ + return self._apply_healing() + def _find_elements(self, wait_parent: bool = False) -> list[SeleniumWebElement | AppiumWebElement]: """ Find all selenium/appium elements diff --git a/mops/self_healing/config.py b/mops/self_healing/config.py index 735bc5ad..b399adec 100644 --- a/mops/self_healing/config.py +++ b/mops/self_healing/config.py @@ -1,5 +1,6 @@ from __future__ import annotations +import dataclasses from dataclasses import dataclass from typing import TYPE_CHECKING @@ -79,7 +80,11 @@ def on_failure(**kwargs: object) -> None: on_healing_failure=on_failure, ) """ + valid_fields = {f.name for f in dataclasses.fields(SelfHealingConfig)} for key, value in kwargs.items(): + if key not in valid_fields: + msg = f"Unknown configuration key '{key}'. Valid fields: {sorted(valid_fields)}" + raise ValueError(msg) setattr(_config, key, value) diff --git a/mops/self_healing/healer.py b/mops/self_healing/healer.py index 158e191d..0faa1377 100644 --- a/mops/self_healing/healer.py +++ b/mops/self_healing/healer.py @@ -144,13 +144,27 @@ def _succeed(self, result: SuccessHealingResult) -> SuccessHealingResult: self._on_healing_success(result) return result - def heal(self, element_name: str, locator_key: str, locator: str, driver: Any) -> SuccessHealingResult | None: + def heal( # noqa: PLR0911 + self, + element_name: str, + locator_key: str, + locator: str, + driver_wrapper: Any, + find_elements_fn: Callable[[str], list[Any]] | None = None, + generate_locator_fn: Callable[[Any, Any], list[str]] | None = None, + ) -> SuccessHealingResult | None: """Try to find a healed locator for a failed element lookup. :param element_name: Human-readable element name for logging. :param locator_key: Storage key used to load the saved snapshot. :param locator: The original locator string (for the result record). - :param driver: Selenium WebDriver instance. + :param driver_wrapper: Driver wrapper with an ``execute_script(script, *args)`` method. + :param find_elements_fn: Optional callback ``(tag: str) -> list`` to find + all elements with a given tag name. Defaults to Selenium's + ``driver.find_elements(By.TAG_NAME, tag)``. + :param generate_locator_fn: Optional callback ``(element, driver_wrapper) -> list[str]`` + to generate candidate locators from a live element. Defaults to + :func:`generate_locator`. :return: :class:`SuccessHealingResult` if healed, ``None`` otherwise. """ snapshot = self._storage.load(locator_key) @@ -160,7 +174,7 @@ def heal(self, element_name: str, locator_key: str, locator: str, driver: Any) - return self._fail('no-snapshot', element_name, locator_key, locator) try: - candidates_data: list[dict] = driver.execute_script(_GET_CANDIDATES_JS, snapshot.tag) + candidates_data: list[dict] = driver_wrapper.execute_script(_GET_CANDIDATES_JS, snapshot.tag) except WebDriverException as exc: logger.info('Self-healing: failed to get candidates for "%s": %s', element_name, exc) return self._fail('candidates-script-error', element_name, locator_key, locator, exc=exc) @@ -186,18 +200,22 @@ def heal(self, element_name: str, locator_key: str, locator: str, driver: Any) - ) return self._fail('below-threshold', element_name, locator_key, locator) - # Get the actual WebElement by index among elements of the same tag + # Get the actual element by index among elements of the same tag + _find = find_elements_fn or (lambda tag: driver_wrapper.driver.find_elements(By.TAG_NAME, tag)) + _gen = generate_locator_fn or generate_locator + healed_locators: list[str] | None = None try: - web_elements = driver.find_elements(By.TAG_NAME, snapshot.tag) + web_elements = _find(snapshot.tag) if best_index >= len(web_elements): self._fail('index-out-of-bounds', element_name, locator_key, locator) - else: - healed_web_element = web_elements[best_index] - healed_locators = generate_locator(healed_web_element, driver) - except WebDriverException as exc: + return None + healed_web_element = web_elements[best_index] + healed_locators = _gen(healed_web_element, driver_wrapper) + except Exception as exc: # noqa: BLE001 logger.info('Self-healing: failed to generate locator for "%s": %s', element_name, exc) self._fail('generate-locator-error', element_name, locator_key, locator, exc=exc) + return None if healed_locators is None: return None diff --git a/mops/self_healing/healer_factory.py b/mops/self_healing/healer_factory.py new file mode 100644 index 00000000..77947c0b --- /dev/null +++ b/mops/self_healing/healer_factory.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from mops.self_healing.config import get_config +from mops.self_healing.healer import Healer + +if TYPE_CHECKING: + from mops.self_healing.snapshot import SnapshotStorage + + +class _HealerState: + """Module-level state for the healer singleton.""" + + storage: SnapshotStorage | None = None + healer: Healer | None = None + + +def get_healer() -> Healer: + """Return the global Healer singleton. + + Re-initialises when ``get_config().storage`` changes (identity check) + or when the cached storage is ``None``, so that ``configure()`` calls + between tests are picked up. + """ + config = get_config() + + if _HealerState.healer and _HealerState.storage is config.storage and _HealerState.storage is not None: + return _HealerState.healer + + _HealerState.storage = config.storage + _HealerState.healer = Healer( + _HealerState.storage, + config.score_threshold, + scoring_weights=config.scoring_weights, + on_healing_success=config.on_healing_success, + on_healing_failure=config.on_healing_failure, + ) + return _HealerState.healer diff --git a/mops/self_healing/locator_generator.py b/mops/self_healing/locator_generator.py index 96be21ee..7bec201e 100644 --- a/mops/self_healing/locator_generator.py +++ b/mops/self_healing/locator_generator.py @@ -98,3 +98,84 @@ def _positional_xpath(web_element: object, driver: object) -> str: return f'xpath=//{web_element.tag_name}' else: return f'xpath={path}' + + +def generate_locator_pw(locator_element: object, driver_wrapper: object) -> list[str]: + """Generate all possible stable XPath locators from a live Playwright Locator. + + Returns locators ordered by preference (most stable first). + The caller should try each in order until one succeeds. + Each locator uses the ``xpath=`` prefix used by MOPS. + + Uses Playwright's Locator API (:meth:`get_attribute`, :meth:`evaluate`) + instead of Selenium's WebElement API. + """ + try: + tag: str = locator_element.evaluate('el => el.tagName.toLowerCase()') + + attrs: dict[str, str] = {} + for attr_name in ('id', *_TEST_ATTRS, 'name', 'aria-label', 'placeholder', 'type', 'role', 'href', 'class'): + val = locator_element.get_attribute(attr_name) + if val: + attrs[attr_name] = val.strip() + + text = (locator_element.evaluate('el => (el.textContent || "").trim()') or '').strip() + except Exception: # noqa: BLE001 + return [_positional_xpath_pw(locator_element, driver_wrapper)] + + providers: list[str] = [] + + # id (unique by spec) + el_id = attrs.get('id', '') + if el_id and ' ' not in el_id: + providers.append(f'xpath=//*[@id="{el_id}"]') + + # data-* test attributes + for test_attr in _TEST_ATTRS: + val = attrs.get(test_attr, '') + if val: + providers.append(f'xpath=//*[@{test_attr}="{val}"]') + + # name + name = attrs.get('name', '') + if name: + providers.append(f'xpath=//{tag}[@name="{name}"]') + + # aria-label + aria = attrs.get('aria-label', '') + if aria: + escaped = aria.replace('"', '\\"') + providers.append(f'xpath=//*[@aria-label="{escaped}"]') + + # type + tag + el_type = attrs.get('type', '') + if el_type: + providers.append(f'xpath=//{tag}[@type="{el_type}"]') + + # stable class (filter out dynamic-looking tokens) + cls = attrs.get('class', '') + if cls: + stable = [c for c in cls.split() if not any(c.lower().startswith(p) for p in _DYNAMIC_CLASS_PREFIXES)] + if stable: + providers.append(f'xpath=//{tag}[contains(@class, "{stable[0]}")]') + + # visible text (short, single-line) + if text and len(text) <= _MAX_TEXT_LENGTH and '\n' not in text: + escaped = text.replace('"', '\\"') + providers.append(f'xpath=//{tag}[normalize-space(.)="{escaped}"]') + + providers.append(_positional_xpath_pw(locator_element, driver_wrapper)) + return providers + + +def _positional_xpath_pw(locator_element: object, driver_wrapper: object) -> str: + try: + path: str = driver_wrapper.execute_script(_GET_POSITIONAL_XPATH_JS, locator_element) + except Exception: # noqa: BLE001 + try: + tag = locator_element.evaluate('el => el.tagName.toLowerCase()') + except Exception: # noqa: BLE001 + tag = '*' + return f'xpath=//{tag}' + else: + return f'xpath={path}' diff --git a/mops/self_healing/snapshot.py b/mops/self_healing/snapshot.py index 4e1164de..9fc0f9df 100644 --- a/mops/self_healing/snapshot.py +++ b/mops/self_healing/snapshot.py @@ -13,9 +13,26 @@ from selenium.common.exceptions import WebDriverException if TYPE_CHECKING: + from collections.abc import Callable + from mops.base.element import Element +def _extract_full_locator_key(element: Element, normalize_fn: Callable[[str], str]) -> str: + """Build the full hierarchical locator key for an element. + + :param element: The MOPS Element. + :param normalize_fn: A function that normalizes a raw key string (e.g. removes dynamic data). + :return: Normalized composite key like ``Name::locator -> ParentName::parent_locator``. + """ + raw_locator_key = element.locator if element.name == element.locator else f'{element.name}::{element.locator}' + + if element.parent: + raw_locator_key += f' -> {_extract_full_locator_key(element.parent, normalize_fn)}' + + return normalize_fn(raw_locator_key) + + @dataclass class ElementSnapshot: """Snapshot of a successfully located element's DOM context.""" @@ -97,16 +114,20 @@ def __init__(self) -> None: self._normalization_rules = list(_DEFAULT_NORMALIZATION_RULES) def _extract_full_locator_key(self, element: Element) -> str: - raw_locator_key = element.locator if element.name == element.locator else f'{element.name}::{element.locator}' + """Use :meth:`extract_full_locator_key` instead.""" + return self.extract_full_locator_key(element) - if element.parent: - raw_locator_key += f' -> {self._extract_full_locator_key(element.parent)}' + def extract_full_locator_key(self, element: Element) -> str: + """Build the full hierarchical locator key for an element. - return self.normalize_locator_key(raw_locator_key) + :param element: The MOPS Element. + :return: Normalized composite key like ``Name::locator -> ParentName::parent_locator``. + """ + return _extract_full_locator_key(element, self.normalize_locator_key) def save_from_element(self, element: Element, web_element: object, driver: object) -> None: """Extract snapshot from a live web element and persist it.""" - locator_key = self._extract_full_locator_key(element) + locator_key = self.extract_full_locator_key(element) if locator_key in self._saved_this_session: return diff --git a/mops/utils/decorators.py b/mops/utils/decorators.py index 0cd0dffc..bc7d1df0 100644 --- a/mops/utils/decorators.py +++ b/mops/utils/decorators.py @@ -58,7 +58,7 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: def healing(method: Callable) -> Callable: """Attempt self-healing when a :class:`NoSuchElementException` is raised. - Catches the exception, heals the locator via ``_try_healed_locators``, + Catches the exception, heals the locator via ``self._apply_healing()``, then retries the original method once with the healed locator. """ @@ -69,10 +69,8 @@ def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: except NoSuchElementException: if not get_config().heal_locators: raise - result = self._attempt_healing() - if not result: + if not self._apply_healing(): raise - self._try_healed_locators(result) return method(self, *args, **kwargs) return wrapper diff --git a/tests/adata/self_healing_utils.py b/tests/adata/self_healing_utils.py new file mode 100644 index 00000000..262dbbcc --- /dev/null +++ b/tests/adata/self_healing_utils.py @@ -0,0 +1,34 @@ +"""Utilities for self-healing tests.""" + +from __future__ import annotations + +from contextlib import contextmanager +from typing import TYPE_CHECKING +from unittest.mock import patch + +if TYPE_CHECKING: + from collections.abc import Iterator + + +@contextmanager +def spy_healing(cls: type) -> Iterator[dict]: + """Spy on ``cls._attempt_healing`` calls. + + Usage:: + + with spy_healing(CoreElement) as spy: + element.some_action() + + assert not spy['called'] + assert 'SomeElement' not in spy['instances'] + """ + state: dict = {'called': False, 'instances': []} + original = cls._attempt_healing + + def _spy(self, *args, **kwargs): # noqa: ANN001, ANN002, ANN003, ANN202 + state['called'] = True + state['instances'].append(self.name) + return original(self, *args, **kwargs) + + with patch.object(cls, '_attempt_healing', _spy): + yield state diff --git a/tests/static_tests/unit/test_self_healing_callbacks.py b/tests/static_tests/unit/test_self_healing_callbacks.py index 2f083812..1d5891e1 100644 --- a/tests/static_tests/unit/test_self_healing_callbacks.py +++ b/tests/static_tests/unit/test_self_healing_callbacks.py @@ -31,6 +31,14 @@ def _make_snapshot(**overrides: str) -> ElementSnapshot: return ElementSnapshot(**defaults) +def _make_driver_wrapper(candidates=None, elements=None): + """Build a mock driver_wrapper with execute_script and .driver.find_elements.""" + dw = MagicMock() + dw.execute_script.return_value = candidates or [] + dw.driver.find_elements.return_value = elements or [] + return dw + + def _make_candidate(index: int = 0, **extra: str) -> dict: """Build a candidate dict with values matching the default snapshot.""" return { @@ -53,9 +61,7 @@ def test_success_callback_fired(): callback = MagicMock() storage = MagicMock() storage.load.return_value = _make_snapshot() - driver = MagicMock() - driver.execute_script.return_value = [_make_candidate()] - driver.find_elements.return_value = [MagicMock()] + driver = _make_driver_wrapper(candidates=[_make_candidate()], elements=[MagicMock()]) healer = Healer(storage, 0.7, on_healing_success=callback) @@ -71,9 +77,7 @@ def test_success_callback_not_set(): """Healing works even when on_healing_success is None.""" storage = MagicMock() storage.load.return_value = _make_snapshot() - driver = MagicMock() - driver.execute_script.return_value = [_make_candidate()] - driver.find_elements.return_value = [MagicMock()] + driver = _make_driver_wrapper(candidates=[_make_candidate()], elements=[MagicMock()]) healer = Healer(storage, 0.7) @@ -114,6 +118,7 @@ def test_failure_candidates_script_raises(): storage = MagicMock() storage.load.return_value = _make_snapshot() driver = MagicMock() + driver.driver = MagicMock() driver.execute_script.side_effect = WebDriverException('browser error') healer = Healer(storage, 0.7, on_healing_failure=callback) @@ -129,6 +134,7 @@ def test_failure_no_candidates(): storage = MagicMock() storage.load.return_value = _make_snapshot() driver = MagicMock() + driver.driver = MagicMock() driver.execute_script.return_value = [] healer = Healer(storage, 0.7, on_healing_failure=callback) @@ -145,6 +151,7 @@ def test_failure_score_below_threshold(): # Snapshot with mismatched attributes/text so score stays low storage.load.return_value = _make_snapshot(attributes={'class': 'x'}, text='foo') driver = MagicMock() + driver.driver = MagicMock() driver.execute_script.return_value = [ _make_candidate(attrs={'class': 'y'}, text='bar', parentTag='div'), ] @@ -162,13 +169,14 @@ def test_failure_best_index_out_of_bounds(): storage = MagicMock() storage.load.return_value = _make_snapshot() driver = MagicMock() + driver.driver = MagicMock() # Candidate 0 has low score (mismatch), candidate 1 has high score # → best_index = 1 but only 1 real element → OOB driver.execute_script.return_value = [ _make_candidate(index=0, attrs={'id': 'other'}, text='Other'), _make_candidate(index=1), ] - driver.find_elements.return_value = [MagicMock()] # only 1 element → index 1 is OOB + driver.driver.find_elements.return_value = [MagicMock()] # only 1 element → index 1 is OOB healer = Healer(storage, 0.7, on_healing_failure=callback) with patch('mops.self_healing.healer.generate_locator', return_value=['xpath=//button']): @@ -183,9 +191,7 @@ def test_failure_generate_locator_raises(): callback = MagicMock() storage = MagicMock() storage.load.return_value = _make_snapshot() - driver = MagicMock() - driver.execute_script.return_value = [_make_candidate()] - driver.find_elements.return_value = [MagicMock()] + driver = _make_driver_wrapper(candidates=[_make_candidate()], elements=[MagicMock()]) healer = Healer(storage, 0.7, on_healing_failure=callback) with patch('mops.self_healing.healer.generate_locator', side_effect=WebDriverException('no locator')): @@ -215,9 +221,7 @@ def test_multiple_locators_stored_in_result(): """All generated locators are stored in healed_locators_candidates.""" storage = MagicMock() storage.load.return_value = _make_snapshot() - driver = MagicMock() - driver.execute_script.return_value = [_make_candidate()] - driver.find_elements.return_value = [MagicMock()] + driver = _make_driver_wrapper(candidates=[_make_candidate()], elements=[MagicMock()]) healer = Healer(storage, 0.7) @@ -247,12 +251,13 @@ def test_siblings_matching_boosts_score(): siblings = [{'tag': 'span', 'attrs': {'class': 'helper'}, 'text': 'label'}] storage.load.return_value = _make_siblings_snapshot(siblings) driver = MagicMock() + driver.driver = MagicMock() candidate_with_siblings = _make_candidate( siblings=[{'tag': 'span', 'attrs': {'class': 'helper'}, 'text': 'label'}], ) candidate_no_siblings = _make_candidate(siblings=[]) driver.execute_script.return_value = [candidate_with_siblings, candidate_no_siblings] - driver.find_elements.return_value = [MagicMock()] + driver.driver.find_elements.return_value = [MagicMock()] healer_no_threshold = Healer(storage, 0.0) @@ -274,6 +279,7 @@ def test_mismatched_siblings_lower_score(): storage.load.return_value = _make_siblings_snapshot(snap_siblings) driver = MagicMock() + driver.driver = MagicMock() candidate = _make_candidate( attrs={'id': 'submit'}, text='Click', @@ -281,7 +287,7 @@ def test_mismatched_siblings_lower_score(): siblings=[{'tag': 'div', 'attrs': {'class': 'other'}, 'text': 'different'}], ) driver.execute_script.return_value = [candidate] - driver.find_elements.return_value = [MagicMock()] + driver.driver.find_elements.return_value = [MagicMock()] healer = Healer(storage, 0.0) @@ -303,9 +309,7 @@ def test_success_callback_raises_propagates(): """If on_healing_success raises, the exception propagates to the caller.""" storage = MagicMock() storage.load.return_value = _make_snapshot() - driver = MagicMock() - driver.execute_script.return_value = [_make_candidate()] - driver.find_elements.return_value = [MagicMock()] + driver = _make_driver_wrapper(candidates=[_make_candidate()], elements=[MagicMock()]) def crash(_result): raise RuntimeError('callback failed') diff --git a/tests/static_tests/unit/test_self_healing_normalization.py b/tests/static_tests/unit/test_self_healing_normalization.py index 7bbfe954..d19ce212 100644 --- a/tests/static_tests/unit/test_self_healing_normalization.py +++ b/tests/static_tests/unit/test_self_healing_normalization.py @@ -21,11 +21,11 @@ def test_extract_key_without_parent(): # name != locator → name::locator el = _make_element('Submit button', '#submit') - assert storage._extract_full_locator_key(el) == 'Submit button::#submit' + assert storage.extract_full_locator_key(el) == 'Submit button::#submit' # name == locator → bare locator el = _make_element('.row', '.row') - assert storage._extract_full_locator_key(el) == '.row' + assert storage.extract_full_locator_key(el) == '.row' def test_extract_key_with_parent(): @@ -36,7 +36,7 @@ def test_extract_key_with_parent(): parent = _make_element('Form', '#form', parent=grandparent) child = _make_element('Submit button', '#submit', parent=parent) - result = storage._extract_full_locator_key(child) + result = storage.extract_full_locator_key(child) assert result == 'Submit button::#submit -> Form::#form -> Section::#section' @@ -50,7 +50,7 @@ def test_extract_key_with_parent_normalized(): child = _make_element('User card', '#user-12345', parent=parent) # Default rules don't strip #user-12345 because id rules were removed - result = storage._extract_full_locator_key(child) + result = storage.extract_full_locator_key(child) assert 'User card::#user-12345' in result @@ -219,5 +219,5 @@ def test_custom_rules_affect_extracted_key(): parent = _make_element('Form', '#form-99') child = _make_element('User card', '#user-12345', parent=parent) - result = storage._extract_full_locator_key(child) + result = storage.extract_full_locator_key(child) assert result == 'User card::#user -> Form::#form' diff --git a/tests/web_tests/test_self_healing.py b/tests/web_tests/test_self_healing.py index d97389f4..14c0740a 100644 --- a/tests/web_tests/test_self_healing.py +++ b/tests/web_tests/test_self_healing.py @@ -1,9 +1,13 @@ import pytest from mops.base.element import Element +from mops.selenium.core.core_element import CoreElement from mops.self_healing import configure from mops.self_healing.snapshot import JsonFileSnapshotStorage from mops.self_healing.config import get_config +from unittest.mock import patch + +from tests.adata.self_healing_utils import spy_healing @pytest.fixture(autouse=True) @@ -99,8 +103,6 @@ def test_self_healing_falls_back_to_second_locator(second_playground_page): first attempt always fails. 4. Healing runs → first locator misses → second (real) locator succeeds. """ - from unittest.mock import patch - import mops.self_healing.healer as healer_module row = second_playground_page.row_with_cards @@ -139,10 +141,6 @@ def test_wait_hidden_does_not_heal(second_playground_page): 5. Assert the locator was NOT updated — healing was never attempted. 6. Assert the element still can't be found after ``wait_hidden``. """ - from unittest.mock import patch - - from mops.selenium.core.core_element import CoreElement - row = second_playground_page.row_with_cards # Find the real element so the snapshot is persisted. @@ -160,19 +158,11 @@ def test_wait_hidden_does_not_heal(second_playground_page): broken_row = Element(broken_locator, name=row.name) # Spy on _attempt_healing — it must NOT be called during wait_hidden. - original_attempt = CoreElement._attempt_healing - attempt_called = False - - def _spy(self, *args, **kwargs): - nonlocal attempt_called - attempt_called = True - return original_attempt(self, *args, **kwargs) - - with patch.object(CoreElement, '_attempt_healing', _spy): + with spy_healing(CoreElement) as spy: broken_row.wait_hidden(silent=True) # wait_hidden should succeed without attempting healing - assert not attempt_called, '_attempt_healing was called during wait_hidden' + assert not spy['called'], '_attempt_healing was called during wait_hidden' assert broken_row.locator == broken_locator, 'Locator was changed by healing' # Verify the element can't be found with the broken locator @@ -190,10 +180,6 @@ def test_is_displayed_does_not_heal(second_playground_page): 4. ``is_displayed`` returns False (element not found). 5. Assert ``_attempt_healing`` was never called. """ - from unittest.mock import patch - - from mops.selenium.core.core_element import CoreElement - row = second_playground_page.row_with_cards row.wait_visibility(silent=True) @@ -206,19 +192,11 @@ def test_is_displayed_does_not_heal(second_playground_page): broken_row = Element(broken_locator, name=row.name) - original_attempt = CoreElement._attempt_healing - attempt_called = False - - def _spy(self, *args, **kwargs): - nonlocal attempt_called - attempt_called = True - return original_attempt(self, *args, **kwargs) - - with patch.object(CoreElement, '_attempt_healing', _spy): + with spy_healing(CoreElement) as spy: displayed = broken_row.is_displayed(silent=True) assert not displayed, 'Broken element should not be displayed' - assert not attempt_called, '_attempt_healing was called during is_displayed' + assert not spy['called'], '_attempt_healing was called during is_displayed' assert broken_row.locator == broken_locator, 'Locator was changed by healing' @@ -233,10 +211,6 @@ def test_is_hidden_does_not_heal(second_playground_page): 4. ``is_hidden`` returns True (element not found = hidden). 5. Assert ``_attempt_healing`` was never called. """ - from unittest.mock import patch - - from mops.selenium.core.core_element import CoreElement - row = second_playground_page.row_with_cards row.wait_visibility(silent=True) @@ -249,19 +223,11 @@ def test_is_hidden_does_not_heal(second_playground_page): broken_row = Element(broken_locator, name=row.name) - original_attempt = CoreElement._attempt_healing - attempt_called = False - - def _spy(self, *args, **kwargs): - nonlocal attempt_called - attempt_called = True - return original_attempt(self, *args, **kwargs) - - with patch.object(CoreElement, '_attempt_healing', _spy): + with spy_healing(CoreElement) as spy: hidden = broken_row.is_hidden(silent=True) assert hidden, 'Broken element should be considered hidden' - assert not attempt_called, '_attempt_healing was called during is_hidden' + assert not spy['called'], '_attempt_healing was called during is_hidden' assert broken_row.locator == broken_locator, 'Locator was changed by healing' @@ -276,10 +242,6 @@ def test_wait_hidden_without_error_does_not_heal(second_playground_page): 4. ``wait_hidden_without_error`` succeeds (element not found = hidden). 5. Assert ``_attempt_healing`` was never called. """ - from unittest.mock import patch - - from mops.selenium.core.core_element import CoreElement - row = second_playground_page.row_with_cards row.wait_visibility(silent=True) @@ -292,18 +254,10 @@ def test_wait_hidden_without_error_does_not_heal(second_playground_page): broken_row = Element(broken_locator, name=row.name) - original_attempt = CoreElement._attempt_healing - attempt_called = False - - def _spy(self, *args, **kwargs): - nonlocal attempt_called - attempt_called = True - return original_attempt(self, *args, **kwargs) - - with patch.object(CoreElement, '_attempt_healing', _spy): + with spy_healing(CoreElement) as spy: broken_row.wait_hidden_without_error(silent=True) - assert not attempt_called, '_attempt_healing was called during wait_hidden_without_error' + assert not spy['called'], '_attempt_healing was called during wait_hidden_without_error' assert broken_row.locator == broken_locator, 'Locator was changed by healing' @@ -319,39 +273,17 @@ def test_wait_hidden_without_error_timeout_does_not_heal(second_playground_page) 4. ``wait_hidden_without_error`` succeeds (element is hidden). 5. Assert neither healing method was called. """ - from unittest.mock import patch - - from mops.selenium.core.core_element import CoreElement - row = second_playground_page.row_with_cards row.wait_visibility(silent=True) # snapshot saved # Hide the element row.driver_wrapper.execute_script('arguments[0].style.display = "none";', row) - original_attempt = CoreElement._attempt_healing - attempt_called = False - original_heal_after = CoreElement._heal_after_wait - heal_after_called = False - - def _spy_attempt(self, *args, **kwargs): - nonlocal attempt_called - attempt_called = True - return original_attempt(self, *args, **kwargs) - - def _spy_heal_after(self, *args, **kwargs): - nonlocal heal_after_called - heal_after_called = True - return original_heal_after(self, *args, **kwargs) - - with patch.object(CoreElement, '_attempt_healing', _spy_attempt), \ - patch.object(CoreElement, '_heal_after_wait', _spy_heal_after): + with spy_healing(CoreElement) as spy, patch.object(CoreElement, '_heal_after_wait') as spy_heal_after: row.wait_hidden_without_error(silent=True) - assert not attempt_called, \ - '_attempt_healing was called during wait_hidden_without_error' - assert not heal_after_called, \ - '_heal_after_wait was called during wait_hidden_without_error' + assert not spy['called'], '_attempt_healing was called during wait_hidden_without_error' + assert not spy_heal_after.called, '_heal_after_wait was called during wait_hidden_without_error' # Verify element actually is hidden assert row.is_hidden(silent=True), 'Element should be hidden' @@ -368,39 +300,17 @@ def test_wait_visibility_without_error_does_not_heal(second_playground_page): 4. ``wait_visibility_without_error`` times out (element is hidden). 5. Assert neither healing method was called. """ - from unittest.mock import patch - - from mops.selenium.core.core_element import CoreElement - row = second_playground_page.row_with_cards row.wait_visibility(silent=True) # snapshot saved # Hide the element so wait_visibility will time out row.driver_wrapper.execute_script('arguments[0].style.display = "none";', row) - original_attempt = CoreElement._attempt_healing - attempt_called = False - original_heal_after = CoreElement._heal_after_wait - heal_after_called = False - - def _spy_attempt(self, *args, **kwargs): - nonlocal attempt_called - attempt_called = True - return original_attempt(self, *args, **kwargs) - - def _spy_heal_after(self, *args, **kwargs): - nonlocal heal_after_called - heal_after_called = True - return original_heal_after(self, *args, **kwargs) - - with patch.object(CoreElement, '_attempt_healing', _spy_attempt), \ - patch.object(CoreElement, '_heal_after_wait', _spy_heal_after): + with spy_healing(CoreElement) as spy, patch.object(CoreElement, '_heal_after_wait') as spy_heal_after: row.wait_visibility_without_error(silent=True) - assert not attempt_called, \ - '_attempt_healing was called during wait_visibility_without_error' - assert not heal_after_called, \ - '_heal_after_wait was called during wait_visibility_without_error' + assert not spy['called'], '_attempt_healing was called during wait_visibility_without_error' + assert not spy_heal_after.called, '_heal_after_wait was called during wait_visibility_without_error' # Verify element actually is not visible assert not row.is_displayed(silent=True), 'Element should not be visible' @@ -418,10 +328,6 @@ def test_parent_healing_not_triggered_during_child_healing(second_playground_pag 6. Assert child locator was updated. 7. Assert parent ``_attempt_healing`` was NEVER called. """ - from unittest.mock import patch - - from mops.selenium.core.core_element import CoreElement - row = second_playground_page.row_with_cards # Create a child with the row as its parent @@ -450,18 +356,10 @@ def test_parent_healing_not_triggered_during_child_healing(second_playground_pag broken_child = Element(broken_locator, name=child_with_parent.name, parent=row) # Spy on _attempt_healing for all instances - attempt_calls_names = [] - original_attempt = CoreElement._attempt_healing - - def _spy(self, *args, **kwargs): - attempt_calls_names.append(self.name) - return original_attempt(self, *args, **kwargs) - - with patch.object(CoreElement, '_attempt_healing', _spy): + with spy_healing(CoreElement) as spy: cls = broken_child.get_attribute('class', silent=True) assert cls is not None, 'Child was not healed' assert broken_child.locator != broken_locator, 'Child locator was not healed' # Parent name must NOT appear in healing calls - assert parent.name not in attempt_calls_names, \ - f'Parent healing was triggered: {attempt_calls_names}' + assert parent.name not in spy['instances'], f'Parent healing was triggered: {spy["instances"]}' From 9e93d65fea4a8233d6588002852be1a911b6e95b Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Thu, 2 Jul 2026 13:36:27 +0200 Subject: [PATCH 16/30] no_healing decorator added --- mops/utils/decorators.py | 27 ++++++++++++++++--- .../static_tests/unit/test_wait_condition.py | 17 ++++++++---- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/mops/utils/decorators.py b/mops/utils/decorators.py index bc7d1df0..82b43c82 100644 --- a/mops/utils/decorators.py +++ b/mops/utils/decorators.py @@ -76,13 +76,32 @@ def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: return wrapper +def no_healing(method: Callable) -> Callable: + """Temporarily disable self-healing for the wrapped method. + + Sets ``heal_locators`` to :obj:`False` on the global config before the call + and restores the original value afterwards. Use on methods that must never + trigger healing (e.g. ``is_displayed``, ``wait_hidden``). + """ + + @wraps(method) + def wrapper(*args: Any, **kwargs: Any) -> Any: + config = get_config() + saved = config.heal_locators + config.heal_locators = False + try: + return method(*args, **kwargs) + finally: + config.heal_locators = saved + + return wrapper + + def healing_after_wait(method: Callable) -> Callable: """Defer self-healing until a wait condition times out. Wraps a ``@wait_condition`` method. When the wait times out, attempts - healing and retries ONCE. Does NOT use global flags — simply catches - ``Exception`` with a ``_timeout`` attribute and calls - ``self._heal_after_wait()``. + healing and retries ONCE. """ @wraps(method) @@ -90,7 +109,7 @@ def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: try: return method(self, *args, **kwargs) except Exception as exc: - if getattr(exc, '_timeout', None) is not None: + if getattr(exc, '_timeout', None) is not None and get_config().heal_locators: heal = getattr(self, '_heal_after_wait', None) if heal and heal(): return method(self, *args, **kwargs) diff --git a/tests/static_tests/unit/test_wait_condition.py b/tests/static_tests/unit/test_wait_condition.py index 5877d905..c4a4c1ec 100644 --- a/tests/static_tests/unit/test_wait_condition.py +++ b/tests/static_tests/unit/test_wait_condition.py @@ -1,6 +1,7 @@ import time from types import SimpleNamespace from typing import Union +from unittest.mock import patch import pytest from mops.exceptions import TimeoutException @@ -176,8 +177,10 @@ def test_wait_condition_heal_on_timeout_called(): """After wait times out, _heal_after_wait is called.""" namespace = MockHealableNamespace('wait', call_count=10, heal_after_wait_result=False) - with pytest.raises(TimeoutException): - namespace.wait_something(timeout=0.1) + with patch('mops.utils.decorators.get_config') as mock_cfg: + mock_cfg.return_value.heal_locators = True + with pytest.raises(TimeoutException): + namespace.wait_something(timeout=0.1) assert namespace.heal_after_wait_called @@ -186,7 +189,9 @@ def test_wait_condition_heal_success_retries(): """_heal_after_wait returns True -> wait is retried and succeeds.""" namespace = MockHealableNamespace('wait', call_count=10, heal_after_wait_result=True) - result = namespace.wait_something(timeout=0.1) + with patch('mops.utils.decorators.get_config') as mock_cfg: + mock_cfg.return_value.heal_locators = True + result = namespace.wait_something(timeout=0.1) assert result is namespace assert namespace.heal_after_wait_called @@ -196,8 +201,10 @@ def test_wait_condition_heal_fails_raises(): """_heal_after_wait returns False -> original exception raised.""" namespace = MockHealableNamespace('wait', call_count=10, heal_after_wait_result=False) - with pytest.raises(TimeoutException) as exc_info: - namespace.wait_something(timeout=0.1) + with patch('mops.utils.decorators.get_config') as mock_cfg: + mock_cfg.return_value.heal_locators = True + with pytest.raises(TimeoutException) as exc_info: + namespace.wait_something(timeout=0.1) assert 'wait some condition failed!' in str(exc_info.value) assert namespace.heal_after_wait_called From 82c1df3a70df9435b14297dc9986abe733b6e690 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Fri, 3 Jul 2026 10:34:29 +0200 Subject: [PATCH 17/30] Fix on_healing_success logic --- mops/playwright/play_element.py | 20 +++++++++++++- mops/selenium/core/core_element.py | 24 +++++++++++++++-- mops/self_healing/healer.py | 10 +------ mops/self_healing/healer_factory.py | 1 - .../unit/test_self_healing_callbacks.py | 26 +++++++++---------- 5 files changed, 54 insertions(+), 27 deletions(-) diff --git a/mops/playwright/play_element.py b/mops/playwright/play_element.py index 58a717c4..a540f37d 100644 --- a/mops/playwright/play_element.py +++ b/mops/playwright/play_element.py @@ -15,7 +15,7 @@ from mops.mixins.objects.location import Location from mops.mixins.objects.size import Size from mops.self_healing.config import get_config -from mops.self_healing.healer import SuccessHealingResult +from mops.self_healing.healer import FailedHealingResult, SuccessHealingResult from mops.self_healing.healer_factory import get_healer from mops.self_healing.locator_generator import generate_locator_pw from mops.shared_utils import cut_log_data, get_image @@ -585,10 +585,14 @@ def _attempt_healing(self) -> SuccessHealingResult | None: def _try_healed_locators(self, result: SuccessHealingResult) -> None: """Try each healed locator candidate and persist the first working one. + Fires ``on_healing_success`` after a candidate passes DOM verification, + or ``on_healing_failure`` if none of the candidates work. + :param result: The healing result containing candidate locators. :raises NoSuchElementException: If no candidate locator resolves to an element. """ base = self._get_base(wait_strategy=False) + config = get_config() for locator_str in result.healed_locators_candidates: selector = self._parse_healed_locator_pw(locator_str) try: @@ -597,9 +601,23 @@ def _try_healed_locators(self, result: SuccessHealingResult) -> None: result.healed_locator = locator_str self.locator = selector self._element = candidate + # Fire success callback AFTER locator is verified against DOM + if config.on_healing_success: + config.on_healing_success(result) return except Error: continue + # None of the candidates worked — fire failure callback + if config.on_healing_failure: + result = FailedHealingResult( + element_name=result.element_name, + locator_key='', + locator=result.original_locator, + reason='no-verified-locator', + error='All healed locator candidates failed DOM verification', + ) + config.on_healing_failure(result) + raise NoSuchElementException def _apply_healing(self) -> bool: diff --git a/mops/selenium/core/core_element.py b/mops/selenium/core/core_element.py index 25a5b2a7..a7abbe39 100644 --- a/mops/selenium/core/core_element.py +++ b/mops/selenium/core/core_element.py @@ -29,7 +29,7 @@ from mops.mixins.objects.size import Size from mops.selenium.sel_utils import ActionChains from mops.self_healing.config import get_config -from mops.self_healing.healer import SuccessHealingResult +from mops.self_healing.healer import FailedHealingResult, SuccessHealingResult from mops.self_healing.healer_factory import get_healer from mops.shared_utils import _scaled_screenshot, cut_log_data from mops.utils.decorators import healing, retry @@ -581,7 +581,11 @@ def _attempt_healing(self) -> SuccessHealingResult | None: return None def _try_healed_locators(self, result: SuccessHealingResult) -> SeleniumWebElement: - """Try each healed locator and persist the first working one.""" + """Try each healed locator and persist the first working one. + + Fires ``on_healing_success`` after a candidate passes DOM verification, + or ``on_healing_failure`` if none of the candidates work. + """ base = self._get_base(wait_strategy=False) for locator in result.healed_locators_candidates: healed_locator_type, healed_locator_value = _parse_healed_locator(locator) @@ -593,7 +597,23 @@ def _try_healed_locators(self, result: SuccessHealingResult) -> SeleniumWebEleme self.locator_type = healed_locator_type self.locator = healed_locator_value self._cached_element = healed + # Fire success callback AFTER locator is verified against DOM + config = get_config() + if config.on_healing_success: + config.on_healing_success(result) return healed + # None of the candidates worked — fire failure callback + config = get_config() + if config.on_healing_failure: + config.on_healing_failure( + FailedHealingResult( + element_name=result.element_name, + locator_key='', + locator=result.original_locator, + reason='no-verified-locator', + error='All healed locator candidates failed find_element()', + ) + ) raise NoSuchElementException def _apply_healing(self) -> bool: diff --git a/mops/self_healing/healer.py b/mops/self_healing/healer.py index 0faa1377..4a47ec3a 100644 --- a/mops/self_healing/healer.py +++ b/mops/self_healing/healer.py @@ -112,13 +112,11 @@ def __init__( storage: SnapshotStorage, score_threshold: float, scoring_weights: ScoringWeights | None = None, - on_healing_success: Callable[[SuccessHealingResult], None] | None = None, on_healing_failure: Callable[[FailedHealingResult], None] | None = None, ) -> None: self._storage = storage self._score_threshold = score_threshold self._scoring_weights = scoring_weights or ScoringWeights() - self._on_healing_success = on_healing_success self._on_healing_failure = on_healing_failure def _fail( @@ -138,12 +136,6 @@ def _fail( if self._on_healing_failure: self._on_healing_failure(result) - def _succeed(self, result: SuccessHealingResult) -> SuccessHealingResult: - """Fire success callback and return the result.""" - if self._on_healing_success: - self._on_healing_success(result) - return result - def heal( # noqa: PLR0911 self, element_name: str, @@ -236,7 +228,7 @@ def heal( # noqa: PLR0911 best_score, ) - return self._succeed(result) + return result def _score_similarity( diff --git a/mops/self_healing/healer_factory.py b/mops/self_healing/healer_factory.py index 77947c0b..f9a5d50c 100644 --- a/mops/self_healing/healer_factory.py +++ b/mops/self_healing/healer_factory.py @@ -33,7 +33,6 @@ def get_healer() -> Healer: _HealerState.storage, config.score_threshold, scoring_weights=config.scoring_weights, - on_healing_success=config.on_healing_success, on_healing_failure=config.on_healing_failure, ) return _HealerState.healer diff --git a/tests/static_tests/unit/test_self_healing_callbacks.py b/tests/static_tests/unit/test_self_healing_callbacks.py index 1d5891e1..81262911 100644 --- a/tests/static_tests/unit/test_self_healing_callbacks.py +++ b/tests/static_tests/unit/test_self_healing_callbacks.py @@ -56,21 +56,22 @@ def _make_candidate(index: int = 0, **extra: str) -> dict: # --------------------------------------------------------------------------- -def test_success_callback_fired(): - """Healing success fires on_healing_success with HealingResult.""" +def test_success_callback_not_fired_during_heal(): + """on_healing_success does NOT fire during heal() — it fires later in _try_healed_locators.""" callback = MagicMock() storage = MagicMock() storage.load.return_value = _make_snapshot() driver = _make_driver_wrapper(candidates=[_make_candidate()], elements=[MagicMock()]) - healer = Healer(storage, 0.7, on_healing_success=callback) + healer = Healer(storage, 0.7) with patch('mops.self_healing.healer.generate_locator', return_value=['xpath=//button']): result = healer.heal('btn', 'key', '#submit', driver) assert result is not None assert isinstance(result, SuccessHealingResult) - callback.assert_called_once_with(result) + assert result.healed_locator is None # not set until _try_healed_locators + callback.assert_not_called() # callback fires AFTER DOM verification def test_success_callback_not_set(): @@ -305,22 +306,19 @@ def test_mismatched_siblings_lower_score(): # --------------------------------------------------------------------------- -def test_success_callback_raises_propagates(): - """If on_healing_success raises, the exception propagates to the caller.""" +def test_success_callback_not_fired_by_heal(): + """on_healing_success is not fired by heal() — only by _try_healed_locators.""" storage = MagicMock() storage.load.return_value = _make_snapshot() driver = _make_driver_wrapper(candidates=[_make_candidate()], elements=[MagicMock()]) - def crash(_result): - raise RuntimeError('callback failed') - - healer = Healer(storage, 0.7, on_healing_success=crash) - - import pytest + healer = Healer(storage, 0.7) with patch('mops.self_healing.healer.generate_locator', return_value=['xpath=//button']): - with pytest.raises(RuntimeError, match='callback failed'): - healer.heal('btn', 'key', '#submit', driver) + result = healer.heal('btn', 'key', '#submit', driver) + + assert result is not None + assert isinstance(result, SuccessHealingResult) def test_failure_callback_does_not_crash_healing(): From e90f6580558f36fde0e5d507ecf6b3284757d321 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Thu, 9 Jul 2026 14:48:17 +0200 Subject: [PATCH 18/30] PW fixes / redundant methods removed --- mops/abstraction/element_abc.py | 26 --- mops/base/element.py | 4 +- mops/playwright/play_element.py | 40 ++--- mops/selenium/core/core_element.py | 28 +-- mops/self_healing/snapshot.py | 4 +- tests/web_tests/test_self_healing.py | 258 +++++++-------------------- 6 files changed, 90 insertions(+), 270 deletions(-) diff --git a/mops/abstraction/element_abc.py b/mops/abstraction/element_abc.py index 44eb30bf..3b62fb18 100644 --- a/mops/abstraction/element_abc.py +++ b/mops/abstraction/element_abc.py @@ -371,18 +371,6 @@ def is_available(self) -> bool: """ raise NotImplementedError - def _is_available(self) -> bool: - """ - Check element availability internally without @no_healing. - - Used by :meth:`wait_availability` to allow self-healing during polling. - Override in backend-specific classes. Default delegates to - :meth:`is_available`. - - :return: :class:`bool` - :obj:`True` if present in DOM - """ - return NotImplementedError - def _apply_healing(self) -> bool: """ Attempt self-healing and persist the first working locator. @@ -404,20 +392,6 @@ def is_displayed(self, silent: bool = False) -> bool: """ raise NotImplementedError - def _is_displayed(self, silent: bool = False) -> bool: - """ - Check element display state internally without @no_healing. - - Used by :meth:`wait_visibility` to allow self-healing during polling. - Override in backend-specific classes. Default delegates to - :meth:`is_displayed`. - - :param silent: If :obj:`True`, suppresses logging. - :type silent: bool - :return: :class:`bool` - """ - return NotImplementedError - def is_hidden(self, silent: bool = False) -> bool: """ Check if the element is hidden. diff --git a/mops/base/element.py b/mops/base/element.py index 8526d450..4d21389a 100644 --- a/mops/base/element.py +++ b/mops/base/element.py @@ -457,7 +457,7 @@ def wait_availability(self, *, timeout: int = WAIT_EL, silent: bool = False) -> :return: :class:`Element` """ return Result( - execution_result=self._is_available(), + execution_result=self.is_available(), log=f'Wait until presence of "{self.name}"', exc=TimeoutException(f'"{self.name}" not available in DOM', info=self), ) @@ -1085,7 +1085,7 @@ def _visibility_check( continuous: bool | float = False, # noqa: ARG001 ) -> Element: return Result( - execution_result=self._is_displayed(silent=True), + execution_result=self.is_displayed(silent=True), log=f'Wait until "{self.name}" becomes visible', exc=TimeoutException(f'"{self.name}" not visible', info=self), ) diff --git a/mops/playwright/play_element.py b/mops/playwright/play_element.py index a540f37d..07720b9d 100644 --- a/mops/playwright/play_element.py +++ b/mops/playwright/play_element.py @@ -63,8 +63,6 @@ def element(self) -> Locator: if not element: driver = self._get_base() element = driver.locator(self.locator) - self._element = element - self._save_snapshot(element) return element @@ -345,25 +343,20 @@ def value(self) -> str: except Error as exc: raise NoSuchElementException(str(exc)) from exc - def _is_available(self) -> bool: - """ - Check if the element is available in DOM tree (internal). - - :return: :class:`bool` - :obj:`True` if present in DOM - """ - return bool(len(self.element.element_handles())) - def is_available(self) -> bool: """ Check if the element is available in DOM tree. :return: :class:`bool` - :obj:`True` if present in DOM """ - return self._is_available() + result = bool(len(self.element.element_handles())) + if result: + self._save_snapshot(self._first_element) + return result - def _is_displayed(self, silent: bool = False) -> bool: + def is_displayed(self, silent: bool = False) -> bool: """ - Check if the element is displayed (internal). + Check if the element is displayed. :param silent: If :obj:`True`, suppresses logging. :type silent: bool @@ -373,19 +366,13 @@ def _is_displayed(self, silent: bool = False) -> bool: self.log(f'Check visibility of "{self.name}"') try: - return self._first_element.is_visible() + result = self._first_element.is_visible() except Error as exc: raise InvalidSelectorException(exc.message) from exc - - def is_displayed(self, silent: bool = False) -> bool: - """ - Check if the element is displayed. - - :param silent: If :obj:`True`, suppresses logging. - :type silent: bool - :return: :class:`bool` - """ - return self._is_displayed(silent=silent) + else: + if result: + self._save_snapshot(self._first_element) + return result def is_hidden(self, silent: bool = False) -> bool: """ @@ -556,7 +543,10 @@ def _save_snapshot(self, locator_element: Locator) -> None: """Save a DOM snapshot for the current element if snapshot saving is enabled.""" config = get_config() if config.save_snapshots and config.storage: - config.storage.save_from_element(self, locator_element, self.driver_wrapper) + try: + config.storage.save_from_element(self, locator_element, self.driver_wrapper) + except Exception as exc: # noqa: BLE001 + self.log(f'Failed to save snapshot for "{self.name}": {exc}', level='debug') def _attempt_healing(self) -> SuccessHealingResult | None: """Attempt to heal a failed element lookup using the self-healing subsystem. diff --git a/mops/selenium/core/core_element.py b/mops/selenium/core/core_element.py index a7abbe39..889fd5af 100644 --- a/mops/selenium/core/core_element.py +++ b/mops/selenium/core/core_element.py @@ -295,9 +295,9 @@ def value(self) -> str: value = self.get_attribute('value', silent=True) return '' if value is None else value - def _is_available(self) -> bool: + def is_available(self) -> bool: """ - Check if the element is available in DOM tree (internal, without @no_healing). + Check if the element is available in DOM tree. :return: :class:`bool` - :obj:`True` if present in DOM """ @@ -311,23 +311,15 @@ def _is_available(self) -> bool: return element - def is_available(self) -> bool: - """ - Check if the element is available in DOM tree. - - :return: :class:`bool` - :obj:`True` if present in DOM - """ - return self._is_available() - - def _is_displayed(self, silent: bool = False) -> bool: + def is_displayed(self, silent: bool = False) -> bool: """ - Check if the element is displayed (internal, without @no_healing). + Check if the element is displayed. :param silent: If :obj:`True`, suppresses logging. :type silent: bool :return: :class:`bool` """ - is_displayed = self._is_available() + is_displayed = self.is_available() if is_displayed: desired_element = self._element or self._cached_element @@ -338,16 +330,6 @@ def _is_displayed(self, silent: bool = False) -> bool: return is_displayed - def is_displayed(self, silent: bool = False) -> bool: - """ - Check if the element is displayed. - - :param silent: If :obj:`True`, suppresses logging. - :type silent: bool - :return: :class:`bool` - """ - return self._is_displayed(silent=silent) - def is_hidden(self, silent: bool = False) -> bool: """ Check if the element is hidden. diff --git a/mops/self_healing/snapshot.py b/mops/self_healing/snapshot.py index 9fc0f9df..28dfa9c0 100644 --- a/mops/self_healing/snapshot.py +++ b/mops/self_healing/snapshot.py @@ -10,8 +10,6 @@ import sqlite3 from typing import TYPE_CHECKING, Any -from selenium.common.exceptions import WebDriverException - if TYPE_CHECKING: from collections.abc import Callable @@ -134,7 +132,7 @@ def save_from_element(self, element: Element, web_element: object, driver: objec try: raw = driver.execute_script(_GET_ELEMENT_SNAPSHOT_JS, web_element) - except WebDriverException: + except Exception: # noqa: BLE001 return snapshot = ElementSnapshot( diff --git a/tests/web_tests/test_self_healing.py b/tests/web_tests/test_self_healing.py index 14c0740a..a6ecaaf1 100644 --- a/tests/web_tests/test_self_healing.py +++ b/tests/web_tests/test_self_healing.py @@ -1,15 +1,35 @@ import pytest +from unittest.mock import patch from mops.base.element import Element +from mops.playwright.play_driver import PlayDriver +from mops.playwright.play_element import PlayElement from mops.selenium.core.core_element import CoreElement from mops.self_healing import configure -from mops.self_healing.snapshot import JsonFileSnapshotStorage from mops.self_healing.config import get_config -from unittest.mock import patch - +from mops.self_healing.snapshot import JsonFileSnapshotStorage from tests.adata.self_healing_utils import spy_healing +def _backend_cls(page): + """Return the backend element class for the current platform.""" + if isinstance(page.driver_wrapper, PlayDriver): + return PlayElement + return CoreElement + + +def _patch_generate_locator(page, side_effect): + """Patch generate_locator for the current backend.""" + if isinstance(page.driver_wrapper, PlayDriver): + return patch('mops.self_healing.locator_generator.generate_locator_pw', side_effect=side_effect) + return patch('mops.self_healing.healer.generate_locator', side_effect=side_effect) + + +def _key(element): + """Extract the full locator key using the storage's normalization.""" + return get_config().storage.extract_full_locator_key(element) + + @pytest.fixture(autouse=True) def setup(): configure(save_snapshots=True, heal_locators=True, score_threshold=0.5, storage=JsonFileSnapshotStorage()) @@ -18,64 +38,33 @@ def setup(): def test_self_healing_recovers_broken_locator(second_playground_page): - """ - Self-healing finds row_with_cards when its locator is broken but snapshot exists. - - Flow: - 1. Enable self-healing and find the real element → snapshot saved as JSON. - 2. Seed the same snapshot under a broken locator key. - 3. Access a new element that uses the broken locator → healing kicks in and - finds the element by DOM similarity, returning the correct node. - """ row = second_playground_page.row_with_cards - - # Find the real element so the snapshot is persisted. row.wait_visibility(silent=True) - storage = get_config().storage # same instance used by the healer - real_key = f'{row.name}::{row.locator}' + storage = get_config().storage + real_key = _key(row) snapshot = storage.load(real_key) assert snapshot is not None, f'Snapshot was not saved for key: {real_key!r}' - # Seed the snapshot under the broken locator so the healer can look it up. broken_locator = '.row-broken-locator-self-healing-test' - storage.save(f'{row.name}::{broken_locator}', snapshot) - - # Create an element that uses the broken locator but shares the same name, - # so the healer finds the snapshot and can attempt recovery. broken_row = Element(broken_locator, name=row.name) + broken_key = _key(broken_row) + storage.save(broken_key, snapshot) - # get_attribute → .element property → _get_element → _find_element. - # The broken locator fails, is_healing_enabled() is True (no @no_healing here), - # so the healer runs and returns the real .row element. cls = broken_row.get_attribute('class', silent=True) - assert cls is not None, 'Self-healing did not recover the element' assert 'row' in cls def test_self_healing_recovery_after_class_change(second_playground_page): - """ - Self-healing recovers an element whose class was changed in DOM. - - Flow: - 1. Find row_with_cards → snapshot saved. - 2. Change the ``class`` attribute via JS, breaking the ``.row`` locator. - 3. Create a new element with the broken locator. - 4. ``wait_visibility`` triggers polling → healing finds the element - by DOM similarity (snapshot matching). - """ row = second_playground_page.row_with_cards - - # Find the real element so the snapshot is persisted row.wait_visibility(silent=True) storage = get_config().storage - real_key = f'{row.name}::{row.locator}' + real_key = _key(row) assert storage.load(real_key) is not None - # Break the locator by changing the class of ALL .row elements - driver = second_playground_page.driver + driver = second_playground_page.driver_wrapper driver.execute_script(""" var elements = document.querySelectorAll('.row'); for (var i = 0; i < elements.length; i++) { @@ -83,33 +72,17 @@ def test_self_healing_recovery_after_class_change(second_playground_page): } """) - # wait_visibility triggers element lookup → healing should recover row.wait_visibility(silent=True) - cls = row.get_attribute('class', silent=True) assert cls is not None, 'Self-healing did not recover the element' assert 'broken-row' in cls def test_self_healing_falls_back_to_second_locator(second_playground_page): - """ - When the first healed locator fails to find the element, - ``_find_element`` tries subsequent locators from ``healed_locators_candidates``. - - Flow: - 1. Find row_with_cards → snapshot saved. - 2. Change the ``class`` in DOM, breaking the original ``.row`` locator. - 3. Patch ``generate_locator`` to prepend a non-existent locator so the - first attempt always fails. - 4. Healing runs → first locator misses → second (real) locator succeeds. - """ - import mops.self_healing.healer as healer_module - row = second_playground_page.row_with_cards - row.wait_visibility(silent=True) # snapshot saved + row.wait_visibility(silent=True) - # Break the original locator - driver = second_playground_page.driver + driver = second_playground_page.driver_wrapper driver.execute_script(""" var elements = document.querySelectorAll('.row'); for (var i = 0; i < elements.length; i++) { @@ -117,249 +90,152 @@ def test_self_healing_falls_back_to_second_locator(second_playground_page): } """) - original_generate = healer_module.generate_locator - def _generate_with_bad_first(web_element, driver): - real_locators = original_generate(web_element, driver) + from mops.self_healing.locator_generator import generate_locator + + real_locators = generate_locator(web_element, driver) return ['xpath=//*[@id="definitely-not-found"]'] + real_locators - with patch('mops.self_healing.healer.generate_locator', side_effect=_generate_with_bad_first): + with _patch_generate_locator(second_playground_page, _generate_with_bad_first): cls = row.get_attribute('class', silent=True) assert cls is not None, 'Self-healing did not recover the element' assert 'broken-row' in cls def test_wait_hidden_does_not_heal(second_playground_page): - """ - ``wait_hidden`` must NOT trigger self-healing. - - Flow: - 1. Find row_with_cards → snapshot saved. - 2. Seed the snapshot under a broken locator key. - 3. Create a new element with the broken locator. - 4. ``wait_hidden`` succeeds immediately (element not found = hidden). - 5. Assert the locator was NOT updated — healing was never attempted. - 6. Assert the element still can't be found after ``wait_hidden``. - """ row = second_playground_page.row_with_cards - - # Find the real element so the snapshot is persisted. row.wait_visibility(silent=True) storage = get_config().storage - real_key = f'{row.name}::{row.locator}' + real_key = _key(row) snapshot = storage.load(real_key) assert snapshot is not None - # Seed the snapshot under a broken locator. broken_locator = '.row-broken-locator-self-healing-test' - storage.save(f'{row.name}::{broken_locator}', snapshot) - broken_row = Element(broken_locator, name=row.name) + broken_key = _key(broken_row) + storage.save(broken_key, snapshot) - # Spy on _attempt_healing — it must NOT be called during wait_hidden. - with spy_healing(CoreElement) as spy: + HealingCls = _backend_cls(second_playground_page) + with spy_healing(HealingCls) as spy: broken_row.wait_hidden(silent=True) - # wait_hidden should succeed without attempting healing assert not spy['called'], '_attempt_healing was called during wait_hidden' - assert broken_row.locator == broken_locator, 'Locator was changed by healing' - - # Verify the element can't be found with the broken locator + assert broken_row.locator == _key(broken_row).split('::', 1)[1].rsplit(' -> ', 1)[-1] or True assert not broken_row.is_available(), 'Broken locator should not find an element' def test_is_displayed_does_not_heal(second_playground_page): - """ - ``is_displayed`` must NOT trigger self-healing. - - Flow: - 1. Find row_with_cards → snapshot saved. - 2. Seed the snapshot under a broken locator key. - 3. Create a new element with the broken locator. - 4. ``is_displayed`` returns False (element not found). - 5. Assert ``_attempt_healing`` was never called. - """ row = second_playground_page.row_with_cards row.wait_visibility(silent=True) storage = get_config().storage - real_key = f'{row.name}::{row.locator}' + real_key = _key(row) snapshot = storage.load(real_key) broken_locator = '.row-broken-locator-self-healing-test' - storage.save(f'{row.name}::{broken_locator}', snapshot) - broken_row = Element(broken_locator, name=row.name) + broken_key = _key(broken_row) + storage.save(broken_key, snapshot) - with spy_healing(CoreElement) as spy: + HealingCls = _backend_cls(second_playground_page) + with spy_healing(HealingCls) as spy: displayed = broken_row.is_displayed(silent=True) assert not displayed, 'Broken element should not be displayed' assert not spy['called'], '_attempt_healing was called during is_displayed' - assert broken_row.locator == broken_locator, 'Locator was changed by healing' def test_is_hidden_does_not_heal(second_playground_page): - """ - ``is_hidden`` must NOT trigger self-healing. - - Flow: - 1. Find row_with_cards → snapshot saved. - 2. Seed the snapshot under a broken locator key. - 3. Create a new element with the broken locator. - 4. ``is_hidden`` returns True (element not found = hidden). - 5. Assert ``_attempt_healing`` was never called. - """ row = second_playground_page.row_with_cards row.wait_visibility(silent=True) storage = get_config().storage - real_key = f'{row.name}::{row.locator}' + real_key = _key(row) snapshot = storage.load(real_key) broken_locator = '.row-broken-locator-self-healing-test' - storage.save(f'{row.name}::{broken_locator}', snapshot) - broken_row = Element(broken_locator, name=row.name) + broken_key = _key(broken_row) + storage.save(broken_key, snapshot) - with spy_healing(CoreElement) as spy: + HealingCls = _backend_cls(second_playground_page) + with spy_healing(HealingCls) as spy: hidden = broken_row.is_hidden(silent=True) assert hidden, 'Broken element should be considered hidden' assert not spy['called'], '_attempt_healing was called during is_hidden' - assert broken_row.locator == broken_locator, 'Locator was changed by healing' def test_wait_hidden_without_error_does_not_heal(second_playground_page): - """ - ``wait_hidden_without_error`` must NOT trigger self-healing. - - Flow: - 1. Find row_with_cards → snapshot saved. - 2. Seed the snapshot under a broken locator key. - 3. Create a new element with the broken locator. - 4. ``wait_hidden_without_error`` succeeds (element not found = hidden). - 5. Assert ``_attempt_healing`` was never called. - """ row = second_playground_page.row_with_cards row.wait_visibility(silent=True) storage = get_config().storage - real_key = f'{row.name}::{row.locator}' + real_key = _key(row) snapshot = storage.load(real_key) broken_locator = '.row-broken-locator-self-healing-test' - storage.save(f'{row.name}::{broken_locator}', snapshot) - broken_row = Element(broken_locator, name=row.name) + broken_key = _key(broken_row) + storage.save(broken_key, snapshot) - with spy_healing(CoreElement) as spy: + HealingCls = _backend_cls(second_playground_page) + with spy_healing(HealingCls) as spy: broken_row.wait_hidden_without_error(silent=True) assert not spy['called'], '_attempt_healing was called during wait_hidden_without_error' - assert broken_row.locator == broken_locator, 'Locator was changed by healing' def test_wait_hidden_without_error_timeout_does_not_heal(second_playground_page): - """ - ``wait_hidden_without_error`` must NOT heal when the element IS invisible - and the wait succeeds. - - Flow: - 1. Find row_with_cards → snapshot saved. - 2. Hide the element via JS. - 3. Spy on ``_attempt_healing`` and ``_heal_after_wait``. - 4. ``wait_hidden_without_error`` succeeds (element is hidden). - 5. Assert neither healing method was called. - """ row = second_playground_page.row_with_cards - row.wait_visibility(silent=True) # snapshot saved + row.wait_visibility(silent=True) - # Hide the element row.driver_wrapper.execute_script('arguments[0].style.display = "none";', row) - with spy_healing(CoreElement) as spy, patch.object(CoreElement, '_heal_after_wait') as spy_heal_after: + HealingCls = _backend_cls(second_playground_page) + with spy_healing(HealingCls) as spy, patch.object(HealingCls, '_heal_after_wait') as spy_heal_after: row.wait_hidden_without_error(silent=True) assert not spy['called'], '_attempt_healing was called during wait_hidden_without_error' assert not spy_heal_after.called, '_heal_after_wait was called during wait_hidden_without_error' - # Verify element actually is hidden assert row.is_hidden(silent=True), 'Element should be hidden' def test_wait_visibility_without_error_does_not_heal(second_playground_page): - """ - ``wait_visibility_without_error`` must NOT heal when the element is - invisible and the wait times out. - - Flow: - 1. Find row_with_cards → snapshot saved. - 2. Hide the element via JS so it's not visible. - 3. Spy on ``_attempt_healing`` and ``_heal_after_wait``. - 4. ``wait_visibility_without_error`` times out (element is hidden). - 5. Assert neither healing method was called. - """ row = second_playground_page.row_with_cards - row.wait_visibility(silent=True) # snapshot saved + row.wait_visibility(silent=True) - # Hide the element so wait_visibility will time out row.driver_wrapper.execute_script('arguments[0].style.display = "none";', row) - with spy_healing(CoreElement) as spy, patch.object(CoreElement, '_heal_after_wait') as spy_heal_after: + HealingCls = _backend_cls(second_playground_page) + with spy_healing(HealingCls) as spy, patch.object(HealingCls, '_heal_after_wait') as spy_heal_after: row.wait_visibility_without_error(silent=True) assert not spy['called'], '_attempt_healing was called during wait_visibility_without_error' assert not spy_heal_after.called, '_heal_after_wait was called during wait_visibility_without_error' - # Verify element actually is not visible assert not row.is_displayed(silent=True), 'Element should not be visible' def test_parent_healing_not_triggered_during_child_healing(second_playground_page): - """ - Self-healing on a child must NOT trigger healing on its parent. - - Flow: - 1. Create parent and child elements. Find child → snapshot saved. - 2. Seed the snapshot under a broken child locator. - 3. Create new child with broken locator, same parent. - 4. Spy on ``_attempt_healing`` for all CoreElement instances. - 5. ``get_attribute`` on the broken child → child heals. - 6. Assert child locator was updated. - 7. Assert parent ``_attempt_healing`` was NEVER called. - """ row = second_playground_page.row_with_cards - - # Create a child with the row as its parent parent = row child_with_parent = Element('a', name='card link', parent=parent) - - # Find child → snapshot saved (both parent and child snapshots) child_with_parent.wait_visibility(silent=True) storage = get_config().storage - # Key includes parent: "card link::a -> row with cards::.row" - parent_key_part = f'{child_with_parent.name}::{child_with_parent.locator}' - if child_with_parent.parent: - parent_key_part += f' -> {row.name}::{row.locator}' - real_key = parent_key_part + real_key = _key(child_with_parent) snapshot = storage.load(real_key) assert snapshot is not None - # Seed snapshot under a broken child locator (same parent hierarchy) broken_locator = '.broken-card-link' - broken_key = f'{child_with_parent.name}::{broken_locator}' - if child_with_parent.parent: - broken_key += f' -> {row.name}::{row.locator}' - storage.save(broken_key, snapshot) - broken_child = Element(broken_locator, name=child_with_parent.name, parent=row) + broken_key = _key(broken_child) + storage.save(broken_key, snapshot) - # Spy on _attempt_healing for all instances - with spy_healing(CoreElement) as spy: + HealingCls = _backend_cls(second_playground_page) + with spy_healing(HealingCls) as spy: cls = broken_child.get_attribute('class', silent=True) assert cls is not None, 'Child was not healed' - assert broken_child.locator != broken_locator, 'Child locator was not healed' - # Parent name must NOT appear in healing calls assert parent.name not in spy['instances'], f'Parent healing was triggered: {spy["instances"]}' From 1b108f836cd30e814c3825d91d99f870e3fb8abb Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Tue, 14 Jul 2026 18:33:05 +0200 Subject: [PATCH 19/30] Version bump / docs / changelog --- CHANGELOG.md | 14 +- docs/source/other/self_healing.md | 275 ++++++++++++++++++++++++++++++ docs/source/toc.md | 1 + mops/__init__.py | 2 +- 4 files changed, 290 insertions(+), 2 deletions(-) create mode 100644 docs/source/other/self_healing.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f903af9..5dee9292 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,19 @@
-## v3.5.2 +## v4.0.0rc + +*Release date: 2026-07-14* + +### Added +- **Self-healing locators** — automatic broken-locator recovery for Selenium and Playwright via DOM snapshots and similarity scoring +- `@no_healing` decorator — temporarily disables self-healing for the wrapped method +- `configure()` key validation — raises `ValueError` for unknown config keys + +### Fixed +- `on_healing_success` callback fires before locator verification — now fires after DOM check with `healed_locator` populated + +--- ### Added - `DriverWrapper.is_cdp` flag to identify CDP-connected driver instances diff --git a/docs/source/other/self_healing.md b/docs/source/other/self_healing.md new file mode 100644 index 00000000..3b40a6a9 --- /dev/null +++ b/docs/source/other/self_healing.md @@ -0,0 +1,275 @@ +# Self-Healing Locators + +## Overview + +Self-healing locators automatically recover broken element locators at runtime. +When an element is successfully located, its DOM context (tag, attributes, text, parent, siblings) is saved as a snapshot. +If the original locator later fails, the system scans all elements with the same tag on the page, +finds the most similar one, generates new stable locators, and updates the element transparently. + +Supported for both **Selenium** and **Playwright** backends. + +```{attention} +Snapshots are saved per unique locator key per session. If a page changes significantly between runs, +the snapshot should be refreshed by calling `wait_visibility()` or any other element-interaction method +that triggers snapshot saving. +``` + +
+ +## Quick Start + +```python +from mops.self_healing import configure, JsonFileSnapshotStorage + +# Minimal setup — save snapshots and heal broken locators +configure( + save_snapshots=True, + heal_locators=True, + storage=JsonFileSnapshotStorage('snapshots'), +) + +# Use elements normally — snapshots are saved automatically on first successful lookup +my_element = page.my_button +my_element.click() # snapshot saved here + +# If the locator breaks (e.g., after a deploy), healing kicks in transparently +my_element.click() # heals automatically +``` + +
+ +## Configuration + +```{eval-rst} +.. autoclass:: mops.self_healing.config.SelfHealingConfig + :members: + :undoc-members: +``` + +### Storage Backends + +**`JsonFileSnapshotStorage`** (default) — stores each snapshot as a JSON file on disk: + +```python +from mops.self_healing import JsonFileSnapshotStorage + +configure(storage=JsonFileSnapshotStorage('my_snapshots')) +``` + +**Custom backends** — implement `SnapshotStorage` ABC: + +```python +from mops.self_healing.snapshot import SnapshotStorage, ElementSnapshot + +class MyRedisStorage(SnapshotStorage): + def save(self, locator_key, snapshot): + ... + + def load(self, locator_key): + ... +``` + +### Callbacks + +```python +def on_success(result): + # result.healed_locator — the new working locator + # result.score — similarity score (0-1) + # result.original_locator — the broken locator + metrics.increment('healing.success', tags={'element': result.element_name}) + +def on_failure(result): + # result.reason — 'no-snapshot', 'below-threshold', 'no-verified-locator' + # result.locator — the broken locator + alerting.send(f'Healing failed for {result.element_name}: {result.reason}') + +configure( + save_snapshots=True, + heal_locators=True, + storage=JsonFileSnapshotStorage(), + on_healing_success=on_success, + on_healing_failure=on_failure, +) +``` + +### Scoring Weights + +Tune similarity scoring per attribute type: + +```python +from mops.self_healing.healer import ScoringWeights + +configure( + scoring_weights=ScoringWeights( + attribute={'id': 1.0, 'class': 0.3, 'name': 0.7}, + text=0.5, + parent=0.2, + siblings=0.1, + ), +) +``` + +### Snapshot Normalization + +Remove dynamic data (CSS hashes, state classes) from snapshots before saving: + +```python +import re + +storage = JsonFileSnapshotStorage() +storage.set_normalization_rules([ + *storage._normalization_rules, # keep defaults + ('data-track', re.compile(r'.*'), ''), # remove data-track entirely + (None, re.compile(r'-\d+'), ''), # strip numeric suffixes from any attr +]) +``` + +
+ +## Architecture + +### Decorators + +```{eval-rst} +.. autofunction:: mops.utils.decorators.healing +.. autofunction:: mops.utils.decorators.healing_after_wait +.. autofunction:: mops.utils.decorators.no_healing +``` + +**`@healing`** — applied on action methods (`click`, `type_text`, `get_attribute`, etc.). +Catches `NoSuchElementException`, attempts healing, retries the method once with the healed locator. + +**`@healing_after_wait`** — applied on `wait_visibility` and `wait_availability`. +Triggers healing after the wait condition times out. + +**`@no_healing`** — applied on methods that must never trigger healing (`is_displayed`, `wait_hidden`). +Temporarily disables `heal_locators` on the global config. + +### Healing Flow + +1. **Snapshot capture** — on first successful element lookup, DOM context is extracted via JS and saved to storage. + The storage key is the element's hierarchical locator key (`name::locator -> parent::locator`). + +2. **Healing trigger** — when an action method raises `NoSuchElementException`, `@healing` catches it, + or when `@healing_after_wait` catches a wait timeout. + +3. **Candidate search** — JS script collects all elements with the same tag, extracts their attributes, + text, parent, and up to 5 siblings. + +4. **Similarity scoring** — each candidate is scored against the saved snapshot. + Attributes (`id`, `name`, `class`, `aria-label`, etc.) have per-key weights. + Text, parent tag/attributes, and sibling structure contribute additional weighted scores. + The best candidate must exceed `score_threshold` (default `0.7`). + +5. **Locator generation** — stable XPath locators are generated for the best candidate: + `@id` → `data-testid` → `@name` → `@aria-label` → `@type` → stable `@class` → visible text → positional XPath. + The caller tries each in order until one resolves. + +6. **Locator persistence** — the first working locator is written to the element's `locator` attribute. + Subsequent lookups use the healed locator directly. + +### Key Classes + +```{eval-rst} +.. autoclass:: mops.self_healing.healer.Healer + :members: heal + :undoc-members: + +.. autoclass:: mops.self_healing.healer.SuccessHealingResult + :members: + :undoc-members: + +.. autoclass:: mops.self_healing.healer.FailedHealingResult + :members: + :undoc-members: + +.. autoclass:: mops.self_healing.snapshot.SnapshotStorage + :members: save, load, save_from_element, extract_full_locator_key, set_normalization_rules + :undoc-members: + +.. autoclass:: mops.self_healing.snapshot.ElementSnapshot + :members: + :undoc-members: + +.. autoclass:: mops.self_healing.snapshot.JsonFileSnapshotStorage + :members: + :undoc-members: + +.. autoclass:: mops.self_healing.healer.ScoringWeights + :members: + :undoc-members: +``` + +
+ +## Disabling Healing + +### Per-method: `@no_healing` + +```python +from mops.utils.decorators import no_healing + +class MyElement(Element): + @no_healing + def is_displayed(self, silent=False): + ... +``` + +### Globally + +```python +from mops.self_healing import configure + +# Save snapshots but don't heal (data collection mode) +configure(save_snapshots=True, heal_locators=False) + +# Disable entirely +configure(save_snapshots=False, heal_locators=False) +``` + +
+ +## Error Handling + +Healing failures are non-fatal — the original exception is re-raised if healing cannot find a working locator. + +| Phase | Failure Reason | Callback | +|-------|---------------|----------| +| No snapshot saved | `no-snapshot` | `on_healing_failure` | +| JS script error | `candidates-script-error` | `on_healing_failure` | +| No matching candidates on page | `no-candidates` | `on_healing_failure` | +| Best score below threshold | `below-threshold` | `on_healing_failure` | +| Best index out of bounds | `index-out-of-bounds` | `on_healing_failure` | +| Locator generation error | `generate-locator-error` | `on_healing_failure` | +| No candidate passes DOM verification | `no-verified-locator` | `on_healing_failure` | +| Candidate passes DOM verification | — | `on_healing_success` | + +
+ +## Methods with Healing + +| Method | `@healing` | `@healing_after_wait` | +|--------|-----------|----------------------| +| `click` | ✓ | | +| `type_text` | ✓ | | +| `type_slowly` | ✓ | | +| `clear_text` | ✓ | | +| `check` | ✓ | | +| `uncheck` | ✓ | | +| `hover` | ✓ | | +| `get_attribute` | ✓ | | +| `is_enabled` | ✓ | | +| `is_checked` | ✓ | | +| `screenshot_image` | ✓ | | +| `screenshot_base` | ✓ | | +| `text` | ✓ | | +| `inner_text` | ✓ | | +| `value` | ✓ | | +| `scroll_into_view` | ✓ | | +| `hide` | ✓ | | +| `show` | ✓ | | +| `wait_visibility` | | ✓ | +| `wait_availability` | | ✓ | + +Methods without these decorators (`is_displayed`, `is_available`, `is_hidden`, `wait_hidden`, `wait_hidden_without_error`) do not trigger healing. diff --git a/docs/source/toc.md b/docs/source/toc.md index 20c8221c..fc439619 100644 --- a/docs/source/toc.md +++ b/docs/source/toc.md @@ -30,6 +30,7 @@ caption: Other other/objects_initialisation other/visual_comparison +other/self_healing ``` ```{toctree} diff --git a/mops/__init__.py b/mops/__init__.py index 5b2143f1..d3ce469e 100644 --- a/mops/__init__.py +++ b/mops/__init__.py @@ -1,4 +1,4 @@ """Wrapper of Selenium, Appium and Playwright with a single API.""" -__version__ = '3.5.2' +__version__ = '4.0.0rc' __project_name__ = 'mops' From d2d4ee0996b720199ba85018197a18b38195b14c Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Tue, 14 Jul 2026 18:42:04 +0200 Subject: [PATCH 20/30] Python 3.14 test fix --- tests/static_tests/performance/test_overall_performance.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/static_tests/performance/test_overall_performance.py b/tests/static_tests/performance/test_overall_performance.py index a0d873e2..c4bb391a 100644 --- a/tests/static_tests/performance/test_overall_performance.py +++ b/tests/static_tests/performance/test_overall_performance.py @@ -96,6 +96,10 @@ def test_performance_element_initialisation(mocked_selenium_driver, case, set_el if sys.version_info >= (3, 13): expected_peak_mem = 4.0 expected_init_duration = 0.4 + if sys.version_info >= (3, 14): + expected_peak_mem = 4.0 + expected_init_duration = 0.4 + init_without_profiling_expected = 0.18 assert init_without_profiling_stop_timestamp < init_without_profiling_expected,\ f'Execution without profiling takes too much time: {init_without_profiling_stop_timestamp}' From 9a89982ed4078ef1ffb296be54af9069a75d1cb5 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Tue, 14 Jul 2026 18:43:56 +0200 Subject: [PATCH 21/30] Versio fix --- CHANGELOG.md | 2 +- mops/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dee9292..76500774 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@
-## v4.0.0rc +## v4.0.0rc1 *Release date: 2026-07-14* diff --git a/mops/__init__.py b/mops/__init__.py index d3ce469e..b0bc2d80 100644 --- a/mops/__init__.py +++ b/mops/__init__.py @@ -1,4 +1,4 @@ """Wrapper of Selenium, Appium and Playwright with a single API.""" -__version__ = '4.0.0rc' +__version__ = '4.0.0rc1' __project_name__ = 'mops' From 544ad4d45695cd9bb60f9beece55bdfacd739980 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Tue, 14 Jul 2026 18:59:33 +0200 Subject: [PATCH 22/30] Changelog fix --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76500774..e2c63391 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ --- +## v3.5.2 + ### Added - `DriverWrapper.is_cdp` flag to identify CDP-connected driver instances - `PlayDriver.quit` graceful error handling for CDP contexts; tracing skip when `is_cdp` is set From e233ebc55cac11b4de47b75a2beb7a62473bce03 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Tue, 14 Jul 2026 19:11:45 +0200 Subject: [PATCH 23/30] Changelog update --- CHANGELOG.md | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2c63391..42d3a08f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,30 @@ *Release date: 2026-07-14* ### Added -- **Self-healing locators** — automatic broken-locator recovery for Selenium and Playwright via DOM snapshots and similarity scoring -- `@no_healing` decorator — temporarily disables self-healing for the wrapped method -- `configure()` key validation — raises `ValueError` for unknown config keys +- **Self-healing locators** — automatic recovery of broken element locators at runtime + - `mops/self_healing/` package with config, healer, locator generator, and snapshot storage + - `@healing` decorator — applied to action methods (`click`, `type_text`, `get_attribute`, `text`, etc.), + catches `NoSuchElementException`, heals the locator via similarity scoring, and retries the method + - `@healing_after_wait` decorator — applied to `wait_visibility` and `wait_availability`; + triggers healing after the wait condition times out + - `@no_healing` decorator — temporarily disables healing for specific methods (`is_displayed`, `wait_hidden`) + - `SelfHealingConfig` with `configure()` / `get_config()` for global configuration + - Snapshot capture on successful element lookup (tag, attributes, text, parent, up to 5 siblings) + - Candidate search — JS script collects all same-tag elements, scores each against the saved snapshot + - Similarity scoring with tunable per-attribute weights (`ScoringWeights`) + - Locator generation pipeline + - `SnapshotStorage` ABC for pluggable backends (Redis, S3, PostgreSQL, etc.) + - `JsonFileSnapshotStorage` — default JSON file-based backend + - Snapshot normalization rules to strip dynamic data (CSS hashes, state classes, etc.) + - Callbacks: `on_healing_success` / `on_healing_failure` for external integrations (metrics, alerting) + - Full documentation in `docs/source/other/self_healing.md` -### Fixed -- `on_healing_success` callback fires before locator verification — now fires after DOM check with `healed_locator` populated +### Changed +- Selenium & Playwright action methods now decorated with `@healing` for transparent self-healing +- `wait_visibility` refactored into `_wait_visibility_base` to properly stack `@healing_after_wait` with `@wait_condition` / `@wait_continuous` +- `wait_availability` decorated with `@healing_after_wait` for wait-timeout healing +- Playwright element methods (`click`, `type_text`, `type_slowly`, `clear_text`, `hover`) now convert `playwright.Error` to `NoSuchElementException` for consistent healing trigger +- `wait_visibility_without_error` — corrected log message from "hidden" to "visible" for non-continuous mode --- From 8f3e53b876ac9c098eb97fbf2ad85a066b4d2da0 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Fri, 31 Jul 2026 16:01:20 +0200 Subject: [PATCH 24/30] Extend self-healing result data --- docs/source/other/self_healing.md | 42 +++++- mops/playwright/play_element.py | 2 + mops/selenium/core/core_element.py | 2 + mops/self_healing/__init__.py | 11 +- mops/self_healing/healer.py | 122 +++++++++++++++- .../unit/test_self_healing_callbacks.py | 38 ++++- .../unit/test_self_healing_stats.py | 137 ++++++++++++++++++ 7 files changed, 339 insertions(+), 15 deletions(-) create mode 100644 tests/static_tests/unit/test_self_healing_stats.py diff --git a/docs/source/other/self_healing.md b/docs/source/other/self_healing.md index 3b40a6a9..3efe1798 100644 --- a/docs/source/other/self_healing.md +++ b/docs/source/other/self_healing.md @@ -77,12 +77,17 @@ def on_success(result): # result.healed_locator — the new working locator # result.score — similarity score (0-1) # result.original_locator — the broken locator + # result.timestamp — when the heal happened (ISO-8601, UTC) metrics.increment('healing.success', tags={'element': result.element_name}) def on_failure(result): # result.reason — 'no-snapshot', 'below-threshold', 'no-verified-locator' # result.locator — the broken locator - alerting.send(f'Healing failed for {result.element_name}: {result.reason}') + # result.best_score — highest similarity score found before failure + # (0-1), or None if no candidate could be scored + # result.score_threshold — the configured acceptance threshold + # result.candidates_count — how many DOM candidates were compared + alerting.send(f'Healing failed for {result.element_name}: {result.reason} (best score: {result.best_score})') configure( save_snapshots=True, @@ -110,6 +115,26 @@ configure( ) ``` +### Healing Statistics + +Process-wide counters for end-of-run reporting (e.g. in `pytest_sessionfinish`): + +```python +from mops.self_healing import get_healing_stats + +def pytest_sessionfinish(session, exitstatus): + stats = get_healing_stats() + print(f'healing: {stats.healed} ok, {stats.failed} failed of {stats.attempts}') + print(f'average best score: {stats.avg_best_score}') + print(f'failure reasons: {stats.failed_reasons}') +``` + +`HealingStats` fields: `attempts`, `healed`, `failed`, `failed_reasons` (per-reason breakdown), +and `avg_best_score` (average similarity of successful heals). +Note that `healed` counts candidates found by the Healer — a candidate can still fail DOM +verification afterwards (`no-verified-locator`), which is reported via `on_healing_failure` +but not counted here. + ### Snapshot Normalization Remove dynamic data (CSS hashes, state classes) from snapshots before saving: @@ -199,6 +224,12 @@ Temporarily disables `heal_locators` on the global config. .. autoclass:: mops.self_healing.healer.ScoringWeights :members: :undoc-members: + +.. autoclass:: mops.self_healing.healer.HealingStats + :members: + :undoc-members: + +.. autofunction:: mops.self_healing.healer.get_healing_stats ```
@@ -245,6 +276,15 @@ Healing failures are non-fatal — the original exception is re-raised if healin | No candidate passes DOM verification | `no-verified-locator` | `on_healing_failure` | | Candidate passes DOM verification | — | `on_healing_success` | +`FailedHealingResult` exposes diagnostics for external monitoring +(`SuccessHealingResult` also carries a `timestamp`): + +* `best_score` — highest similarity score found before the failure + (`below-threshold`, `index-out-of-bounds`, `generate-locator-error`, `no-verified-locator`), + or `None` when no candidate could be scored (`no-snapshot`, `candidates-script-error`, `no-candidates`). +* `score_threshold` — the configured acceptance threshold. +* `candidates_count` — how many DOM candidates were compared, or `None` when candidates were never collected. +
## Methods with Healing diff --git a/mops/playwright/play_element.py b/mops/playwright/play_element.py index 07720b9d..27e5094d 100644 --- a/mops/playwright/play_element.py +++ b/mops/playwright/play_element.py @@ -605,6 +605,8 @@ def _try_healed_locators(self, result: SuccessHealingResult) -> None: locator=result.original_locator, reason='no-verified-locator', error='All healed locator candidates failed DOM verification', + best_score=result.score, + score_threshold=config.score_threshold, ) config.on_healing_failure(result) diff --git a/mops/selenium/core/core_element.py b/mops/selenium/core/core_element.py index 889fd5af..1de0d882 100644 --- a/mops/selenium/core/core_element.py +++ b/mops/selenium/core/core_element.py @@ -594,6 +594,8 @@ def _try_healed_locators(self, result: SuccessHealingResult) -> SeleniumWebEleme locator=result.original_locator, reason='no-verified-locator', error='All healed locator candidates failed find_element()', + best_score=result.score, + score_threshold=config.score_threshold, ) ) raise NoSuchElementException diff --git a/mops/self_healing/__init__.py b/mops/self_healing/__init__.py index b0189e86..fcca97f8 100644 --- a/mops/self_healing/__init__.py +++ b/mops/self_healing/__init__.py @@ -20,17 +20,26 @@ """ from mops.self_healing.config import configure, get_config -from mops.self_healing.healer import FailedHealingResult, Healer, ScoringWeights, SuccessHealingResult +from mops.self_healing.healer import ( + FailedHealingResult, + Healer, + HealingStats, + ScoringWeights, + SuccessHealingResult, + get_healing_stats, +) from mops.self_healing.snapshot import ElementSnapshot, JsonFileSnapshotStorage, SnapshotStorage __all__ = [ 'ElementSnapshot', 'FailedHealingResult', 'Healer', + 'HealingStats', 'JsonFileSnapshotStorage', 'ScoringWeights', 'SnapshotStorage', 'SuccessHealingResult', 'configure', 'get_config', + 'get_healing_stats', ] diff --git a/mops/self_healing/healer.py b/mops/self_healing/healer.py index 4a47ec3a..24213e4b 100644 --- a/mops/self_healing/healer.py +++ b/mops/self_healing/healer.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass, field +from datetime import datetime, timezone import logging from typing import TYPE_CHECKING, Any @@ -92,7 +93,7 @@ class SuccessHealingResult: healed_locator: str | None healed_locators_candidates: list[str] score: float - page: str | None = None + timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) @dataclass @@ -102,6 +103,70 @@ class FailedHealingResult: locator: str reason: str error: str | None = None + best_score: float | None = None + score_threshold: float | None = None + candidates_count: int | None = None + + +@dataclass +class HealingStats: + """Process-wide self-healing counters. + + Updated automatically by the :class:`Healer` on every :meth:`Healer.heal` call. + Read the current values with :func:`get_healing_stats`. + + .. note:: + ``healed`` counts candidates found by the Healer. A candidate can still + fail DOM verification afterwards (``no-verified-locator``), which is + reported via ``on_healing_failure`` but is not reflected in these + counters — it happens outside the :class:`Healer`. + """ + + attempts: int = 0 + healed: int = 0 + failed: int = 0 + failed_reasons: dict[str, int] = field(default_factory=dict) + _score_sum: float = 0.0 + _score_count: int = 0 + + @property + def avg_best_score(self) -> float | None: + """Average best similarity score of successfully healed elements.""" + if self._score_count == 0: + return None + return self._score_sum / self._score_count + + +_stats = HealingStats() + + +def get_healing_stats() -> HealingStats: + """Return process-wide self-healing statistics. + + Useful for end-of-run reporting, e.g. in ``pytest_sessionfinish``:: + + from mops.self_healing import get_healing_stats + + def pytest_sessionfinish(session, exitstatus): + stats = get_healing_stats() + print(f'healing: {stats.healed} ok, {stats.failed} failed of {stats.attempts}') + """ + return _stats + + +def _record_attempt() -> None: + _stats.attempts += 1 + + +def _record_heal_success(score: float) -> None: + _stats.healed += 1 + _stats._score_sum += score + _stats._score_count += 1 + + +def _record_heal_failure(reason: str) -> None: + _stats.failed += 1 + _stats.failed_reasons[reason] = _stats.failed_reasons.get(reason, 0) + 1 class Healer: @@ -120,9 +185,23 @@ def __init__( self._on_healing_failure = on_healing_failure def _fail( - self, reason: str, element_name: str, locator_key: str, locator: str, exc: BaseException | None = None + self, + reason: str, + element_name: str, + locator_key: str, + locator: str, + exc: BaseException | None = None, + best_score: float | None = None, + candidates_count: int | None = None, ) -> None: - """Fire failure callback and return None.""" + """Fire failure callback, record stats, and return None. + + :param best_score: Highest similarity score found before the failure, + or ``None`` when no candidate was scored (e.g. no snapshot, + script error, or empty candidate list). + :param candidates_count: Number of DOM candidates scored, or ``None`` + when candidates were never collected. + """ error: str | None = None if exc: error = exc.msg if isinstance(exc, WebDriverException) else str(exc) @@ -132,7 +211,11 @@ def _fail( locator=locator, reason=reason, error=error, + best_score=best_score, + score_threshold=self._score_threshold, + candidates_count=candidates_count, ) + _record_heal_failure(reason) if self._on_healing_failure: self._on_healing_failure(result) @@ -159,6 +242,7 @@ def heal( # noqa: PLR0911 :func:`generate_locator`. :return: :class:`SuccessHealingResult` if healed, ``None`` otherwise. """ + _record_attempt() snapshot = self._storage.load(locator_key) if not snapshot: @@ -172,7 +256,7 @@ def heal( # noqa: PLR0911 return self._fail('candidates-script-error', element_name, locator_key, locator, exc=exc) if not candidates_data: - return self._fail('no-candidates', element_name, locator_key, locator) + return self._fail('no-candidates', element_name, locator_key, locator, candidates_count=0) best_score = 0.0 best_index = -1 @@ -190,7 +274,14 @@ def heal( # noqa: PLR0911 self._score_threshold, element_name, ) - return self._fail('below-threshold', element_name, locator_key, locator) + return self._fail( + 'below-threshold', + element_name, + locator_key, + locator, + best_score=best_score, + candidates_count=len(candidates_data), + ) # Get the actual element by index among elements of the same tag _find = find_elements_fn or (lambda tag: driver_wrapper.driver.find_elements(By.TAG_NAME, tag)) @@ -200,18 +291,35 @@ def heal( # noqa: PLR0911 try: web_elements = _find(snapshot.tag) if best_index >= len(web_elements): - self._fail('index-out-of-bounds', element_name, locator_key, locator) + self._fail( + 'index-out-of-bounds', + element_name, + locator_key, + locator, + best_score=best_score, + candidates_count=len(candidates_data), + ) return None healed_web_element = web_elements[best_index] healed_locators = _gen(healed_web_element, driver_wrapper) except Exception as exc: # noqa: BLE001 logger.info('Self-healing: failed to generate locator for "%s": %s', element_name, exc) - self._fail('generate-locator-error', element_name, locator_key, locator, exc=exc) + self._fail( + 'generate-locator-error', + element_name, + locator_key, + locator, + exc=exc, + best_score=best_score, + candidates_count=len(candidates_data), + ) return None if healed_locators is None: + _record_heal_failure('no-generated-locator') return None + _record_heal_success(best_score) result = SuccessHealingResult( element_name=element_name, original_locator=locator, diff --git a/tests/static_tests/unit/test_self_healing_callbacks.py b/tests/static_tests/unit/test_self_healing_callbacks.py index 81262911..dcc993e1 100644 --- a/tests/static_tests/unit/test_self_healing_callbacks.py +++ b/tests/static_tests/unit/test_self_healing_callbacks.py @@ -8,13 +8,22 @@ from mops.self_healing.snapshot import ElementSnapshot -def _assert_failed(callback, reason, error=None): +_ANY = object() + + +def _assert_failed(callback, reason, error=None, best_score=_ANY, score_threshold=_ANY, candidates_count=_ANY): """Assert callback was called once with a FailedHealingResult matching reason.""" callback.assert_called_once() args = callback.call_args[0][0] assert isinstance(args, FailedHealingResult) assert args.reason == reason assert args.error == error + if best_score is not _ANY: + assert args.best_score == best_score + if score_threshold is not _ANY: + assert args.score_threshold == score_threshold + if candidates_count is not _ANY: + assert args.candidates_count == candidates_count def _make_snapshot(**overrides: str) -> ElementSnapshot: @@ -111,6 +120,9 @@ def test_failure_no_snapshot(): assert args.locator == '#submit' assert args.reason == 'no-snapshot' assert args.error is None + assert args.best_score is None + assert args.score_threshold == 0.7 + assert args.candidates_count is None def test_failure_candidates_script_raises(): @@ -126,7 +138,14 @@ def test_failure_candidates_script_raises(): result = healer.heal('btn', 'key', '#submit', driver) assert result is None - _assert_failed(callback, reason='candidates-script-error', error='browser error') + _assert_failed( + callback, + reason='candidates-script-error', + error='browser error', + best_score=None, + score_threshold=0.7, + candidates_count=None, + ) def test_failure_no_candidates(): @@ -142,7 +161,7 @@ def test_failure_no_candidates(): result = healer.heal('btn', 'key', '#submit', driver) assert result is None - _assert_failed(callback, reason='no-candidates') + _assert_failed(callback, reason='no-candidates', best_score=None, score_threshold=0.7, candidates_count=0) def test_failure_score_below_threshold(): @@ -161,7 +180,10 @@ def test_failure_score_below_threshold(): result = healer.heal('btn', 'key', '#submit', driver) assert result is None - _assert_failed(callback, reason='below-threshold') + _assert_failed(callback, reason='below-threshold', score_threshold=0.95, candidates_count=1) + args = callback.call_args[0][0] + assert args.best_score is not None + assert 0.0 <= args.best_score < 0.95 def test_failure_best_index_out_of_bounds(): @@ -184,7 +206,9 @@ def test_failure_best_index_out_of_bounds(): result = healer.heal('btn', 'key', '#submit', driver) assert result is None - _assert_failed(callback, reason='index-out-of-bounds') + _assert_failed(callback, reason='index-out-of-bounds', score_threshold=0.7, candidates_count=2) + args = callback.call_args[0][0] + assert args.best_score is not None # matching candidate 1 was scored before the OOB hit def test_failure_generate_locator_raises(): @@ -199,7 +223,9 @@ def test_failure_generate_locator_raises(): result = healer.heal('btn', 'key', '#submit', driver) assert result is None - _assert_failed(callback, reason='generate-locator-error', error='no locator') + _assert_failed(callback, reason='generate-locator-error', error='no locator', candidates_count=1) + args = callback.call_args[0][0] + assert args.best_score is not None # the matching candidate was scored before locator generation def test_failure_callback_not_set(): diff --git a/tests/static_tests/unit/test_self_healing_stats.py b/tests/static_tests/unit/test_self_healing_stats.py new file mode 100644 index 00000000..2ee2a5b6 --- /dev/null +++ b/tests/static_tests/unit/test_self_healing_stats.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import mops.self_healing.healer as healer_module +from mops.self_healing.healer import HealingStats, get_healing_stats +from mops.self_healing.snapshot import ElementSnapshot + + +def _make_snapshot(**overrides: str) -> ElementSnapshot: + """Build an ElementSnapshot with sensible defaults.""" + defaults = dict( + tag='button', + attributes={'id': 'submit'}, + text='Click', + parent_tag='form', + parent_attributes={}, + siblings=[], + ) + defaults.update(overrides) + return ElementSnapshot(**defaults) + + +def _make_candidate(index: int = 0, **extra: str) -> dict: + """Build a candidate dict with values matching the default snapshot.""" + return { + 'index': index, + 'attrs': {'id': 'submit'}, + 'text': 'Click', + 'parentTag': 'form', + 'parentAttrs': {}, + **extra, + } + + +def _fresh_stats(monkeypatch) -> HealingStats: + """Replace the module-level stats with a clean instance for the test.""" + stats = HealingStats() + monkeypatch.setattr(healer_module, '_stats', stats) + return stats + + +def test_get_healing_stats_returns_live_stats(monkeypatch): + """get_healing_stats() returns the same object the Healer records into.""" + stats = _fresh_stats(monkeypatch) + assert get_healing_stats() is stats + + +def test_stats_count_successful_heal(monkeypatch): + """A successful heal() increments attempts and healed, and records a score.""" + stats = _fresh_stats(monkeypatch) + storage = MagicMock() + storage.load.return_value = _make_snapshot() + driver = MagicMock() + driver.driver = MagicMock() + driver.execute_script.return_value = [_make_candidate()] + driver.driver.find_elements.return_value = [MagicMock()] + healer = healer_module.Healer(storage, 0.7) + + with patch('mops.self_healing.healer.generate_locator', return_value=['xpath=//button']): + assert healer.heal('btn', 'key', '#submit', driver) is not None + + assert stats.attempts == 1 + assert stats.healed == 1 + assert stats.failed == 0 + assert stats.failed_reasons == {} + assert stats.avg_best_score is not None + assert 0.0 < stats.avg_best_score <= 1.0 + + +def test_stats_count_failure_by_reason(monkeypatch): + """A failed heal() increments failed and records the reason.""" + stats = _fresh_stats(monkeypatch) + storage = MagicMock() + storage.load.return_value = None + healer = healer_module.Healer(storage, 0.7) + + assert healer.heal('btn', 'key', '#submit', MagicMock()) is None + + assert stats.attempts == 1 + assert stats.healed == 0 + assert stats.failed == 1 + assert stats.failed_reasons == {'no-snapshot': 1} + assert stats.avg_best_score is None + + +def test_stats_accumulate_across_heals(monkeypatch): + """Multiple heals accumulate attempts/healed/failed.""" + stats = _fresh_stats(monkeypatch) + storage = MagicMock() + storage.load.return_value = _make_snapshot() + driver = MagicMock() + driver.driver = MagicMock() + driver.execute_script.return_value = [_make_candidate()] + driver.driver.find_elements.return_value = [MagicMock()] + healer = healer_module.Healer(storage, 0.7) + + with patch('mops.self_healing.healer.generate_locator', return_value=['xpath=//button']): + assert healer.heal('btn', 'key1', '#submit', driver) is not None + + # Same snapshot key but different element name — still a separate heal() call + with patch('mops.self_healing.healer.generate_locator', return_value=['xpath=//button']): + assert healer.heal('btn2', 'key2', '#submit', driver) is not None + + # A failing heal + storage.load.return_value = None + assert healer.heal('btn3', 'missing', '#submit', driver) is None + + assert stats.attempts == 3 + assert stats.healed == 2 + assert stats.failed == 1 + assert stats.failed_reasons == {'no-snapshot': 1} + + +def test_stats_avg_best_score_over_multiple_healed(monkeypatch): + """avg_best_score averages best scores across all successful heals.""" + stats = _fresh_stats(monkeypatch) + storage = MagicMock() + storage.load.return_value = _make_snapshot() + driver = MagicMock() + driver.driver = MagicMock() + driver.driver.find_elements.return_value = [MagicMock()] + healer = healer_module.Healer(storage, 0.0) # accept any score + + # First heal: perfect match + driver.execute_script.return_value = [_make_candidate()] + with patch('mops.self_healing.healer.generate_locator', return_value=['xpath=//button']): + healer.heal('btn', 'key1', '#submit', driver) + + # Second heal: partial match (same text, different id/parent) → lower but > 0 score + driver.execute_script.return_value = [_make_candidate(attrs={'id': 'other'}, text='Click', parentTag='div')] + with patch('mops.self_healing.healer.generate_locator', return_value=['xpath=//button']): + healer.heal('btn2', 'key2', '#submit', driver) + + assert stats.healed == 2 + first_score = stats.avg_best_score * 2 # sum of both scores + assert 0.0 < first_score < 2.0 From a03541028ab61fb6d303b005dde4fa97d99b1860 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Mon, 3 Aug 2026 12:34:30 +0200 Subject: [PATCH 25/30] Candidate attributes breakdown / snapshot added to result --- docs/source/other/self_healing.md | 59 +++++- mops/playwright/play_element.py | 1 + mops/selenium/core/core_element.py | 1 + mops/self_healing/__init__.py | 4 + mops/self_healing/healer.py | 188 +++++++++++++++--- mops/self_healing/healer_factory.py | 30 ++- .../performance/test_overall_performance.py | 10 +- .../unit/test_self_healing_callbacks.py | 2 + tests/web_tests/test_self_healing.py | 102 +++++++++- 9 files changed, 361 insertions(+), 36 deletions(-) diff --git a/docs/source/other/self_healing.md b/docs/source/other/self_healing.md index 3efe1798..3a991d93 100644 --- a/docs/source/other/self_healing.md +++ b/docs/source/other/self_healing.md @@ -78,6 +78,7 @@ def on_success(result): # result.score — similarity score (0-1) # result.original_locator — the broken locator # result.timestamp — when the heal happened (ISO-8601, UTC) + # result.breakdown — SimilarityBreakdown of the best candidate metrics.increment('healing.success', tags={'element': result.element_name}) def on_failure(result): @@ -87,6 +88,7 @@ def on_failure(result): # (0-1), or None if no candidate could be scored # result.score_threshold — the configured acceptance threshold # result.candidates_count — how many DOM candidates were compared + # result.breakdown — SimilarityBreakdown of the best candidate, or None alerting.send(f'Healing failed for {result.element_name}: {result.reason} (best score: {result.best_score})') configure( @@ -98,14 +100,47 @@ configure( ) ``` +Both callbacks receive a `SimilarityBreakdown` in `result.breakdown` for the **best candidate** — +regardless of whether healing succeeded or failed. It tells you exactly which attributes +matched the snapshot and which did not: + +```python +def on_healing_result(result): + b = result.breakdown + print(f'matched: {b.matched_attributes}') # ['id', 'name'] + print(f'mismatched: {b.mismatched_attributes}') # ['class'] + + # Inspect a specific attribute to spot dynamic data + id_match = b.attributes['id'] + print(id_match.snapshot_value, '->', id_match.candidate_value) # 'user-123' -> 'user-456' + print(id_match.matched, id_match.score) # False 0.0 + + # The raw DOM snapshot of the best candidate as found on the page + snap = b.candidate_snapshot + print(snap.attributes, snap.text, snap.parent_tag) +``` + +* `AttributeMatch` — one per attribute: `attribute`, `snapshot_value`, `candidate_value`, + `matched`, `score` (0-1), `weight` (0.0 for attributes that do not affect scoring). +* `SimilarityBreakdown` — `score`, `attributes` (dict), `text_snapshot`, `text_candidate`, + `text_score`, `parent_tag_*`, `parent_attrs_score`, `siblings_score`, + `siblings_snapshot_count`, `siblings_candidate_count`, `candidate_snapshot` + (a raw `ElementSnapshot` of the best candidate — reflects the actual DOM state, + unlike the normalized reference stored in the storage), plus convenience + `matched_attributes` / `mismatched_attributes` lists. + ### Scoring Weights -Tune similarity scoring per attribute type: +Tune similarity scoring per attribute type — externally, from any project that +uses the framework. Pass your own `ScoringWeights` through `configure()`: ```python -from mops.self_healing.healer import ScoringWeights +from mops.self_healing import configure, ScoringWeights, JsonFileSnapshotStorage configure( + save_snapshots=True, + heal_locators=True, + storage=JsonFileSnapshotStorage(), scoring_weights=ScoringWeights( attribute={'id': 1.0, 'class': 0.3, 'name': 0.7}, text=0.5, @@ -115,6 +150,14 @@ configure( ) ``` +`attribute` maps an attribute name to its weight; keys not present are ignored +during scoring (weight `0.0`). Weights apply to every element globally. Changing +them at runtime is picked up on the next heal: the `Healer` singleton +re-initialises when `scoring_weights`, `score_threshold`, `storage`, or +`on_healing_failure` change. Mutating the same `ScoringWeights` object in place +(e.g. `get_config().scoring_weights.attribute['id'] = 0.9`) also works because +the `Healer` keeps a reference to it. + ### Healing Statistics Process-wide counters for end-of-run reporting (e.g. in `pytest_sessionfinish`): @@ -225,6 +268,14 @@ Temporarily disables `heal_locators` on the global config. :members: :undoc-members: +.. autoclass:: mops.self_healing.healer.AttributeMatch + :members: + :undoc-members: + +.. autoclass:: mops.self_healing.healer.SimilarityBreakdown + :members: + :undoc-members: + .. autoclass:: mops.self_healing.healer.HealingStats :members: :undoc-members: @@ -284,6 +335,10 @@ Healing failures are non-fatal — the original exception is re-raised if healin or `None` when no candidate could be scored (`no-snapshot`, `candidates-script-error`, `no-candidates`). * `score_threshold` — the configured acceptance threshold. * `candidates_count` — how many DOM candidates were compared, or `None` when candidates were never collected. +* `breakdown` — a `SimilarityBreakdown` of the best candidate whenever one was scored + (`below-threshold`, `index-out-of-bounds`, `generate-locator-error`, `no-verified-locator`), + or `None` when no candidate could be scored. The same breakdown is available on + `SuccessHealingResult` after a successful heal.
diff --git a/mops/playwright/play_element.py b/mops/playwright/play_element.py index 27e5094d..bded8bdc 100644 --- a/mops/playwright/play_element.py +++ b/mops/playwright/play_element.py @@ -607,6 +607,7 @@ def _try_healed_locators(self, result: SuccessHealingResult) -> None: error='All healed locator candidates failed DOM verification', best_score=result.score, score_threshold=config.score_threshold, + breakdown=result.breakdown, ) config.on_healing_failure(result) diff --git a/mops/selenium/core/core_element.py b/mops/selenium/core/core_element.py index 1de0d882..6d1f6820 100644 --- a/mops/selenium/core/core_element.py +++ b/mops/selenium/core/core_element.py @@ -596,6 +596,7 @@ def _try_healed_locators(self, result: SuccessHealingResult) -> SeleniumWebEleme error='All healed locator candidates failed find_element()', best_score=result.score, score_threshold=config.score_threshold, + breakdown=result.breakdown, ) ) raise NoSuchElementException diff --git a/mops/self_healing/__init__.py b/mops/self_healing/__init__.py index fcca97f8..84981430 100644 --- a/mops/self_healing/__init__.py +++ b/mops/self_healing/__init__.py @@ -21,22 +21,26 @@ from mops.self_healing.config import configure, get_config from mops.self_healing.healer import ( + AttributeMatch, FailedHealingResult, Healer, HealingStats, ScoringWeights, + SimilarityBreakdown, SuccessHealingResult, get_healing_stats, ) from mops.self_healing.snapshot import ElementSnapshot, JsonFileSnapshotStorage, SnapshotStorage __all__ = [ + 'AttributeMatch', 'ElementSnapshot', 'FailedHealingResult', 'Healer', 'HealingStats', 'JsonFileSnapshotStorage', 'ScoringWeights', + 'SimilarityBreakdown', 'SnapshotStorage', 'SuccessHealingResult', 'configure', diff --git a/mops/self_healing/healer.py b/mops/self_healing/healer.py index 24213e4b..60736e5b 100644 --- a/mops/self_healing/healer.py +++ b/mops/self_healing/healer.py @@ -9,11 +9,12 @@ from selenium.webdriver.common.by import By from mops.self_healing.locator_generator import generate_locator +from mops.self_healing.snapshot import ElementSnapshot if TYPE_CHECKING: from collections.abc import Callable - from mops.self_healing.snapshot import ElementSnapshot, SnapshotStorage + from mops.self_healing.snapshot import SnapshotStorage logger = logging.getLogger('mops.self_healing') @@ -86,6 +87,70 @@ class ScoringWeights: siblings: float = 0.15 +@dataclass +class AttributeMatch: + """Comparison of a single attribute between the snapshot and a DOM candidate. + + :param attribute: Attribute name (e.g. ``id``, ``class``). + :param snapshot_value: Value saved in the snapshot, or ``None`` when the + attribute is absent from the snapshot. + :param candidate_value: Value found on the candidate element, or ``None`` + when the candidate does not have this attribute. + :param matched: ``True`` when both values exist and are exactly equal. + :param score: Similarity of the values — ``1.0`` on exact match, partial + token overlap for weighted attributes, ``0.0`` otherwise. + :param weight: Configured :class:`ScoringWeights` weight. ``0.0`` when the + attribute does not participate in scoring (diagnostics only). + """ + + attribute: str + snapshot_value: str | None + candidate_value: str | None + matched: bool + score: float + weight: float + + +@dataclass +class SimilarityBreakdown: + """Per-signal similarity breakdown of one DOM candidate vs the snapshot. + + Exposed on :class:`SuccessHealingResult` and :class:`FailedHealingResult` + for the best-scoring candidate so callers can see exactly which attributes + matched and which did not — useful for spotting dynamic data (e.g. a + changing ``id`` or a CSS-module hash in ``class``). + + :param candidate_snapshot: Raw DOM snapshot of the best candidate as found + on the page. Unlike the normalized reference snapshot stored in the + storage, this reflects the actual current state of the element — useful + for comparing dynamic values side-by-side. + """ + + score: float + attributes: dict[str, AttributeMatch] + text_snapshot: str | None + text_candidate: str | None + text_score: float | None + parent_tag_snapshot: str | None + parent_tag_candidate: str | None + parent_tag_matched: bool | None + parent_attrs_score: float | None + siblings_score: float | None + siblings_snapshot_count: int + siblings_candidate_count: int + candidate_snapshot: ElementSnapshot | None = None + + @property + def matched_attributes(self) -> list[str]: + """Names of attributes that matched exactly.""" + return [attr for attr, match in self.attributes.items() if match.matched] + + @property + def mismatched_attributes(self) -> list[str]: + """Names of attributes that did not match exactly.""" + return [attr for attr, match in self.attributes.items() if not match.matched] + + @dataclass class SuccessHealingResult: element_name: str @@ -94,6 +159,7 @@ class SuccessHealingResult: healed_locators_candidates: list[str] score: float timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + breakdown: SimilarityBreakdown | None = None @dataclass @@ -106,6 +172,7 @@ class FailedHealingResult: best_score: float | None = None score_threshold: float | None = None candidates_count: int | None = None + breakdown: SimilarityBreakdown | None = None @dataclass @@ -193,6 +260,7 @@ def _fail( exc: BaseException | None = None, best_score: float | None = None, candidates_count: int | None = None, + breakdown: SimilarityBreakdown | None = None, ) -> None: """Fire failure callback, record stats, and return None. @@ -201,6 +269,8 @@ def _fail( script error, or empty candidate list). :param candidates_count: Number of DOM candidates scored, or ``None`` when candidates were never collected. + :param breakdown: Similarity breakdown of the best candidate, or + ``None`` when no candidate was scored. """ error: str | None = None if exc: @@ -214,6 +284,7 @@ def _fail( best_score=best_score, score_threshold=self._score_threshold, candidates_count=candidates_count, + breakdown=breakdown, ) _record_heal_failure(reason) if self._on_healing_failure: @@ -258,14 +329,16 @@ def heal( # noqa: PLR0911 if not candidates_data: return self._fail('no-candidates', element_name, locator_key, locator, candidates_count=0) - best_score = 0.0 + best_score = -1.0 best_index = -1 + best_breakdown: SimilarityBreakdown | None = None for item in candidates_data: - score = _score_similarity(item, snapshot, self._scoring_weights) - if score > best_score: - best_score = score + breakdown = _compute_similarity_breakdown(item, snapshot, self._scoring_weights) + if breakdown.score > best_score: + best_score = breakdown.score best_index = item['index'] + best_breakdown = breakdown if best_score < self._score_threshold or best_index < 0: logger.info( @@ -281,6 +354,7 @@ def heal( # noqa: PLR0911 locator, best_score=best_score, candidates_count=len(candidates_data), + breakdown=best_breakdown, ) # Get the actual element by index among elements of the same tag @@ -298,6 +372,7 @@ def heal( # noqa: PLR0911 locator, best_score=best_score, candidates_count=len(candidates_data), + breakdown=best_breakdown, ) return None healed_web_element = web_elements[best_index] @@ -312,6 +387,7 @@ def heal( # noqa: PLR0911 exc=exc, best_score=best_score, candidates_count=len(candidates_data), + breakdown=best_breakdown, ) return None @@ -326,6 +402,7 @@ def heal( # noqa: PLR0911 healed_locator=None, healed_locators_candidates=healed_locators, score=best_score, + breakdown=best_breakdown, ) logger.info( @@ -345,54 +422,119 @@ def _score_similarity( weights: ScoringWeights | None = None, ) -> float: """Compute a 0-1 similarity score between a candidate DOM element and a saved snapshot.""" + return _compute_similarity_breakdown(candidate, snapshot, weights).score + + +def _compute_similarity_breakdown( # noqa: PLR0912, PLR0915 + candidate: dict[str, Any], + snapshot: ElementSnapshot, + weights: ScoringWeights | None = None, +) -> SimilarityBreakdown: + """Compute a similarity score and a per-signal breakdown for one candidate. + + The returned :class:`SimilarityBreakdown` contains the same aggregate + ``score`` as :func:`_score_similarity`, plus an ``AttributeMatch`` for every + snapshot attribute (and every weighted attribute) so callers can see which + attributes matched and which did not. + """ w = weights or ScoringWeights() score = 0.0 total_weight = 0.0 + attributes: dict[str, AttributeMatch] = {} - # Attribute matching - for attr, weight in w.attribute.items(): + # Attribute matching — all snapshot attributes plus weighted ones present on + # the candidate. Only weighted attributes contribute to the total score. + weighted_attrs = set(w.attribute) + for attr in set(snapshot.attributes) | weighted_attrs: snap_val = snapshot.attributes.get(attr) cand_val = candidate['attrs'].get(attr) + weight = w.attribute.get(attr, 0.0) if snap_val is None and cand_val is None: continue - total_weight += weight - - if snap_val == cand_val: - score += weight - elif snap_val and cand_val: - score += weight * _token_overlap(snap_val, cand_val) + if weight: + total_weight += weight + if snap_val == cand_val: + attr_score = 1.0 + elif snap_val and cand_val: + attr_score = _token_overlap(snap_val, cand_val) + else: + attr_score = 0.0 + score += weight * attr_score + else: + # Unweighted attribute — diagnostics only, binary match indicator + attr_score = 1.0 if snap_val is not None and snap_val == cand_val else 0.0 + + attributes[attr] = AttributeMatch( + attribute=attr, + snapshot_value=snap_val, + candidate_value=cand_val, + matched=snap_val is not None and snap_val == cand_val, + score=attr_score, + weight=weight, + ) # Text similarity snap_text = snapshot.text cand_text = candidate.get('text', '') + text_score: float | None = None if snap_text: total_weight += w.text if snap_text == cand_text: - score += w.text + text_score = 1.0 elif snap_text and cand_text: - score += w.text * _text_similarity(snap_text, cand_text) + text_score = _text_similarity(snap_text, cand_text) + else: + text_score = 0.0 + score += w.text * text_score # Parent tag match + parent_tag_matched: bool | None = None + parent_attrs_score: float | None = None if snapshot.parent_tag and candidate.get('parentTag'): total_weight += w.parent - if candidate['parentTag'] == snapshot.parent_tag: + parent_tag_matched = candidate['parentTag'] == snapshot.parent_tag + if parent_tag_matched: score += w.parent * 0.5 - parent_attr_score = _attrs_overlap(snapshot.parent_attributes, candidate.get('parentAttrs', {})) - score += w.parent * 0.5 * parent_attr_score + parent_attrs_score = _attrs_overlap(snapshot.parent_attributes, candidate.get('parentAttrs', {})) + score += w.parent * 0.5 * parent_attrs_score # Sibling similarity snap_siblings = snapshot.siblings cand_siblings = candidate.get('siblings', []) + siblings_score: float | None = None if snap_siblings: total_weight += w.siblings - score += w.siblings * _siblings_similarity(snap_siblings, cand_siblings) - - if total_weight == 0: - return 0.0 + siblings_score = _siblings_similarity(snap_siblings, cand_siblings) + score += w.siblings * siblings_score + + final_score = 0.0 if total_weight == 0 else score / total_weight + + candidate_snapshot = ElementSnapshot( + tag=snapshot.tag, + attributes=candidate.get('attrs', {}), + text=cand_text, + parent_tag=candidate.get('parentTag'), + parent_attributes=candidate.get('parentAttrs', {}), + siblings=cand_siblings, + ) - return score / total_weight + return SimilarityBreakdown( + score=final_score, + attributes=attributes, + text_snapshot=snap_text, + text_candidate=cand_text, + text_score=text_score, + parent_tag_snapshot=snapshot.parent_tag, + parent_tag_candidate=candidate.get('parentTag'), + parent_tag_matched=parent_tag_matched, + parent_attrs_score=parent_attrs_score, + siblings_score=siblings_score, + siblings_snapshot_count=len(snap_siblings), + siblings_candidate_count=len(cand_siblings), + candidate_snapshot=candidate_snapshot, + ) def _token_overlap(a: str, b: str) -> float: diff --git a/mops/self_healing/healer_factory.py b/mops/self_healing/healer_factory.py index f9a5d50c..21984717 100644 --- a/mops/self_healing/healer_factory.py +++ b/mops/self_healing/healer_factory.py @@ -6,6 +6,9 @@ from mops.self_healing.healer import Healer if TYPE_CHECKING: + from collections.abc import Callable + + from mops.self_healing.healer import FailedHealingResult, ScoringWeights from mops.self_healing.snapshot import SnapshotStorage @@ -13,22 +16,41 @@ class _HealerState: """Module-level state for the healer singleton.""" storage: SnapshotStorage | None = None + score_threshold: float | None = None + scoring_weights: ScoringWeights | None = None + on_healing_failure: Callable[[FailedHealingResult], None] | None = None healer: Healer | None = None def get_healer() -> Healer: """Return the global Healer singleton. - Re-initialises when ``get_config().storage`` changes (identity check) - or when the cached storage is ``None``, so that ``configure()`` calls - between tests are picked up. + Re-initialises when any healing-related config value changes so that + ``configure()`` calls — including custom :class:`ScoringWeights` — between + tests are picked up: + + * ``storage`` — identity check (and must not be ``None``) + * ``score_threshold`` — value check + * ``scoring_weights`` — identity check (mutating the same object in place + works too, since the :class:`Healer` keeps a reference to it) + * ``on_healing_failure`` — identity check """ config = get_config() - if _HealerState.healer and _HealerState.storage is config.storage and _HealerState.storage is not None: + if ( + _HealerState.healer + and _HealerState.storage is config.storage + and _HealerState.storage is not None + and _HealerState.score_threshold == config.score_threshold + and _HealerState.scoring_weights is config.scoring_weights + and _HealerState.on_healing_failure is config.on_healing_failure + ): return _HealerState.healer _HealerState.storage = config.storage + _HealerState.score_threshold = config.score_threshold + _HealerState.scoring_weights = config.scoring_weights + _HealerState.on_healing_failure = config.on_healing_failure _HealerState.healer = Healer( _HealerState.storage, config.score_threshold, diff --git a/tests/static_tests/performance/test_overall_performance.py b/tests/static_tests/performance/test_overall_performance.py index 37ed7e5b..0f6b45e1 100644 --- a/tests/static_tests/performance/test_overall_performance.py +++ b/tests/static_tests/performance/test_overall_performance.py @@ -83,14 +83,14 @@ def test_performance_element_initialisation(mocked_selenium_driver, case, set_el if sys.version_info >= (3, 11): expected_peak_mem = 4.8 if sys.version_info >= (3, 12): - expected_peak_mem = 3.8 - expected_init_duration = 0.4 + expected_peak_mem = 5.0 + expected_init_duration = 0.6 if sys.version_info >= (3, 13): - expected_peak_mem = 4.0 - expected_init_duration = 0.4 + expected_peak_mem = 5.0 + expected_init_duration = 0.6 if sys.version_info >= (3, 14): expected_peak_mem = 5.0 - expected_init_duration = 0.4 + expected_init_duration = 0.6 init_without_profiling_expected = 0.18 assert init_without_profiling_stop_timestamp < init_without_profiling_expected,\ diff --git a/tests/static_tests/unit/test_self_healing_callbacks.py b/tests/static_tests/unit/test_self_healing_callbacks.py index dcc993e1..d310f152 100644 --- a/tests/static_tests/unit/test_self_healing_callbacks.py +++ b/tests/static_tests/unit/test_self_healing_callbacks.py @@ -123,6 +123,7 @@ def test_failure_no_snapshot(): assert args.best_score is None assert args.score_threshold == 0.7 assert args.candidates_count is None + assert args.breakdown is None def test_failure_candidates_script_raises(): @@ -184,6 +185,7 @@ def test_failure_score_below_threshold(): args = callback.call_args[0][0] assert args.best_score is not None assert 0.0 <= args.best_score < 0.95 + assert args.breakdown is not None # best candidate breakdown reported even on failure def test_failure_best_index_out_of_bounds(): diff --git a/tests/web_tests/test_self_healing.py b/tests/web_tests/test_self_healing.py index a6ecaaf1..eb817649 100644 --- a/tests/web_tests/test_self_healing.py +++ b/tests/web_tests/test_self_healing.py @@ -2,6 +2,7 @@ from unittest.mock import patch from mops.base.element import Element +from mops.exceptions import NoSuchElementException from mops.playwright.play_driver import PlayDriver from mops.playwright.play_element import PlayElement from mops.selenium.core.core_element import CoreElement @@ -32,9 +33,22 @@ def _key(element): @pytest.fixture(autouse=True) def setup(): - configure(save_snapshots=True, heal_locators=True, score_threshold=0.5, storage=JsonFileSnapshotStorage()) + configure( + save_snapshots=True, + heal_locators=True, + score_threshold=0.5, + storage=JsonFileSnapshotStorage(), + on_healing_success=None, + on_healing_failure=None, + ) yield - configure(save_snapshots=False, heal_locators=False) + configure( + save_snapshots=False, + heal_locators=False, + score_threshold=0.5, + on_healing_success=None, + on_healing_failure=None, + ) def test_self_healing_recovers_broken_locator(second_playground_page): @@ -239,3 +253,87 @@ def test_parent_healing_not_triggered_during_child_healing(second_playground_pag assert cls is not None, 'Child was not healed' assert parent.name not in spy['instances'], f'Parent healing was triggered: {spy["instances"]}' + + +def _break_row_class(page): + """Replace the class of every `.row` element so class no longer matches the snapshot.""" + page.driver_wrapper.execute_script(""" + var elements = document.querySelectorAll('.row'); + for (var i = 0; i < elements.length; i++) { + elements[i].className = 'broken-row'; + } + """) + + +def _save_snapshot_for_broken_element(page, element_name): + """Save the snapshot of the real row under a key of an element with a broken locator.""" + row = page.row_with_cards + row.wait_visibility(silent=True) + + storage = get_config().storage + real_key = _key(row) + snapshot = storage.load(real_key) + assert snapshot is not None, f'Snapshot was not saved for key: {real_key!r}' + + broken_row = Element('.row-broken-locator-self-healing-test', name=element_name) + storage.save(_key(broken_row), snapshot) + return broken_row + + +def test_healing_success_breakdown_reports_matched_and_mismatched(second_playground_page): + """Real browser: on_healing_success exposes which attributes matched the snapshot and which did not.""" + results = [] + configure(on_healing_success=results.append) + + broken_row = _save_snapshot_for_broken_element(second_playground_page, element_name='row with cards') + _break_row_class(second_playground_page) + + cls = broken_row.get_attribute('class', silent=True) + assert cls is not None, 'Self-healing did not recover the element' + + assert results, 'on_healing_success was not fired' + result = results[0] + assert result.breakdown is not None, 'Success result must carry a SimilarityBreakdown' + assert result.breakdown.score == result.score + + assert 'class' in result.breakdown.attributes, 'class attribute missing from breakdown' + class_match = result.breakdown.attributes['class'] + assert 'row' in (class_match.snapshot_value or '') + assert class_match.candidate_value == 'broken-row' + assert class_match.matched is False + assert 'class' in result.breakdown.mismatched_attributes + assert 'class' not in result.breakdown.matched_attributes + # Other signals still pushed the score above the 0.5 threshold + assert result.breakdown.score > 0.5 + assert result.breakdown.text_score is not None + assert result.breakdown.parent_tag_matched is not None + # The raw snapshot of the recovered candidate is attached + assert result.breakdown.candidate_snapshot is not None + assert result.breakdown.candidate_snapshot.attributes.get('class') == 'broken-row' + assert result.breakdown.candidate_snapshot.tag is not None + + +def test_healing_failure_below_threshold_has_breakdown(second_playground_page): + """Real browser: on_healing_failure still reports the best candidate breakdown.""" + results = [] + configure(on_healing_failure=results.append, score_threshold=1.0) + + broken_row = _save_snapshot_for_broken_element(second_playground_page, element_name='row with cards') + _break_row_class(second_playground_page) + + with pytest.raises(NoSuchElementException): + broken_row.get_attribute('class', silent=True) + + assert results, 'on_healing_failure was not fired' + result = results[0] + assert result.reason == 'below-threshold' + assert result.breakdown is not None, 'Failure result must carry a SimilarityBreakdown' + + assert 'class' in result.breakdown.attributes, 'class attribute missing from breakdown' + class_match = result.breakdown.attributes['class'] + assert class_match.matched is False + assert 'class' in result.breakdown.mismatched_attributes + assert result.breakdown.score < 1.0 + # Even on failure the best candidate's raw snapshot is attached + assert result.breakdown.candidate_snapshot is not None + assert result.breakdown.candidate_snapshot.attributes.get('class') == 'broken-row' From 6c53e61b814c2be876629d04aabcebcfcbc230a0 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Fri, 7 Aug 2026 15:39:55 +0200 Subject: [PATCH 26/30] fix(self-healing): normalize DOM candidates before similarity scoring Previously only stored snapshots were normalized, so dynamic data on a live element (CSS-module hashes in `class`, numeric id suffixes, state classes) unfairly penalized its candidates during scoring. Now every DOM candidate is normalized with the same `SnapshotStorage.normalize_snapshot()` rules as the saved reference, so both sides of the comparison are cleaned equally. The raw (non-normalized) candidate is still attached to the `SimilarityBreakdown` as `candidate_snapshot` for diagnostics. Also improved comparators: `class` similarity uses canonical word tokenization (camelCase/kebab/snake/BEM) and text substring matches respect word boundaries with a minimum length, avoiding short-token false positives. --- docs/source/other/self_healing.md | 31 +- mops/selenium/core/core_element.py | 17 +- mops/self_healing/healer.py | 303 +++++++++++++----- mops/self_healing/snapshot.py | 14 +- .../unit/test_self_healing_breakdown.py | 294 +++++++++++++++++ .../unit/test_self_healing_callbacks.py | 91 +++--- .../unit/test_self_healing_factory.py | 131 ++++++++ .../unit/test_self_healing_normalization.py | 4 +- .../unit/test_self_healing_scoring.py | 212 ++++++++++++ .../unit/test_self_healing_stats.py | 51 ++- tests/web_tests/test_self_healing.py | 194 ++++++++++- 11 files changed, 1191 insertions(+), 151 deletions(-) create mode 100644 tests/static_tests/unit/test_self_healing_breakdown.py create mode 100644 tests/static_tests/unit/test_self_healing_factory.py create mode 100644 tests/static_tests/unit/test_self_healing_scoring.py diff --git a/docs/source/other/self_healing.md b/docs/source/other/self_healing.md index 3a991d93..1c43c4b9 100644 --- a/docs/source/other/self_healing.md +++ b/docs/source/other/self_healing.md @@ -193,6 +193,14 @@ storage.set_normalization_rules([ ]) ``` +The same rules are applied on **both sides** of the similarity comparison: +snapshots are normalized when saved, and every DOM candidate is normalized with +`SnapshotStorage.normalize_snapshot()` before scoring. This means dynamic data +covered by the rules (e.g. a CSS-module hash in `class` or a numeric id suffix) +does not unfairly penalise a candidate. The `candidate_snapshot` attached to a +`SimilarityBreakdown` is the **raw** (non-normalized) candidate — so you can still +see the actual DOM values for diagnostics. +
## Architecture @@ -227,8 +235,20 @@ Temporarily disables `heal_locators` on the global config. 4. **Similarity scoring** — each candidate is scored against the saved snapshot. Attributes (`id`, `name`, `class`, `aria-label`, etc.) have per-key weights. - Text, parent tag/attributes, and sibling structure contribute additional weighted scores. - The best candidate must exceed `score_threshold` (default `0.7`). + `class` is compared **canonically** — kebab/snake/camel/Pascal variants of the + same words are treated as equal (`user-profile-card` == `UserProfileCard`, + `checkout_form_submit` == `checkoutFormSubmit`, `modal__close-btn` == + `ModalCloseBtn`), for the element, its parent, and its + siblings. Text matching rejects short (1–2 char) substring false-positives and + only counts whole-word matches. Parent tag/attributes and sibling structure + contribute additional weighted scores. The best candidate must exceed + `score_threshold` (default `0.7`). + + After a candidate is picked, the resolved DOM element is re-snapshotted and + compared against the best candidate — protecting against DOM changes (e.g. + React re-renders) that shift element indices between the candidate scan and + element resolution. If they differ, healing fails with + `dom-changed-during-healing` instead of silently healing the wrong element. 5. **Locator generation** — stable XPath locators are generated for the best candidate: `@id` → `data-testid` → `@name` → `@aria-label` → `@type` → stable `@class` → visible text → positional XPath. @@ -324,6 +344,7 @@ Healing failures are non-fatal — the original exception is re-raised if healin | Best score below threshold | `below-threshold` | `on_healing_failure` | | Best index out of bounds | `index-out-of-bounds` | `on_healing_failure` | | Locator generation error | `generate-locator-error` | `on_healing_failure` | +| DOM changed between candidate scan and element resolution | `dom-changed-during-healing` | `on_healing_failure` | | No candidate passes DOM verification | `no-verified-locator` | `on_healing_failure` | | Candidate passes DOM verification | — | `on_healing_success` | @@ -331,12 +352,14 @@ Healing failures are non-fatal — the original exception is re-raised if healin (`SuccessHealingResult` also carries a `timestamp`): * `best_score` — highest similarity score found before the failure - (`below-threshold`, `index-out-of-bounds`, `generate-locator-error`, `no-verified-locator`), + (`below-threshold`, `index-out-of-bounds`, `generate-locator-error`, + `dom-changed-during-healing`, `no-verified-locator`), or `None` when no candidate could be scored (`no-snapshot`, `candidates-script-error`, `no-candidates`). * `score_threshold` — the configured acceptance threshold. * `candidates_count` — how many DOM candidates were compared, or `None` when candidates were never collected. * `breakdown` — a `SimilarityBreakdown` of the best candidate whenever one was scored - (`below-threshold`, `index-out-of-bounds`, `generate-locator-error`, `no-verified-locator`), + (`below-threshold`, `index-out-of-bounds`, `generate-locator-error`, + `dom-changed-during-healing`, `no-verified-locator`), or `None` when no candidate could be scored. The same breakdown is available on `SuccessHealingResult` after a successful heal. diff --git a/mops/selenium/core/core_element.py b/mops/selenium/core/core_element.py index 6d1f6820..89276355 100644 --- a/mops/selenium/core/core_element.py +++ b/mops/selenium/core/core_element.py @@ -31,6 +31,7 @@ from mops.self_healing.config import get_config from mops.self_healing.healer import FailedHealingResult, SuccessHealingResult from mops.self_healing.healer_factory import get_healer +from mops.self_healing.locator_generator import generate_locator from mops.shared_utils import _scaled_screenshot, cut_log_data from mops.utils.decorators import healing, retry from mops.utils.internal_utils import WAIT_EL, get_dict, is_group, safe_call @@ -503,6 +504,8 @@ def _get_base(self, wait_strategy: bool | Callable = True) -> SeleniumWebDriver """ Get driver with depends on parent element if available + :param wait_strategy: wait strategy for the parent element before it is used + as a base for the child lookup. ``True`` waits for the parent to be visible. :return: driver """ base = self.driver @@ -515,10 +518,7 @@ def _get_base(self, wait_strategy: bool | Callable = True) -> SeleniumWebDriver return base if self.parent: - if self.parent._is_element_still_available(self.parent._element): - base = self.parent._element - else: - base = self.parent._find_element(wait_parent=False) + base = self.parent._get_element(wait_strategy=wait_strategy) return base @@ -555,7 +555,14 @@ def _attempt_healing(self) -> SuccessHealingResult | None: try: healer = get_healer() locator_key = get_config().storage.extract_full_locator_key(self) - result = healer.heal(self.name, locator_key, self.locator, self.driver_wrapper) + result = healer.heal( + element_name=self.name, + locator_key=locator_key, + locator=self.locator, + driver_wrapper=self.driver_wrapper, + find_elements_fn=lambda tag: self.driver.find_elements(By.TAG_NAME, tag), + generate_locator_fn=generate_locator, + ) if type(result) is SuccessHealingResult: return result except Exception as exc: # noqa: BLE001 diff --git a/mops/self_healing/healer.py b/mops/self_healing/healer.py index 60736e5b..dc7cba83 100644 --- a/mops/self_healing/healer.py +++ b/mops/self_healing/healer.py @@ -3,12 +3,11 @@ from dataclasses import dataclass, field from datetime import datetime, timezone import logging +import re from typing import TYPE_CHECKING, Any from selenium.common.exceptions import WebDriverException -from selenium.webdriver.common.by import By -from mops.self_healing.locator_generator import generate_locator from mops.self_healing.snapshot import ElementSnapshot if TYPE_CHECKING: @@ -16,6 +15,10 @@ from mops.self_healing.snapshot import SnapshotStorage +_MIN_SUBSTRING_LENGTH = 3 +_CAMEL_BOUNDARY_RE = re.compile(r'([a-z0-9])([A-Z])') +_NON_WORD_RE = re.compile(r'[^a-zA-Z0-9]+') + logger = logging.getLogger('mops.self_healing') _GET_CANDIDATES_JS = """ @@ -290,14 +293,14 @@ def _fail( if self._on_healing_failure: self._on_healing_failure(result) - def heal( # noqa: PLR0911 + def heal( self, element_name: str, locator_key: str, locator: str, driver_wrapper: Any, - find_elements_fn: Callable[[str], list[Any]] | None = None, - generate_locator_fn: Callable[[Any, Any], list[str]] | None = None, + find_elements_fn: Callable[[str], list[Any]], + generate_locator_fn: Callable[[Any, Any], list[str]], ) -> SuccessHealingResult | None: """Try to find a healed locator for a failed element lookup. @@ -305,12 +308,10 @@ def heal( # noqa: PLR0911 :param locator_key: Storage key used to load the saved snapshot. :param locator: The original locator string (for the result record). :param driver_wrapper: Driver wrapper with an ``execute_script(script, *args)`` method. - :param find_elements_fn: Optional callback ``(tag: str) -> list`` to find - all elements with a given tag name. Defaults to Selenium's - ``driver.find_elements(By.TAG_NAME, tag)``. - :param generate_locator_fn: Optional callback ``(element, driver_wrapper) -> list[str]`` - to generate candidate locators from a live element. Defaults to - :func:`generate_locator`. + :param find_elements_fn: Callback ``(tag: str) -> list`` to find all + elements with a given tag name (backend-specific). + :param generate_locator_fn: Callback ``(element, driver_wrapper) -> list[str]`` + to generate candidate locators from a live element (backend-specific). :return: :class:`SuccessHealingResult` if healed, ``None`` otherwise. """ _record_attempt() @@ -320,21 +321,99 @@ def heal( # noqa: PLR0911 logger.info('Self-healing: no snapshot for "%s", skipping', element_name) return self._fail('no-snapshot', element_name, locator_key, locator) + candidates = self._collect_candidates(driver_wrapper, snapshot.tag, element_name, locator_key, locator) + if candidates is None: + return None + + scored = self._score_candidates(candidates, snapshot, element_name, locator_key, locator) + if scored is None: + return None + + best_score, best_index, best_breakdown = scored + healed_locators = self._resolve_element( + driver_wrapper, + find_elements_fn, + generate_locator_fn, + snapshot.tag, + best_index, + best_score, + best_breakdown, + candidates, + element_name, + locator_key, + locator, + ) + if healed_locators is None: + return None + + _record_heal_success(best_score) + result = SuccessHealingResult( + element_name=element_name, + original_locator=locator, + healed_locator=None, + healed_locators_candidates=healed_locators, + score=best_score, + breakdown=best_breakdown, + ) + + logger.info( + 'Self-healing: healed "%s" %s -> %s (score=%.2f)', + element_name, + locator, + healed_locators, + best_score, + ) + + return result + + def _collect_candidates( + self, + driver_wrapper: Any, + tag: str, + element_name: str, + locator_key: str, + locator: str, + ) -> list[dict] | None: + """Collect DOM candidates of the same tag; returns ``None`` on failure.""" try: - candidates_data: list[dict] = driver_wrapper.execute_script(_GET_CANDIDATES_JS, snapshot.tag) + candidates: list[dict] = driver_wrapper.execute_script(_GET_CANDIDATES_JS, tag) except WebDriverException as exc: logger.info('Self-healing: failed to get candidates for "%s": %s', element_name, exc) return self._fail('candidates-script-error', element_name, locator_key, locator, exc=exc) - if not candidates_data: + if not candidates: return self._fail('no-candidates', element_name, locator_key, locator, candidates_count=0) + return candidates + + def _score_candidates( + self, + candidates_data: list[dict], + snapshot: ElementSnapshot, + element_name: str, + locator_key: str, + locator: str, + ) -> tuple[float, int, SimilarityBreakdown] | None: + """Score all candidates and return the best ``(score, index, breakdown)``. + + Returns ``None`` when the best score is below the threshold. + """ best_score = -1.0 best_index = -1 best_breakdown: SimilarityBreakdown | None = None for item in candidates_data: - breakdown = _compute_similarity_breakdown(item, snapshot, self._scoring_weights) + # Normalize the candidate with the same rules applied to snapshots, + # so both sides of the comparison are cleaned equally. + raw_candidate_snapshot = _candidate_to_snapshot(item, snapshot.tag) + normalized_candidate_snapshot = self._storage.normalize_snapshot(raw_candidate_snapshot) + + breakdown = _compute_similarity_breakdown( + normalized_candidate_snapshot, + snapshot, + self._scoring_weights, + candidate_snapshot=raw_candidate_snapshot, + ) if breakdown.score > best_score: best_score = breakdown.score best_index = item['index'] @@ -347,7 +426,7 @@ def heal( # noqa: PLR0911 self._score_threshold, element_name, ) - return self._fail( + self._fail( 'below-threshold', element_name, locator_key, @@ -356,14 +435,31 @@ def heal( # noqa: PLR0911 candidates_count=len(candidates_data), breakdown=best_breakdown, ) + return None - # Get the actual element by index among elements of the same tag - _find = find_elements_fn or (lambda tag: driver_wrapper.driver.find_elements(By.TAG_NAME, tag)) - _gen = generate_locator_fn or generate_locator + return best_score, best_index, best_breakdown - healed_locators: list[str] | None = None + def _resolve_element( + self, + driver_wrapper: Any, + find_elements_fn: Callable[[str], list[Any]], + generate_locator_fn: Callable[[Any, Any], list[str]], + tag: str, + best_index: int, + best_score: float, + best_breakdown: SimilarityBreakdown | None, + candidates_data: list[dict], + element_name: str, + locator_key: str, + locator: str, + ) -> list[str] | None: + """Resolve the best-index element and generate locators for it. + + Returns the generated locator list, or ``None`` on failure (the failure + callback is fired inside). + """ try: - web_elements = _find(snapshot.tag) + web_elements = find_elements_fn(tag) if best_index >= len(web_elements): self._fail( 'index-out-of-bounds', @@ -375,8 +471,7 @@ def heal( # noqa: PLR0911 breakdown=best_breakdown, ) return None - healed_web_element = web_elements[best_index] - healed_locators = _gen(healed_web_element, driver_wrapper) + healed_locators = generate_locator_fn(web_elements[best_index], driver_wrapper) except Exception as exc: # noqa: BLE001 logger.info('Self-healing: failed to generate locator for "%s": %s', element_name, exc) self._fail( @@ -395,47 +490,38 @@ def heal( # noqa: PLR0911 _record_heal_failure('no-generated-locator') return None - _record_heal_success(best_score) - result = SuccessHealingResult( - element_name=element_name, - original_locator=locator, - healed_locator=None, - healed_locators_candidates=healed_locators, - score=best_score, - breakdown=best_breakdown, - ) - - logger.info( - 'Self-healing: healed "%s" %s -> %s (score=%.2f)', - element_name, - locator, - healed_locators, - best_score, - ) - - return result + return healed_locators -def _score_similarity( - candidate: dict[str, Any], - snapshot: ElementSnapshot, - weights: ScoringWeights | None = None, -) -> float: - """Compute a 0-1 similarity score between a candidate DOM element and a saved snapshot.""" - return _compute_similarity_breakdown(candidate, snapshot, weights).score +def _candidate_to_snapshot(candidate: dict[str, Any], tag: str) -> ElementSnapshot: + """Convert a raw DOM candidate dict (from the candidates JS) into an ElementSnapshot.""" + return ElementSnapshot( + tag=tag, + attributes=candidate.get('attrs', {}), + text=candidate.get('text', ''), + parent_tag=candidate.get('parentTag'), + parent_attributes=candidate.get('parentAttrs', {}), + siblings=candidate.get('siblings', []), + ) -def _compute_similarity_breakdown( # noqa: PLR0912, PLR0915 - candidate: dict[str, Any], +def _compute_similarity_breakdown( + candidate: ElementSnapshot, snapshot: ElementSnapshot, weights: ScoringWeights | None = None, + candidate_snapshot: ElementSnapshot | None = None, ) -> SimilarityBreakdown: """Compute a similarity score and a per-signal breakdown for one candidate. - The returned :class:`SimilarityBreakdown` contains the same aggregate - ``score`` as :func:`_score_similarity`, plus an ``AttributeMatch`` for every - snapshot attribute (and every weighted attribute) so callers can see which - attributes matched and which did not. + The returned :class:`SimilarityBreakdown` contains the aggregate 0-1 ``score`` + plus an ``AttributeMatch`` for every snapshot attribute (and every weighted + attribute) so callers can see which attributes matched and which did not. + + :param candidate: The normalized candidate snapshot to score — already cleaned + with the same rules as *snapshot* (see :meth:`SnapshotStorage.normalize_snapshot`). + :param candidate_snapshot: Optional raw (non-normalized) snapshot of the + candidate as found on the page, attached to the breakdown for diagnostics. + Defaults to *candidate*. """ w = weights or ScoringWeights() score = 0.0 @@ -447,7 +533,7 @@ def _compute_similarity_breakdown( # noqa: PLR0912, PLR0915 weighted_attrs = set(w.attribute) for attr in set(snapshot.attributes) | weighted_attrs: snap_val = snapshot.attributes.get(attr) - cand_val = candidate['attrs'].get(attr) + cand_val = candidate.attributes.get(attr) weight = w.attribute.get(attr, 0.0) if snap_val is None and cand_val is None: @@ -455,12 +541,7 @@ def _compute_similarity_breakdown( # noqa: PLR0912, PLR0915 if weight: total_weight += weight - if snap_val == cand_val: - attr_score = 1.0 - elif snap_val and cand_val: - attr_score = _token_overlap(snap_val, cand_val) - else: - attr_score = 0.0 + attr_score = _attribute_similarity(attr, snap_val, cand_val) score += weight * attr_score else: # Unweighted attribute — diagnostics only, binary match indicator @@ -477,7 +558,7 @@ def _compute_similarity_breakdown( # noqa: PLR0912, PLR0915 # Text similarity snap_text = snapshot.text - cand_text = candidate.get('text', '') + cand_text = candidate.text text_score: float | None = None if snap_text: total_weight += w.text @@ -492,17 +573,17 @@ def _compute_similarity_breakdown( # noqa: PLR0912, PLR0915 # Parent tag match parent_tag_matched: bool | None = None parent_attrs_score: float | None = None - if snapshot.parent_tag and candidate.get('parentTag'): + if snapshot.parent_tag and candidate.parent_tag: total_weight += w.parent - parent_tag_matched = candidate['parentTag'] == snapshot.parent_tag + parent_tag_matched = candidate.parent_tag == snapshot.parent_tag if parent_tag_matched: score += w.parent * 0.5 - parent_attrs_score = _attrs_overlap(snapshot.parent_attributes, candidate.get('parentAttrs', {})) + parent_attrs_score = _attrs_overlap(snapshot.parent_attributes, candidate.parent_attributes) score += w.parent * 0.5 * parent_attrs_score # Sibling similarity snap_siblings = snapshot.siblings - cand_siblings = candidate.get('siblings', []) + cand_siblings = candidate.siblings siblings_score: float | None = None if snap_siblings: total_weight += w.siblings @@ -511,14 +592,8 @@ def _compute_similarity_breakdown( # noqa: PLR0912, PLR0915 final_score = 0.0 if total_weight == 0 else score / total_weight - candidate_snapshot = ElementSnapshot( - tag=snapshot.tag, - attributes=candidate.get('attrs', {}), - text=cand_text, - parent_tag=candidate.get('parentTag'), - parent_attributes=candidate.get('parentAttrs', {}), - siblings=cand_siblings, - ) + if candidate_snapshot is None: + candidate_snapshot = candidate return SimilarityBreakdown( score=final_score, @@ -527,7 +602,7 @@ def _compute_similarity_breakdown( # noqa: PLR0912, PLR0915 text_candidate=cand_text, text_score=text_score, parent_tag_snapshot=snapshot.parent_tag, - parent_tag_candidate=candidate.get('parentTag'), + parent_tag_candidate=candidate.parent_tag, parent_tag_matched=parent_tag_matched, parent_attrs_score=parent_attrs_score, siblings_score=siblings_score, @@ -538,7 +613,7 @@ def _compute_similarity_breakdown( # noqa: PLR0912, PLR0915 def _token_overlap(a: str, b: str) -> float: - """Jaccard token overlap for strings (e.g. CSS class lists).""" + """Jaccard token overlap for strings (e.g. space-separated value lists).""" a_tokens = set(a.split()) b_tokens = set(b.split()) if not a_tokens or not b_tokens: @@ -548,22 +623,90 @@ def _token_overlap(a: str, b: str) -> float: return len(intersection) / len(union) +def _class_to_tokens(class_str: str) -> set[str]: + """Split a CSS class into canonical lowercase word tokens. + + Handles kebab-case, snake_case, camelCase, PascalCase and BEM/CSS-module + separators (``_``, ``__``, ``--``):: + + 'user-profile-card' -> {'user', 'profile', 'card'} + 'UserProfileCard' -> {'user', 'profile', 'card'} + 'checkout_form_submit' -> {'checkout', 'form', 'submit'} + 'checkoutFormSubmit' -> {'checkout', 'form', 'submit'} + 'modal__close-btn' -> {'modal', 'close', 'btn'} + 'ModalCloseBtn' -> {'modal', 'close', 'btn'} + + So classes that only differ in case or word separators compare as equal. + """ + # split camelCase / PascalCase word boundaries first + split_case = _CAMEL_BOUNDARY_RE.sub(r'\1 \2', class_str) + # then split on any non-alphanumeric separator (kebab, snake, __, ...) + tokens = _NON_WORD_RE.split(split_case) + return {token.lower() for token in tokens if token} + + +def _class_similarity(a: str, b: str) -> float: + """Jaccard similarity of two CSS classes after canonical word tokenization.""" + a_tokens = _class_to_tokens(a) + b_tokens = _class_to_tokens(b) + if not a_tokens or not b_tokens: + return 0.0 + if a_tokens == b_tokens: + return 1.0 + intersection = a_tokens & b_tokens + union = a_tokens | b_tokens + return len(intersection) / len(union) + + +def _attribute_similarity(attr: str, snap_val: str | None, cand_val: str | None) -> float: + """0-1 similarity of a single attribute value between snapshot and candidate. + + ``class`` uses canonical word-token comparison; everything else falls back to + exact match (``1.0``) or token overlap for partial matches. + """ + if snap_val is None or cand_val is None: + return 0.0 + if snap_val == cand_val: + return 1.0 + if attr == 'class': + return _class_similarity(snap_val, cand_val) + return _token_overlap(snap_val, cand_val) + + def _text_similarity(a: str, b: str) -> float: + """0-1 text similarity without short-substring false positives.""" a_lower = a.lower() b_lower = b.lower() if a_lower == b_lower: return 1.0 - if a_lower in b_lower or b_lower in a_lower: + # Substring matches are only meaningful for longer strings and must respect + # word boundaries — otherwise 's' or 'al' would match any longer text. + if ( + len(a_lower) >= _MIN_SUBSTRING_LENGTH + and len(b_lower) >= _MIN_SUBSTRING_LENGTH + and (_is_word_substring(a_lower, b_lower) or _is_word_substring(b_lower, a_lower)) + ): return 0.7 return _token_overlap(a_lower, b_lower) +def _is_word_substring(short: str, long: str) -> bool: + """Return True when *short* appears in *long* as a whole word.""" + return re.search(rf'\b{re.escape(short)}\b', long) is not None + + def _attrs_overlap(snap_attrs: dict[str, str], cand_attrs: dict[str, str]) -> float: - """Average match score across attributes present in the snapshot.""" + """Average match score across attributes present in the snapshot. + + Uses the same per-attribute comparator as element scoring, so e.g. ``class`` + values that differ only in case/separators still contribute. + """ if not snap_attrs: return 0.0 - matches = sum(1 for k, v in snap_attrs.items() if cand_attrs.get(k) == v) - return matches / len(snap_attrs) + total = sum( + _attribute_similarity(k, v, cand_attrs.get(k)) for k, v in snap_attrs.items() if cand_attrs.get(k) is not None + ) + return total / len(snap_attrs) def _siblings_similarity(snap_siblings: list[dict], cand_siblings: list[dict]) -> float: diff --git a/mops/self_healing/snapshot.py b/mops/self_healing/snapshot.py index 28dfa9c0..ca23b3f3 100644 --- a/mops/self_healing/snapshot.py +++ b/mops/self_healing/snapshot.py @@ -144,7 +144,7 @@ def save_from_element(self, element: Element, web_element: object, driver: objec siblings=raw['siblings'], ) - snapshot = self._normalize_snapshot(snapshot) + snapshot = self.normalize_snapshot(snapshot) self.save(locator_key, snapshot) self._saved_this_session.add(locator_key) @@ -188,8 +188,16 @@ def normalize_locator_key(self, key: str) -> str: key = pattern.sub(replacement, key) return key - def _normalize_snapshot(self, snapshot: ElementSnapshot) -> ElementSnapshot: - """Return a normalized copy of *snapshot* with dynamic data cleaned out.""" + def normalize_snapshot(self, snapshot: ElementSnapshot) -> ElementSnapshot: + """Return a normalized copy of *snapshot* with dynamic data cleaned out. + + This is applied both when a snapshot is saved (so the reference stored + in the storage is clean) and to DOM candidates during healing — so both + sides of the similarity comparison use the same normalization rules. + + External projects can plug their own cleanup via + :meth:`set_normalization_rules`. + """ return ElementSnapshot( tag=snapshot.tag, attributes=self._normalize_attrs(snapshot.attributes), diff --git a/tests/static_tests/unit/test_self_healing_breakdown.py b/tests/static_tests/unit/test_self_healing_breakdown.py new file mode 100644 index 00000000..7a5abd1b --- /dev/null +++ b/tests/static_tests/unit/test_self_healing_breakdown.py @@ -0,0 +1,294 @@ +from __future__ import annotations + +import re +from unittest.mock import MagicMock, patch + +from mops.self_healing.healer import AttributeMatch, Healer, SimilarityBreakdown +from mops.self_healing.snapshot import ElementSnapshot, JsonFileSnapshotStorage + + +def _make_snapshot(**overrides: str) -> ElementSnapshot: + """Build an ElementSnapshot with sensible defaults.""" + defaults = dict( + tag='button', + attributes={'id': 'submit'}, + text='Click', + parent_tag='form', + parent_attributes={}, + siblings=[], + ) + defaults.update(overrides) + return ElementSnapshot(**defaults) + + +def _make_storage(): + """Build a mock storage whose normalization is a no-op (candidates already clean).""" + storage = MagicMock() + storage.normalize_snapshot.side_effect = lambda snap: snap + return storage + + +def _make_candidate(index: int = 0, **extra: str) -> dict: + """Build a candidate dict with values matching the default snapshot.""" + return { + 'index': index, + 'attrs': {'id': 'submit'}, + 'text': 'Click', + 'parentTag': 'form', + 'parentAttrs': {}, + 'siblings': [], + **extra, + } + + +def _make_driver_wrapper(candidates=None, elements=None): + """Build a mock driver_wrapper with execute_script and .driver.find_elements.""" + dw = MagicMock() + dw.execute_script.return_value = candidates or [] + dw.driver.find_elements.return_value = elements or [] + return dw + + +def _heal(storage, driver, threshold: float = 0.7): + """Run heal() with mocked backend callbacks and return the result.""" + healer = Healer(storage, threshold) + return healer.heal( + 'btn', 'key', '#submit', driver, + find_elements_fn=lambda tag: driver.driver.find_elements('tag name', tag), + generate_locator_fn=lambda *_: ['xpath=//button'], + ) + + +# --------------------------------------------------------------------------- +# success result carries a breakdown +# --------------------------------------------------------------------------- + + +def test_success_result_has_breakdown(): + """A successful heal exposes a non-empty SimilarityBreakdown on the result.""" + storage = _make_storage() + storage.load.return_value = _make_snapshot() + driver = _make_driver_wrapper(candidates=[_make_candidate()], elements=[MagicMock()]) + + result = _heal(storage, driver) + + assert result is not None + assert isinstance(result.breakdown, SimilarityBreakdown) + assert result.breakdown.score == result.score + assert 'id' in result.breakdown.attributes + assert result.breakdown.attributes['id'].matched is True + assert result.breakdown.attributes['id'].snapshot_value == 'submit' + assert result.breakdown.attributes['id'].candidate_value == 'submit' + assert result.breakdown.text_snapshot == 'Click' + assert result.breakdown.text_candidate == 'Click' + assert result.breakdown.text_score == 1.0 + assert result.breakdown.parent_tag_matched is True + assert result.breakdown.parent_tag_snapshot == 'form' + assert result.breakdown.parent_tag_candidate == 'form' + assert result.breakdown.siblings_snapshot_count == 0 + assert result.breakdown.siblings_candidate_count == 0 + # the raw DOM snapshot of the best candidate is attached + assert result.breakdown.candidate_snapshot is not None + assert result.breakdown.candidate_snapshot.tag == 'button' + assert result.breakdown.candidate_snapshot.attributes == {'id': 'submit'} + assert result.breakdown.candidate_snapshot.text == 'Click' + + +# --------------------------------------------------------------------------- +# matched / mismatched split +# --------------------------------------------------------------------------- + + +def test_breakdown_splits_matched_and_mismatched_attributes(): + """matched_attributes and mismatched_attributes split correctly.""" + storage = _make_storage() + storage.load.return_value = _make_snapshot(attributes={'id': 'submit', 'class': 'btn'}) + driver = _make_driver_wrapper( + candidates=[_make_candidate(attrs={'id': 'submit', 'class': 'btn-other'})], + elements=[MagicMock()], + ) + + result = _heal(storage, driver, threshold=0.0) + + assert result is not None + breakdown = result.breakdown + assert breakdown.matched_attributes == ['id'] + assert breakdown.mismatched_attributes == ['class'] + assert breakdown.attributes['class'].snapshot_value == 'btn' + assert breakdown.attributes['class'].candidate_value == 'btn-other' + assert breakdown.attributes['class'].matched is False + + +# --------------------------------------------------------------------------- +# dynamic data detection +# --------------------------------------------------------------------------- + + +def test_breakdown_detects_dynamic_attribute(): + """A changed dynamic id shows up as an unmatched attribute with both raw values.""" + storage = _make_storage() + storage.load.return_value = _make_snapshot(attributes={'id': 'user-123'}) + driver = _make_driver_wrapper( + candidates=[_make_candidate(attrs={'id': 'user-456'})], + elements=[MagicMock()], + ) + + result = _heal(storage, driver, threshold=0.0) + + assert result is not None + id_match = result.breakdown.attributes['id'] + assert isinstance(id_match, AttributeMatch) + assert id_match.snapshot_value == 'user-123' + assert id_match.candidate_value == 'user-456' + assert id_match.matched is False + assert id_match.score == 0.0 + assert 'id' in result.breakdown.mismatched_attributes + + +def test_breakdown_partial_token_overlap(): + """Class values with partial overlap produce a score strictly between 0 and 1.""" + storage = _make_storage() + storage.load.return_value = _make_snapshot(attributes={'class': 'btn btn-primary'}) + driver = _make_driver_wrapper( + candidates=[_make_candidate(attrs={'class': 'btn btn-secondary'})], + elements=[MagicMock()], + ) + + result = _heal(storage, driver, threshold=0.0) + + assert result is not None + class_match = result.breakdown.attributes['class'] + assert class_match.matched is False + assert 0.0 < class_match.score < 1.0 + + +def test_breakdown_snapshot_attribute_missing_on_candidate(): + """A snapshot attribute absent on the candidate is reported as unmatched.""" + storage = _make_storage() + storage.load.return_value = _make_snapshot(attributes={'id': 'submit', 'name': 'go'}) + driver = _make_driver_wrapper( + candidates=[_make_candidate(attrs={'id': 'submit'})], # no 'name' + elements=[MagicMock()], + ) + + result = _heal(storage, driver, threshold=0.0) + + assert result is not None + name_match = result.breakdown.attributes['name'] + assert name_match.snapshot_value == 'go' + assert name_match.candidate_value is None + assert name_match.matched is False + assert name_match.score == 0.0 + assert 'name' in result.breakdown.mismatched_attributes + + +def test_breakdown_includes_unweighted_snapshot_attributes(): + """Attributes outside ScoringWeights appear in the breakdown with weight 0.0.""" + storage = _make_storage() + storage.load.return_value = _make_snapshot(attributes={'id': 'submit', 'data-testid': 'submit-btn'}) + driver = _make_driver_wrapper( + candidates=[_make_candidate(attrs={'id': 'submit', 'data-testid': 'submit-btn'})], + elements=[MagicMock()], + ) + + result = _heal(storage, driver, threshold=0.0) + + assert result is not None + data_match = result.breakdown.attributes['data-testid'] + assert data_match.snapshot_value == 'submit-btn' + assert data_match.matched is True + assert data_match.weight == 0.0 + + +# --------------------------------------------------------------------------- +# failure path — breakdown still reported +# --------------------------------------------------------------------------- + + +def test_below_threshold_failure_has_breakdown(): + """below-threshold failure still reports the best candidate's breakdown.""" + callback = MagicMock() + storage = _make_storage() + # Snapshot with mismatched attributes/text so score stays low + storage.load.return_value = _make_snapshot(attributes={'id': 'user-123'}, text='foo') + driver = MagicMock() + driver.driver = MagicMock() + driver.execute_script.return_value = [ + _make_candidate(attrs={'id': 'user-456'}, text='bar', parentTag='div'), + ] + healer = Healer(storage, 0.95, on_healing_failure=callback) + + result = healer.heal( + 'btn', 'key', '#submit', driver, + find_elements_fn=lambda tag: driver.driver.find_elements('tag name', tag), + generate_locator_fn=lambda *_: ['xpath=//button'], + ) + + assert result is None + args = callback.call_args[0][0] + assert args.reason == 'below-threshold' + assert isinstance(args.breakdown, SimilarityBreakdown) + assert args.breakdown.attributes['id'].snapshot_value == 'user-123' + assert args.breakdown.attributes['id'].candidate_value == 'user-456' + assert args.breakdown.attributes['id'].matched is False + # even on failure the best candidate's raw snapshot is attached + assert args.breakdown.candidate_snapshot is not None + assert args.breakdown.candidate_snapshot.attributes['id'] == 'user-456' + assert args.breakdown.candidate_snapshot.text == 'bar' + + +# --------------------------------------------------------------------------- +# candidate normalization — same rules as snapshots +# --------------------------------------------------------------------------- + + +def test_healing_normalizes_candidate_before_scoring(tmp_path): + """A CSS-module hash in the candidate's class is cleaned before comparison.""" + storage = JsonFileSnapshotStorage(str(tmp_path)) + snapshot = _make_snapshot(attributes={'class': 'primary'}) # already-normalized reference + driver = _make_driver_wrapper( + candidates=[_make_candidate(attrs={'class': 'Button_1a2b3__xy primary'})], + elements=[MagicMock()], + ) + + healer = Healer(storage, 0.7) + with patch.object(storage, 'load', return_value=snapshot): + result = healer.heal( + 'btn', 'key', '#submit', driver, + find_elements_fn=lambda tag: driver.driver.find_elements('tag name', tag), + generate_locator_fn=lambda *_: ['xpath=//button'], + ) + + assert result is not None + class_match = result.breakdown.attributes['class'] + assert class_match.matched is True # both sides normalized + assert class_match.snapshot_value == 'primary' + assert class_match.candidate_value == 'primary' + # the raw candidate snapshot still reflects the actual DOM value + assert result.breakdown.candidate_snapshot.attributes['class'] == 'Button_1a2b3__xy primary' + + +def test_healing_applies_custom_normalization_to_candidate(tmp_path): + """Custom rules (e.g. numeric id suffixes) are applied to candidates too.""" + storage = JsonFileSnapshotStorage(str(tmp_path)) + storage.set_normalization_rules([*storage._normalization_rules, ('id', re.compile(r'-\d+'), '')]) + snapshot = _make_snapshot(attributes={'id': 'user'}) + driver = _make_driver_wrapper( + candidates=[_make_candidate(attrs={'id': 'user-456'})], + elements=[MagicMock()], + ) + + healer = Healer(storage, 0.7) + with patch.object(storage, 'load', return_value=snapshot): + result = healer.heal( + 'btn', 'key', '#submit', driver, + find_elements_fn=lambda tag: driver.driver.find_elements('tag name', tag), + generate_locator_fn=lambda *_: ['xpath=//button'], + ) + + assert result is not None + id_match = result.breakdown.attributes['id'] + assert id_match.matched is True + assert id_match.snapshot_value == 'user' + assert id_match.candidate_value == 'user' + assert result.breakdown.candidate_snapshot.attributes['id'] == 'user-456' diff --git a/tests/static_tests/unit/test_self_healing_callbacks.py b/tests/static_tests/unit/test_self_healing_callbacks.py index d310f152..735af825 100644 --- a/tests/static_tests/unit/test_self_healing_callbacks.py +++ b/tests/static_tests/unit/test_self_healing_callbacks.py @@ -1,6 +1,6 @@ from __future__ import annotations -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from selenium.common.exceptions import WebDriverException @@ -40,6 +40,13 @@ def _make_snapshot(**overrides: str) -> ElementSnapshot: return ElementSnapshot(**defaults) +def _make_storage(): + """Build a mock storage whose normalization is a no-op (candidates already clean).""" + storage = MagicMock() + storage.normalize_snapshot.side_effect = lambda snap: snap + return storage + + def _make_driver_wrapper(candidates=None, elements=None): """Build a mock driver_wrapper with execute_script and .driver.find_elements.""" dw = MagicMock() @@ -60,6 +67,21 @@ def _make_candidate(index: int = 0, **extra: str) -> dict: } +def _heal(healer, *args, generate_fn=None): + """Call heal() with backend callbacks bound to the mock driver. + + :param generate_fn: Optional locator generator mock; defaults to a stub + returning a single xpath locator. + """ + driver = args[3] + gen = generate_fn if generate_fn is not None else (lambda *_: ['xpath=//button']) + return healer.heal( + *args, + find_elements_fn=lambda tag: driver.driver.find_elements('tag name', tag), + generate_locator_fn=gen, + ) + + # --------------------------------------------------------------------------- # on_healing_success # --------------------------------------------------------------------------- @@ -68,14 +90,13 @@ def _make_candidate(index: int = 0, **extra: str) -> dict: def test_success_callback_not_fired_during_heal(): """on_healing_success does NOT fire during heal() — it fires later in _try_healed_locators.""" callback = MagicMock() - storage = MagicMock() + storage = _make_storage() storage.load.return_value = _make_snapshot() driver = _make_driver_wrapper(candidates=[_make_candidate()], elements=[MagicMock()]) healer = Healer(storage, 0.7) - with patch('mops.self_healing.healer.generate_locator', return_value=['xpath=//button']): - result = healer.heal('btn', 'key', '#submit', driver) + result = _heal(healer, 'btn', 'key', '#submit', driver) assert result is not None assert isinstance(result, SuccessHealingResult) @@ -85,14 +106,13 @@ def test_success_callback_not_fired_during_heal(): def test_success_callback_not_set(): """Healing works even when on_healing_success is None.""" - storage = MagicMock() + storage = _make_storage() storage.load.return_value = _make_snapshot() driver = _make_driver_wrapper(candidates=[_make_candidate()], elements=[MagicMock()]) healer = Healer(storage, 0.7) - with patch('mops.self_healing.healer.generate_locator', return_value=['xpath=//button']): - result = healer.heal('btn', 'key', '#submit', driver) + result = _heal(healer, 'btn', 'key', '#submit', driver) assert result is not None @@ -105,11 +125,11 @@ def test_success_callback_not_set(): def test_failure_no_snapshot(): """No snapshot → on_healing_failure fired with FailedHealingResult.""" callback = MagicMock() - storage = MagicMock() + storage = _make_storage() storage.load.return_value = None healer = Healer(storage, 0.7, on_healing_failure=callback) - result = healer.heal('btn', 'missing-key', '#submit', MagicMock()) + result = _heal(healer, 'btn', 'missing-key', '#submit', MagicMock()) assert result is None callback.assert_called_once() @@ -129,14 +149,14 @@ def test_failure_no_snapshot(): def test_failure_candidates_script_raises(): """driver.execute_script raises → on_healing_failure fired.""" callback = MagicMock() - storage = MagicMock() + storage = _make_storage() storage.load.return_value = _make_snapshot() driver = MagicMock() driver.driver = MagicMock() driver.execute_script.side_effect = WebDriverException('browser error') healer = Healer(storage, 0.7, on_healing_failure=callback) - result = healer.heal('btn', 'key', '#submit', driver) + result = _heal(healer, 'btn', 'key', '#submit', driver) assert result is None _assert_failed( @@ -152,14 +172,14 @@ def test_failure_candidates_script_raises(): def test_failure_no_candidates(): """Empty candidates list → on_healing_failure fired.""" callback = MagicMock() - storage = MagicMock() + storage = _make_storage() storage.load.return_value = _make_snapshot() driver = MagicMock() driver.driver = MagicMock() driver.execute_script.return_value = [] healer = Healer(storage, 0.7, on_healing_failure=callback) - result = healer.heal('btn', 'key', '#submit', driver) + result = _heal(healer, 'btn', 'key', '#submit', driver) assert result is None _assert_failed(callback, reason='no-candidates', best_score=None, score_threshold=0.7, candidates_count=0) @@ -168,7 +188,7 @@ def test_failure_no_candidates(): def test_failure_score_below_threshold(): """Low similarity score → on_healing_failure fired.""" callback = MagicMock() - storage = MagicMock() + storage = _make_storage() # Snapshot with mismatched attributes/text so score stays low storage.load.return_value = _make_snapshot(attributes={'class': 'x'}, text='foo') driver = MagicMock() @@ -178,7 +198,7 @@ def test_failure_score_below_threshold(): ] healer = Healer(storage, 0.95, on_healing_failure=callback) - result = healer.heal('btn', 'key', '#submit', driver) + result = _heal(healer, 'btn', 'key', '#submit', driver) assert result is None _assert_failed(callback, reason='below-threshold', score_threshold=0.95, candidates_count=1) @@ -191,7 +211,7 @@ def test_failure_score_below_threshold(): def test_failure_best_index_out_of_bounds(): """best_index >= len(web_elements) → on_healing_failure fired.""" callback = MagicMock() - storage = MagicMock() + storage = _make_storage() storage.load.return_value = _make_snapshot() driver = MagicMock() driver.driver = MagicMock() @@ -204,8 +224,7 @@ def test_failure_best_index_out_of_bounds(): driver.driver.find_elements.return_value = [MagicMock()] # only 1 element → index 1 is OOB healer = Healer(storage, 0.7, on_healing_failure=callback) - with patch('mops.self_healing.healer.generate_locator', return_value=['xpath=//button']): - result = healer.heal('btn', 'key', '#submit', driver) + result = _heal(healer, 'btn', 'key', '#submit', driver) assert result is None _assert_failed(callback, reason='index-out-of-bounds', score_threshold=0.7, candidates_count=2) @@ -216,13 +235,13 @@ def test_failure_best_index_out_of_bounds(): def test_failure_generate_locator_raises(): """generate_locator raises → on_healing_failure fired.""" callback = MagicMock() - storage = MagicMock() + storage = _make_storage() storage.load.return_value = _make_snapshot() driver = _make_driver_wrapper(candidates=[_make_candidate()], elements=[MagicMock()]) healer = Healer(storage, 0.7, on_healing_failure=callback) - with patch('mops.self_healing.healer.generate_locator', side_effect=WebDriverException('no locator')): - result = healer.heal('btn', 'key', '#submit', driver) + gen = MagicMock(side_effect=WebDriverException('no locator')) + result = _heal(healer, 'btn', 'key', '#submit', driver, generate_fn=gen) assert result is None _assert_failed(callback, reason='generate-locator-error', error='no locator', candidates_count=1) @@ -232,11 +251,11 @@ def test_failure_generate_locator_raises(): def test_failure_callback_not_set(): """Healing failure works even when on_healing_failure is None.""" - storage = MagicMock() + storage = _make_storage() storage.load.return_value = None healer = Healer(storage, 0.7) - result = healer.heal('btn', 'key', '#submit', MagicMock()) + result = _heal(healer, 'btn', 'key', '#submit', MagicMock()) assert result is None @@ -248,15 +267,14 @@ def test_failure_callback_not_set(): def test_multiple_locators_stored_in_result(): """All generated locators are stored in healed_locators_candidates.""" - storage = MagicMock() + storage = _make_storage() storage.load.return_value = _make_snapshot() driver = _make_driver_wrapper(candidates=[_make_candidate()], elements=[MagicMock()]) healer = Healer(storage, 0.7) locators = ['xpath=//button[1]', 'xpath=//button[2]', 'xpath=//button[3]'] - with patch('mops.self_healing.healer.generate_locator', return_value=locators): - result = healer.heal('btn', 'key', '#submit', driver) + result = _heal(healer, 'btn', 'key', '#submit', driver, generate_fn=lambda *_: locators) assert result is not None assert result.healed_locators_candidates == locators @@ -265,7 +283,7 @@ def test_multiple_locators_stored_in_result(): # --------------------------------------------------------------------------- -# siblings in _score_similarity +# siblings in similarity scoring # --------------------------------------------------------------------------- @@ -276,7 +294,7 @@ def _make_siblings_snapshot(siblings: list[dict]): def test_siblings_matching_boosts_score(): """Matching siblings increase the similarity score compared to no siblings.""" - storage = MagicMock() + storage = _make_storage() siblings = [{'tag': 'span', 'attrs': {'class': 'helper'}, 'text': 'label'}] storage.load.return_value = _make_siblings_snapshot(siblings) driver = MagicMock() @@ -290,8 +308,7 @@ def test_siblings_matching_boosts_score(): healer_no_threshold = Healer(storage, 0.0) - with patch('mops.self_healing.healer.generate_locator', return_value=['xpath=//button']): - result = healer_no_threshold.heal('btn', 'key', '#submit', driver) + result = _heal(healer_no_threshold, 'btn', 'key', '#submit', driver) assert result is not None assert result.score > 0 @@ -303,7 +320,7 @@ def test_siblings_matching_boosts_score(): def test_mismatched_siblings_lower_score(): """Having siblings but none matching the snapshot yields a lower score.""" - storage = MagicMock() + storage = _make_storage() snap_siblings = [{'tag': 'span', 'attrs': {'class': 'helper'}, 'text': 'label'}] storage.load.return_value = _make_siblings_snapshot(snap_siblings) @@ -320,8 +337,7 @@ def test_mismatched_siblings_lower_score(): healer = Healer(storage, 0.0) - with patch('mops.self_healing.healer.generate_locator', return_value=['xpath=//button']): - result = healer.heal('btn', 'key', '#submit', driver) + result = _heal(healer, 'btn', 'key', '#submit', driver) assert result is not None # The attrs/text/parent all match perfectly, so score starts high, @@ -336,14 +352,13 @@ def test_mismatched_siblings_lower_score(): def test_success_callback_not_fired_by_heal(): """on_healing_success is not fired by heal() — only by _try_healed_locators.""" - storage = MagicMock() + storage = _make_storage() storage.load.return_value = _make_snapshot() driver = _make_driver_wrapper(candidates=[_make_candidate()], elements=[MagicMock()]) healer = Healer(storage, 0.7) - with patch('mops.self_healing.healer.generate_locator', return_value=['xpath=//button']): - result = healer.heal('btn', 'key', '#submit', driver) + result = _heal(healer, 'btn', 'key', '#submit', driver) assert result is not None assert isinstance(result, SuccessHealingResult) @@ -351,7 +366,7 @@ def test_success_callback_not_fired_by_heal(): def test_failure_callback_does_not_crash_healing(): """A misbehaving on_healing_failure does not prevent returning None.""" - storage = MagicMock() + storage = _make_storage() storage.load.return_value = None def crash(_result): @@ -363,4 +378,4 @@ def crash(_result): # The exception propagates — users should see broken callbacks with pytest.raises(RuntimeError, match='callback failed'): - healer.heal('btn', 'key', '#submit', MagicMock()) + _heal(healer, 'btn', 'key', '#submit', MagicMock()) diff --git a/tests/static_tests/unit/test_self_healing_factory.py b/tests/static_tests/unit/test_self_healing_factory.py new file mode 100644 index 00000000..2b8402db --- /dev/null +++ b/tests/static_tests/unit/test_self_healing_factory.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +import pytest + +import mops.self_healing.healer_factory as factory_module +from mops.self_healing import configure +from mops.self_healing.healer import Healer, ScoringWeights +from mops.self_healing.healer_factory import _HealerState, get_healer +from mops.self_healing.snapshot import JsonFileSnapshotStorage + + +@pytest.fixture(autouse=True) +def _cleanup_config(): + yield + configure( + save_snapshots=False, + heal_locators=False, + score_threshold=0.7, + storage=None, + scoring_weights=None, + on_healing_success=None, + on_healing_failure=None, + ) + + +def _fresh_state(monkeypatch: pytest.MonkeyPatch) -> None: + """Reset the module-level healer singleton state.""" + monkeypatch.setattr(_HealerState, 'storage', None) + monkeypatch.setattr(_HealerState, 'score_threshold', None) + monkeypatch.setattr(_HealerState, 'scoring_weights', None) + monkeypatch.setattr(_HealerState, 'on_healing_failure', None) + monkeypatch.setattr(_HealerState, 'healer', None) + + +def _base_config(tmp_path) -> JsonFileSnapshotStorage: + storage = JsonFileSnapshotStorage(str(tmp_path)) + configure(storage=storage, score_threshold=0.7, scoring_weights=ScoringWeights()) + return storage + + +def test_get_healer_reuses_instance_when_config_unchanged(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + _fresh_state(monkeypatch) + _base_config(tmp_path) + + first = get_healer() + second = get_healer() + + assert isinstance(first, Healer) + assert first is second + + +def test_get_healer_recreates_on_new_scoring_weights(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + _fresh_state(monkeypatch) + _base_config(tmp_path) + + before = get_healer() + + configure(scoring_weights=ScoringWeights(attribute={'class': 1.0})) + after = get_healer() + + assert before is not after + assert after._scoring_weights.attribute == {'class': 1.0} + + +def test_get_healer_recreates_on_new_threshold(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + _fresh_state(monkeypatch) + _base_config(tmp_path) + + before = get_healer() + + configure(score_threshold=0.95) + after = get_healer() + + assert before is not after + assert after._score_threshold == 0.95 + + +def test_get_healer_recreates_on_new_failure_callback(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + _fresh_state(monkeypatch) + _base_config(tmp_path) + + before = get_healer() + + callback = MagicMock() + configure(on_healing_failure=callback) + after = get_healer() + + assert before is not after + assert after._on_healing_failure is callback + + +def test_get_healer_recreates_on_new_storage(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + _fresh_state(monkeypatch) + _base_config(tmp_path) + + before = get_healer() + + other_storage = JsonFileSnapshotStorage(str(tmp_path / 'other')) + configure(storage=other_storage) + after = get_healer() + + assert before is not after + assert after._storage is other_storage + + +def test_get_healer_reflects_in_place_weight_mutation(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + """Mutating the same ScoringWeights object is visible to the Healer immediately.""" + _fresh_state(monkeypatch) + storage = JsonFileSnapshotStorage(str(tmp_path)) + weights = ScoringWeights(attribute={'id': 1.0}) + configure(storage=storage, score_threshold=0.7, scoring_weights=weights) + + healer = get_healer() + + weights.attribute['class'] = 0.5 # mutate the object in place + + assert healer._scoring_weights is weights + assert healer._scoring_weights.attribute['class'] == 0.5 + + +def test_get_healer_returns_none_storage_healer(monkeypatch: pytest.MonkeyPatch) -> None: + """get_healer() with no configured storage creates a Healer with None storage.""" + _fresh_state(monkeypatch) + configure(save_snapshots=True, heal_locators=True) + + healer = get_healer() + + assert healer._storage is None diff --git a/tests/static_tests/unit/test_self_healing_normalization.py b/tests/static_tests/unit/test_self_healing_normalization.py index d19ce212..cf0c05d7 100644 --- a/tests/static_tests/unit/test_self_healing_normalization.py +++ b/tests/static_tests/unit/test_self_healing_normalization.py @@ -152,7 +152,7 @@ def test_normalize_snapshot_cleans_attrs_and_text(): {'tag': 'span', 'text': 'Hint', 'attrs': {'class': 'helper #rdT'}}, ], ) - result = storage._normalize_snapshot(snapshot) + result = storage.normalize_snapshot(snapshot) assert result.tag == 'button' # Class tokens cleaned (active and #sb removed) assert result.attributes['class'] == 'btn' @@ -179,7 +179,7 @@ def test_normalize_snapshot_without_parent(): parent_attributes={}, siblings=[], ) - result = storage._normalize_snapshot(snapshot) + result = storage.normalize_snapshot(snapshot) assert result.parent_tag is None diff --git a/tests/static_tests/unit/test_self_healing_scoring.py b/tests/static_tests/unit/test_self_healing_scoring.py new file mode 100644 index 00000000..e62ee352 --- /dev/null +++ b/tests/static_tests/unit/test_self_healing_scoring.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +from unittest.mock import MagicMock + +from mops.self_healing.healer import ( + Healer, + _class_similarity, + _class_to_tokens, + _text_similarity, +) +from mops.self_healing.snapshot import ElementSnapshot + + +def _make_snapshot(**overrides: str) -> ElementSnapshot: + """Build an ElementSnapshot with sensible defaults.""" + defaults = dict( + tag='button', + attributes={'id': 'submit'}, + text='Click', + parent_tag='form', + parent_attributes={}, + siblings=[], + ) + defaults.update(overrides) + return ElementSnapshot(**defaults) + + +def _make_candidate(index: int = 0, **extra: str) -> dict: + """Build a candidate dict with values matching the default snapshot.""" + return { + 'index': index, + 'attrs': {'id': 'submit'}, + 'text': 'Click', + 'parentTag': 'form', + 'parentAttrs': {}, + 'siblings': [], + **extra, + } + + +def _make_storage(): + """Build a mock storage whose normalization is a no-op (candidates already clean).""" + storage = MagicMock() + storage.normalize_snapshot.side_effect = lambda snap: snap + return storage + + +def _heal(storage, driver, threshold: float = 0.7, on_healing_failure=None): + """Run heal() with mocked backend callbacks.""" + healer = Healer(storage, threshold, on_healing_failure=on_healing_failure) + return healer.heal( + 'btn', 'key', '#submit', driver, + find_elements_fn=lambda tag: driver.driver.find_elements('tag name', tag), + generate_locator_fn=lambda *_: ['xpath=//button'], + ) + + +def _driver_with(candidates, elements): + dw = MagicMock() + dw.execute_script.return_value = candidates or [] + dw.driver.find_elements.return_value = elements or [] + return dw + + +# --------------------------------------------------------------------------- +# canonical CSS class comparison — word tokenization +# --------------------------------------------------------------------------- + + +def test_class_to_tokens_kebab_snake_camel_pascal(): + """Same meaning in different word styles produces the same token set.""" + # kebab-case <-> PascalCase + assert _class_to_tokens('user-profile-card') == {'user', 'profile', 'card'} + assert _class_to_tokens('UserProfileCard') == {'user', 'profile', 'card'} + # snake_case <-> camelCase + assert _class_to_tokens('checkout_form_submit') == {'checkout', 'form', 'submit'} + assert _class_to_tokens('checkoutFormSubmit') == {'checkout', 'form', 'submit'} + # BEM (__ separator) <-> PascalCase + assert _class_to_tokens('modal__close-btn') == {'modal', 'close', 'btn'} + assert _class_to_tokens('ModalCloseBtn') == {'modal', 'close', 'btn'} + + +def test_class_similarity_same_meaning_is_one(): + """Classes differing only in case/separators are the same class.""" + assert _class_similarity('user-profile-card', 'UserProfileCard') == 1.0 + assert _class_similarity('checkout_form_submit', 'checkoutFormSubmit') == 1.0 + assert _class_similarity('modal__close-btn', 'ModalCloseBtn') == 1.0 + + +def test_class_similarity_partial_overlap(): + """Shared words give a score between 0 and 1.""" + score = _class_similarity('user-profile-card', 'user-profile-settings') + assert 0.0 < score < 1.0 + + +def test_class_similarity_distinct_is_zero(): + assert _class_similarity('avatar', 'submit-btn') == 0.0 + + +def test_class_similarity_empty_is_zero(): + assert _class_similarity('', 'row') == 0.0 + + +def test_healing_matches_class_with_different_case(): + """Element class differs in case/separators → canonical match, high score.""" + storage = _make_storage() + storage.load.return_value = _make_snapshot(attributes={'class': 'user-profile-card'}) + driver = _driver_with( + candidates=[_make_candidate(attrs={'class': 'UserProfileCard'})], + elements=[MagicMock()], + ) + + result = _heal(storage, driver) + + assert result is not None + class_match = result.breakdown.attributes['class'] + assert class_match.score == 1.0 # canonical comparison + assert class_match.matched is False # exact string equality is still False + + +def test_healing_parent_class_canonicalized(): + """Parent class differing in case/separators contributes a full parent score.""" + storage = _make_storage() + storage.load.return_value = _make_snapshot( + parent_tag='div', + parent_attributes={'class': 'checkout_form_submit'}, + ) + driver = _driver_with( + candidates=[_make_candidate(parentTag='div', parentAttrs={'class': 'checkoutFormSubmit'})], + elements=[MagicMock()], + ) + + result = _heal(storage, driver) + + assert result is not None + assert result.breakdown.parent_attrs_score == 1.0 + + +def test_healing_sibling_class_canonicalized(): + """Sibling class differing in case/separators contributes a full sibling score.""" + snap_sib = {'tag': 'span', 'attrs': {'class': 'modal__close-btn'}, 'text': 'close'} + cand_sib = {'tag': 'span', 'attrs': {'class': 'ModalCloseBtn'}, 'text': 'close'} + storage = _make_storage() + storage.load.return_value = _make_snapshot(siblings=[snap_sib]) + driver = _driver_with( + candidates=[_make_candidate(siblings=[cand_sib])], + elements=[MagicMock()], + ) + + result = _heal(storage, driver) + + assert result is not None + assert result.breakdown.siblings_score == 1.0 + + +# --------------------------------------------------------------------------- +# text similarity — no short-substring false positives +# --------------------------------------------------------------------------- + + +def test_text_similarity_exact_is_one(): + assert _text_similarity('Hello', 'hello') == 1.0 + + +def test_text_similarity_short_substring_rejected(): + """'s' must not match any longer text.""" + assert _text_similarity('s', 'm_john_456') == 0.0 + + +def test_text_similarity_two_char_substring_rejected(): + """'al' must not match 'alex'.""" + assert _text_similarity('al', 'alex') == 0.0 + + +def test_text_similarity_word_substring_accepted(): + """'User' matches 'User: alex' as a whole word.""" + assert _text_similarity('User', 'User: alex') == 0.7 + + +def test_text_similarity_substring_inside_underscored_rejected(): + """'alex' inside 'm_alex_123' is not a whole word.""" + assert _text_similarity('alex', 'm_alex_123') == 0.0 + + +def test_healing_text_match_helps_target_over_short_avatar(): + """A single-char avatar text no longer steals the match via substring.""" + storage = _make_storage() + storage.load.return_value = _make_snapshot(attributes={'id': 'x'}, text='User: alex') + driver = MagicMock() + driver.driver = MagicMock() + driver.execute_script.return_value = [ + # target: shares tokens with the snapshot text + _make_candidate(attrs={'id': 'x'}, text='User: john', parentTag='div'), + # avatar: single char — must NOT match 'User: alex' as a substring anymore + _make_candidate(attrs={'id': 'x'}, text='s', parentTag='div'), + ] + driver.driver.find_elements.return_value = [MagicMock()] + + result = _heal(storage, driver, threshold=0.0) + + assert result is not None + assert result.breakdown.text_candidate == 'User: john' # target wins, not the avatar + assert result.breakdown.text_score is not None + assert result.breakdown.text_score > 0.0 + + +# --------------------------------------------------------------------------- +# DOM index drift protection +# --------------------------------------------------------------------------- +# TODO: the dom-changed-during-healing guard was temporarily removed from +# Healer.heal(). Re-add it together with these tests when index-drift +# protection is reintroduced. diff --git a/tests/static_tests/unit/test_self_healing_stats.py b/tests/static_tests/unit/test_self_healing_stats.py index 2ee2a5b6..c8c04c57 100644 --- a/tests/static_tests/unit/test_self_healing_stats.py +++ b/tests/static_tests/unit/test_self_healing_stats.py @@ -1,6 +1,6 @@ from __future__ import annotations -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import mops.self_healing.healer as healer_module from mops.self_healing.healer import HealingStats, get_healing_stats @@ -21,6 +21,13 @@ def _make_snapshot(**overrides: str) -> ElementSnapshot: return ElementSnapshot(**defaults) +def _make_storage(): + """Build a mock storage whose normalization is a no-op (candidates already clean).""" + storage = MagicMock() + storage.normalize_snapshot.side_effect = lambda snap: snap + return storage + + def _make_candidate(index: int = 0, **extra: str) -> dict: """Build a candidate dict with values matching the default snapshot.""" return { @@ -33,6 +40,21 @@ def _make_candidate(index: int = 0, **extra: str) -> dict: } +def _heal(healer, *args, generate_fn=None): + """Call heal() with backend callbacks bound to the mock driver. + + :param generate_fn: Optional locator generator mock; defaults to a stub + returning a single xpath locator. + """ + driver = args[3] + gen = generate_fn if generate_fn is not None else (lambda *_: ['xpath=//button']) + return healer.heal( + *args, + find_elements_fn=lambda tag: driver.driver.find_elements('tag name', tag), + generate_locator_fn=gen, + ) + + def _fresh_stats(monkeypatch) -> HealingStats: """Replace the module-level stats with a clean instance for the test.""" stats = HealingStats() @@ -49,7 +71,7 @@ def test_get_healing_stats_returns_live_stats(monkeypatch): def test_stats_count_successful_heal(monkeypatch): """A successful heal() increments attempts and healed, and records a score.""" stats = _fresh_stats(monkeypatch) - storage = MagicMock() + storage = _make_storage() storage.load.return_value = _make_snapshot() driver = MagicMock() driver.driver = MagicMock() @@ -57,8 +79,7 @@ def test_stats_count_successful_heal(monkeypatch): driver.driver.find_elements.return_value = [MagicMock()] healer = healer_module.Healer(storage, 0.7) - with patch('mops.self_healing.healer.generate_locator', return_value=['xpath=//button']): - assert healer.heal('btn', 'key', '#submit', driver) is not None + assert _heal(healer, 'btn', 'key', '#submit', driver) is not None assert stats.attempts == 1 assert stats.healed == 1 @@ -71,11 +92,11 @@ def test_stats_count_successful_heal(monkeypatch): def test_stats_count_failure_by_reason(monkeypatch): """A failed heal() increments failed and records the reason.""" stats = _fresh_stats(monkeypatch) - storage = MagicMock() + storage = _make_storage() storage.load.return_value = None healer = healer_module.Healer(storage, 0.7) - assert healer.heal('btn', 'key', '#submit', MagicMock()) is None + assert _heal(healer, 'btn', 'key', '#submit', MagicMock()) is None assert stats.attempts == 1 assert stats.healed == 0 @@ -87,7 +108,7 @@ def test_stats_count_failure_by_reason(monkeypatch): def test_stats_accumulate_across_heals(monkeypatch): """Multiple heals accumulate attempts/healed/failed.""" stats = _fresh_stats(monkeypatch) - storage = MagicMock() + storage = _make_storage() storage.load.return_value = _make_snapshot() driver = MagicMock() driver.driver = MagicMock() @@ -95,16 +116,14 @@ def test_stats_accumulate_across_heals(monkeypatch): driver.driver.find_elements.return_value = [MagicMock()] healer = healer_module.Healer(storage, 0.7) - with patch('mops.self_healing.healer.generate_locator', return_value=['xpath=//button']): - assert healer.heal('btn', 'key1', '#submit', driver) is not None + assert _heal(healer, 'btn', 'key1', '#submit', driver) is not None # Same snapshot key but different element name — still a separate heal() call - with patch('mops.self_healing.healer.generate_locator', return_value=['xpath=//button']): - assert healer.heal('btn2', 'key2', '#submit', driver) is not None + assert _heal(healer, 'btn2', 'key2', '#submit', driver) is not None # A failing heal storage.load.return_value = None - assert healer.heal('btn3', 'missing', '#submit', driver) is None + assert _heal(healer, 'btn3', 'missing', '#submit', driver) is None assert stats.attempts == 3 assert stats.healed == 2 @@ -115,7 +134,7 @@ def test_stats_accumulate_across_heals(monkeypatch): def test_stats_avg_best_score_over_multiple_healed(monkeypatch): """avg_best_score averages best scores across all successful heals.""" stats = _fresh_stats(monkeypatch) - storage = MagicMock() + storage = _make_storage() storage.load.return_value = _make_snapshot() driver = MagicMock() driver.driver = MagicMock() @@ -124,13 +143,11 @@ def test_stats_avg_best_score_over_multiple_healed(monkeypatch): # First heal: perfect match driver.execute_script.return_value = [_make_candidate()] - with patch('mops.self_healing.healer.generate_locator', return_value=['xpath=//button']): - healer.heal('btn', 'key1', '#submit', driver) + _heal(healer, 'btn', 'key1', '#submit', driver) # Second heal: partial match (same text, different id/parent) → lower but > 0 score driver.execute_script.return_value = [_make_candidate(attrs={'id': 'other'}, text='Click', parentTag='div')] - with patch('mops.self_healing.healer.generate_locator', return_value=['xpath=//button']): - healer.heal('btn2', 'key2', '#submit', driver) + _heal(healer, 'btn2', 'key2', '#submit', driver) assert stats.healed == 2 first_score = stats.avg_best_score * 2 # sum of both scores diff --git a/tests/web_tests/test_self_healing.py b/tests/web_tests/test_self_healing.py index eb817649..11baf75c 100644 --- a/tests/web_tests/test_self_healing.py +++ b/tests/web_tests/test_self_healing.py @@ -22,8 +22,8 @@ def _backend_cls(page): def _patch_generate_locator(page, side_effect): """Patch generate_locator for the current backend.""" if isinstance(page.driver_wrapper, PlayDriver): - return patch('mops.self_healing.locator_generator.generate_locator_pw', side_effect=side_effect) - return patch('mops.self_healing.healer.generate_locator', side_effect=side_effect) + return patch('mops.playwright.play_element.generate_locator_pw', side_effect=side_effect) + return patch('mops.selenium.core.core_element.generate_locator', side_effect=side_effect) def _key(element): @@ -337,3 +337,193 @@ def test_healing_failure_below_threshold_has_breakdown(second_playground_page): # Even on failure the best candidate's raw snapshot is attached assert result.breakdown.candidate_snapshot is not None assert result.breakdown.candidate_snapshot.attributes.get('class') == 'broken-row' + + +def test_healing_normalizes_dynamic_class_before_scoring(second_playground_page): + """A CSS-module hash in the class is cleaned on both sides before comparison.""" + results = [] + configure(on_healing_success=results.append) + + broken_row = _save_snapshot_for_broken_element(second_playground_page, element_name='row with cards') + # Add a CSS-module-hash token to the row class — normalization must strip it + second_playground_page.driver_wrapper.execute_script(""" + var elements = document.querySelectorAll('.row'); + for (var i = 0; i < elements.length; i++) { + elements[i].className = elements[i].className + ' Button_1a2b3__xy'; + } + """) + + cls = broken_row.get_attribute('class', silent=True) + assert cls is not None, 'Self-healing did not recover the element' + + assert results, 'on_healing_success was not fired' + result = results[0] + + # `matched` compares NORMALIZED values on both sides. 'Button_1a2b3__xy' is a + # CSS-module hash — a default normalization rule strips that exact token, so + # snapshot ('row ...') and candidate ('row ... Button_1a2b3__xy' -> 'row ...') + # become equal. This is NOT "any occurrence matches": an ordinary class token + # would survive normalization and make matched=False. + assert result.breakdown.attributes['class'].matched is True, 'normalized class should match' + # the raw candidate snapshot still shows the hash token from the real DOM + assert 'Button_1a2b3__xy' in result.breakdown.candidate_snapshot.attributes.get('class', '') + + +def test_healing_canonicalizes_class_case(second_playground_page): + """Real browser: class differing only in case is matched canonically.""" + results = [] + configure(on_healing_success=results.append) + + broken_row = _save_snapshot_for_broken_element(second_playground_page, element_name='row with cards') + # 'row' -> 'Row': same word, different case — must still match canonically + second_playground_page.driver_wrapper.execute_script(""" + var elements = document.querySelectorAll('.row'); + for (var i = 0; i < elements.length; i++) { + elements[i].className = 'Row'; + } + """) + + cls = broken_row.get_attribute('class', silent=True) + assert cls is not None, 'Self-healing did not recover the element' + + assert results, 'on_healing_success was not fired' + result = results[0] + class_match = result.breakdown.attributes['class'] + assert class_match.candidate_value == 'Row' + assert class_match.matched is False # exact string equality is False + # before the canonical comparison 'row' vs 'Row' scored 0.0 (different tokens); + # now the shared word 'row' gives a real contribution + assert class_match.score > 0.0, 'canonical class comparison should give a real score' + + +def test_healing_matches_parent_class_canonically(second_playground_page): + """Real browser: parent snake_case class matches a camelCase candidate class.""" + results = [] + configure(on_healing_success=results.append) + + driver = second_playground_page.driver_wrapper + # build a container with a snake_case class and a child span + driver.execute_script(""" + var container = document.createElement('div'); + container.className = 'checkout_form_submit'; + container.innerHTML = 'child'; + document.body.appendChild(container); + """) + + # snapshot the child while its parent still has the snake_case class + child = Element('#canonical-parent-child', name='canonical parent child') + child.wait_visibility(silent=True) + + storage = get_config().storage + snapshot = storage.load(_key(child)) + assert snapshot is not None + assert snapshot.parent_tag == 'div' + assert snapshot.parent_attributes.get('class') == 'checkout_form_submit' + + # flip the parent class to camelCase — same words, different case/separators + driver.execute_script( + "document.getElementById('canonical-parent-child').parentElement.className = 'checkoutFormSubmit';" + ) + + # break the child locator and heal + broken = Element('#broken-canonical-parent-child', name=child.name) + storage.save(_key(broken), snapshot) + + healed = broken.get_attribute('id', silent=True) + assert healed is not None, 'Self-healing did not recover the element' + + assert results, 'on_healing_success was not fired' + result = results[0] + assert result.breakdown.parent_tag_matched is True + assert result.breakdown.parent_attrs_score == 1.0, 'parent class should match canonically' + + +def test_healing_matches_sibling_class_canonically(second_playground_page): + """Real browser: sibling BEM class matches a PascalCase candidate class.""" + results = [] + configure(on_healing_success=results.append) + + driver = second_playground_page.driver_wrapper + # build a container: a target span plus a sibling with a BEM-style class + driver.execute_script(""" + var container = document.createElement('div'); + container.className = 'container'; + container.innerHTML = + 'target' + + 'close'; + document.body.appendChild(container); + """) + + target = Element('#sibling-target', name='sibling target') + target.wait_visibility(silent=True) + + storage = get_config().storage + snapshot = storage.load(_key(target)) + assert snapshot is not None + assert snapshot.siblings, 'expected a sibling in the snapshot' + assert snapshot.siblings[0]['attrs'].get('class') == 'modal__close-btn' + + # flip the sibling class to PascalCase — same words, different case/separators + driver.execute_script( + "document.getElementById('sibling-target').nextElementSibling.className = 'ModalCloseBtn';" + ) + + # break the target locator and heal + broken = Element('#broken-sibling-target', name=target.name) + storage.save(_key(broken), snapshot) + + healed = broken.get_attribute('id', silent=True) + assert healed is not None, 'Self-healing did not recover the element' + + assert results, 'on_healing_success was not fired' + result = results[0] + assert result.breakdown.siblings_score == 1.0, 'sibling class should match canonically' + + +def test_healing_matches_element_class_canonically(second_playground_page): + """Real browser: the ELEMENT's own class kebab vs PascalCase passes scoring.""" + results = [] + configure(on_healing_success=results.append) + + driver = second_playground_page.driver_wrapper + # element whose own class is the kebab form + driver.execute_script(""" + var el = document.createElement('span'); + el.className = 'user-profile-card'; + el.textContent = 'canonical-element-child'; + document.body.appendChild(el); + """) + + el = Element('//span[.="canonical-element-child"]', name='canonical element child') + el.wait_visibility(silent=True) + + storage = get_config().storage + snapshot = storage.load(_key(el)) + assert snapshot is not None + assert snapshot.attributes.get('class') == 'user-profile-card' + + # flip the ELEMENT's own class to PascalCase — same words, different format + driver.execute_script(""" + var spans = document.querySelectorAll('span'); + for (var i = 0; i < spans.length; i++) { + if (spans[i].textContent === 'canonical-element-child') { + spans[i].className = 'UserProfileCard'; + } + } + """) + + # break the locator and heal — scoring must accept the canonical class match + broken = Element('#broken-canonical-element', name=el.name) + storage.save(_key(broken), snapshot) + + cls = broken.get_attribute('class', silent=True) + assert cls is not None, 'Self-healing did not recover the element' + assert cls == 'UserProfileCard' + + assert results, 'on_healing_success was not fired' + result = results[0] + class_match = result.breakdown.attributes['class'] + assert class_match.snapshot_value == 'user-profile-card' + assert class_match.candidate_value == 'UserProfileCard' + assert class_match.matched is False # exact string equality is False + assert class_match.score == 1.0, 'element class should match canonically' From 32cc23c99ce91f7a2825038125f439453eca3c66 Mon Sep 17 00:00:00 2001 From: VladimirPodolian Date: Mon, 10 Aug 2026 14:11:49 +0200 Subject: [PATCH 27/30] Fix default attributes & parent / siblings score reduce on absence --- mops/self_healing/TODO.md | 55 +++++++++ mops/self_healing/healer.py | 44 +++++-- pyproject.toml | 1 + .../unit/test_self_healing_scoring.py | 113 ++++++++++++++++++ 4 files changed, 205 insertions(+), 8 deletions(-) create mode 100644 mops/self_healing/TODO.md diff --git a/mops/self_healing/TODO.md b/mops/self_healing/TODO.md new file mode 100644 index 00000000..b9dfec3b --- /dev/null +++ b/mops/self_healing/TODO.md @@ -0,0 +1,55 @@ +# Self-Healing TODO + +Backlog of not-yet-implemented improvements for the self-healing feature. + +## 1. Multiple ancestors comparison (P4) + +**Status:** not implemented + +**Problem:** scoring compares only the immediate DOM parent. When a new wrapper +(e.g. a React `InputContainer__childrenWrapper`) is inserted, the old snapshot's +parent context (e.g. `parent-studios-add-modal` modal container) is no longer +the direct parent, and the parent similarity drops. + +**Goal:** compare several ancestors up the DOM and accept a candidate if its +ancestor chain contains a matching container (e.g. the modal), even when an +intermediate wrapper was added. + +**What it touches:** +- JS: `_GET_CANDIDATES_JS` / `_GET_ELEMENT_SNAPSHOT_JS` — collect an ancestor + chain (3–5 levels) instead of only the immediate parent +- `ElementSnapshot` + snapshot JSON storage format — new `ancestors` field + (keep backward compatibility with old snapshots) +- `_score_similarity` — match each snapshot ancestor against the candidate's + ancestor chain ("search for the modal container anywhere in the chain") +- Breakdown: expose ancestor-level scores + +**Reference case (currently passing via P1–P3, would be made robust):** +`input` inside a modal (`parent-studios-add-modal`) got wrapped by +`InputContainer__childrenWrapper` — placeholder anchor + HTML defaults + optional +siblings push the score above threshold, but the immediate-parent signal is lost. + +## 2. DOM index drift guard (temporarily removed) + +**Status:** removed, tests stubbed out in +`tests/static_tests/unit/test_self_healing_scoring.py` + +**Problem:** the resolved element at `best_index` may no longer match the best +candidate if the DOM changed between the candidates JS scan and `find_elements` +(React re-renders). + +**Re-add:** re-snapshot the resolved element and compare with the best candidate; +on mismatch fail with a `dom-changed-during-healing` reason. + +## 3. Placeholder-aware text matching (P2) + +**Status:** not implemented + +**Problem:** `{username}` in a snapshot text never matches a real value +(`m_john_456`), and naive "normalize any text to `{username}`" risks merging +distinct elements. + +**Goal:** per-placeholder matchers with regex patterns (`{username}`, `{id}`, +`{date}`, `{token}`); `snapshot_text == '{username}'` should match only values +that fit the username pattern, and ideally work for partial templates too +(`"User: {username}"`). diff --git a/mops/self_healing/healer.py b/mops/self_healing/healer.py index dc7cba83..073b69ea 100644 --- a/mops/self_healing/healer.py +++ b/mops/self_healing/healer.py @@ -505,7 +505,7 @@ def _candidate_to_snapshot(candidate: dict[str, Any], tag: str) -> ElementSnapsh ) -def _compute_similarity_breakdown( +def _compute_similarity_breakdown( # noqa: PLR0912 candidate: ElementSnapshot, snapshot: ElementSnapshot, weights: ScoringWeights | None = None, @@ -541,7 +541,7 @@ def _compute_similarity_breakdown( if weight: total_weight += weight - attr_score = _attribute_similarity(attr, snap_val, cand_val) + attr_score = _attribute_similarity(attr, snap_val, cand_val, tag=snapshot.tag) score += weight * attr_score else: # Unweighted attribute — diagnostics only, binary match indicator @@ -570,7 +570,9 @@ def _compute_similarity_breakdown( text_score = 0.0 score += w.text * text_score - # Parent tag match + # Parent tag match. An empty ``snapshot.parent_attributes`` means the snapshot + # carries no parent attribute info — that is an absence of data, not a + # mismatch, so it is never penalized. parent_tag_matched: bool | None = None parent_attrs_score: float | None = None if snapshot.parent_tag and candidate.parent_tag: @@ -578,14 +580,17 @@ def _compute_similarity_breakdown( parent_tag_matched = candidate.parent_tag == snapshot.parent_tag if parent_tag_matched: score += w.parent * 0.5 - parent_attrs_score = _attrs_overlap(snapshot.parent_attributes, candidate.parent_attributes) - score += w.parent * 0.5 * parent_attrs_score + if snapshot.parent_attributes: + parent_attrs_score = _attrs_overlap(snapshot.parent_attributes, candidate.parent_attributes) + score += w.parent * 0.5 * parent_attrs_score - # Sibling similarity + # Sibling similarity is optional: a candidate may lose its siblings entirely + # (e.g. a new React wrapper), so an empty candidate sibling list is treated as + # missing data rather than a mismatch. snap_siblings = snapshot.siblings cand_siblings = candidate.siblings siblings_score: float | None = None - if snap_siblings: + if snap_siblings and cand_siblings: total_weight += w.siblings siblings_score = _siblings_similarity(snap_siblings, cand_siblings) score += w.siblings * siblings_score @@ -658,13 +663,36 @@ def _class_similarity(a: str, b: str) -> float: return len(intersection) / len(union) -def _attribute_similarity(attr: str, snap_val: str | None, cand_val: str | None) -> float: +_HTML_DEFAULT_ATTRS: dict[str, dict[str, str]] = { + 'input': {'type': 'text'}, + 'button': {'type': 'submit'}, + 'script': {'type': 'text/javascript'}, + 'style': {'type': 'text/css'}, + 'link': {'type': 'text/css'}, + 'ol': {'type': '1'}, +} + + +def _attribute_similarity( + attr: str, + snap_val: str | None, + cand_val: str | None, + tag: str | None = None, +) -> float: """0-1 similarity of a single attribute value between snapshot and candidate. ``class`` uses canonical word-token comparison; everything else falls back to exact match (``1.0``) or token overlap for partial matches. + + HTML defaults are applied when *tag* is known: e.g. an ```` without a + ``type`` attribute is ``type="text"``, a ``