Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,21 @@ jobs:
python -c "import waitless; print(f'waitless {waitless.__version__} imports successfully')"
python -c "from waitless.adapters import ReactAdapter, AngularAdapter, VueAdapter; print('Framework adapters OK')"

package-smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Build distributions
run: |
python -m pip install --upgrade pip build
python -m build
- name: Verify clean wheel installation
run: |
python -m venv wheel-smoke
source wheel-smoke/bin/activate
python -m pip install dist/*.whl
python -c "import selenium, waitless; assert waitless.__version__ == '1.0.1'; print(waitless.__version__, selenium.__version__)"

12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ 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.1] - Unreleased

### Fixed
- Propagate Python stabilization options into browser instrumentation before it initializes.
- Count individual DOM mutation records, including Shadow DOM mutations.
- Execute configured React, Angular, and Vue adapter detection/status hooks.
- Reconfigure signal evaluation when an existing wrapped driver receives new settings.
- Preserve Selenium `find_elements()` semantics and propagate stabilization failures.
- Stabilize after wrapped navigation methods and avoid retaining discarded drivers.
- Redact URL queries/fragments and align diagnostics output with its documented API.
- Install Selenium as a runtime dependency and correct misleading documentation.

## [1.0.0] - 2026-01-05

### Added
Expand Down
24 changes: 15 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

[![CI](https://github.com/godhiraj-code/waitless/actions/workflows/ci.yml/badge.svg)](https://github.com/godhiraj-code/waitless/actions/workflows/ci.yml)

**Zero-wait UI automation stabilization for Selenium**
**Automatic UI stabilization for Selenium**

Eliminate explicit waits and sleeps by automatically detecting true UI stability.
Reduce explicit waits and sleeps by automatically evaluating multiple UI stability signals.



Expand Down Expand Up @@ -62,7 +62,7 @@ Waitless monitors the **entire page** for stability signals:
- ✅ Layout stability (element movement)
- ✅ WebSocket/SSE activity (opt-in)
- ✅ Framework hooks (React/Angular/Vue, opt-in)
- ✅ iframe monitoring (opt-in)
- ✅ Same-origin iframe load readiness (opt-in; not full child-frame signal injection)

When you interact, waitless ensures the page is truly ready.

Expand Down Expand Up @@ -135,7 +135,7 @@ try:
driver.find_element(By.ID, "slow-button").click()
except StabilizationTimeout as e:
diagnostics = get_diagnostics(driver)
print_report(engine) # Print detailed report
print_report(diagnostics) # Print detailed report
```

### CLI Doctor Command
Expand Down Expand Up @@ -210,20 +210,26 @@ config = StabilizationConfig(

| Metric | Typical Value |
|--------|---------------|
| Instrumentation injection | ~5-10ms |
| Per-poll overhead | ~1-2ms |
| Instrumentation injection | Environment-dependent; run `python -m benchmarks.overhead_test` |
| Per-poll overhead | Environment-dependent; run the bundled benchmark |
| Poll interval (default) | 50ms |
| Typical stabilization | 50-200ms after activity |

### SPA Navigation Handling
### Navigation Handling

Waitless automatically re-injects instrumentation after SPA route changes:
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 works transparently with React Router, Vue Router, Angular Router, etc.
This does not observe routes continuously, and cross-origin iframe internals remain
outside the browser same-origin boundary.

`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.

## Current Limitations

Expand Down
10 changes: 6 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"

[project]
name = "waitless"
version = "1.0.0"
version = "1.0.1"
description = "Eliminate explicit waits in UI automation by detecting true UI stability"
readme = "README.md"
license = {text = "MIT"}
license = "MIT"
requires-python = ">=3.9"
authors = [
{name = "Dhiraj Das", email = "dhirajdas.666@gmail.com"}
Expand All @@ -24,7 +24,6 @@ keywords = [
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
Expand All @@ -34,6 +33,9 @@ classifiers = [
"Topic :: Software Development :: Testing",
"Topic :: Software Development :: Quality Assurance",
]
dependencies = [
"selenium>=4.0",
]

[project.urls]
Homepage = "https://www.dhirajdas.dev"
Expand All @@ -47,7 +49,7 @@ waitless = "waitless.__main__:main"
[project.optional-dependencies]
dev = [
"pytest>=7.0",
"selenium>=4.0",
"build>=1.0",
]

[tool.setuptools.packages.find]
Expand Down
143 changes: 141 additions & 2 deletions tests/integration/test_selenium_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,17 @@ def test_stabilize_returns_wrapped_driver(self, driver):

assert isinstance(wrapped, StabilizedWebDriver)
assert wrapped.unwrapped is driver


def test_stabilize_is_idempotent_for_wrapped_driver(self, driver):
wrapped = stabilize(driver)
new_config = StabilizationConfig(network_idle_threshold=0)

configured = stabilize(wrapped, config=new_config)

assert configured is wrapped
assert configured._engine.config is new_config
assert configured._engine.evaluator.config is new_config

def test_unstabilize_returns_original(self, driver):
"""Test that unstabilize returns original driver."""
wrapped = stabilize(driver)
Expand Down Expand Up @@ -173,7 +183,7 @@ class TestManualWait:
"""Test manual wait_for_stability function."""

def test_manual_wait(self, driver, fixture_url):
"""Test using wait_for_stability directly."""
"""Test using manual wait without wrapping."""
driver.get(fixture_url)

# Use manual wait without wrapping
Expand All @@ -182,3 +192,132 @@ def test_manual_wait(self, driver, fixture_url):
# Page should be stable
h1 = driver.find_element(By.TAG_NAME, "h1")
assert h1.text == "Waitless Test Fixture"


class TestBrowserBehaviorContracts:
"""Verify Python options actually change the browser runtime."""

def test_config_is_applied_before_instrumentation_initializes(self, driver, fixture_url):
driver.get(fixture_url)
config = StabilizationConfig(
layout_stability=False,
animation_detection=False,
track_websocket=True,
track_sse=True,
track_iframes=True,
websocket_quiet_time=0.75,
)
wrapped = stabilize(driver, config=config)
wrapped._engine.ensure_instrumented()

browser_config = driver.execute_script("return window.__waitless__.config")
assert browser_config == {
"trackLayout": False,
"trackAnimations": False,
"trackWebSocket": True,
"trackSSE": True,
"webSocketQuietTime": 750,
"trackIframes": True,
"redactQueryStrings": True,
}

def test_mutation_rate_counts_records_not_callbacks(self, driver, fixture_url):
driver.get(fixture_url)
wrapped = stabilize(driver)
wrapped._engine.ensure_instrumented()

rate = driver.execute_async_script(
"""
const done = arguments[arguments.length - 1];
const container = document.createElement('div');
document.body.appendChild(container);
for (let i = 0; i < 100; i++) {
container.appendChild(document.createElement('span'));
}
setTimeout(() => done(window.__waitless__.getMutationRate()), 0);
"""
)
assert rate >= 100

def test_framework_adapter_detection_and_status_are_executed(self, driver, fixture_url):
driver.get(fixture_url)
driver.execute_script(
"window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = {onCommitFiberRoot: function() {}}"
)
wrapped = stabilize(driver, config=StabilizationConfig(framework_hooks=["react"]))
wrapped._engine.ensure_instrumented()

assert [adapter.name for adapter in wrapped._engine._active_adapters] == ["react"]
state = wrapped._engine._get_browser_status()
assert state["framework_status"][0]["name"] == "react"
assert state["framework_status"][0]["stable"] is True

def test_reconfigure_uninstalls_framework_hooks(self, driver, fixture_url):
driver.get(fixture_url)
driver.execute_script(
"window.__originalReactCommit = function() { return 'original'; };"
"window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = {onCommitFiberRoot: window.__originalReactCommit};"
)
wrapped = stabilize(driver, config=StabilizationConfig(framework_hooks=["react"]))
wrapped._engine.ensure_instrumented()
assert driver.execute_script(
"return window.__REACT_DEVTOOLS_GLOBAL_HOOK__.onCommitFiberRoot !== window.__originalReactCommit;"
) is True

configured = stabilize(wrapped, config=StabilizationConfig(framework_hooks=[]))

assert configured is wrapped
assert driver.execute_script(
"return window.__REACT_DEVTOOLS_GLOBAL_HOOK__.onCommitFiberRoot === window.__originalReactCommit;"
) is True
assert driver.execute_script(
"return window.__REACT_DEVTOOLS_GLOBAL_HOOK__.onCommitFiberRoot();"
) == "original"
wrapped._engine.ensure_instrumented()
assert wrapped._engine._active_adapters == []
assert driver.execute_script(
"return !window.__waitless__.framework || !window.__waitless__.framework.react;"
) is True

def test_reconfigure_uninstalls_angular_hook(self, driver, fixture_url):
driver.get(fixture_url)
driver.execute_script(
"window.ng = {};"
"window.Zone = function Zone() {};"
"window.Zone.current = {};"
"window.__originalZoneRun = function(callback) { return callback(); };"
"window.Zone.prototype.run = window.__originalZoneRun;"
)
wrapped = stabilize(driver, config=StabilizationConfig(framework_hooks=["angular"]))
wrapped._engine.ensure_instrumented()
assert driver.execute_script("return window.Zone.prototype.run !== window.__originalZoneRun;") is True

stabilize(wrapped, config=StabilizationConfig(framework_hooks=[]))

assert driver.execute_script("return window.Zone.prototype.run === window.__originalZoneRun;") is True
assert driver.execute_script("return window.Zone.prototype.run(function() { return 'original'; });") == "original"

def test_reconfigure_uninstalls_vue_hooks(self, driver, fixture_url):
driver.get(fixture_url)
driver.execute_script(
"window.__originalVueNextTick = function(callback) { if (callback) callback(); return 'original'; };"
"window.__vueListeners = {};"
"window.__VUE_DEVTOOLS_GLOBAL_HOOK__ = {"
" Vue: {nextTick: window.__originalVueNextTick},"
" on: function(name, callback) { window.__vueListeners[name] = callback; },"
" off: function(name, callback) { if (window.__vueListeners[name] === callback) delete window.__vueListeners[name]; }"
"};"
)
wrapped = stabilize(driver, config=StabilizationConfig(framework_hooks=["vue"]))
wrapped._engine.ensure_instrumented()
assert driver.execute_script(
"return window.__VUE_DEVTOOLS_GLOBAL_HOOK__.Vue.nextTick !== window.__originalVueNextTick;"
) is True
assert driver.execute_script("return !!window.__vueListeners['component:updated'];") is True

stabilize(wrapped, config=StabilizationConfig(framework_hooks=[]))

assert driver.execute_script(
"return window.__VUE_DEVTOOLS_GLOBAL_HOOK__.Vue.nextTick === window.__originalVueNextTick;"
) is True
assert driver.execute_script("return !window.__vueListeners['component:updated'];") is True
33 changes: 33 additions & 0 deletions tests/unit/test_diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,39 @@ def test_timeline_display_limited(self):
# Should only show last 10 events
assert "RECENT EVENTS (last 10)" in text

def test_engine_diagnostics_redact_url_queries_and_fragments(self):
from waitless.engine import StabilizationEngine

engine = StabilizationEngine(driver=object())
engine._last_browser_state = {
'pending_request_details': [
{'url': 'https://user:secret@example.test/api?token=secret#fragment', 'type': 'fetch'}
],
'websocket_details': [
{'url': 'wss://user:secret@example.test/socket?access_token=secret', 'state': 'open'}
],
'sse_details': [
{'url': 'https://user:secret@example.test/events?token=secret', 'state': 'open'}
],
'iframe_status': [
{'src': 'https://user:secret@example.test/frame?token=secret', 'loaded': True}
],
'timeline': [
{
'message': 'Request started',
'data': {'url': 'https://user:secret@example.test/data?email=user@example.test'},
}
],
}

diagnostics = engine.get_diagnostics()

assert diagnostics['browser_state']['pending_request_details'][0]['url'] == 'https://example.test/api'
assert diagnostics['browser_state']['websocket_details'][0]['url'] == 'wss://example.test/socket'
assert diagnostics['browser_state']['sse_details'][0]['url'] == 'https://example.test/events'
assert diagnostics['browser_state']['iframe_status'][0]['src'] == 'https://example.test/frame'
assert diagnostics['browser_state']['timeline'][0]['data']['url'] == 'https://example.test/data'


class TestHelperFunctions:
"""Test module-level helper functions."""
Expand Down
2 changes: 1 addition & 1 deletion waitless/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
driver = unstabilize(driver) # Back to original behavior
"""

__version__ = '1.0.0'
__version__ = '1.0.1'
__author__ = 'Dhiraj Das'

# Public API
Expand Down
3 changes: 2 additions & 1 deletion waitless/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ def run_doctor(args):
print("| try:".ljust(67) + "|")
print("| driver.find_element(...).click()".ljust(67) + "|")
print("| except StabilizationTimeout as e:".ljust(67) + "|")
print("| print_report(engine) # Print diagnostic report".ljust(67) + "|")
print("| diagnostics = get_diagnostics(driver)".ljust(67) + "|")
print("| print_report(diagnostics)".ljust(67) + "|")
print("|".ljust(67) + "|")
print("| 3. Export diagnostics for CI:".ljust(67) + "|")
print("|".ljust(67) + "|")
Expand Down
Loading
Loading