From 86a350ec07e707c40b81ed24fb1c8e9c8454eb9a Mon Sep 17 00:00:00 2001 From: Dhiraj Das Date: Sat, 24 Oct 2026 20:44:44 +0530 Subject: [PATCH 1/3] Fix stale element clicks and wait for pending requests in 1.0.6 --- CHANGELOG.md | 8 ++ README.md | 8 +- pyproject.toml | 2 +- tests/integration/test_click_regressions.py | 130 ++++++++++++++++++++ tests/unit/test_config.py | 3 +- tests/unit/test_selenium_wrappers.py | 93 +++++++++++++- waitless/__init__.py | 2 +- waitless/config.py | 7 +- waitless/selenium_integration.py | 49 +++++++- 9 files changed, 287 insertions(+), 15 deletions(-) create mode 100644 tests/integration/test_click_regressions.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e415b17..c383ad7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.6] - 2026-10-24 + +### Fixed +- Re-find elements with the same locator after the page settles, so list redraws during stabilization do not return an outdated element. +- Retry a locator-backed wrapped click once after a stale element reference, and wait for the element to be enabled using the configured timeout and polling interval. +- Set the default and strict network idle thresholds to zero so delayed XHR/fetch responses finish before interactions. Background traffic can still be allowed with an explicit threshold. +- Add local-browser regressions for delayed list replacement and disabled buttons covered by an overlay during a delayed fetch, plus unit coverage for stale retries and enabled-state timeouts. + ## [1.0.5] - 2026-10-18 ### Changed diff --git a/README.md b/README.md index ba85480..da718be 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ from waitless import stabilize, StabilizationConfig config = StabilizationConfig( timeout=10, # Max wait time (seconds) mutation_rate_threshold=50, # mutations/sec considered stable (allows animations) - network_idle_threshold=2, # Max pending requests (allows background traffic) + network_idle_threshold=0, # Wait for all tracked XHR/fetch requests animation_detection=True, # Track CSS animations (non-blocking in normal mode) strictness='normal', # 'strict' | 'normal' | 'relaxed' debug_mode=True # Enable logging @@ -191,7 +191,11 @@ Sample output: ### Network Threshold Warning -The default `network_idle_threshold=2` allows some background traffic. +The default and `StabilizationConfig.strict()` use `network_idle_threshold=0`, +so all tracked XHR/fetch requests must finish before the network is considered +idle. This prevents a delayed response from leaving a loading overlay in place +when an interaction starts. Set `network_idle_threshold=2` to allow up to two +requests to remain pending for background analytics or polling. Many apps have background traffic that never stops: - Analytics calls diff --git a/pyproject.toml b/pyproject.toml index c53f05c..b55aa17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "waitless" -version = "1.0.5" +version = "1.0.6" description = "Auto-wait for Selenium in Python: fix flaky tests by waiting for DOM, network, animation and layout stability instead of time.sleep." readme = "README.md" license = "MIT" diff --git a/tests/integration/test_click_regressions.py b/tests/integration/test_click_regressions.py new file mode 100644 index 0000000..35a3106 --- /dev/null +++ b/tests/integration/test_click_regressions.py @@ -0,0 +1,130 @@ +"""Real-browser regressions for delayed list redraws and loading overlays.""" + +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from threading import Thread +import time + +import pytest +from selenium import webdriver +from selenium.webdriver.chrome.options import Options +from selenium.webdriver.common.by import By + +from waitless import StabilizationConfig, stabilize + + +PAGE = rb""" +
+""" + + +@pytest.fixture(scope="module") +def local_page(): + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path in ('/delay', '/slow'): + time.sleep(0.5 if self.path == '/delay' else 1.5) + body = b'ready' + else: + body = PAGE + self.send_response(200) + self.send_header('Content-Type', 'text/html') + self.send_header('Content-Length', str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + server = ThreadingHTTPServer(('127.0.0.1', 0), Handler) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f'http://127.0.0.1:{server.server_port}/' + finally: + server.shutdown() + server.server_close() + thread.join() + + +@pytest.fixture +def driver(): + options = Options() + options.add_argument('--headless') + options.add_argument('--no-sandbox') + options.add_argument('--disable-dev-shm-usage') + browser = webdriver.Chrome(options=options) + try: + yield browser + finally: + browser.quit() + + +@pytest.mark.parametrize('strict', [False, True]) +def test_find_returns_fresh_element_after_delayed_list_redraw(driver, local_page, strict): + config = StabilizationConfig.strict() if strict else StabilizationConfig() + wrapped = stabilize(driver, config=config) + wrapped.get(local_page) + driver.execute_script('startList()') + + button = wrapped.find_element(By.ID, 'action') + + assert button.get_attribute('data-generation') == 'fresh' + button.click() + assert driver.find_element(By.ID, 'result').text == 'clicked' + + +def test_click_recovers_when_list_is_replaced_after_find(driver, local_page): + wrapped = stabilize(driver, config=StabilizationConfig.strict()) + wrapped.get(local_page) + driver.execute_script("render('initial')") + button = wrapped.find_element(By.ID, 'action') + original_id = button.unwrap().id + driver.execute_script('redraw()') + + button.click() + + assert button.unwrap().id != original_id + assert button.get_attribute('data-generation') == 'fresh' + assert driver.find_element(By.ID, 'result').text == 'clicked' + + +@pytest.mark.parametrize('mode', ['default', 'strict', 'background']) +def test_click_waits_for_disabled_button_and_delayed_fetch_overlay(driver, local_page, mode): + config = StabilizationConfig.strict() if mode == 'strict' else StabilizationConfig() + if mode == 'background': + # Even when a caller permits pending requests, click must wait for enabled. + config = config.with_overrides(network_idle_threshold=2) + wrapped = stabilize(driver, config=config) + wrapped.get(local_page) + driver.execute_script('startOverlay()') + + wrapped.find_element(By.ID, 'action').click() + + assert driver.find_element(By.ID, 'result').text == 'clicked' + assert driver.find_elements(By.ID, 'overlay') == [] + assert driver.execute_script('return window.__waitless__.pendingRequests') == 0 diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 00aad90..34851c3 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -17,7 +17,7 @@ def test_default_values(self): assert config.timeout == 10.0 assert config.dom_settle_time == 0.1 - assert config.network_idle_threshold == 2 + assert config.network_idle_threshold == 0 assert config.animation_detection is True assert config.strictness == 'normal' assert config.debug_mode is False @@ -117,6 +117,7 @@ def test_factory_strict(self): assert config.strictness == 'strict' assert config.timeout == 5.0 assert config.animation_detection is True + assert config.network_idle_threshold == 0 assert config.layout_stability is True def test_factory_relaxed(self): diff --git a/tests/unit/test_selenium_wrappers.py b/tests/unit/test_selenium_wrappers.py index 3e9beaa..b2524ef 100644 --- a/tests/unit/test_selenium_wrappers.py +++ b/tests/unit/test_selenium_wrappers.py @@ -5,6 +5,7 @@ from unittest.mock import Mock import pytest +from selenium.common.exceptions import StaleElementReferenceException, TimeoutException import waitless.selenium_integration as selenium_integration from waitless.selenium_integration import ( @@ -18,6 +19,8 @@ class FakeWebElement: def __init__(self): self.find_element = Mock() self.find_elements = Mock() + self.click = Mock() + self.is_enabled = Mock(return_value=True) class FakeDriver: @@ -34,7 +37,7 @@ def engine(): return SimpleNamespace( wait_for_stability=Mock(), teardown=Mock(), - config=SimpleNamespace(debug_mode=False), + config=SimpleNamespace(debug_mode=False, timeout=0.1, poll_interval=0.01), ) @@ -92,7 +95,7 @@ def test_descendant_element_lookups_stay_wrapped(engine): assert wrapped_child.unwrap() is child assert [item.unwrap() for item in wrapped_children] == children assert all(isinstance(item, StabilizedWebElement) for item in wrapped_children) - assert engine.wait_for_stability.call_count == 2 + assert engine.wait_for_stability.call_count == 3 def test_shadow_root_element_lookups_stay_wrapped(engine): @@ -107,7 +110,7 @@ def test_shadow_root_element_lookups_stay_wrapped(engine): assert isinstance(wrapped_child, StabilizedWebElement) assert wrapped_child.unwrap() is child - engine.wait_for_stability.assert_called_once_with() + assert engine.wait_for_stability.call_count == 2 def test_shadow_root_is_unwrapped_in_script_arguments(engine): @@ -155,3 +158,87 @@ def test_unstabilize_tears_down_engine_for_original_driver(engine): assert result is driver engine.teardown.assert_called_once_with() assert integration.is_stabilized(driver) is False + + +@pytest.mark.parametrize("context", ["driver", "element", "shadow_root"]) +def test_find_element_refinds_after_settling_with_same_locator(engine, context): + raw = FakeDriver() if context == "driver" else FakeWebElement() + first, fresh = FakeWebElement(), FakeWebElement() + events = [] + raw.find_element.side_effect = lambda *a, **kw: events.append("find") or ( + first if events.count("find") == 1 else fresh + ) + engine.wait_for_stability.side_effect = lambda: events.append("settle") + if context == "driver": + wrapped = StabilizedWebDriver(raw, engine) + elif context == "element": + wrapped = StabilizedWebElement(raw, engine) + else: + wrapped = selenium_integration._StabilizedSearchContext(raw, engine) + + result = wrapped.find_element(by="css selector", value="#button") + + assert result.unwrap() is fresh + assert events == ["settle", "find", "settle", "find"] + assert raw.find_element.call_count == 2 + for call in raw.find_element.call_args_list: + assert call.kwargs == {"by": "css selector", "value": "#button"} + + +@pytest.mark.parametrize("stale_method", ["is_enabled", "click"]) +def test_click_refinds_once_when_enabled_check_or_click_is_stale(engine, stale_method): + first, fresh = FakeWebElement(), FakeWebElement() + getattr(first, stale_method).side_effect = StaleElementReferenceException() + refind = Mock(return_value=fresh) + wrapped = StabilizedWebElement(first, engine, refind=refind) + + wrapped.click() + + refind.assert_called_once_with() + fresh.click.assert_called_once_with() + assert wrapped.unwrap() is fresh + assert engine.wait_for_stability.call_count == 2 + + +def test_click_does_not_retry_a_second_stale_failure(engine): + first, fresh = FakeWebElement(), FakeWebElement() + first.click.side_effect = StaleElementReferenceException() + fresh.click.side_effect = StaleElementReferenceException() + refind = Mock(return_value=fresh) + + with pytest.raises(StaleElementReferenceException): + StabilizedWebElement(first, engine, refind=refind).click() + + refind.assert_called_once_with() + first.click.assert_called_once_with() + fresh.click.assert_called_once_with() + + +def test_click_without_locator_propagates_stale_failure(engine): + element = FakeWebElement() + element.click.side_effect = StaleElementReferenceException() + + with pytest.raises(StaleElementReferenceException): + StabilizedWebElement(element, engine).click() + + element.click.assert_called_once_with() + + +def test_click_waits_until_enabled(engine): + element = FakeWebElement() + element.is_enabled.side_effect = [False, False, True] + + StabilizedWebElement(element, engine).click() + + assert element.is_enabled.call_count == 3 + element.click.assert_called_once_with() + + +def test_click_times_out_without_clicking_a_disabled_element(engine): + element = FakeWebElement() + element.is_enabled.return_value = False + + with pytest.raises(TimeoutException): + StabilizedWebElement(element, engine).click() + + element.click.assert_not_called() diff --git a/waitless/__init__.py b/waitless/__init__.py index 63ee115..ddaacdb 100644 --- a/waitless/__init__.py +++ b/waitless/__init__.py @@ -36,7 +36,7 @@ driver = unstabilize(driver) # Back to original behavior """ -__version__ = '1.0.5' +__version__ = '1.0.6' __author__ = 'Dhiraj Das' # Public API diff --git a/waitless/config.py b/waitless/config.py index b56a8e7..3edabca 100644 --- a/waitless/config.py +++ b/waitless/config.py @@ -57,8 +57,8 @@ class StabilizationConfig: effects ~30/sec) while catching loading bursts (100+/sec). network_idle_threshold: Maximum pending requests allowed for stability. - Default 2 (allows background analytics/polling). - Set to 0 for strict mode if all requests must complete. + Default 0 (waits for all tracked requests to complete). + Raise to allow background analytics/polling. animation_detection: Whether to wait for CSS animations/transitions. Default True. Disable for apps with infinite animations. @@ -101,7 +101,7 @@ class StabilizationConfig: timeout: float = 10.0 dom_settle_time: float = 0.1 mutation_rate_threshold: float = 50.0 # mutations/sec - allows animations - network_idle_threshold: int = 2 # Allow background traffic + network_idle_threshold: int = 0 # Wait for all tracked requests animation_detection: bool = True layout_stability: bool = True strictness: StrictnessLevel = 'normal' @@ -208,6 +208,7 @@ def strict(cls) -> 'StabilizationConfig': """Factory for strict configuration (all signals, lower timeout).""" return cls( strictness='strict', + network_idle_threshold=0, timeout=5.0, animation_detection=True, layout_stability=True, diff --git a/waitless/selenium_integration.py b/waitless/selenium_integration.py index d045fb8..1eb3297 100644 --- a/waitless/selenium_integration.py +++ b/waitless/selenium_integration.py @@ -11,7 +11,7 @@ import functools import logging import weakref -from typing import Optional, Any, List, Dict, TYPE_CHECKING +from typing import Optional, Any, Callable, List, Dict, TYPE_CHECKING from weakref import WeakKeyDictionary from .config import StabilizationConfig, DEFAULT_CONFIG @@ -39,10 +39,13 @@ def __init__(self, context: Any, engine: StabilizationEngine): self._engine = engine def find_element(self, *args, **kwargs) -> 'StabilizedWebElement': + self._engine.wait_for_stability() + self._context.find_element(*args, **kwargs) self._engine.wait_for_stability() return StabilizedWebElement( self._context.find_element(*args, **kwargs), self._engine, + refind=functools.partial(self._context.find_element, *args, **kwargs), ) def find_elements(self, *args, **kwargs) -> List['StabilizedWebElement']: @@ -76,9 +79,15 @@ class StabilizedWebElement: INTERACTION_METHODS = {'click', 'send_keys', 'submit', 'clear'} - def __init__(self, element: 'WebElement', engine: StabilizationEngine): + def __init__( + self, + element: 'WebElement', + engine: StabilizationEngine, + refind: Optional[Callable[[], 'WebElement']] = None, + ): self._element = element self._engine = engine + self._refind = refind def __getattr__(self, name: str) -> Any: """Delegate attribute access to the underlying element.""" @@ -97,8 +106,13 @@ def __getattr__(self, name: str) -> Any: def _stabilized_find_element(self, *args, **kwargs) -> 'StabilizedWebElement': """Keep elements found below this element inside the wrapper boundary.""" self._engine.wait_for_stability() + self._element.find_element(*args, **kwargs) + self._engine.wait_for_stability() element = self._element.find_element(*args, **kwargs) - return StabilizedWebElement(element, self._engine) + return StabilizedWebElement( + element, self._engine, + refind=lambda: self._element.find_element(*args, **kwargs), + ) def _stabilized_find_elements(self, *args, **kwargs) -> List['StabilizedWebElement']: """Return wrapped descendants while preserving Selenium's list contract.""" @@ -113,10 +127,31 @@ def stabilized_method(*args, **kwargs): if self._engine.config.debug_mode: logger.debug(f"[waitless] Stabilizing before {name}()") + if name == 'click': + return self._click(*args, **kwargs) self._engine.wait_for_stability() return method(*args, **kwargs) return stabilized_method + + def _click(self, *args, **kwargs): + """Wait for enabled state and recover once from a locator-backed stale click.""" + from selenium.common.exceptions import StaleElementReferenceException + from selenium.webdriver.support.ui import WebDriverWait + + for attempt in range(2): + self._engine.wait_for_stability() + try: + WebDriverWait( + self._element, + self._engine.config.timeout, + poll_frequency=self._engine.config.poll_interval, + ).until(lambda element: element.is_enabled()) + return self._element.click(*args, **kwargs) + except StaleElementReferenceException: + if attempt or self._refind is None: + raise + self._element = self._refind() @property def wrapped_element(self) -> 'WebElement': @@ -231,6 +266,7 @@ def _stabilized_find_element(self, *args, **kwargs) -> StabilizedWebElement: 1. Waits for page stability first 2. Tries to find the element 3. If element not found, retries until timeout + 4. Waits for stability again and re-finds using the same locator This eliminates the need for explicit WebDriverWait in test code. """ @@ -248,8 +284,13 @@ def _stabilized_find_element(self, *args, **kwargs) -> StabilizedWebElement: while (time.monotonic() - start_time) < timeout: try: + self._driver.find_element(*args, **kwargs) + self._engine.wait_for_stability() element = self._driver.find_element(*args, **kwargs) - return StabilizedWebElement(element, self._engine) + return StabilizedWebElement( + element, self._engine, + refind=functools.partial(self._driver.find_element, *args, **kwargs), + ) except NoSuchElementException as e: last_exception = e time.sleep(poll_interval) From 5e2a85c85166a115774c296834de534ad8e6eb3f Mon Sep 17 00:00:00 2001 From: Dhiraj Das Date: Sat, 24 Oct 2026 21:24:42 +0530 Subject: [PATCH 2/3] Guard stale retries and share wait deadlines for 1.1.0 --- CHANGELOG.md | 14 +- README.md | 26 +++- pyproject.toml | 2 +- tests/integration/test_click_regressions.py | 85 +++++++++++- tests/unit/test_selenium_wrappers.py | 121 +++++++++++++++- waitless/__init__.py | 2 +- waitless/selenium_integration.py | 146 ++++++++++++-------- 7 files changed, 329 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c383ad7..0d0e919 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.0.6] - 2026-10-24 +## [1.1.0] - 2026-10-24 + +### Changed +- Set the default and strict network idle thresholds to zero so all tracked XHR/fetch requests finish before interactions. +- Upgrade note: apps with constant polling or analytics can set `network_idle_threshold=2` to restore the previous allowance of up to two pending requests. ### Fixed - Re-find elements with the same locator after the page settles, so list redraws during stabilization do not return an outdated element. -- Retry a locator-backed wrapped click once after a stale element reference, and wait for the element to be enabled using the configured timeout and polling interval. -- Set the default and strict network idle thresholds to zero so delayed XHR/fetch responses finish before interactions. Background traffic can still be allowed with an explicit threshold. -- Add local-browser regressions for delayed list replacement and disabled buttons covered by an overlay during a delayed fetch, plus unit coverage for stale retries and enabled-state timeouts. +- Retry a locator-backed wrapped click once after a stale element reference, settling before the retry lookup and waiting through a temporarily empty list. +- Capture the original element's tag, visible text, and identifying attributes while it is live; refuse a stale retry if the replacement fingerprint differs. +- Wait for the element to be enabled, with a timeout message identifying the element and configured duration. +- Share one timeout deadline across each `click()` or `find_element()` call's stabilization, polling, and retry waits. +- Add local-browser and unit regressions for list redraws, reordered targets, transient empty lists, disabled overlays, retry limits, and total timeout budgets. ## [1.0.5] - 2026-10-18 diff --git a/README.md b/README.md index da718be..61c38f9 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,9 @@ idle. This prevents a delayed response from leaving a loading overlay in place when an interaction starts. Set `network_idle_threshold=2` to allow up to two requests to remain pending for background analytics or polling. +When upgrading to 1.1.0, apps with constant polling or analytics can set +`network_idle_threshold=2` to restore the previous allowance. + Many apps have background traffic that never stops: - Analytics calls - Long polling @@ -211,7 +214,26 @@ config = StabilizationConfig(network_idle_threshold=3) ### Wrapped Elements The stabilized driver returns wrapped elements that auto-wait before `click()`, -`send_keys()`, `submit()`, and `clear()`. They behave like WebElements but: +`send_keys()`, `submit()`, and `clear()`. + +`click()` also waits for `is_enabled()` to become true. If the element stays +disabled, its timeout message includes the tag, visible text, and configured +duration. Each `click()` and `find_element()` call shares one timeout budget +across its stabilization, polling, and retry waits. + +Elements returned by `find_element()` retain their locator and a fingerprint +captured while the element is live: tag name, visible text, and the `id`, `name`, +`data-testid`, and `aria-label` attributes when present. A stale click can retry +once: Waitless waits for the page to settle, looks up the candidate using the +same locator, and checks its fingerprint before clicking. If the fingerprint +differs, Waitless raises `StaleElementReferenceException` explaining that it +refused to click a different element. Identical replacements can be retried; +elements indistinguishable by these fields cannot be distinguished by this check. + +Elements returned by `find_elements()` or `execute_script()` (including +`execute_async_script()`) do not retain a locator and do not retry stale clicks. +Their clicks still wait for stability and enabled state. They behave like +WebElements but: - `isinstance(element, WebElement)` returns `False` - Use `.unwrap()` to get the original element if needed @@ -303,7 +325,7 @@ It isn't tested against SeleniumBase in this repository. `stabilize()` accepts a Run the diagnostics (see [Diagnostics](#diagnostics)) to see which signal is blocking. WebSocket/SSE tracking is off by default, so it doesn't block unless you enable it. For background XHR/fetch traffic, raise `network_idle_threshold` or use `StabilizationConfig.relaxed()` (DOM only). For infinite spinners, set `animation_detection=False`. **How much slower does it make my tests?** -Waitless polls every 50 ms (`poll_interval`) and returns as soon as the enabled signals are stable, after a 0.1 s DOM quiet period (`dom_settle_time`). On a settled page that's a short pause per interaction; on a busy page it waits as long as the page is busy, up to `timeout` (10 s by default). Measure it in your environment with `python benchmarks/overhead_test.py` from a repository checkout. +Waitless polls every 50 ms (`poll_interval`) while waiting for stability. The DOM must have been quiet for 0.1 s (`dom_settle_time`), and the other configured signals must also be stable. An already settled page can pass the check immediately; Waitless does not add a fixed pause to every interaction. New activity extends the wait within the call's timeout budget (10 s by default). `find_element()` checks stability before and after locating an element, and `click()` also waits for enabled state. WebDriver commands themselves, including any Selenium implicit waits, may extend wall-clock time beyond that budget. Measure it in your environment with `python benchmarks/overhead_test.py` from a repository checkout. **`isinstance(element, WebElement)` is `False`.** That's expected: the stabilized driver returns wrapped elements. Call `.unwrap()` on an element, or use `driver.unwrapped` for the original driver. See [Wrapped Elements](#wrapped-elements). diff --git a/pyproject.toml b/pyproject.toml index b55aa17..e8709d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "waitless" -version = "1.0.6" +version = "1.1.0" description = "Auto-wait for Selenium in Python: fix flaky tests by waiting for DOM, network, animation and layout stability instead of time.sleep." readme = "README.md" license = "MIT" diff --git a/tests/integration/test_click_regressions.py b/tests/integration/test_click_regressions.py index 35a3106..b0da519 100644 --- a/tests/integration/test_click_regressions.py +++ b/tests/integration/test_click_regressions.py @@ -6,6 +6,9 @@ import pytest from selenium import webdriver +from selenium.common.exceptions import ( + NoSuchElementException, StaleElementReferenceException, TimeoutException, +) from selenium.webdriver.chrome.options import Options from selenium.webdriver.common.by import By @@ -39,6 +42,13 @@ document.querySelector('#action').disabled = false; }); } +function renderFruits(reverse) { + const fruits = reverse ? ['banana', 'apple'] : ['apple', 'banana']; + document.querySelector('#list').innerHTML = fruits.map(fruit => + '').join(''); +} """ @@ -46,8 +56,8 @@ def local_page(): class Handler(BaseHTTPRequestHandler): def do_GET(self): - if self.path in ('/delay', '/slow'): - time.sleep(0.5 if self.path == '/delay' else 1.5) + if self.path in ('/delay', '/slow', '/empty'): + time.sleep({'/delay': 0.5, '/slow': 1.5, '/empty': 0.4}[self.path]) body = b'ready' else: body = PAGE @@ -128,3 +138,74 @@ def test_click_waits_for_disabled_button_and_delayed_fetch_overlay(driver, local assert driver.find_element(By.ID, 'result').text == 'clicked' assert driver.find_elements(By.ID, 'overlay') == [] assert driver.execute_script('return window.__waitless__.pendingRequests') == 0 + + +def test_stale_retry_refuses_reordered_list_element(driver, local_page): + wrapped = stabilize(driver) + wrapped.get(local_page) + driver.execute_script('renderFruits(false)') + apple = wrapped.find_element(By.CSS_SELECTOR, '.fruit') + assert apple.text == 'apple' + driver.execute_script('renderFruits(true)') + + with pytest.raises(StaleElementReferenceException, match='Element changed; Waitless refused'): + apple.click() + + assert driver.find_element(By.ID, 'result').text == '' + + +@pytest.mark.parametrize('pending_fetch', [True, False]) +def test_stale_retry_waits_through_transient_empty_list(driver, local_page, monkeypatch, pending_fetch): + wrapped = stabilize(driver, config=StabilizationConfig.strict()) + wrapped.get(local_page) + driver.execute_script("render('initial')") + button = wrapped.find_element(By.ID, 'action') + original_enabled = button.unwrap().is_enabled + + def redraw_during_enabled_check(): + # Deterministically redraw after click's initial stability wait. + driver.execute_script(""" + document.querySelector('#list').innerHTML = ''; + if (arguments[0]) { + fetch('/empty').then(r => r.text()).then(() => render('fresh')); + } else { + setTimeout(() => render('fresh'), 400); + } + """, pending_fetch) + return original_enabled() # Real WebDriver stale-reference failure. + + monkeypatch.setattr(button.unwrap(), 'is_enabled', redraw_during_enabled_check) + button.click() + + assert button.get_attribute('data-generation') == 'fresh' + assert driver.find_element(By.ID, 'result').text == 'clicked' + + +def test_disabled_click_reports_element_and_respects_total_timeout(driver, local_page): + wrapped = stabilize(driver, config=StabilizationConfig(timeout=2.0)) + wrapped.get(local_page) + driver.execute_script("render('initial'); document.querySelector('#action').disabled = true") + button = wrapped.find_element(By.ID, 'action') + # Network settling consumes most of the same two-second click budget. + driver.execute_script("fetch('/slow')") + started = time.monotonic() + + with pytest.raises(TimeoutException) as caught: + button.click() + + elapsed = time.monotonic() - started + assert 1.9 <= elapsed <= 2.5 + assert "Element ').join(''); } +function renderDeleteRows(reverse) { + const fruits = reverse ? ['banana', 'apple'] : ['apple', 'banana']; + document.querySelector('#list').innerHTML = fruits.map(fruit => + '
' + fruit + '
').join(''); +} """ @@ -209,3 +217,84 @@ def test_find_missing_element_respects_total_timeout(driver, local_page): wrapped.find_element(By.ID, 'absent') assert 1.9 <= time.monotonic() - started <= 2.5 + + +@pytest.mark.parametrize('reorder', [True, False]) +def test_delete_buttons_use_row_context_on_stale_retry(driver, local_page, reorder): + wrapped = stabilize(driver) + wrapped.get(local_page) + driver.execute_script('renderDeleteRows(false)') + button = wrapped.find_element(By.CSS_SELECTOR, '.delete') + driver.execute_script('renderDeleteRows(arguments[0])', reorder) + + if reorder: + with pytest.raises(StaleElementReferenceException, match='Element changed'): + button.click() + assert driver.find_element(By.ID, 'result').text == '' + assert len(driver.find_elements(By.CSS_SELECTOR, '.row')) == 2 + else: + button.click() + assert driver.find_element(By.ID, 'result').text == 'apple' + assert len(driver.find_elements(By.CSS_SELECTOR, '.row')) == 1 + + +def test_permanently_disappeared_button_reports_missing(driver, local_page): + wrapped = stabilize(driver, config=StabilizationConfig(timeout=2.0)) + wrapped.get(local_page) + driver.execute_script("render('initial')") + button = wrapped.find_element(By.ID, 'action') + driver.execute_script("document.querySelector('#list').innerHTML = ''") + + with pytest.raises(NoSuchElementException, match='is no longer on the page') as caught: + button.click() + + assert 'not enabled' not in str(caught.value) + assert driver.find_element(By.ID, 'result').text == '' + + +def test_fingerprint_snapshot_caps_text_in_single_browser_read(driver, local_page, monkeypatch): + wrapped = stabilize(driver) + wrapped.get(local_page) + driver.execute_script(""" + document.querySelector('#list').innerHTML = '
'; + document.querySelector('#large').textContent = ' ' + 'X'.repeat(2000) + ' '; + """) + original_script = driver.execute_script + snapshots = [] + + def record_snapshot(script, *args): + result = original_script(script, *args) + if script == FINGERPRINT_SCRIPT: + snapshots.append(result) + return result + + monkeypatch.setattr(driver, 'execute_script', record_snapshot) + element = wrapped.find_element(By.ID, 'large') + + assert len(snapshots) == 1 + assert snapshots[0][1] == 'X' * 200 + assert snapshots[0][4] == 'X' * 200 + assert element._fingerprint[3] == 0 + + +def test_find_recovers_when_element_goes_stale_during_snapshot(driver, local_page, monkeypatch): + wrapped = stabilize(driver) + wrapped.get(local_page) + driver.execute_script("render('initial')") + original_script = driver.execute_script + reads = [] + + def replace_during_snapshot(script, *args): + if script == FINGERPRINT_SCRIPT: + reads.append(True) + if len(reads) == 1: + original_script("render('fresh')") + return original_script(script, *args) + + monkeypatch.setattr(driver, 'execute_script', replace_during_snapshot) + button = wrapped.find_element(By.ID, 'action') + button.click() + + assert len(reads) == 2 + assert button.get_attribute('data-generation') == 'fresh' + assert driver.find_element(By.ID, 'result').text == 'clicked' diff --git a/tests/unit/test_selenium_wrappers.py b/tests/unit/test_selenium_wrappers.py index b0b27d3..47c7f98 100644 --- a/tests/unit/test_selenium_wrappers.py +++ b/tests/unit/test_selenium_wrappers.py @@ -22,9 +22,11 @@ def __init__(self): self.tag_name = "button" self.text = "Apple" self.attributes = {} + self.parent_text = "Apple row" + self.stale = False self.get_attribute = Mock(side_effect=self.attributes.get) self.find_element = Mock() - self.find_elements = Mock() + self.find_elements = Mock(return_value=[]) self.click = Mock() self.is_enabled = Mock(return_value=True) @@ -36,11 +38,26 @@ def __init__(self): self.execute_script = Mock() self.execute_async_script = Mock() self.find_element = Mock() + self.find_elements = Mock(return_value=[]) @pytest.fixture def engine(): + def fingerprint(script, element, matches): + assert script == selenium_integration.FINGERPRINT_SCRIPT + if element.stale: + raise StaleElementReferenceException() + if matches is not None and element not in matches: + return None + return [ + element.tag_name, element.text.strip()[:200], + [element.attributes.get(name) for name in ('id', 'name', 'data-testid', 'aria-label')], + matches.index(element) if matches is not None else None, + element.parent_text.strip()[:200], + ] + return SimpleNamespace( + driver=SimpleNamespace(execute_script=Mock(side_effect=fingerprint)), wait_for_stability=Mock(), teardown=Mock(), config=SimpleNamespace(debug_mode=False, timeout=0.1, poll_interval=0.01), @@ -89,7 +106,7 @@ def test_script_calls_unwrap_nested_arguments_and_wrap_nested_results( def test_descendant_element_lookups_stay_wrapped(engine): parent = FakeWebElement() child = FakeWebElement() - children = [FakeWebElement(), FakeWebElement()] + children = [child, FakeWebElement()] parent.find_element.return_value = child parent.find_elements.return_value = children wrapped_parent = StabilizedWebElement(parent, engine) @@ -109,6 +126,7 @@ def test_shadow_root_element_lookups_stay_wrapped(engine): shadow_root = FakeWebElement() child = FakeWebElement() shadow_root.find_element.return_value = child + shadow_root.find_elements.return_value = [child] parent.shadow_root = shadow_root wrapped_parent = StabilizedWebElement(parent, engine) @@ -170,6 +188,7 @@ def test_unstabilize_tears_down_engine_for_original_driver(engine): def test_find_element_refinds_after_settling_with_same_locator(engine, context): raw = FakeDriver() if context == "driver" else FakeWebElement() first, fresh = FakeWebElement(), FakeWebElement() + raw.find_elements.return_value = [fresh] events = [] raw.find_element.side_effect = lambda *a, **kw: events.append("find") or ( first if events.count("find") == 1 else fresh @@ -263,7 +282,7 @@ def test_stale_retry_refuses_changed_fingerprint(engine, field): first.is_enabled.side_effect = StaleElementReferenceException() wrapped = StabilizedWebElement(first, engine, refind=Mock(return_value=replacement)) # The old element's attributes are no longer readable once it is stale. - first.get_attribute.side_effect = StaleElementReferenceException() + first.stale = True with pytest.raises(StaleElementReferenceException, match="Element changed; Waitless refused"): wrapped.click() @@ -350,10 +369,141 @@ def test_find_missing_polling_uses_only_remaining_deadline(engine, clock): engine.config.poll_interval = 0.3 engine.wait_for_stability.side_effect = lambda **kw: clock.advance(1.5) raw = FakeDriver() - raw.find_element.side_effect = NoSuchElementException() + original = NoSuchElementException('Unable to locate element: #absent') + original_message = original.msg + raw.find_element.side_effect = original - with pytest.raises(NoSuchElementException, match="within 2.0s"): + with pytest.raises(NoSuchElementException) as caught: StabilizedWebDriver(raw, engine).find_element("id", "absent") + assert caught.value is original + assert caught.value.msg == original_message + assert '{}' not in str(caught.value) assert clock.now == pytest.approx(2.0) assert raw.find_element.call_count == 2 + + +@pytest.mark.parametrize('field', ['index', 'parent_text']) +def test_stale_retry_refuses_changed_row_context(engine, field): + first, replacement, other = FakeWebElement(), FakeWebElement(), FakeWebElement() + first.text = replacement.text = 'Delete' + first.is_enabled.side_effect = StaleElementReferenceException() + if field == 'parent_text': + replacement.parent_text = 'Banana row' + matches = Mock(side_effect=[ + [first, other], [other, replacement] if field == 'index' else [replacement, other], + ]) + wrapped = StabilizedWebElement( + first, engine, refind=Mock(return_value=replacement), find_matches=matches, + ) + + with pytest.raises(StaleElementReferenceException, match='Element changed'): + wrapped.click() + + replacement.click.assert_not_called() + + +def test_fingerprint_uses_one_script_and_no_attribute_calls(engine): + element = FakeWebElement() + matches = Mock(return_value=[element]) + + wrapped = StabilizedWebElement( + element, engine, refind=Mock(return_value=element), find_matches=matches, + ) + + engine.driver.execute_script.assert_called_once_with( + selenium_integration.FINGERPRINT_SCRIPT, element, [element], + ) + matches.assert_called_once_with() + element.get_attribute.assert_not_called() + assert wrapped._fingerprint[3] == 0 + + +@pytest.mark.parametrize('slow_step', ['matches', 'script']) +def test_fingerprint_reads_obey_find_deadline(engine, clock, slow_step): + engine.config.timeout = 2.0 + engine.wait_for_stability.side_effect = lambda **kw: clock.advance(0.6) + raw, element = FakeDriver(), FakeWebElement() + raw.find_element.return_value = element + raw.find_elements.return_value = [element] + if slow_step == 'matches': + raw.find_elements.side_effect = lambda *a: clock.advance(0.9) or [element] + else: + read = engine.driver.execute_script.side_effect + engine.driver.execute_script.side_effect = lambda *a: clock.advance(0.9) or read(*a) + + with pytest.raises(TimeoutException, match='Timed out reading element fingerprint'): + StabilizedWebDriver(raw, engine).find_element('id', 'action') + + assert clock.now == pytest.approx(2.1) + assert engine.driver.execute_script.call_count == (0 if slow_step == 'matches' else 1) + + +def test_find_recovers_stale_fingerprint_read_within_same_deadline(engine, clock): + raw = FakeDriver() + first, replacement = FakeWebElement(), FakeWebElement() + first.stale = True + raw.find_element.side_effect = [first, first, replacement, replacement] + raw.find_elements.side_effect = [[first], [replacement]] + + wrapped = StabilizedWebDriver(raw, engine).find_element('id', 'action') + + assert wrapped.unwrap() is replacement + assert engine.driver.execute_script.call_count == 2 + assert clock.now == pytest.approx(engine.config.poll_interval) + assert engine.wait_for_stability.call_args_list[-1].kwargs['timeout'] < engine.config.timeout + + +def test_retry_does_not_click_after_stale_fingerprint_read(engine): + first, replacement = FakeWebElement(), FakeWebElement() + first.click.side_effect = StaleElementReferenceException() + replacement.stale = True + refind = Mock(return_value=replacement) + wrapped = StabilizedWebElement(first, engine, refind=refind) + + with pytest.raises(StaleElementReferenceException): + wrapped.click() + + refind.assert_called_once_with() + replacement.click.assert_not_called() + + +def test_repeated_stale_snapshots_do_not_restart_find_deadline(engine, clock): + raw, element = FakeDriver(), FakeWebElement() + raw.find_element.return_value = element + raw.find_elements.return_value = [element] + engine.driver.execute_script.return_value = None + engine.driver.execute_script.side_effect = None + + with pytest.raises(StaleElementReferenceException, match='while reading its fingerprint'): + StabilizedWebDriver(raw, engine).find_element('id', 'action') + + assert clock.now == pytest.approx(engine.config.timeout) + assert engine.driver.execute_script.call_count <= 11 + assert engine.wait_for_stability.call_args_list[-1].kwargs['timeout'] < engine.config.timeout + + +def test_disappeared_element_reports_missing_instead_of_disabled(engine, clock): + first = FakeWebElement() + first.is_enabled.side_effect = StaleElementReferenceException() + wrapped = StabilizedWebElement( + first, engine, refind=Mock(side_effect=NoSuchElementException('not found')), + ) + + with pytest.raises(NoSuchElementException, match='is no longer on the page') as caught: + wrapped.click() + + assert 'not enabled' not in str(caught.value) + first.click.assert_not_called() + + +def test_find_timeout_before_lookup_omits_empty_kwargs(engine, clock): + engine.wait_for_stability.side_effect = lambda **kw: clock.advance(engine.config.timeout) + raw = FakeDriver() + + with pytest.raises(NoSuchElementException) as caught: + StabilizedWebDriver(raw, engine).find_element('id', 'action') + + assert "('id', 'action')" in caught.value.msg + assert '{}' not in caught.value.msg + raw.find_element.assert_not_called() diff --git a/waitless/selenium_integration.py b/waitless/selenium_integration.py index ed448ff..5df5686 100644 --- a/waitless/selenium_integration.py +++ b/waitless/selenium_integration.py @@ -32,6 +32,24 @@ logger = logging.getLogger('waitless') +FINGERPRINT_SCRIPT = """ +const element = arguments[0]; +const matches = arguments[1]; +if (!element.isConnected) return null; +const index = matches === null ? null : matches.indexOf(element); +if (index === -1) return null; +const text = node => node && node.getClientRects().length + ? (node.innerText || '').trim().slice(0, 200) : ''; +return [ + element.tagName.toLowerCase(), + text(element), + ['id', 'name', 'data-testid', 'aria-label'].map(name => element.getAttribute(name)), + index, + text(element.parentElement) +]; +""" + + def _remaining(deadline: float, error: Exception) -> float: """Never start another wait after the public call's budget is exhausted.""" remaining = deadline - time.monotonic() @@ -45,7 +63,10 @@ def _settle(engine, deadline, error): _remaining(deadline, error) -def _lookup(find, engine, deadline, error, *, settle_after=False, retry_missing=True): +def _lookup( + find, engine, deadline, error, *, settle_after=False, retry_missing=True, + preserve_not_found=False, +): from selenium.common.exceptions import NoSuchElementException while True: @@ -57,27 +78,38 @@ def _lookup(find, engine, deadline, error, *, settle_after=False, retry_missing= element = find() _remaining(deadline, error) return element - except NoSuchElementException: + except NoSuchElementException as exc: if not retry_missing: raise + if preserve_not_found: + error = exc time.sleep(min(engine.config.poll_interval, _remaining(deadline, error))) def _find_element(context, engine, args, kwargs, *, retry_missing=False): - from selenium.common.exceptions import NoSuchElementException + from selenium.common.exceptions import NoSuchElementException, StaleElementReferenceException timeout = engine.config.timeout deadline = time.monotonic() + timeout - error = NoSuchElementException(f"Element not found within {timeout}s: {args} {kwargs}") + locator = f"{args}" + (f" {kwargs}" if kwargs else "") + error = NoSuchElementException(f"Element not found within {timeout}s: {locator}") find = functools.partial(context.find_element, *args, **kwargs) - _settle(engine, deadline, error) - element = _lookup( - find, engine, deadline, error, - settle_after=True, retry_missing=retry_missing, - ) - wrapped = StabilizedWebElement(element, engine, refind=find) - _remaining(deadline, error) - return wrapped + find_matches = functools.partial(context.find_elements, *args, **kwargs) + while True: + _settle(engine, deadline, error) + element = _lookup( + find, engine, deadline, error, + settle_after=True, retry_missing=retry_missing, preserve_not_found=True, + ) + try: + return StabilizedWebElement( + element, engine, refind=find, find_matches=find_matches, deadline=deadline, + ) + except StaleElementReferenceException as exc: + # The caller has not received an element yet. Retry a redraw during + # the snapshot without starting a fresh timeout budget. + error = exc + time.sleep(min(engine.config.poll_interval, _remaining(deadline, exc))) class _StabilizedSearchContext: @@ -126,22 +158,35 @@ def __init__( element: 'WebElement', engine: StabilizationEngine, refind: Optional[Callable[[], 'WebElement']] = None, + find_matches: Optional[Callable[[], List['WebElement']]] = None, + *, + deadline: Optional[float] = None, ): self._element = element self._engine = engine self._refind = refind + self._find_matches = find_matches # Capture while the original is live, not after a stale-reference error. - self._fingerprint = self._capture_fingerprint(element) if refind else None - - @staticmethod - def _capture_fingerprint(element): - return ( - element.tag_name, - element.text, - tuple(element.get_attribute(name) for name in ( - 'id', 'name', 'data-testid', 'aria-label', - )), + self._fingerprint = None + if refind: + if deadline is None: + deadline = time.monotonic() + engine.config.timeout + self._fingerprint = self._capture_fingerprint(element, deadline) + + def _capture_fingerprint(self, element, deadline): + from selenium.common.exceptions import StaleElementReferenceException, TimeoutException + + error = TimeoutException("Timed out reading element fingerprint") + _remaining(deadline, error) + matches = self._find_matches() if self._find_matches else None + _remaining(deadline, error) + fingerprint = self._engine.driver.execute_script( + FINGERPRINT_SCRIPT, element, matches, ) + _remaining(deadline, error) + if fingerprint is None: + raise StaleElementReferenceException("Element became stale while reading its fingerprint") + return fingerprint def __getattr__(self, name: str) -> Any: """Delegate attribute access to the underlying element.""" @@ -183,20 +228,25 @@ def stabilized_method(*args, **kwargs): def _click(self, *args, **kwargs): """Wait for enabled state and recover once from a locator-backed stale click.""" - from selenium.common.exceptions import StaleElementReferenceException, TimeoutException + from selenium.common.exceptions import ( + NoSuchElementException, StaleElementReferenceException, TimeoutException, + ) timeout = self._engine.config.timeout deadline = time.monotonic() + timeout tag, text = ( self._fingerprint[:2] if self._fingerprint is not None - else (self._element.tag_name, self._element.text) + else self._capture_fingerprint(self._element, deadline)[:2] ) error = TimeoutException(f"Element <{tag}> {text!r} was not enabled after {timeout}s") for attempt in range(2): _settle(self._engine, deadline, error) if attempt: - candidate = _lookup(self._refind, self._engine, deadline, error) - if self._capture_fingerprint(candidate) != self._fingerprint: + missing = NoSuchElementException( + f"Element <{tag}> {text!r} is no longer on the page" + ) + candidate = _lookup(self._refind, self._engine, deadline, missing) + if self._capture_fingerprint(candidate, deadline) != self._fingerprint: raise StaleElementReferenceException( "Element changed; Waitless refused to click a different element " f"instead of <{tag}> {text!r}."