Metadata-Version: 2.4
Name: waitless
Version: 1.1.0
Summary: Auto-wait for Selenium in Python: fix flaky tests by waiting for DOM, network, animation and layout stability instead of time.sleep.
Author-email: Dhiraj Das <dhirajdas.666@gmail.com>
License-Expression: MIT
Project-URL: Homepage, https://www.dhirajdas.dev/project/waitless
Project-URL: Documentation, https://github.com/godhiraj-code/waitless#readme
Project-URL: Repository, https://github.com/godhiraj-code/waitless
Project-URL: Issues, https://github.com/godhiraj-code/waitless/issues
Project-URL: Changelog, https://github.com/godhiraj-code/waitless/blob/main/CHANGELOG.md
Project-URL: Blog, https://www.dhirajdas.dev/blog/waitless-v1-end-of-flaky-tests
Project-URL: X, https://x.com/dkkkkd
Keywords: selenium,automation,testing,ui-testing,wait,stability,flaky-tests,selenium-python,webdriver,auto-wait,explicit-wait,time-sleep,pytest,test-automation,StaleElementReferenceException,ElementClickInterceptedException
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Software Development :: Testing
Classifier: Topic :: Software Development :: Testing :: Acceptance
Classifier: Topic :: Software Development :: Quality Assurance
Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: selenium>=4.0
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: build>=1.0; extra == "dev"
Requires-Dist: ruff>=0.12; extra == "dev"
Dynamic: license-file

# Waitless

[![PyPI version](https://img.shields.io/pypi/v/waitless.svg)](https://pypi.org/project/waitless/)
[![Python versions](https://img.shields.io/pypi/pyversions/waitless.svg)](https://pypi.org/project/waitless/)
[![License: MIT](https://img.shields.io/pypi/l/waitless.svg)](https://github.com/godhiraj-code/waitless/blob/main/LICENSE)
[![CI](https://github.com/godhiraj-code/waitless/actions/workflows/ci.yml/badge.svg)](https://github.com/godhiraj-code/waitless/actions/workflows/ci.yml)

**Stop flaky Selenium tests caused by clicking before the page is ready.** Waitless wraps your WebDriver so `get`, `find_element` and `click` wait for the DOM and network to settle (and, in strict mode, for animations and layout too): no `time.sleep()`, less `WebDriverWait` boilerplate, and fewer `StaleElementReferenceException`, `ElementClickInterceptedException` and `NoSuchElementException` failures caused by timing races.

**Automatic UI stabilization for Selenium.** Reduce explicit waits and sleeps by automatically evaluating multiple UI stability signals.

**Learn more:** [Waitless v1: the end of flaky tests](https://www.dhirajdas.dev/blog/waitless-v1-end-of-flaky-tests) · [Eliminating flaky tests with Waitless](https://www.dhirajdas.dev/blog/waitless-eliminate-flaky-tests) · [Project page](https://www.dhirajdas.dev/project/waitless) · [Follow @dkkkkd on X](https://x.com/dkkkkd)

## Installation

```bash
pip install waitless
```

## Quick Start

```python
from selenium import webdriver
from selenium.webdriver.common.by import By
from waitless import stabilize

driver = webdriver.Chrome()
driver = stabilize(driver)

# Navigation, lookups, and common element actions now wait for stability.
driver.get(
    "data:text/html,<input id='username'><button id='login-button'>Log in</button>"
)
driver.find_element(By.ID, "username").send_keys("user")
driver.find_element(By.ID, "login-button").click()
driver.quit()
```

## Why Waitless?

### The Problem

Automation tests fail because interactions happen while the UI is still changing:

- DOM mutations from React/Vue/Angular updates
- In-flight AJAX requests
- CSS animations and transitions
- Layout shifts from lazy-loaded content

### Traditional Solutions (and why they fail)

| Approach | Problem |
|----------|---------|
| `time.sleep(2)` | Too slow, still fails sometimes |
| `driver.implicitly_wait(10)` | Only retries element lookup until the element exists; it does not wait for pending requests, animations or DOM churn |
| `WebDriverWait` | Requires an explicit condition at each synchronization point |
| Retries | Masks the real problem, adds flakiness |

### The Waitless Solution

Waitless evaluates page-level stability signals:

- DOM mutation activity (MutationObserver, including **Shadow DOM**)
- Pending network requests (XHR/fetch interception after instrumentation is installed)
- CSS animations and transitions
- Layout stability for interactive elements
- WebSocket/SSE activity (opt-in)
- Framework hooks (React/Angular/Vue, opt-in)
- Same-origin iframe load readiness (opt-in; not full child-frame signal injection)

Before supported interactions, Waitless polls until the enabled mandatory signals
meet their configured thresholds.

## When to use Waitless (and when not to)

**Good fit:**

- Selenium 4 test suites in Python, on single-page apps (React, Angular, Vue) where clicks land while the page is still re-rendering
- Pages with AJAX/fetch calls, lazy-loaded content or long-polling that make tests pass locally and fail in CI
- Suites full of `time.sleep()` calls or copy-pasted `WebDriverWait` boilerplate that you want to remove

**Not a good fit:**

- **Playwright.** Playwright already auto-waits for elements to be actionable, and Waitless only supports Selenium.
- **Specific business conditions.** If a test must wait for "the order status says Shipped", keep a `WebDriverWait` with an explicit condition for that. Waitless waits for the page to be *stable*, not for your app to reach a particular state. The two work well together.
- **Async Selenium clients or Service Worker traffic.** See [Current Limitations](#current-limitations).

| | `time.sleep` | `implicitly_wait` | `WebDriverWait` | Waitless |
|---|---|---|---|---|
| Waits for element presence | No (fixed delay) | Yes | Yes (per condition) | Yes (`find_element` retries until timeout) |
| Waits for pending XHR/fetch | No | No | Only if you write the condition | Yes |
| Waits for DOM mutations to settle | No | No | Only if you write the condition | Yes |
| Waits for animations/layout | No | No | Only if you write the condition | Strict mode |
| Code needed per interaction | One sleep | None | One condition | None after `stabilize(driver)` |

## Configuration

```python
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=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
)

driver = stabilize(driver, config=config)
```

### Strictness Levels

| Level | What It Waits For |
|-------|-------------------|
| `strict` | DOM + Network + Animations + Layout |
| `normal` | DOM + Network (default) |
| `relaxed` | DOM only |

### Factory Methods

```python
# For strict testing
config = StabilizationConfig.strict()

# For apps with background traffic
config = StabilizationConfig.relaxed()

# For CI environments
config = StabilizationConfig.ci()
```

## Manual Stabilization

If you don't want to wrap the driver:

```python
from waitless import wait_for_stability

wait_for_stability(driver)
driver.find_element(By.ID, "button").click()
```

## Disabling Stabilization

```python
from waitless import unstabilize

driver = unstabilize(driver)  # Back to original behavior
```

## Diagnostics

When tests fail, get detailed analysis:

```python
from waitless import get_diagnostics, StabilizationTimeout
from waitless.diagnostics import print_report

try:
    driver.find_element(By.ID, "slow-button").click()
except StabilizationTimeout as e:
    diagnostics = get_diagnostics(driver)
    print_report(diagnostics)  # Print detailed report
```

### CLI Doctor Command

```bash
python -m waitless doctor --file diagnostics.json
```

Sample output:
```
+--------------------------------------------------------------------+
|                     WAITLESS STABILITY REPORT                      |
+--------------------------------------------------------------------+
| BLOCKING FACTORS:                                                  |
|   [!] NETWORK: 2 request(s) still pending                          |
|   -> GET /api/users                                                |
|   [!] ANIMATIONS: 1 active animation(s)                            |
+--------------------------------------------------------------------+
| SUGGESTIONS:                                                       |
|   1. Set network_idle_threshold=2 for background traffic           |
|   2. Use animation_detection=False for infinite spinners           |
+--------------------------------------------------------------------+
```

## Important Notes

### Network Threshold Warning

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.

When upgrading to 1.1.0, apps with constant polling or analytics can set
`network_idle_threshold=2` to restore the previous allowance.
Two other behavior changes apply: clicking a disabled element now waits and
raises a timeout if it stays disabled, and a stale retry refuses a replaced
element with different text or a different fingerprint rather than clicking it.

Many apps have background traffic that never stops:
- Analytics calls
- Long polling
- Feature flags
- WebSocket heartbeats

If known background traffic exceeds the default, raise the threshold carefully:
```python
config = StabilizationConfig(network_idle_threshold=3)
```

### Wrapped Elements

The stabilized driver returns wrapped elements that auto-wait before `click()`,
`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.
If an element permanently disappears before a click retry, the error says it
is no longer on the page instead of reporting that it was not enabled.

Elements returned by `find_element()` retain their locator and a fingerprint
captured while the element is live: tag name, visible text, the `id`, `name`,
`data-testid`, and `aria-label` attributes when present, and its parent's trimmed
text. Element and parent text are capped at 200 characters in the browser.
Fingerprint fields are read in one
script call within the existing timeout budget, without scanning all matches
of the locator. 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.

Changes to surrounding row text can also cause refusal: if a button is replaced
and its parent's text changes from `Cart (1)` to `Cart (2)`, the stale retry raises
`StaleElementReferenceException`, the same exception plain Selenium raises for a
stale element. Use an ID or a row-scoped locator to target the intended button
instead of an ambiguous first match. A specific locator does not bypass the
parent-text check, so it cannot guarantee a retry after surrounding text changes.
If that change is expected, explicitly find the element again after the update.

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. Successful clicks do
not read a fingerprint. If the enabled wait times out, one diagnostic script read
adds the element's tag and text to the error; this read adds a WebDriver round trip
after the wait budget expires. If the element has become stale, the error retains
a generic element label. They behave like
WebElements but:

- `isinstance(element, WebElement)` returns `False`
- Use `.unwrap()` to get the original element if needed

```python
element = driver.find_element(By.ID, "button")
original = element.unwrap()  # Gets the real WebElement
```

## Optional Signals

- **WebSocket/SSE Awareness** - Track WebSocket and Server-Sent Events activity
- **Framework Adapters** - React, Angular, Vue hooks for framework-specific settling
- **iframe Support** - Monitor same-origin iframes
- **Performance benchmarks** - Run the repository benchmark against your environment

```python
config = StabilizationConfig(
    track_websocket=True,         # WebSocket monitoring
    track_sse=True,               # SSE monitoring
    framework_hooks=['react'],    # React adapter
    track_iframes=True,           # iframe monitoring
)
```

## Performance

| Metric | Typical Value |
|--------|---------------|
| Instrumentation injection | Environment-dependent; from a repository checkout, run `python benchmarks/overhead_test.py` |
| Per-poll overhead | Environment-dependent; run the repository benchmark |
| Poll interval (default) | 50ms |
| Stabilization time | Depends on page activity, thresholds, and environment |

### Navigation Handling

Wrapped `get()`, `refresh()`, `back()`, and `forward()` wait after Selenium's
synchronous navigation call returns. For SPA route changes initiated by page
JavaScript, Waitless validates/re-injects instrumentation on the next wait:

1. Checks `__waitless__.isAlive()` before each wait
2. Detects URL changes via `driver.current_url`
3. Re-injects if instrumentation is missing

This does not observe routes continuously, and cross-origin iframe internals remain
outside the browser same-origin boundary.

Because instrumentation is installed after Selenium's synchronous navigation call
returns, requests that start and finish during navigation are not observed. Requests
started after instrumentation is installed are tracked.

`find_elements()` keeps Selenium's immediate-empty lookup semantics: after the
page-stability wait, it performs one lookup and returns `[]` when there are no matches.
By contrast, `find_element()` retries `NoSuchElementException` until the configured
timeout after the page-stability wait.

## Current Limitations

- **Selenium only** - No Playwright integration
- **Sync only** - No async/await support yet
- **No Service Workers** - SW network requests not intercepted

See [CHANGELOG.md](https://github.com/godhiraj-code/waitless/blob/main/CHANGELOG.md) for version history.

## FAQ and Troubleshooting

**Does it work with pytest?**
Yes. Waitless has no runner dependency; wrap the driver in your fixture:

```python
import pytest
from selenium import webdriver
from waitless import stabilize

@pytest.fixture
def driver():
    d = stabilize(webdriver.Chrome())
    yield d
    d.quit()
```

**Does it work with Shadow DOM?**
Yes, for open shadow roots. The MutationObserver also watches open shadow roots, and elements and shadow roots returned from searches are wrapped so they auto-wait too. Closed shadow roots are not visible to page JavaScript, so they are not observed.

**Does it work with SeleniumBase or other wrappers?**
It isn't tested against SeleniumBase in this repository. `stabilize()` accepts any WebDriver-like object that has `execute_script`, `find_element` and `current_url`, so you can wrap the underlying Selenium driver. If a framework needs a real `WebDriver` instance, pass it `driver.unwrapped`.

**My app has WebSockets, analytics beacons or long polling and never goes idle. I get `StabilizationTimeout`.**
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`) 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).

## API Reference

### Functions

| Function | Description |
|----------|-------------|
| `stabilize(driver, config=None)` | Enable auto-stabilization |
| `unstabilize(driver)` | Disable and return original driver |
| `wait_for_stability(driver, timeout=None)` | Manual one-time wait |
| `get_diagnostics(driver)` | Get diagnostic data |

### Classes

| Class | Description |
|-------|-------------|
| `StabilizationConfig` | Configuration options |
| `StabilizedWebDriver` | Wrapped driver with auto-wait |
| `StabilizedWebElement` | Wrapped element with auto-wait |
| `StabilizationTimeout` | Exception when UI doesn't stabilize |

## License

MIT. See [LICENSE](https://github.com/godhiraj-code/waitless/blob/main/LICENSE).
