wavexis 2.2.5__tar.gz → 2.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {wavexis-2.2.5 → wavexis-2.3.0}/CHANGELOG.md +18 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/PKG-INFO +1 -1
- {wavexis-2.2.5 → wavexis-2.3.0}/pyproject.toml +1 -1
- wavexis-2.3.0/tests/unit/test_auto_wait.py +97 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_bidi_phase5.py +3 -1
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/__init__.py +1 -1
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/backend/bidi.py +36 -1
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/backend/cdp.py +31 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/cleanup.py +3 -2
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/cli/_session.py +52 -6
- {wavexis-2.2.5 → wavexis-2.3.0}/.dockerignore +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/.github/workflows/ci.yml +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/.github/workflows/docs.yml +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/.github/workflows/release.yml +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/.gitignore +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/CODE_OF_CONDUCT.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/CONTRIBUTING.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/Dockerfile +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/LICENSE +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/README.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/SECURITY.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/docs/api/actions.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/docs/api/backends.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/docs/api/cli.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/docs/api/config.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/docs/api/exceptions.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/docs/api/multi.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/docs/api/plugins.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/docs/api/serve.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/docs/cookbook/auth.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/docs/cookbook/ci-cd.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/docs/cookbook/css.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/docs/cookbook/debug.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/docs/cookbook/record-replay.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/docs/cookbook/scraping.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/docs/cookbook/screenshots.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/docs/cookbook/serve-mode.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/docs/guide/assert.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/docs/guide/backends.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/docs/guide/commands.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/docs/guide/init.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/docs/guide/installation.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/docs/guide/multi.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/docs/guide/perf.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/docs/guide/plugins.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/docs/guide/quickstart.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/docs/guide/raw.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/docs/guide/repl.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/docs/guide/troubleshooting.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/docs/index.md +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/mkdocs.yml +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/stubs/bidiwave/__init__.pyi +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/stubs/cdpwave/__init__.pyi +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/__init__.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/conftest.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/__init__.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_a11y.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_animation.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_backend_selection.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_browser.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_console.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_css.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_debug.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_dialog.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_dom.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_dom_snapshot.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_emulation.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_emulation_advanced.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_eval.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_har.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_input.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_media.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_multi.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_navigate.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_network.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_network_advanced.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_overlay.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_pdf.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_perf.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_permissions.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_raw.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_record.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_scrape.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_screenshot.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_security.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_serve.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_service_worker.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_storage.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_tabs.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_webaudio.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/integration/test_webauthn.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/__init__.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_abstract_backend_phase5.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_actions.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_actions_phase5.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_animation_action.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_assert_multi.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_auth.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_backend_manager.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_bidi_backend.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_bluetooth_action.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_cast_action.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_cleanup.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_cli_phase5.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_config.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_config_phase5.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_cookies_headers_actions.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_css_action.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_debug_action.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_dom_snapshot_action.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_download_screencast.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_emulation_action.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_error_handling.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_exceptions.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_format_record.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_global_options.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_init.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_input_action.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_interactions_crawl.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_manager.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_media_action.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_multi.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_multi_improvements.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_new_features.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_output.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_overlay_action.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_perf_action.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_perf_console.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_plugins.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_raw.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_record.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_repl.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_serve.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_service_worker_action.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_storage_action.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_webaudio_action.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/tests/unit/test_webauthn_action.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/__main__.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/__init__.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/accessibility.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/animation.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/base.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/bluetooth.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/browser.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/cast.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/console.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/cookies.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/crawl.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/css.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/debug.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/dialog.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/dom.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/dom_snapshot.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/download.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/emulation.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/eval.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/extract.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/form.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/har.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/headers.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/input.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/lighthouse.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/media.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/multi.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/navigate.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/network.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/overlay.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/pdf.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/performance.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/permissions.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/record.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/scrape.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/screencast.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/screenshot.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/security.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/service_worker.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/session.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/storage.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/tabs.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/wait.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/webaudio.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/webauthn.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/actions/websocket.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/auth.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/backend/__init__.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/backend/base.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/backend/manager.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/cli/__init__.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/cli/_advanced.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/cli/_capture.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/cli/_config.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/cli/_debug.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/cli/_emulation.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/cli/_experimental.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/cli/_input.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/cli/_navigation.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/cli/_network.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/cli/_perf.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/cli/_serve.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/cli/_shared.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/cli/_workflow.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/cli/app.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/config.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/exceptions.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/init.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/multi.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/output.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/plugins.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/record.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/repl.py +0 -0
- {wavexis-2.2.5 → wavexis-2.3.0}/wavexis/serve.py +0 -0
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to wavexis are documented in this file.
|
|
4
4
|
|
|
5
|
+
## v2.3.0 — 2026-07-07
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Auto-waiting before `click`, `fill`, and `hover` in both CDP and BiDi backends
|
|
10
|
+
- `_wait_for_element` helper polls every 100ms until element exists and is visible (non-zero size)
|
|
11
|
+
- Configurable timeout (default 30s) via `WaitTimeoutError` with actionable hint
|
|
12
|
+
- Prevents `ElementNotFoundError` on dynamically loaded content
|
|
13
|
+
|
|
14
|
+
## v2.2.6 — 2026-07-07
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- Named sessions: `--name` flag stores sessions in `~/.wavexis/sessions/`
|
|
19
|
+
- `wavexis session list` — list all saved named sessions with size and modification date
|
|
20
|
+
- `wavexis session delete --name <name>` — delete a named session
|
|
21
|
+
- Named sessions persist between runs, enabling stateful workflows
|
|
22
|
+
|
|
5
23
|
## v2.2.5 — 2026-07-07
|
|
6
24
|
|
|
7
25
|
### Improved
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wavexis
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: Browser automation CLI — wraps cdpwave and bidiwave, no Node.js, no Chromium download
|
|
5
5
|
Project-URL: Homepage, https://github.com/MathiasPaulenko/wavexis
|
|
6
6
|
Project-URL: Repository, https://github.com/MathiasPaulenko/wavexis
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Unit tests for auto-waiting before click/fill/hover."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from wavexis.exceptions import WaitTimeoutError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.mark.unit
|
|
14
|
+
class TestAutoWait:
|
|
15
|
+
"""Tests for auto-waiting before input actions."""
|
|
16
|
+
|
|
17
|
+
def test_cdp_wait_for_element_found(self) -> None:
|
|
18
|
+
"""Test _wait_for_element returns when element is visible."""
|
|
19
|
+
from wavexis.backend.cdp import CDPBackend
|
|
20
|
+
|
|
21
|
+
backend = CDPBackend()
|
|
22
|
+
backend._session = MagicMock()
|
|
23
|
+
backend._session.runtime = MagicMock()
|
|
24
|
+
backend._session.runtime.evaluate = AsyncMock(
|
|
25
|
+
return_value={"result": {"value": True}}
|
|
26
|
+
)
|
|
27
|
+
asyncio.run(backend._wait_for_element("#btn", timeout_ms=100))
|
|
28
|
+
assert backend._session.runtime.evaluate.call_count >= 1
|
|
29
|
+
|
|
30
|
+
def test_cdp_wait_for_element_timeout(self) -> None:
|
|
31
|
+
"""Test _wait_for_element raises WaitTimeoutError on timeout."""
|
|
32
|
+
from wavexis.backend.cdp import CDPBackend
|
|
33
|
+
|
|
34
|
+
backend = CDPBackend()
|
|
35
|
+
backend._session = MagicMock()
|
|
36
|
+
backend._session.runtime = MagicMock()
|
|
37
|
+
backend._session.runtime.evaluate = AsyncMock(
|
|
38
|
+
return_value={"result": {"value": False}}
|
|
39
|
+
)
|
|
40
|
+
with pytest.raises(WaitTimeoutError):
|
|
41
|
+
asyncio.run(backend._wait_for_element("#missing", timeout_ms=50))
|
|
42
|
+
|
|
43
|
+
def test_cdp_click_calls_wait_for_element(self) -> None:
|
|
44
|
+
"""Test that click calls _wait_for_element before acting."""
|
|
45
|
+
from wavexis.backend.cdp import CDPBackend
|
|
46
|
+
|
|
47
|
+
backend = CDPBackend()
|
|
48
|
+
backend._session = MagicMock()
|
|
49
|
+
backend._session.input = MagicMock()
|
|
50
|
+
backend._session.input.dispatch_mouse_event = AsyncMock()
|
|
51
|
+
|
|
52
|
+
call_order: list[str] = []
|
|
53
|
+
|
|
54
|
+
async def _mock_wait(selector: str, timeout_ms: int = 30000) -> None:
|
|
55
|
+
call_order.append("wait")
|
|
56
|
+
|
|
57
|
+
async def _mock_scroll(selector: str) -> None:
|
|
58
|
+
call_order.append("scroll")
|
|
59
|
+
|
|
60
|
+
async def _mock_box(selector: str) -> tuple[float, float]:
|
|
61
|
+
call_order.append("box")
|
|
62
|
+
return 50.0, 25.0
|
|
63
|
+
|
|
64
|
+
backend._wait_for_element = _mock_wait # type: ignore[assignment]
|
|
65
|
+
backend._scroll_into_view_if_needed = _mock_scroll # type: ignore[assignment]
|
|
66
|
+
backend._get_box_center = _mock_box # type: ignore[assignment]
|
|
67
|
+
|
|
68
|
+
asyncio.run(backend.click("#btn"))
|
|
69
|
+
assert call_order == ["wait", "scroll", "box"]
|
|
70
|
+
|
|
71
|
+
def test_bidi_wait_for_element_found(self) -> None:
|
|
72
|
+
"""Test BiDi _wait_for_element returns when element is visible."""
|
|
73
|
+
from wavexis.backend.bidi import BiDiBackend
|
|
74
|
+
|
|
75
|
+
backend = BiDiBackend()
|
|
76
|
+
backend._client = MagicMock()
|
|
77
|
+
backend._context = MagicMock()
|
|
78
|
+
backend._client.script = MagicMock()
|
|
79
|
+
result = MagicMock()
|
|
80
|
+
result.value = True
|
|
81
|
+
backend._client.script.evaluate = AsyncMock(return_value=result)
|
|
82
|
+
asyncio.run(backend._wait_for_element("#btn", timeout_ms=100))
|
|
83
|
+
assert backend._client.script.evaluate.call_count >= 1
|
|
84
|
+
|
|
85
|
+
def test_bidi_wait_for_element_timeout(self) -> None:
|
|
86
|
+
"""Test BiDi _wait_for_element raises WaitTimeoutError on timeout."""
|
|
87
|
+
from wavexis.backend.bidi import BiDiBackend
|
|
88
|
+
|
|
89
|
+
backend = BiDiBackend()
|
|
90
|
+
backend._client = MagicMock()
|
|
91
|
+
backend._context = MagicMock()
|
|
92
|
+
backend._client.script = MagicMock()
|
|
93
|
+
result = MagicMock()
|
|
94
|
+
result.value = False
|
|
95
|
+
backend._client.script.evaluate = AsyncMock(return_value=result)
|
|
96
|
+
with pytest.raises(WaitTimeoutError):
|
|
97
|
+
asyncio.run(backend._wait_for_element("#missing", timeout_ms=50))
|
|
@@ -20,7 +20,9 @@ class TestBiDiBackendPhase5:
|
|
|
20
20
|
backend._client = MagicMock()
|
|
21
21
|
backend._context = MagicMock()
|
|
22
22
|
backend._client.script = MagicMock()
|
|
23
|
-
|
|
23
|
+
result = MagicMock()
|
|
24
|
+
result.value = True
|
|
25
|
+
backend._client.script.evaluate = AsyncMock(return_value=result)
|
|
24
26
|
backend._client._connection = MagicMock()
|
|
25
27
|
backend._client._connection.send_command = AsyncMock()
|
|
26
28
|
return backend
|
|
@@ -26,7 +26,7 @@ from wavexis.config import (
|
|
|
26
26
|
ThrottleParams,
|
|
27
27
|
WaitStrategy,
|
|
28
28
|
)
|
|
29
|
-
from wavexis.exceptions import SessionNotInitializedError
|
|
29
|
+
from wavexis.exceptions import SessionNotInitializedError, WaitTimeoutError
|
|
30
30
|
|
|
31
31
|
try:
|
|
32
32
|
from bidiwave import BiDiClient
|
|
@@ -807,6 +807,38 @@ class BiDiBackend(AbstractBackend):
|
|
|
807
807
|
|
|
808
808
|
# ── Input ──────────────────────────────────────────────
|
|
809
809
|
|
|
810
|
+
async def _wait_for_element(self, selector: str, timeout_ms: int = 30000) -> None:
|
|
811
|
+
"""Wait for an element to exist and be visible in the DOM.
|
|
812
|
+
|
|
813
|
+
Polls until the element matches, is attached, and has non-zero size.
|
|
814
|
+
|
|
815
|
+
Args:
|
|
816
|
+
selector: CSS selector for the target element.
|
|
817
|
+
timeout_ms: Maximum wait time in milliseconds.
|
|
818
|
+
|
|
819
|
+
Raises:
|
|
820
|
+
WaitTimeoutError: If the element is not found within the timeout.
|
|
821
|
+
"""
|
|
822
|
+
import asyncio as _asyncio
|
|
823
|
+
import time as _time
|
|
824
|
+
|
|
825
|
+
client = self._require_client()
|
|
826
|
+
escaped = selector.replace("'", "\\'")
|
|
827
|
+
js = (
|
|
828
|
+
f"(function(){{var el=document.querySelector('{escaped}');"
|
|
829
|
+
f"if(!el)return false;"
|
|
830
|
+
f"var rect=el.getBoundingClientRect();"
|
|
831
|
+
f"return rect.width>0&&rect.height>0;}})()"
|
|
832
|
+
)
|
|
833
|
+
deadline = _time.monotonic() + timeout_ms / 1000
|
|
834
|
+
while _time.monotonic() < deadline:
|
|
835
|
+
result = await client.script.evaluate(self._context, js)
|
|
836
|
+
value = getattr(result, "value", None)
|
|
837
|
+
if value is True or value == "true":
|
|
838
|
+
return
|
|
839
|
+
await _asyncio.sleep(0.1)
|
|
840
|
+
raise WaitTimeoutError("selector", timeout_ms)
|
|
841
|
+
|
|
810
842
|
async def _scroll_into_view_if_needed(self, selector: str) -> None:
|
|
811
843
|
"""Scroll element into view if it's not visible in the viewport."""
|
|
812
844
|
client = self._require_client()
|
|
@@ -825,6 +857,7 @@ class BiDiBackend(AbstractBackend):
|
|
|
825
857
|
) -> None:
|
|
826
858
|
"""Click an element via BiDi script.evaluate."""
|
|
827
859
|
client = self._require_client()
|
|
860
|
+
await self._wait_for_element(selector)
|
|
828
861
|
await self._scroll_into_view_if_needed(selector)
|
|
829
862
|
escaped = selector.replace("'", "\\'")
|
|
830
863
|
js = (
|
|
@@ -857,6 +890,7 @@ class BiDiBackend(AbstractBackend):
|
|
|
857
890
|
async def fill(self, selector: str, value: str) -> None:
|
|
858
891
|
"""Fill an input element with a value via BiDi."""
|
|
859
892
|
client = self._require_client()
|
|
893
|
+
await self._wait_for_element(selector)
|
|
860
894
|
await self._scroll_into_view_if_needed(selector)
|
|
861
895
|
escaped = selector.replace("'", "\\'")
|
|
862
896
|
escaped_val = value.replace("\\", "\\\\").replace("'", "\\'")
|
|
@@ -875,6 +909,7 @@ class BiDiBackend(AbstractBackend):
|
|
|
875
909
|
async def hover(self, selector: str) -> None:
|
|
876
910
|
"""Hover over an element via BiDi script.evaluate."""
|
|
877
911
|
client = self._require_client()
|
|
912
|
+
await self._wait_for_element(selector)
|
|
878
913
|
await self._scroll_into_view_if_needed(selector)
|
|
879
914
|
escaped = selector.replace("'", "\\'")
|
|
880
915
|
js = (
|
|
@@ -1026,6 +1026,34 @@ class CDPBackend(AbstractBackend):
|
|
|
1026
1026
|
cy = (min(ys) + max(ys)) / 2
|
|
1027
1027
|
return cx, cy
|
|
1028
1028
|
|
|
1029
|
+
async def _wait_for_element(self, selector: str, timeout_ms: int = 30000) -> None:
|
|
1030
|
+
"""Wait for an element to exist and be visible in the DOM.
|
|
1031
|
+
|
|
1032
|
+
Polls until the element matches, is attached, and has non-zero size.
|
|
1033
|
+
|
|
1034
|
+
Args:
|
|
1035
|
+
selector: CSS selector for the target element.
|
|
1036
|
+
timeout_ms: Maximum wait time in milliseconds.
|
|
1037
|
+
|
|
1038
|
+
Raises:
|
|
1039
|
+
WaitTimeoutError: If the element is not found within the timeout.
|
|
1040
|
+
"""
|
|
1041
|
+
session = self._require_session()
|
|
1042
|
+
escaped = selector.replace("'", "\\'")
|
|
1043
|
+
js = (
|
|
1044
|
+
f"(function(){{var el=document.querySelector('{escaped}');"
|
|
1045
|
+
f"if(!el)return false;"
|
|
1046
|
+
f"var rect=el.getBoundingClientRect();"
|
|
1047
|
+
f"return rect.width>0&&rect.height>0;}})()"
|
|
1048
|
+
)
|
|
1049
|
+
deadline = time.monotonic() + timeout_ms / 1000
|
|
1050
|
+
while time.monotonic() < deadline:
|
|
1051
|
+
result = await session.runtime.evaluate(js)
|
|
1052
|
+
if result.get("result", {}).get("value") is True:
|
|
1053
|
+
return
|
|
1054
|
+
await asyncio.sleep(0.1)
|
|
1055
|
+
raise WaitTimeoutError("selector", timeout_ms)
|
|
1056
|
+
|
|
1029
1057
|
async def _scroll_into_view_if_needed(self, selector: str) -> None:
|
|
1030
1058
|
"""Scroll element into view if it's not visible in the viewport.
|
|
1031
1059
|
|
|
@@ -1054,6 +1082,7 @@ class CDPBackend(AbstractBackend):
|
|
|
1054
1082
|
click_count: Number of clicks to dispatch.
|
|
1055
1083
|
"""
|
|
1056
1084
|
session = self._require_session()
|
|
1085
|
+
await self._wait_for_element(selector)
|
|
1057
1086
|
await self._scroll_into_view_if_needed(selector)
|
|
1058
1087
|
x, y = await self._get_box_center(selector)
|
|
1059
1088
|
btn_map = {"left": "left", "right": "right", "middle": "middle"}
|
|
@@ -1092,6 +1121,7 @@ class CDPBackend(AbstractBackend):
|
|
|
1092
1121
|
value: Value to set in the input field.
|
|
1093
1122
|
"""
|
|
1094
1123
|
session = self._require_session()
|
|
1124
|
+
await self._wait_for_element(selector)
|
|
1095
1125
|
await self._scroll_into_view_if_needed(selector)
|
|
1096
1126
|
escaped = selector.replace("'", "\\'")
|
|
1097
1127
|
js = (
|
|
@@ -1132,6 +1162,7 @@ class CDPBackend(AbstractBackend):
|
|
|
1132
1162
|
selector: CSS selector for the target element.
|
|
1133
1163
|
"""
|
|
1134
1164
|
session = self._require_session()
|
|
1165
|
+
await self._wait_for_element(selector)
|
|
1135
1166
|
await self._scroll_into_view_if_needed(selector)
|
|
1136
1167
|
x, y = await self._get_box_center(selector)
|
|
1137
1168
|
await session.input.dispatch_mouse_event(
|
|
@@ -23,8 +23,9 @@ _SIGNAL_NAMES: dict[int, str] = {
|
|
|
23
23
|
signal.SIGTERM: "SIGTERM",
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
_sigbreak = getattr(signal, "SIGBREAK", None)
|
|
27
|
+
if _sigbreak is not None:
|
|
28
|
+
_SIGNAL_NAMES[_sigbreak] = "SIGBREAK"
|
|
28
29
|
|
|
29
30
|
|
|
30
31
|
def register_backend(backend: Any) -> None:
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import time
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
from typing import Any
|
|
8
9
|
|
|
@@ -21,7 +22,7 @@ from wavexis.config import WaitStrategy
|
|
|
21
22
|
@app.command()
|
|
22
23
|
def session(
|
|
23
24
|
action: str = typer.Argument(
|
|
24
|
-
..., help="Session action: save, load"
|
|
25
|
+
..., help="Session action: save, load, list, delete"
|
|
25
26
|
),
|
|
26
27
|
url: str = typer.Argument(
|
|
27
28
|
"", help="URL to navigate to (for save) or load before navigating (for load)"
|
|
@@ -29,18 +30,60 @@ def session(
|
|
|
29
30
|
output: str = typer.Option(
|
|
30
31
|
"session.json", "--output", "-o", help="Session file path"
|
|
31
32
|
),
|
|
33
|
+
name: str = typer.Option(
|
|
34
|
+
"", "--name", "-n", help="Named session (stored in ~/.wavexis/sessions/)"
|
|
35
|
+
),
|
|
32
36
|
) -> None:
|
|
33
37
|
"""Save or load browser session state (cookies + localStorage + sessionStorage).
|
|
34
38
|
|
|
35
39
|
\b
|
|
36
40
|
Save: wavexis session save https://app.com -o mysession.json
|
|
37
41
|
Load: wavexis session load mysession.json https://app.com
|
|
42
|
+
Named: wavexis session save https://app.com --name mysession
|
|
43
|
+
List: wavexis session list
|
|
44
|
+
Delete: wavexis session delete --name mysession
|
|
38
45
|
"""
|
|
39
46
|
from wavexis.actions.session import SessionLoadAction, SessionSaveAction
|
|
40
47
|
|
|
41
|
-
|
|
48
|
+
sessions_dir = Path.home() / ".wavexis" / "sessions"
|
|
49
|
+
|
|
50
|
+
def _session_path() -> Path:
|
|
51
|
+
if name:
|
|
52
|
+
return sessions_dir / f"{name}.json"
|
|
53
|
+
return Path(output)
|
|
54
|
+
|
|
55
|
+
if action == "list":
|
|
56
|
+
if not sessions_dir.exists():
|
|
57
|
+
typer.echo("No saved sessions found.")
|
|
58
|
+
return
|
|
59
|
+
sessions = list(sessions_dir.glob("*.json"))
|
|
60
|
+
if not sessions:
|
|
61
|
+
typer.echo("No saved sessions found.")
|
|
62
|
+
return
|
|
63
|
+
typer.echo(f"Saved sessions ({len(sessions)}):")
|
|
64
|
+
for s in sorted(sessions):
|
|
65
|
+
stat = s.stat()
|
|
66
|
+
modified = time.strftime("%Y-%m-%d %H:%M", time.localtime(stat.st_mtime))
|
|
67
|
+
typer.echo(f" {s.stem} ({stat.st_size} bytes, {modified})")
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
if action == "delete":
|
|
71
|
+
if not name:
|
|
72
|
+
typer.echo("Error: --name required for delete", err=True)
|
|
73
|
+
raise typer.Exit(1)
|
|
74
|
+
target = _session_path()
|
|
75
|
+
if not target.exists():
|
|
76
|
+
typer.echo(f"Error: session '{name}' not found", err=True)
|
|
77
|
+
raise typer.Exit(1)
|
|
78
|
+
target.unlink()
|
|
79
|
+
typer.echo(f"Deleted session '{name}'")
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
session_path = _session_path()
|
|
42
83
|
|
|
43
84
|
if action == "save":
|
|
85
|
+
if name:
|
|
86
|
+
sessions_dir.mkdir(parents=True, exist_ok=True)
|
|
44
87
|
if not url:
|
|
45
88
|
typer.echo("Error: URL required for session save", err=True)
|
|
46
89
|
raise typer.Exit(1)
|
|
@@ -56,11 +99,11 @@ def session(
|
|
|
56
99
|
await backend.close()
|
|
57
100
|
|
|
58
101
|
_run_async(_save_session())
|
|
59
|
-
typer.echo(f"Session saved to {
|
|
102
|
+
typer.echo(f"Session saved to {session_path}")
|
|
60
103
|
|
|
61
104
|
elif action == "load":
|
|
62
105
|
if not session_path.exists():
|
|
63
|
-
typer.echo(f"Error: session file not found: {
|
|
106
|
+
typer.echo(f"Error: session file not found: {session_path}", err=True)
|
|
64
107
|
raise typer.Exit(1)
|
|
65
108
|
|
|
66
109
|
async def _load_session() -> Any:
|
|
@@ -80,10 +123,13 @@ def session(
|
|
|
80
123
|
result = _run_async(_load_session())
|
|
81
124
|
if result is None:
|
|
82
125
|
return
|
|
83
|
-
typer.echo(f"Session loaded from {
|
|
126
|
+
typer.echo(f"Session loaded from {session_path}: {result}")
|
|
84
127
|
|
|
85
128
|
else:
|
|
86
|
-
typer.echo(
|
|
129
|
+
typer.echo(
|
|
130
|
+
f"Error: unknown session action '{action}'. Use save, load, list, or delete.",
|
|
131
|
+
err=True,
|
|
132
|
+
)
|
|
87
133
|
raise typer.Exit(1)
|
|
88
134
|
|
|
89
135
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|