From 12956852b25f966f97d552c02c61f977a7289109 Mon Sep 17 00:00:00 2001 From: Dhiraj Das Date: Fri, 10 Jul 2026 13:04:17 +0530 Subject: [PATCH] fix: make v1 stability signals operational --- .github/workflows/ci.yml | 18 ++ CHANGELOG.md | 12 ++ README.md | 24 ++- pyproject.toml | 10 +- .../integration/test_selenium_integration.py | 143 +++++++++++++++- tests/unit/test_diagnostics.py | 33 ++++ waitless/__init__.py | 2 +- waitless/__main__.py | 3 +- waitless/adapters/angular.py | 54 ++++-- waitless/adapters/base.py | 6 + waitless/adapters/react.py | 51 ++++-- waitless/adapters/vue.py | 79 ++++++--- waitless/config.py | 23 +++ waitless/diagnostics.py | 30 ++-- waitless/engine.py | 155 ++++++++++++++++-- waitless/instrumentation.py | 58 ++++--- waitless/selenium_integration.py | 98 +++++------ waitless/signals.py | 39 ++++- 18 files changed, 673 insertions(+), 165 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92aba52..c6a745f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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__)" + diff --git a/CHANGELOG.md b/CHANGELOG.md index 293f3c7..f8caa21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 13f6277..8416c43 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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. @@ -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 @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 58240e2..2283f69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"} @@ -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", @@ -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" @@ -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] diff --git a/tests/integration/test_selenium_integration.py b/tests/integration/test_selenium_integration.py index 1ab70dc..b96f8ba 100644 --- a/tests/integration/test_selenium_integration.py +++ b/tests/integration/test_selenium_integration.py @@ -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) @@ -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 @@ -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 diff --git a/tests/unit/test_diagnostics.py b/tests/unit/test_diagnostics.py index ca73bc8..258437a 100644 --- a/tests/unit/test_diagnostics.py +++ b/tests/unit/test_diagnostics.py @@ -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.""" diff --git a/waitless/__init__.py b/waitless/__init__.py index 74eb2fc..e03aba7 100644 --- a/waitless/__init__.py +++ b/waitless/__init__.py @@ -36,7 +36,7 @@ driver = unstabilize(driver) # Back to original behavior """ -__version__ = '1.0.0' +__version__ = '1.0.1' __author__ = 'Dhiraj Das' # Public API diff --git a/waitless/__main__.py b/waitless/__main__.py index bcb3a00..8b2eb58 100644 --- a/waitless/__main__.py +++ b/waitless/__main__.py @@ -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) + "|") diff --git a/waitless/adapters/angular.py b/waitless/adapters/angular.py index d68c81d..3c068a5 100644 --- a/waitless/adapters/angular.py +++ b/waitless/adapters/angular.py @@ -56,9 +56,12 @@ def instrumentation_script(self) -> str: if (testability && testability.length > 0) { testability.forEach(function(t) { t.whenStable(function() { - window.__waitless__.framework.angular.isStable = true; - window.__waitless__.framework.angular.lastStableTime = Date.now(); - window.__waitless__._log('Angular became stable'); + var angular = window.__waitless__ && window.__waitless__.framework && window.__waitless__.framework.angular; + if (angular) { + angular.isStable = true; + angular.lastStableTime = Date.now(); + window.__waitless__._log('Angular became stable'); + } }); }); } @@ -66,20 +69,29 @@ def instrumentation_script(self) -> str: // Also try to hook into Zone.js if available if (window.Zone && window.Zone.current) { var originalRun = Zone.prototype.run; + window.__waitless__._angularOriginals = { + zonePrototype: Zone.prototype, + run: originalRun + }; Zone.prototype.run = function(callback, applyThis, applyArgs) { - if (this.name === 'angular') { - window.__waitless__.framework.angular.isStable = false; - window.__waitless__.framework.angular.pendingTasks++; + var angular = window.__waitless__ && window.__waitless__.framework && window.__waitless__.framework.angular; + var tracked = this.name === 'angular' && angular; + if (tracked) { + angular.isStable = false; + angular.pendingTasks++; } - var result = originalRun.apply(this, arguments); - if (this.name === 'angular') { - window.__waitless__.framework.angular.pendingTasks--; - if (window.__waitless__.framework.angular.pendingTasks === 0) { - window.__waitless__.framework.angular.isStable = true; - window.__waitless__.framework.angular.lastStableTime = Date.now(); + try { + return originalRun.apply(this, arguments); + } finally { + var current = window.__waitless__ && window.__waitless__.framework && window.__waitless__.framework.angular; + if (tracked && current) { + current.pendingTasks--; + if (current.pendingTasks === 0) { + current.isStable = true; + current.lastStableTime = Date.now(); + } } } - return result; }; } @@ -89,6 +101,22 @@ def instrumentation_script(self) -> str: })(); """ + @property + def uninstall_script(self) -> str: + return """ + (function() { + if (!window.__waitless__) return true; + var originals = window.__waitless__._angularOriginals || {}; + if (originals.zonePrototype && originals.run) { + originals.zonePrototype.run = originals.run; + } + if (window.__waitless__.framework) delete window.__waitless__.framework.angular; + delete window.__waitless__._angularOriginals; + delete window.__waitless__._angularHooked; + return true; + })(); + """ + def get_status_script(self) -> str: return """ (function() { diff --git a/waitless/adapters/base.py b/waitless/adapters/base.py index deb1828..df7fdc4 100644 --- a/waitless/adapters/base.py +++ b/waitless/adapters/base.py @@ -41,6 +41,12 @@ def instrumentation_script(self) -> str: """ pass + @property + @abstractmethod + def uninstall_script(self) -> str: + """JavaScript that restores every function patched by this adapter.""" + pass + @abstractmethod def get_status_script(self) -> str: """ diff --git a/waitless/adapters/react.py b/waitless/adapters/react.py index bf323d7..908221c 100644 --- a/waitless/adapters/react.py +++ b/waitless/adapters/react.py @@ -53,19 +53,25 @@ def instrumentation_script(self) -> str: isSettled: true }; + var originals = window.__waitless__._reactOriginals = {}; var hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__; if (hook && hook.onCommitFiberRoot) { var originalOnCommit = hook.onCommitFiberRoot; + originals.hook = hook; + originals.onCommitFiberRoot = originalOnCommit; hook.onCommitFiberRoot = function(id, root, priority) { - window.__waitless__.framework.react.lastCommitTime = Date.now(); - window.__waitless__.framework.react.isSettled = false; - window.__waitless__._log('React commit', { priority: priority }); - - // Mark as settled after a short delay (microtask completion) - setTimeout(function() { - window.__waitless__.framework.react.isSettled = true; - }, 50); - + var react = window.__waitless__ && window.__waitless__.framework && window.__waitless__.framework.react; + if (react) { + react.lastCommitTime = Date.now(); + react.isSettled = false; + window.__waitless__._log('React commit', { priority: priority }); + + setTimeout(function() { + var current = window.__waitless__ && window.__waitless__.framework && window.__waitless__.framework.react; + if (current) current.isSettled = true; + }, 50); + } + return originalOnCommit.apply(this, arguments); }; } @@ -73,11 +79,15 @@ def instrumentation_script(self) -> str: // Also try to intercept React's scheduler if available if (window.scheduler && window.scheduler.unstable_scheduleCallback) { var originalSchedule = window.scheduler.unstable_scheduleCallback; + originals.scheduler = window.scheduler; + originals.scheduleCallback = originalSchedule; window.scheduler.unstable_scheduleCallback = function(priority, callback) { - window.__waitless__.framework.react.pendingUpdates++; + var react = window.__waitless__ && window.__waitless__.framework && window.__waitless__.framework.react; + if (react) react.pendingUpdates++; var wrappedCallback = function() { var result = callback.apply(this, arguments); - window.__waitless__.framework.react.pendingUpdates--; + var current = window.__waitless__ && window.__waitless__.framework && window.__waitless__.framework.react; + if (current) current.pendingUpdates--; return result; }; return originalSchedule.call(this, priority, wrappedCallback); @@ -90,6 +100,25 @@ def instrumentation_script(self) -> str: })(); """ + @property + def uninstall_script(self) -> str: + return """ + (function() { + if (!window.__waitless__) return true; + var originals = window.__waitless__._reactOriginals || {}; + if (originals.hook && originals.onCommitFiberRoot) { + originals.hook.onCommitFiberRoot = originals.onCommitFiberRoot; + } + if (originals.scheduler && originals.scheduleCallback) { + originals.scheduler.unstable_scheduleCallback = originals.scheduleCallback; + } + if (window.__waitless__.framework) delete window.__waitless__.framework.react; + delete window.__waitless__._reactOriginals; + delete window.__waitless__._reactHooked; + return true; + })(); + """ + def get_status_script(self) -> str: return """ (function() { diff --git a/waitless/adapters/vue.py b/waitless/adapters/vue.py index 1b1980e..a11f8ca 100644 --- a/waitless/adapters/vue.py +++ b/waitless/adapters/vue.py @@ -57,34 +57,53 @@ def instrumentation_script(self) -> str: }; var hook = window.__VUE_DEVTOOLS_GLOBAL_HOOK__; + var originals = window.__waitless__._vueOriginals = {}; if (hook) { - // Vue 3 DevTools hook - hook.on && hook.on('component:updated', function() { - window.__waitless__.framework.vue.lastUpdateTime = Date.now(); - window.__waitless__.framework.vue.isSettled = false; - window.__waitless__._log('Vue component updated'); - - setTimeout(function() { - window.__waitless__.framework.vue.isSettled = true; - }, 50); - }); - + // Vue 3 DevTools hook. Reuse a stable handler if the hook has no off(). + if (hook.on) { + var updateHandler = hook.__waitlessVueUpdateHandler; + if (!updateHandler) { + updateHandler = function() { + var vue = window.__waitless__ && window.__waitless__.framework && window.__waitless__.framework.vue; + if (!vue) return; + vue.lastUpdateTime = Date.now(); + vue.isSettled = false; + window.__waitless__._log('Vue component updated'); + + setTimeout(function() { + var current = window.__waitless__ && window.__waitless__.framework && window.__waitless__.framework.vue; + if (current) current.isSettled = true; + }, 50); + }; + hook.__waitlessVueUpdateHandler = updateHandler; + hook.on('component:updated', updateHandler); + } + originals.hook = hook; + originals.updateHandler = updateHandler; + } + // Vue 2 compatibility if (hook.Vue && hook.Vue.nextTick) { var originalNextTick = hook.Vue.nextTick; + originals.vue = hook.Vue; + originals.nextTick = originalNextTick; hook.Vue.nextTick = function(callback, context) { - window.__waitless__.framework.vue.pendingTicks++; - window.__waitless__.framework.vue.isSettled = false; - + var vue = window.__waitless__ && window.__waitless__.framework && window.__waitless__.framework.vue; + if (vue) { + vue.pendingTicks++; + vue.isSettled = false; + } + var wrappedCallback = function() { - window.__waitless__.framework.vue.pendingTicks--; - window.__waitless__.framework.vue.lastUpdateTime = Date.now(); - if (window.__waitless__.framework.vue.pendingTicks === 0) { - window.__waitless__.framework.vue.isSettled = true; + var current = window.__waitless__ && window.__waitless__.framework && window.__waitless__.framework.vue; + if (current) { + current.pendingTicks--; + current.lastUpdateTime = Date.now(); + if (current.pendingTicks === 0) current.isSettled = true; } - if (callback) callback.apply(this, arguments); + if (callback) return callback.apply(this, arguments); }; - + return originalNextTick.call(this, wrappedCallback, context); }; } @@ -96,6 +115,26 @@ def instrumentation_script(self) -> str: })(); """ + @property + def uninstall_script(self) -> str: + return """ + (function() { + if (!window.__waitless__) return true; + var originals = window.__waitless__._vueOriginals || {}; + if (originals.vue && originals.nextTick) { + originals.vue.nextTick = originals.nextTick; + } + if (originals.hook && originals.updateHandler && originals.hook.off) { + originals.hook.off('component:updated', originals.updateHandler); + delete originals.hook.__waitlessVueUpdateHandler; + } + if (window.__waitless__.framework) delete window.__waitless__.framework.vue; + delete window.__waitless__._vueOriginals; + delete window.__waitless__._vueHooked; + return true; + })(); + """ + def get_status_script(self) -> str: return """ (function() { diff --git a/waitless/config.py b/waitless/config.py index ed4e68d..0cac5c1 100644 --- a/waitless/config.py +++ b/waitless/config.py @@ -107,6 +107,12 @@ def _validate(self) -> None: raise ConfigurationError( f"dom_settle_time must be non-negative, got {self.dom_settle_time}" ) + + if self.mutation_rate_threshold < 0: + raise ConfigurationError( + "mutation_rate_threshold must be non-negative, " + f"got {self.mutation_rate_threshold}" + ) if self.network_idle_threshold < 0: raise ConfigurationError( @@ -122,11 +128,28 @@ def _validate(self) -> None: raise ConfigurationError( f"poll_interval ({self.poll_interval}) cannot exceed timeout ({self.timeout})" ) + + if self.websocket_quiet_time < 0: + raise ConfigurationError( + "websocket_quiet_time must be non-negative, " + f"got {self.websocket_quiet_time}" + ) if self.strictness not in ('strict', 'normal', 'relaxed'): raise ConfigurationError( f"strictness must be 'strict', 'normal', or 'relaxed', got '{self.strictness}'" ) + + supported_hooks = {'react', 'angular', 'vue'} + normalized_hooks = [] + for hook in self.framework_hooks: + if not isinstance(hook, str) or hook.lower() not in supported_hooks: + raise ConfigurationError( + f"unsupported framework hook {hook!r}; expected one of " + f"{sorted(supported_hooks)}" + ) + normalized_hooks.append(hook.lower()) + self.framework_hooks = list(dict.fromkeys(normalized_hooks)) def with_overrides(self, **kwargs) -> 'StabilizationConfig': """ diff --git a/waitless/diagnostics.py b/waitless/diagnostics.py index f939ee2..fb1d91e 100644 --- a/waitless/diagnostics.py +++ b/waitless/diagnostics.py @@ -166,14 +166,14 @@ def _generate_suggestions(self) -> List[str]: ) if suggestions: - lines.append("╠" + "═" * 66 + "╣") - lines.append("║ SUGGESTIONS:".ljust(67) + "║") - lines.append("║".ljust(67) + "║") + lines.append("+-" + "-" * 66 + "-+") + lines.append("| SUGGESTIONS:".ljust(67) + "|") + lines.append("|".ljust(67) + "|") for i, suggestion in enumerate(suggestions, 1): for line in f"{i}. {suggestion}".split('\n'): - lines.append(f"║ {line}".ljust(67) + "║") - lines.append("║".ljust(67) + "║") + lines.append(f"| {line}".ljust(67) + "|") + lines.append("|".ljust(67) + "|") return lines @@ -185,13 +185,19 @@ def to_json(self) -> str: }, indent=2, default=str) -def generate_report(engine: 'StabilizationEngine') -> DiagnosticReport: - """Generate a diagnostic report from an engine.""" - diagnostics = engine.get_diagnostics() +def generate_report(source: Any) -> DiagnosticReport: + """Generate a report from an engine or a diagnostics dictionary.""" + if isinstance(source, dict): + diagnostics = source.get('diagnostics', source) + elif hasattr(source, 'get_diagnostics'): + diagnostics = source.get_diagnostics() + elif hasattr(source, '_engine'): + diagnostics = source._engine.get_diagnostics() + else: + raise TypeError("expected diagnostics dict, engine, or stabilized driver") return DiagnosticReport(diagnostics) -def print_report(engine: 'StabilizationEngine') -> None: - """Print a diagnostic report to stdout.""" - report = generate_report(engine) - print(report.generate_text_report()) +def print_report(source: Any) -> None: + """Print a report from diagnostics, an engine, or a stabilized driver.""" + print(generate_report(source).generate_text_report()) diff --git a/waitless/engine.py b/waitless/engine.py index 4b7ee4e..66d4cbf 100644 --- a/waitless/engine.py +++ b/waitless/engine.py @@ -8,6 +8,7 @@ import time import threading import logging +from urllib.parse import urlsplit, urlunsplit from typing import Optional, Dict, Any, TYPE_CHECKING # Import Selenium exceptions for specific error handling @@ -32,6 +33,7 @@ CHECK_ALIVE_SCRIPT, GET_STATUS_SCRIPT, ) +from .adapters import get_adapter from .exceptions import StabilizationTimeout, InstrumentationError @@ -71,10 +73,39 @@ def __init__( self._last_browser_state: Optional[Dict[str, Any]] = None self._last_blocking_factors: Dict[str, Any] = {} self._timeline: list = [] + self._active_adapters: list = [] if self.config.debug_mode: logging.basicConfig(level=logging.DEBUG) logger.setLevel(logging.DEBUG) + + def configure(self, config: StabilizationConfig) -> None: + """Apply a new config and force browser instrumentation to be rebuilt.""" + self.config = config + self.evaluator = SignalEvaluator(config) + if self._instrumented: + try: + self._uninstall_adapters() + self.driver.execute_script( + "if (window.__waitless__) { window.__waitless__.destroy(); }" + ) + except (JavascriptException, WebDriverException, NoSuchWindowException): + # A navigation may already have discarded the old page. The next + # wait will inject into the current document regardless. + pass + self._active_adapters = [] + self.reset() + + def _browser_config(self) -> Dict[str, Any]: + return { + 'trackLayout': self.config.layout_stability, + 'trackAnimations': self.config.animation_detection, + 'trackWebSocket': self.config.track_websocket, + 'trackSSE': self.config.track_sse, + 'webSocketQuietTime': self.config.websocket_quiet_time * 1000, + 'trackIframes': self.config.track_iframes, + 'redactQueryStrings': True, + } def ensure_instrumented(self) -> None: """ @@ -119,24 +150,56 @@ def _is_instrumentation_alive(self) -> bool: self._debug(f"Instrumentation check failed: {e}") return False + def _uninstall_adapters(self) -> None: + """Restore every browser API patched by active framework adapters.""" + for adapter in reversed(self._active_adapters): + try: + self.driver.execute_script("return " + adapter.uninstall_script.strip()) + except (JavascriptException, WebDriverException, NoSuchWindowException): + # The page may have navigated or the adapter may already be gone. + continue + def _inject_instrumentation(self) -> None: - """Inject JavaScript instrumentation into the page.""" + """Inject configured JavaScript instrumentation and framework hooks.""" try: - self.driver.execute_script(INSTRUMENTATION_SCRIPT) + self.driver.execute_script(INSTRUMENTATION_SCRIPT, self._browser_config()) + self._active_adapters = [] + for name in self.config.framework_hooks: + adapter = get_adapter(name) + detected = adapter and self.driver.execute_script( + "return " + adapter.detection_script.strip() + ) + if detected: + installed = self.driver.execute_script( + "return " + adapter.instrumentation_script.strip() + ) + if installed: + self._active_adapters.append(adapter) self._instrumented = True self._debug("Instrumentation injected successfully") - except Exception as e: + except (JavascriptException, WebDriverException, NoSuchWindowException) as e: self._instrumented = False raise InstrumentationError( f"Failed to inject instrumentation: {e}", original_error=e - ) + ) from e def _get_browser_status(self) -> Optional[Dict[str, Any]]: """Get current stability status from browser.""" try: - return self.driver.execute_script(GET_STATUS_SCRIPT) - except Exception as e: + state = self.driver.execute_script(GET_STATUS_SCRIPT) + if state is not None and self._active_adapters: + state['framework_status'] = [ + { + 'name': adapter.name, + **self.driver.execute_script( + "return " + adapter.get_status_script().strip() + ), + } + for adapter in self._active_adapters + ] + return state + except (JavascriptException, WebDriverException, NoSuchWindowException) as e: self._debug(f"Failed to get browser status: {e}") return None @@ -193,13 +256,13 @@ def _wait_for_stability_impl(self, timeout: Optional[float] = None) -> Stability status = self.evaluator.evaluate(browser_state, current_time) last_status = status self._last_status = status - self._last_browser_state = browser_state # Store for diagnostics + self._last_browser_state = browser_state + self._update_diagnostics(browser_state, status) if status.is_stable: self._debug(f"UI stable after {elapsed:.2f}s") return status - self._update_diagnostics(browser_state, status) time.sleep(self.config.poll_interval) def _handle_timeout( @@ -242,6 +305,44 @@ def _handle_timeout( timeline=self._timeline[-50:], ) + @staticmethod + def _redact_url(url: Any) -> str: + """Remove query strings/fragments from diagnostic URLs by default.""" + value = str(url or 'unknown') + parts = urlsplit(value) + hostname = parts.hostname or '' + if ':' in hostname and not hostname.startswith('['): + hostname = f'[{hostname}]' + try: + port = parts.port + except ValueError: + port = None + netloc = f'{hostname}:{port}' if port is not None else hostname + return urlunsplit((parts.scheme, netloc, parts.path, '', '')) + + def _redact_details(self, details: Any) -> list: + redacted = [] + for item in (details or []): + clean = dict(item) + for key in ('url', 'src'): + if key in clean: + clean[key] = self._redact_url(clean[key]) + redacted.append(clean) + return redacted + + def _redact_timeline(self, timeline: Any) -> list: + result = [] + for entry in (timeline or []): + clean = dict(entry) + if isinstance(clean.get('data'), dict): + data = dict(clean['data']) + for key in ('url', 'src'): + if key in data: + data[key] = self._redact_url(data[key]) + clean['data'] = data + result.append(clean) + return result + def _update_diagnostics( self, browser_state: Dict[str, Any], @@ -250,13 +351,24 @@ def _update_diagnostics( """Update diagnostic information for the doctor command.""" self._last_blocking_factors = { 'pending_requests': browser_state.get('pending_requests', 0), - 'pending_request_details': browser_state.get('pending_request_details', []), + 'pending_request_details': self._redact_details( + browser_state.get('pending_request_details') + ), 'active_animations': browser_state.get('active_animations', 0), 'layout_shifting': browser_state.get('layout_shifting', False), 'last_mutation_time': browser_state.get('last_mutation_time', 0), + 'mutation_rate': browser_state.get('mutation_rate', 0), + 'active_websockets': browser_state.get('active_websockets', 0), + 'last_websocket_activity': browser_state.get('last_websocket_activity', 0), + 'websocket_details': self._redact_details(browser_state.get('websocket_details')), + 'active_sse': browser_state.get('active_sse', 0), + 'last_sse_activity': browser_state.get('last_sse_activity', 0), + 'sse_details': self._redact_details(browser_state.get('sse_details')), + 'iframe_status': self._redact_details(browser_state.get('iframe_status')), + 'framework_status': browser_state.get('framework_status', []), } - timeline = browser_state.get('timeline', []) + timeline = self._redact_timeline(browser_state.get('timeline')) self._timeline.extend(timeline) self._timeline = self._timeline[-200:] @@ -272,16 +384,31 @@ def get_diagnostics(self) -> Dict[str, Any]: Returns: Dictionary with diagnostic data """ + browser_state = dict(self._last_browser_state or {}) + for key in ('pending_request_details', 'websocket_details', 'sse_details', 'iframe_status'): + browser_state[key] = self._redact_details(browser_state.get(key)) + browser_state['timeline'] = self._redact_timeline(browser_state.get('timeline')) return { + 'schema_version': 1, 'config': { 'timeout': self.config.timeout, - 'strictness': self.config.strictness, + 'dom_settle_time': self.config.dom_settle_time, + 'mutation_rate_threshold': self.config.mutation_rate_threshold, 'network_idle_threshold': self.config.network_idle_threshold, 'animation_detection': self.config.animation_detection, + 'layout_stability': self.config.layout_stability, + 'strictness': self.config.strictness, + 'poll_interval': self.config.poll_interval, + 'track_websocket': self.config.track_websocket, + 'track_sse': self.config.track_sse, + 'websocket_quiet_time': self.config.websocket_quiet_time, + 'framework_hooks': list(self.config.framework_hooks), + 'track_iframes': self.config.track_iframes, }, - 'last_status': self._last_browser_state, # Raw browser state with mutation_rate, etc. - 'blocking_factors': self._last_blocking_factors, - 'timeline': self._timeline[-50:], + 'last_status': self._last_status.to_dict() if self._last_status else None, + 'browser_state': browser_state, + 'blocking_factors': dict(self._last_blocking_factors), + 'timeline': list(self._timeline[-50:]), 'instrumented': self._instrumented, } diff --git a/waitless/instrumentation.py b/waitless/instrumentation.py index eb6dbae..ad01892 100644 --- a/waitless/instrumentation.py +++ b/waitless/instrumentation.py @@ -3,7 +3,8 @@ """ INSTRUMENTATION_SCRIPT = """ -(function() { +(function(suppliedConfig) { + suppliedConfig = suppliedConfig || {}; // Avoid re-initialization if (window.__waitless__ && window.__waitless__._initialized) { return window.__waitless__; @@ -11,7 +12,7 @@ window.__waitless__ = { _initialized: true, - _version: '1.0.0', + _version: '1.0.1', // State tracking pendingRequests: 0, @@ -39,14 +40,15 @@ pendingRequestDetails: [], // Configuration (updated from Python) - config: { + config: Object.assign({ trackLayout: true, trackAnimations: true, trackWebSocket: false, trackSSE: false, webSocketQuietTime: 500, // ms of silence for stability trackIframes: false, - }, + redactQueryStrings: true + }, suppliedConfig), // Lifecycle _observers: [], @@ -61,7 +63,9 @@ init: function() { this._setupMutationObserver(); this._setupNetworkInterceptors(); - this._setupAnimationTracking(); + if (this.config.trackAnimations) { + this._setupAnimationTracking(); + } if (this.config.trackLayout) { this._setupLayoutTracking(); } @@ -98,21 +102,26 @@ _mutationTimestamps: [], _mutationWindowMs: 1000, // 1 second window for rate calculation _observedShadowRoots: new WeakSet(), + + _recordMutations: function(mutations, label) { + var now = Date.now(); + this.lastMutationTime = now; + // MutationObserver callbacks can contain many MutationRecords. Count + // each record, not merely each callback invocation. + for (var i = 0; i < mutations.length; i++) { + this._mutationTimestamps.push(now); + } + var cutoff = now - this._mutationWindowMs; + while (this._mutationTimestamps.length > 0 && this._mutationTimestamps[0] < cutoff) { + this._mutationTimestamps.shift(); + } + this._log(label, { count: mutations.length, rate: this.getMutationRate() }); + }, _setupMutationObserver: function() { var self = this; var observer = new MutationObserver(function(mutations) { - var now = Date.now(); - self.lastMutationTime = now; - - // Add to rolling window - self._mutationTimestamps.push(now); - - // Remove old timestamps outside the window - var cutoff = now - self._mutationWindowMs; - while (self._mutationTimestamps.length > 0 && self._mutationTimestamps[0] < cutoff) { - self._mutationTimestamps.shift(); - } + self._recordMutations(mutations, 'DOM mutation'); // Check for new shadow roots in added nodes mutations.forEach(function(mutation) { @@ -122,8 +131,6 @@ } }); }); - - self._log('DOM mutation', { count: mutations.length, rate: self.getMutationRate() }); }); var config = { @@ -149,10 +156,7 @@ self._observedShadowRoots.add(node.shadowRoot); var observer = new MutationObserver(function(mutations) { - var now = Date.now(); - self.lastMutationTime = now; - self._mutationTimestamps.push(now); - self._log('Shadow DOM mutation', { count: mutations.length }); + self._recordMutations(mutations, 'Shadow DOM mutation'); // Scan new nodes in shadow DOM for nested shadow roots mutations.forEach(function(mutation) { @@ -249,8 +253,14 @@ return self._originalXHRSend.apply(this, arguments); }; }, + + _redactUrl: function(url) { + var value = String(url || 'unknown'); + return this.config.redactQueryStrings ? value.split(/[?#]/, 1)[0] : value; + }, _requestStarted: function(url, type) { + url = this._redactUrl(url); this.pendingRequests++; this.pendingRequestDetails.push({ url: url, @@ -261,6 +271,7 @@ }, _requestEnded: function(url, type, status) { + url = this._redactUrl(url); this.pendingRequests = Math.max(0, this.pendingRequests - 1); // Remove from pending details @@ -577,6 +588,7 @@ last_sse_activity: this.lastSSEActivity, websocket_details: this.webSocketDetails.slice(), sse_details: this.sseDetails.slice(), + iframe_status: this.iframeStatus.slice(), timeline: this.timeline.slice(-20) }; }, @@ -625,7 +637,7 @@ }; return window.__waitless__.init(); -})(); +})(arguments[0]); """ # Script to check if instrumentation is alive diff --git a/waitless/selenium_integration.py b/waitless/selenium_integration.py index 23e834f..e813d3f 100644 --- a/waitless/selenium_integration.py +++ b/waitless/selenium_integration.py @@ -10,8 +10,9 @@ import functools import logging +import weakref from typing import Optional, Any, List, Dict, TYPE_CHECKING -from weakref import WeakValueDictionary +from weakref import WeakKeyDictionary from .config import StabilizationConfig, DEFAULT_CONFIG from .engine import StabilizationEngine @@ -25,9 +26,6 @@ logger = logging.getLogger('waitless') -_stabilized_drivers: WeakValueDictionary = WeakValueDictionary() - - class StabilizedWebElement: """ Wrapper around WebElement that auto-waits for stability before interactions. @@ -112,8 +110,20 @@ def __getattr__(self, name: str) -> Any: return self._stabilized_find_element elif name == 'find_elements': return self._stabilized_find_elements + elif name in {'get', 'refresh', 'back', 'forward'}: + return self._create_stabilized_navigation(attr, name) return attr + + def _create_stabilized_navigation(self, method: callable, name: str) -> callable: + """Wait for the destination document after a synchronous navigation.""" + @functools.wraps(method) + def stabilized_navigation(*args, **kwargs): + result = method(*args, **kwargs) + self._engine.reset() + self._engine.wait_for_stability() + return result + return stabilized_navigation def _stabilized_find_element(self, *args, **kwargs) -> StabilizedWebElement: """ @@ -131,23 +141,19 @@ def _stabilized_find_element(self, *args, **kwargs) -> StabilizedWebElement: timeout = self._engine.config.timeout poll_interval = self._engine.config.poll_interval - start_time = time.time() last_exception = None + + # Instrumentation/stabilization failures are part of the public contract: + # do not hide them behind element lookup retries. + self._engine.wait_for_stability() + start_time = time.monotonic() - while (time.time() - start_time) < timeout: - # Wait for page stability first - try: - self._engine.wait_for_stability() - except Exception: - pass # Continue trying to find element - - # Try to find the element + while (time.monotonic() - start_time) < timeout: try: element = self._driver.find_element(*args, **kwargs) return StabilizedWebElement(element, self._engine) except NoSuchElementException as e: last_exception = e - # Element not found - wait and retry time.sleep(poll_interval) # Timeout reached - raise the last exception @@ -161,29 +167,11 @@ def _stabilized_find_elements(self, *args, **kwargs) -> List[StabilizedWebElemen Similar to find_element but returns list (may be empty). """ - import time - - timeout = self._engine.config.timeout - poll_interval = self._engine.config.poll_interval - start_time = time.time() - - while (time.time() - start_time) < timeout: - # Wait for stability first - try: - self._engine.wait_for_stability() - except Exception: - pass - - # Try to find elements - elements = self._driver.find_elements(*args, **kwargs) - if elements: - return [StabilizedWebElement(el, self._engine) for el in elements] - - # No elements found - wait and retry - time.sleep(poll_interval) - - # Return empty list if nothing found - return [] + # Match Selenium's contract: one lookup and an immediate empty list when + # there are no matches. Only the page-stability wait is added. + self._engine.wait_for_stability() + elements = self._driver.find_elements(*args, **kwargs) + return [StabilizedWebElement(el, self._engine) for el in elements] @property def unwrapped(self) -> 'WebDriver': @@ -206,9 +194,13 @@ class SeleniumIntegration: """ def __init__(self): - self._engines: Dict[int, StabilizationEngine] = {} - self._original_drivers: Dict[int, 'WebDriver'] = {} - self._wrapped_drivers: Dict[int, StabilizedWebDriver] = {} + # Both keys and values are weak. A registry entry cannot keep either the + # Selenium driver or its wrapper alive after user code releases them. + self._wrapped_drivers: WeakKeyDictionary = WeakKeyDictionary() + + def _lookup(self, driver: 'WebDriver') -> Optional[StabilizedWebDriver]: + ref = self._wrapped_drivers.get(driver) + return ref() if ref is not None else None def stabilize( self, @@ -232,6 +224,11 @@ def stabilize( The returned driver wraps the original but is not a true WebDriver. If you need the original for framework integration, use .unwrapped """ + if isinstance(driver, StabilizedWebDriver): + if config is not None: + driver._engine.configure(config) + return driver + # Validate driver is a WebDriver-like object if driver is None: raise TypeError("driver cannot be None") @@ -245,20 +242,16 @@ def stabilize( ) driver_id = id(driver) - - if driver_id in self._wrapped_drivers: - existing = self._wrapped_drivers[driver_id] + existing = self._lookup(driver) + if existing is not None: if config: - existing._engine.config = config + existing._engine.configure(config) return existing effective_config = config or DEFAULT_CONFIG engine = StabilizationEngine(driver, effective_config) wrapped = StabilizedWebDriver(driver, engine) - - self._engines[driver_id] = engine - self._original_drivers[driver_id] = driver - self._wrapped_drivers[driver_id] = wrapped + self._wrapped_drivers[driver] = weakref.ref(wrapped) if effective_config.debug_mode: logger.info(f"[waitless] Stabilization enabled for driver {driver_id}") @@ -282,9 +275,7 @@ def unstabilize(self, driver: 'WebDriver') -> 'WebDriver': original = driver driver_id = id(driver) - self._engines.pop(driver_id, None) - self._original_drivers.pop(driver_id, None) - self._wrapped_drivers.pop(driver_id, None) + self._wrapped_drivers.pop(original, None) logger.info(f"[waitless] Stabilization disabled for driver {driver_id}") @@ -294,13 +285,14 @@ def get_engine(self, driver: 'WebDriver') -> Optional[StabilizationEngine]: """Get the engine for a driver (for diagnostics).""" if isinstance(driver, StabilizedWebDriver): return driver._engine - return self._engines.get(id(driver)) + wrapped = self._lookup(driver) + return wrapped._engine if wrapped is not None else None def is_stabilized(self, driver: 'WebDriver') -> bool: """Check if a driver is currently stabilized.""" if isinstance(driver, StabilizedWebDriver): return True - return id(driver) in self._wrapped_drivers + return self._lookup(driver) is not None _integration = SeleniumIntegration() diff --git a/waitless/signals.py b/waitless/signals.py index f78b2cf..c38ac18 100644 --- a/waitless/signals.py +++ b/waitless/signals.py @@ -25,6 +25,8 @@ class SignalType(Enum): RAF_ACTIVITY = auto() WEBSOCKET_ACTIVITY = auto() SSE_ACTIVITY = auto() + FRAMEWORK_ACTIVITY = auto() + IFRAME_READINESS = auto() class SignalState(Enum): @@ -131,8 +133,9 @@ def evaluate(self, browser_state: Dict[str, Any], current_time: float) -> Stabil dom_signal = self._evaluate_dom(browser_state, current_time) signals.append(dom_signal) - network_signal = self._evaluate_network(browser_state) - signals.append(network_signal) + if self.config.strictness != 'relaxed': + network_signal = self._evaluate_network(browser_state) + signals.append(network_signal) if self.config.animation_detection: animation_signal = self._evaluate_animations(browser_state) @@ -150,6 +153,12 @@ def evaluate(self, browser_state: Dict[str, Any], current_time: float) -> Stabil if self.config.track_sse: sse_signal = self._evaluate_sse(browser_state, current_time) signals.append(sse_signal) + + for framework in browser_state.get('framework_status', []): + signals.append(self._evaluate_framework(framework)) + + if self.config.track_iframes: + signals.append(self._evaluate_iframes(browser_state)) is_stable = all( s.is_stable for s in signals if s.is_mandatory @@ -306,3 +315,29 @@ def _evaluate_sse(self, state: Dict[str, Any], current_time: float) -> Signal: is_mandatory=True, # Mandatory when enabled details=f"{active_sse} SSE connection(s), last activity {time_since_activity:.0f}ms ago", ) + + def _evaluate_framework(self, framework: Dict[str, Any]) -> Signal: + name = framework.get('name', 'framework') + stable = bool(framework.get('stable', False)) + return Signal( + signal_type=SignalType.FRAMEWORK_ACTIVITY, + state=SignalState.STABLE if stable else SignalState.UNSTABLE, + value={'name': name, 'stable': stable}, + threshold=True, + is_mandatory=True, + details=f"{name}: {framework.get('details', 'no details')}", + ) + + def _evaluate_iframes(self, state: Dict[str, Any]) -> Signal: + frames = state.get('iframe_status', []) + # Cross-origin frames are explicitly outside Waitless's visibility and + # therefore do not block. Accessible same-origin frames must finish load. + pending = [f for f in frames if f.get('accessible') and not f.get('loaded')] + return Signal( + signal_type=SignalType.IFRAME_READINESS, + state=SignalState.STABLE if not pending else SignalState.UNSTABLE, + value=len(pending), + threshold=0, + is_mandatory=True, + details=f"{len(pending)} same-origin iframe(s) still loading", + )