wavexis 2.3.2__tar.gz → 2.3.3__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.3.2 → wavexis-2.3.3}/CHANGELOG.md +11 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/PKG-INFO +1 -1
- {wavexis-2.3.2 → wavexis-2.3.3}/pyproject.toml +1 -1
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_backend_manager.py +18 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_manager.py +7 -0
- wavexis-2.3.3/tests/unit/test_shadow.py +232 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/__init__.py +1 -1
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/backend/base.py +39 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/backend/bidi.py +129 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/backend/cdp.py +127 -0
- wavexis-2.3.3/wavexis/cli/_shadow.py +135 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/cli/app.py +1 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/.dockerignore +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/.github/workflows/ci.yml +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/.github/workflows/docs.yml +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/.github/workflows/release.yml +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/.gitignore +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/CODE_OF_CONDUCT.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/CONTRIBUTING.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/Dockerfile +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/LICENSE +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/README.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/SECURITY.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/docs/api/actions.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/docs/api/backends.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/docs/api/cli.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/docs/api/config.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/docs/api/exceptions.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/docs/api/multi.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/docs/api/plugins.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/docs/api/serve.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/docs/cookbook/auth.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/docs/cookbook/ci-cd.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/docs/cookbook/css.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/docs/cookbook/debug.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/docs/cookbook/record-replay.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/docs/cookbook/scraping.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/docs/cookbook/screenshots.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/docs/cookbook/serve-mode.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/docs/guide/assert.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/docs/guide/backends.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/docs/guide/commands.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/docs/guide/init.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/docs/guide/installation.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/docs/guide/multi.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/docs/guide/perf.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/docs/guide/plugins.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/docs/guide/quickstart.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/docs/guide/raw.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/docs/guide/repl.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/docs/guide/troubleshooting.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/docs/index.md +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/mkdocs.yml +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/stubs/bidiwave/__init__.pyi +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/stubs/cdpwave/__init__.pyi +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/__init__.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/conftest.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/__init__.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_a11y.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_animation.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_backend_selection.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_browser.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_console.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_css.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_debug.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_dialog.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_dom.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_dom_snapshot.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_emulation.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_emulation_advanced.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_eval.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_har.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_input.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_media.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_multi.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_navigate.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_network.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_network_advanced.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_overlay.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_pdf.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_perf.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_permissions.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_raw.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_record.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_scrape.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_screenshot.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_security.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_serve.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_service_worker.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_storage.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_tabs.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_webaudio.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/integration/test_webauthn.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/__init__.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_abstract_backend_phase5.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_actions.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_actions_phase5.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_animation_action.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_assert_multi.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_auth.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_auto_wait.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_bidi_backend.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_bidi_phase5.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_bluetooth_action.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_cast_action.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_cleanup.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_cli_phase5.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_config.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_config_phase5.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_cookies_headers_actions.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_css_action.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_debug_action.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_dom_snapshot_action.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_download_screencast.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_emulation_action.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_error_handling.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_exceptions.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_format_record.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_global_options.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_iframe.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_init.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_input_action.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_interactions_crawl.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_media_action.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_multi.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_multi_improvements.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_new_features.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_output.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_overlay_action.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_perf_action.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_perf_console.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_plugins.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_raw.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_record.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_repl.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_serve.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_service_worker_action.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_storage_action.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_webaudio_action.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/tests/unit/test_webauthn_action.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/__main__.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/__init__.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/accessibility.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/animation.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/base.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/bluetooth.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/browser.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/cast.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/console.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/cookies.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/crawl.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/css.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/debug.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/dialog.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/dom.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/dom_snapshot.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/download.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/emulation.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/eval.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/extract.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/form.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/har.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/headers.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/input.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/lighthouse.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/media.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/multi.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/navigate.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/network.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/overlay.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/pdf.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/performance.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/permissions.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/record.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/scrape.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/screencast.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/screenshot.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/security.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/service_worker.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/session.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/storage.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/tabs.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/wait.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/webaudio.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/webauthn.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/actions/websocket.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/auth.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/backend/__init__.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/backend/manager.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/cleanup.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/cli/__init__.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/cli/_advanced.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/cli/_capture.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/cli/_config.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/cli/_debug.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/cli/_emulation.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/cli/_experimental.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/cli/_iframe.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/cli/_input.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/cli/_navigation.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/cli/_network.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/cli/_perf.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/cli/_serve.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/cli/_session.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/cli/_shared.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/cli/_workflow.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/config.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/exceptions.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/init.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/multi.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/output.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/plugins.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/record.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/repl.py +0 -0
- {wavexis-2.3.2 → wavexis-2.3.3}/wavexis/serve.py +0 -0
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to wavexis are documented in this file.
|
|
4
4
|
|
|
5
|
+
## v2.3.3 — 2026-07-07
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Shadow DOM support: `shadow_click`, `shadow_fill`, `shadow_eval` in both CDP and BiDi backends
|
|
10
|
+
- `wavexis shadow click/fill/eval` CLI command with `--selectors` (comma-separated piercing chain)
|
|
11
|
+
- `_build_shadow_pierce_js` helper generates JS that traverses `shadowRoot` boundaries
|
|
12
|
+
- Auto-waiting inside shadow DOM via `_wait_for_element_in_shadow` with polling and configurable timeout
|
|
13
|
+
- `--no-wait` flag to skip auto-waiting for shadow interactions
|
|
14
|
+
- Events dispatched with `composed: true` to cross shadow boundaries
|
|
15
|
+
|
|
5
16
|
## v2.3.2 — 2026-07-07
|
|
6
17
|
|
|
7
18
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wavexis
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.3
|
|
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
|
|
@@ -268,6 +268,24 @@ class DummyBackend(AbstractBackend):
|
|
|
268
268
|
"""Iframe fill."""
|
|
269
269
|
pass
|
|
270
270
|
|
|
271
|
+
async def shadow_eval(
|
|
272
|
+
self, selectors: list[str], expression: str, await_promise: bool = False
|
|
273
|
+
) -> object:
|
|
274
|
+
"""Shadow eval."""
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
async def shadow_click(
|
|
278
|
+
self, selectors: list[str], auto_wait: bool = True
|
|
279
|
+
) -> None:
|
|
280
|
+
"""Shadow click."""
|
|
281
|
+
pass
|
|
282
|
+
|
|
283
|
+
async def shadow_fill(
|
|
284
|
+
self, selectors: list[str], value: str, auto_wait: bool = True
|
|
285
|
+
) -> None:
|
|
286
|
+
"""Shadow fill."""
|
|
287
|
+
pass
|
|
288
|
+
|
|
271
289
|
async def throttle_network(self, params: object) -> None:
|
|
272
290
|
"""Throttle network."""
|
|
273
291
|
pass
|
|
@@ -269,6 +269,13 @@ class FakeBackend(AbstractBackend):
|
|
|
269
269
|
"""Iframe click."""
|
|
270
270
|
async def iframe_fill(self, iframe_selector, selector, value, auto_wait=True):
|
|
271
271
|
"""Iframe fill."""
|
|
272
|
+
async def shadow_eval(self, selectors, expression, await_promise=False):
|
|
273
|
+
"""Shadow eval."""
|
|
274
|
+
return None
|
|
275
|
+
async def shadow_click(self, selectors, auto_wait=True):
|
|
276
|
+
"""Shadow click."""
|
|
277
|
+
async def shadow_fill(self, selectors, value, auto_wait=True):
|
|
278
|
+
"""Shadow fill."""
|
|
272
279
|
|
|
273
280
|
|
|
274
281
|
class TestBackendManager:
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Unit tests for Shadow DOM support — shadow_eval, shadow_click, shadow_fill."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Any
|
|
7
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from wavexis.exceptions import ElementNotFoundError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _make_cdp_backend() -> Any:
|
|
15
|
+
"""Create a CDPBackend with mocked session."""
|
|
16
|
+
from wavexis.backend.cdp import CDPBackend
|
|
17
|
+
|
|
18
|
+
backend = CDPBackend()
|
|
19
|
+
backend._session = MagicMock()
|
|
20
|
+
backend._session.runtime = MagicMock()
|
|
21
|
+
backend._session.runtime.evaluate = AsyncMock(
|
|
22
|
+
return_value={"result": {"value": True}}
|
|
23
|
+
)
|
|
24
|
+
return backend
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _make_bidi_backend() -> Any:
|
|
28
|
+
"""Create a BiDiBackend with mocked client."""
|
|
29
|
+
from wavexis.backend.bidi import BiDiBackend
|
|
30
|
+
|
|
31
|
+
backend = BiDiBackend()
|
|
32
|
+
backend._client = MagicMock()
|
|
33
|
+
backend._context = MagicMock()
|
|
34
|
+
backend._client.script = MagicMock()
|
|
35
|
+
result = MagicMock()
|
|
36
|
+
result.value = True
|
|
37
|
+
backend._client.script.evaluate = AsyncMock(return_value=result)
|
|
38
|
+
return backend
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.mark.unit
|
|
42
|
+
class TestShadowEval:
|
|
43
|
+
"""Tests for shadow_eval in both backends."""
|
|
44
|
+
|
|
45
|
+
def test_cdp_shadow_eval_returns_value(self) -> None:
|
|
46
|
+
"""Test CDP shadow_eval returns the evaluated value."""
|
|
47
|
+
backend = _make_cdp_backend()
|
|
48
|
+
backend._session.runtime.evaluate = AsyncMock(
|
|
49
|
+
return_value={"result": {"value": 42}}
|
|
50
|
+
)
|
|
51
|
+
result = asyncio.run(
|
|
52
|
+
backend.shadow_eval(["my-component", "button"], "1 + 1")
|
|
53
|
+
)
|
|
54
|
+
assert result == 42
|
|
55
|
+
|
|
56
|
+
def test_cdp_shadow_eval_null_element_not_found(self) -> None:
|
|
57
|
+
"""Test CDP shadow_eval returns None when element is not found."""
|
|
58
|
+
backend = _make_cdp_backend()
|
|
59
|
+
backend._session.runtime.evaluate = AsyncMock(
|
|
60
|
+
return_value={"result": {"value": None}}
|
|
61
|
+
)
|
|
62
|
+
result = asyncio.run(
|
|
63
|
+
backend.shadow_eval(["missing", "button"], "this.textContent")
|
|
64
|
+
)
|
|
65
|
+
assert result is None
|
|
66
|
+
|
|
67
|
+
def test_bidi_shadow_eval_returns_value(self) -> None:
|
|
68
|
+
"""Test BiDi shadow_eval returns the evaluated value."""
|
|
69
|
+
backend = _make_bidi_backend()
|
|
70
|
+
result_mock = MagicMock()
|
|
71
|
+
result_mock.value = "hello"
|
|
72
|
+
backend._client.script.evaluate = AsyncMock(return_value=result_mock)
|
|
73
|
+
result = asyncio.run(
|
|
74
|
+
backend.shadow_eval(["my-component", "button"], "this.textContent")
|
|
75
|
+
)
|
|
76
|
+
assert result == "hello"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@pytest.mark.unit
|
|
80
|
+
class TestShadowClick:
|
|
81
|
+
"""Tests for shadow_click in both backends."""
|
|
82
|
+
|
|
83
|
+
def test_cdp_shadow_click_success(self) -> None:
|
|
84
|
+
"""Test CDP shadow_click dispatches click inside shadow DOM."""
|
|
85
|
+
backend = _make_cdp_backend()
|
|
86
|
+
backend._session.runtime.evaluate = AsyncMock(
|
|
87
|
+
return_value={"result": {"value": True}}
|
|
88
|
+
)
|
|
89
|
+
asyncio.run(backend.shadow_click(["my-component", "button"], auto_wait=False))
|
|
90
|
+
backend._session.runtime.evaluate.assert_called()
|
|
91
|
+
|
|
92
|
+
def test_cdp_shadow_click_element_not_found(self) -> None:
|
|
93
|
+
"""Test CDP shadow_click raises ElementNotFoundError when element missing."""
|
|
94
|
+
backend = _make_cdp_backend()
|
|
95
|
+
backend._session.runtime.evaluate = AsyncMock(
|
|
96
|
+
return_value={"result": {"value": False}}
|
|
97
|
+
)
|
|
98
|
+
with pytest.raises(ElementNotFoundError):
|
|
99
|
+
asyncio.run(backend.shadow_click(["missing", "btn"], auto_wait=False))
|
|
100
|
+
|
|
101
|
+
def test_bidi_shadow_click_success(self) -> None:
|
|
102
|
+
"""Test BiDi shadow_click dispatches click inside shadow DOM."""
|
|
103
|
+
backend = _make_bidi_backend()
|
|
104
|
+
result_mock = MagicMock()
|
|
105
|
+
result_mock.value = True
|
|
106
|
+
backend._client.script.evaluate = AsyncMock(return_value=result_mock)
|
|
107
|
+
asyncio.run(backend.shadow_click(["my-component", "button"], auto_wait=False))
|
|
108
|
+
backend._client.script.evaluate.assert_called()
|
|
109
|
+
|
|
110
|
+
def test_bidi_shadow_click_element_not_found(self) -> None:
|
|
111
|
+
"""Test BiDi shadow_click raises ElementNotFoundError when element missing."""
|
|
112
|
+
backend = _make_bidi_backend()
|
|
113
|
+
result_mock = MagicMock()
|
|
114
|
+
result_mock.value = False
|
|
115
|
+
backend._client.script.evaluate = AsyncMock(return_value=result_mock)
|
|
116
|
+
with pytest.raises(ElementNotFoundError):
|
|
117
|
+
asyncio.run(backend.shadow_click(["missing", "btn"], auto_wait=False))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@pytest.mark.unit
|
|
121
|
+
class TestShadowFill:
|
|
122
|
+
"""Tests for shadow_fill in both backends."""
|
|
123
|
+
|
|
124
|
+
def test_cdp_shadow_fill_success(self) -> None:
|
|
125
|
+
"""Test CDP shadow_fill sets value inside shadow DOM."""
|
|
126
|
+
backend = _make_cdp_backend()
|
|
127
|
+
backend._session.runtime.evaluate = AsyncMock(
|
|
128
|
+
return_value={"result": {"value": True}}
|
|
129
|
+
)
|
|
130
|
+
asyncio.run(
|
|
131
|
+
backend.shadow_fill(["my-component", "input"], "test value", auto_wait=False)
|
|
132
|
+
)
|
|
133
|
+
backend._session.runtime.evaluate.assert_called()
|
|
134
|
+
|
|
135
|
+
def test_cdp_shadow_fill_element_not_found(self) -> None:
|
|
136
|
+
"""Test CDP shadow_fill raises ElementNotFoundError when element missing."""
|
|
137
|
+
backend = _make_cdp_backend()
|
|
138
|
+
backend._session.runtime.evaluate = AsyncMock(
|
|
139
|
+
return_value={"result": {"value": False}}
|
|
140
|
+
)
|
|
141
|
+
with pytest.raises(ElementNotFoundError):
|
|
142
|
+
asyncio.run(
|
|
143
|
+
backend.shadow_fill(["missing", "input"], "val", auto_wait=False)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def test_bidi_shadow_fill_success(self) -> None:
|
|
147
|
+
"""Test BiDi shadow_fill sets value inside shadow DOM."""
|
|
148
|
+
backend = _make_bidi_backend()
|
|
149
|
+
result_mock = MagicMock()
|
|
150
|
+
result_mock.value = True
|
|
151
|
+
backend._client.script.evaluate = AsyncMock(return_value=result_mock)
|
|
152
|
+
asyncio.run(
|
|
153
|
+
backend.shadow_fill(["my-component", "input"], "test value", auto_wait=False)
|
|
154
|
+
)
|
|
155
|
+
backend._client.script.evaluate.assert_called()
|
|
156
|
+
|
|
157
|
+
def test_bidi_shadow_fill_element_not_found(self) -> None:
|
|
158
|
+
"""Test BiDi shadow_fill raises ElementNotFoundError when element missing."""
|
|
159
|
+
backend = _make_bidi_backend()
|
|
160
|
+
result_mock = MagicMock()
|
|
161
|
+
result_mock.value = False
|
|
162
|
+
backend._client.script.evaluate = AsyncMock(return_value=result_mock)
|
|
163
|
+
with pytest.raises(ElementNotFoundError):
|
|
164
|
+
asyncio.run(
|
|
165
|
+
backend.shadow_fill(["missing", "input"], "val", auto_wait=False)
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@pytest.mark.unit
|
|
170
|
+
class TestShadowAutoWait:
|
|
171
|
+
"""Tests for auto_wait in shadow operations."""
|
|
172
|
+
|
|
173
|
+
def test_cdp_shadow_click_auto_wait_false_skips_wait(self) -> None:
|
|
174
|
+
"""Test that shadow_click with auto_wait=False skips _wait_for_element_in_shadow."""
|
|
175
|
+
backend = _make_cdp_backend()
|
|
176
|
+
call_order: list[str] = []
|
|
177
|
+
|
|
178
|
+
async def _mock_wait(selectors: list[str], timeout_ms: int = 30000) -> None:
|
|
179
|
+
call_order.append("wait")
|
|
180
|
+
|
|
181
|
+
backend._wait_for_element_in_shadow = _mock_wait # type: ignore[assignment]
|
|
182
|
+
backend._session.runtime.evaluate = AsyncMock(
|
|
183
|
+
return_value={"result": {"value": True}}
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
asyncio.run(backend.shadow_click(["my-component", "btn"], auto_wait=False))
|
|
187
|
+
assert "wait" not in call_order
|
|
188
|
+
|
|
189
|
+
def test_cdp_shadow_click_auto_wait_true_calls_wait(self) -> None:
|
|
190
|
+
"""Test that shadow_click with auto_wait=True calls _wait_for_element_in_shadow."""
|
|
191
|
+
backend = _make_cdp_backend()
|
|
192
|
+
call_order: list[str] = []
|
|
193
|
+
|
|
194
|
+
async def _mock_wait(selectors: list[str], timeout_ms: int = 30000) -> None:
|
|
195
|
+
call_order.append("wait")
|
|
196
|
+
|
|
197
|
+
backend._wait_for_element_in_shadow = _mock_wait # type: ignore[assignment]
|
|
198
|
+
backend._session.runtime.evaluate = AsyncMock(
|
|
199
|
+
return_value={"result": {"value": True}}
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
asyncio.run(backend.shadow_click(["my-component", "btn"], auto_wait=True))
|
|
203
|
+
assert "wait" in call_order
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@pytest.mark.unit
|
|
207
|
+
class TestBuildShadowPierceJs:
|
|
208
|
+
"""Tests for _build_shadow_pierce_js helper."""
|
|
209
|
+
|
|
210
|
+
def test_single_selector(self) -> None:
|
|
211
|
+
"""Test JS generation for a single selector (no shadow piercing)."""
|
|
212
|
+
from wavexis.backend.cdp import CDPBackend
|
|
213
|
+
|
|
214
|
+
js = CDPBackend._build_shadow_pierce_js(["#my-element"])
|
|
215
|
+
assert "document.querySelector" in js
|
|
216
|
+
assert "shadowRoot" not in js
|
|
217
|
+
|
|
218
|
+
def test_nested_selectors(self) -> None:
|
|
219
|
+
"""Test JS generation for nested shadow piercing."""
|
|
220
|
+
from wavexis.backend.cdp import CDPBackend
|
|
221
|
+
|
|
222
|
+
js = CDPBackend._build_shadow_pierce_js(["my-component", "inner", "button"])
|
|
223
|
+
assert js.count("shadowRoot") == 4
|
|
224
|
+
assert "inner" in js
|
|
225
|
+
assert "button" in js
|
|
226
|
+
|
|
227
|
+
def test_selector_escaping(self) -> None:
|
|
228
|
+
"""Test that single quotes in selectors are escaped."""
|
|
229
|
+
from wavexis.backend.cdp import CDPBackend
|
|
230
|
+
|
|
231
|
+
js = CDPBackend._build_shadow_pierce_js(["[data-test='foo']"])
|
|
232
|
+
assert "\\'" in js
|
|
@@ -323,6 +323,45 @@ class AbstractBackend(ABC):
|
|
|
323
323
|
auto_wait: If True, wait for element to be visible before filling.
|
|
324
324
|
"""
|
|
325
325
|
|
|
326
|
+
# ── Shadow DOM ──────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
@abstractmethod
|
|
329
|
+
async def shadow_eval(
|
|
330
|
+
self, selectors: list[str], expression: str, await_promise: bool = False
|
|
331
|
+
) -> Any:
|
|
332
|
+
"""Evaluate a JavaScript expression inside a shadow DOM tree.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
selectors: List of CSS selectors piercing shadow boundaries.
|
|
336
|
+
selectors[0] is in the main document, selectors[1] in
|
|
337
|
+
selectors[0].shadowRoot, and so on.
|
|
338
|
+
expression: JavaScript expression to evaluate in the shadow context.
|
|
339
|
+
await_promise: Whether to await a returned Promise.
|
|
340
|
+
"""
|
|
341
|
+
|
|
342
|
+
@abstractmethod
|
|
343
|
+
async def shadow_click(
|
|
344
|
+
self, selectors: list[str], auto_wait: bool = True
|
|
345
|
+
) -> None:
|
|
346
|
+
"""Click an element inside a shadow DOM tree.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
selectors: List of CSS selectors piercing shadow boundaries.
|
|
350
|
+
auto_wait: If True, wait for element to be visible before clicking.
|
|
351
|
+
"""
|
|
352
|
+
|
|
353
|
+
@abstractmethod
|
|
354
|
+
async def shadow_fill(
|
|
355
|
+
self, selectors: list[str], value: str, auto_wait: bool = True
|
|
356
|
+
) -> None:
|
|
357
|
+
"""Fill an input element inside a shadow DOM tree.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
selectors: List of CSS selectors piercing shadow boundaries.
|
|
361
|
+
value: Value to set in the input field.
|
|
362
|
+
auto_wait: If True, wait for element to be visible before filling.
|
|
363
|
+
"""
|
|
364
|
+
|
|
326
365
|
# ── Network advanced ───────────────────────────────────
|
|
327
366
|
|
|
328
367
|
@abstractmethod
|
|
@@ -1128,6 +1128,135 @@ class BiDiBackend(AbstractBackend):
|
|
|
1128
1128
|
if not result_value:
|
|
1129
1129
|
raise ElementNotFoundError(selector)
|
|
1130
1130
|
|
|
1131
|
+
# ── Shadow DOM ──────────────────────────────────────────
|
|
1132
|
+
|
|
1133
|
+
@staticmethod
|
|
1134
|
+
def _build_shadow_pierce_js(selectors: list[str]) -> str:
|
|
1135
|
+
"""Build JS that pierces shadow boundaries via a selector chain."""
|
|
1136
|
+
escaped = [s.replace("'", "\\'") for s in selectors]
|
|
1137
|
+
parts = [f"var el=document.querySelector('{escaped[0]}')"]
|
|
1138
|
+
for sel in escaped[1:]:
|
|
1139
|
+
parts.append(
|
|
1140
|
+
f"if(!el||!el.shadowRoot)return null;"
|
|
1141
|
+
f"el=el.shadowRoot.querySelector('{sel}')"
|
|
1142
|
+
)
|
|
1143
|
+
parts.append("return el")
|
|
1144
|
+
body = ";".join(parts)
|
|
1145
|
+
return f"(function(){{{body};}})()"
|
|
1146
|
+
|
|
1147
|
+
async def shadow_eval(
|
|
1148
|
+
self, selectors: list[str], expression: str, await_promise: bool = False
|
|
1149
|
+
) -> Any:
|
|
1150
|
+
"""Evaluate a JavaScript expression inside a shadow DOM tree.
|
|
1151
|
+
|
|
1152
|
+
Args:
|
|
1153
|
+
selectors: List of CSS selectors piercing shadow boundaries.
|
|
1154
|
+
expression: JavaScript expression to evaluate in the shadow context.
|
|
1155
|
+
await_promise: Whether to await a returned Promise.
|
|
1156
|
+
|
|
1157
|
+
Returns:
|
|
1158
|
+
The evaluation result value.
|
|
1159
|
+
"""
|
|
1160
|
+
client = self._require_client()
|
|
1161
|
+
pierce_js = self._build_shadow_pierce_js(selectors)
|
|
1162
|
+
escaped_expr = expression.replace("\\", "\\\\").replace("'", "\\'")
|
|
1163
|
+
js = (
|
|
1164
|
+
f"(function(){{var el=({pierce_js});"
|
|
1165
|
+
f"if(!el)return null;"
|
|
1166
|
+
f"return (function(){{{escaped_expr}}}).call(el);}})()"
|
|
1167
|
+
)
|
|
1168
|
+
result = await client.script.evaluate(
|
|
1169
|
+
self._context, js, await_promise=await_promise
|
|
1170
|
+
)
|
|
1171
|
+
if hasattr(result, "value"):
|
|
1172
|
+
return result.value
|
|
1173
|
+
return result
|
|
1174
|
+
|
|
1175
|
+
async def _wait_for_element_in_shadow(
|
|
1176
|
+
self, selectors: list[str], timeout_ms: int = 30000
|
|
1177
|
+
) -> None:
|
|
1178
|
+
"""Wait for an element inside a shadow DOM tree to exist and be visible.
|
|
1179
|
+
|
|
1180
|
+
Args:
|
|
1181
|
+
selectors: List of CSS selectors piercing shadow boundaries.
|
|
1182
|
+
timeout_ms: Maximum wait time in milliseconds.
|
|
1183
|
+
|
|
1184
|
+
Raises:
|
|
1185
|
+
WaitTimeoutError: If the element is not found within the timeout.
|
|
1186
|
+
"""
|
|
1187
|
+
import asyncio as _asyncio
|
|
1188
|
+
import time as _time
|
|
1189
|
+
|
|
1190
|
+
client = self._require_client()
|
|
1191
|
+
pierce_js = self._build_shadow_pierce_js(selectors)
|
|
1192
|
+
js = (
|
|
1193
|
+
f"(function(){{var el=({pierce_js});"
|
|
1194
|
+
f"if(!el)return false;"
|
|
1195
|
+
f"var rect=el.getBoundingClientRect();"
|
|
1196
|
+
f"return rect.width>0&&rect.height>0;}})()"
|
|
1197
|
+
)
|
|
1198
|
+
deadline = _time.monotonic() + timeout_ms / 1000
|
|
1199
|
+
while _time.monotonic() < deadline:
|
|
1200
|
+
result = await client.script.evaluate(self._context, js)
|
|
1201
|
+
value = getattr(result, "value", None)
|
|
1202
|
+
if value is True or value == "true":
|
|
1203
|
+
return
|
|
1204
|
+
await _asyncio.sleep(0.1)
|
|
1205
|
+
raise WaitTimeoutError("selector", timeout_ms)
|
|
1206
|
+
|
|
1207
|
+
async def shadow_click(
|
|
1208
|
+
self, selectors: list[str], auto_wait: bool = True
|
|
1209
|
+
) -> None:
|
|
1210
|
+
"""Click an element inside a shadow DOM tree.
|
|
1211
|
+
|
|
1212
|
+
Args:
|
|
1213
|
+
selectors: List of CSS selectors piercing shadow boundaries.
|
|
1214
|
+
auto_wait: If True, wait for element to be visible before clicking.
|
|
1215
|
+
"""
|
|
1216
|
+
client = self._require_client()
|
|
1217
|
+
if auto_wait:
|
|
1218
|
+
await self._wait_for_element_in_shadow(selectors)
|
|
1219
|
+
pierce_js = self._build_shadow_pierce_js(selectors)
|
|
1220
|
+
js = (
|
|
1221
|
+
f"(function(){{var el=({pierce_js});"
|
|
1222
|
+
f"if(!el)return false;"
|
|
1223
|
+
f"el.scrollIntoView({{block:'center',behavior:'instant'}});"
|
|
1224
|
+
f"el.dispatchEvent(new MouseEvent('click',{{bubbles:true,composed:true}}));"
|
|
1225
|
+
f"return true;}})()"
|
|
1226
|
+
)
|
|
1227
|
+
result = await client.script.evaluate(self._context, js)
|
|
1228
|
+
result_value = getattr(result, "value", None)
|
|
1229
|
+
if not result_value:
|
|
1230
|
+
raise ElementNotFoundError(" -> ".join(selectors))
|
|
1231
|
+
|
|
1232
|
+
async def shadow_fill(
|
|
1233
|
+
self, selectors: list[str], value: str, auto_wait: bool = True
|
|
1234
|
+
) -> None:
|
|
1235
|
+
"""Fill an input element inside a shadow DOM tree.
|
|
1236
|
+
|
|
1237
|
+
Args:
|
|
1238
|
+
selectors: List of CSS selectors piercing shadow boundaries.
|
|
1239
|
+
value: Value to set in the input field.
|
|
1240
|
+
auto_wait: If True, wait for element to be visible before filling.
|
|
1241
|
+
"""
|
|
1242
|
+
client = self._require_client()
|
|
1243
|
+
if auto_wait:
|
|
1244
|
+
await self._wait_for_element_in_shadow(selectors)
|
|
1245
|
+
pierce_js = self._build_shadow_pierce_js(selectors)
|
|
1246
|
+
escaped_val = value.replace("\\", "\\\\").replace("'", "\\'")
|
|
1247
|
+
js = (
|
|
1248
|
+
f"(function(){{var el=({pierce_js});"
|
|
1249
|
+
f"if(!el)return false;"
|
|
1250
|
+
f"el.focus();el.value='{escaped_val}';"
|
|
1251
|
+
f"el.dispatchEvent(new Event('input',{{bubbles:true,composed:true}}));"
|
|
1252
|
+
f"el.dispatchEvent(new Event('change',{{bubbles:true,composed:true}}));"
|
|
1253
|
+
f"return true;}})()"
|
|
1254
|
+
)
|
|
1255
|
+
result = await client.script.evaluate(self._context, js)
|
|
1256
|
+
result_value = getattr(result, "value", None)
|
|
1257
|
+
if not result_value:
|
|
1258
|
+
raise ElementNotFoundError(" -> ".join(selectors))
|
|
1259
|
+
|
|
1131
1260
|
async def block_requests(self, patterns: list[str]) -> None:
|
|
1132
1261
|
"""Block requests matching URL patterns (partial BiDi support)."""
|
|
1133
1262
|
if self._client is None:
|
|
@@ -1369,6 +1369,133 @@ class CDPBackend(AbstractBackend):
|
|
|
1369
1369
|
if not result.get("result", {}).get("value"):
|
|
1370
1370
|
raise ElementNotFoundError(selector)
|
|
1371
1371
|
|
|
1372
|
+
# ── Shadow DOM ──────────────────────────────────────────
|
|
1373
|
+
|
|
1374
|
+
@staticmethod
|
|
1375
|
+
def _build_shadow_pierce_js(selectors: list[str]) -> str:
|
|
1376
|
+
"""Build JS that pierces shadow boundaries via a selector chain.
|
|
1377
|
+
|
|
1378
|
+
Args:
|
|
1379
|
+
selectors: List of CSS selectors. selectors[0] in document,
|
|
1380
|
+
each subsequent selector in the previous element's shadowRoot.
|
|
1381
|
+
|
|
1382
|
+
Returns:
|
|
1383
|
+
JavaScript IIFE that returns the final element or null.
|
|
1384
|
+
"""
|
|
1385
|
+
escaped = [s.replace("'", "\\'") for s in selectors]
|
|
1386
|
+
parts = [f"var el=document.querySelector('{escaped[0]}')"]
|
|
1387
|
+
for sel in escaped[1:]:
|
|
1388
|
+
parts.append(
|
|
1389
|
+
f"if(!el||!el.shadowRoot)return null;"
|
|
1390
|
+
f"el=el.shadowRoot.querySelector('{sel}')"
|
|
1391
|
+
)
|
|
1392
|
+
parts.append("return el")
|
|
1393
|
+
body = ";".join(parts)
|
|
1394
|
+
return f"(function(){{{body};}})()"
|
|
1395
|
+
|
|
1396
|
+
async def shadow_eval(
|
|
1397
|
+
self, selectors: list[str], expression: str, await_promise: bool = False
|
|
1398
|
+
) -> Any:
|
|
1399
|
+
"""Evaluate a JavaScript expression inside a shadow DOM tree.
|
|
1400
|
+
|
|
1401
|
+
Args:
|
|
1402
|
+
selectors: List of CSS selectors piercing shadow boundaries.
|
|
1403
|
+
expression: JavaScript expression to evaluate in the shadow context.
|
|
1404
|
+
await_promise: Whether to await a returned Promise.
|
|
1405
|
+
|
|
1406
|
+
Returns:
|
|
1407
|
+
The evaluation result value.
|
|
1408
|
+
"""
|
|
1409
|
+
session = self._require_session()
|
|
1410
|
+
pierce_js = self._build_shadow_pierce_js(selectors)
|
|
1411
|
+
escaped_expr = expression.replace("\\", "\\\\").replace("'", "\\'")
|
|
1412
|
+
js = (
|
|
1413
|
+
f"(function(){{var el=({pierce_js});"
|
|
1414
|
+
f"if(!el)return null;"
|
|
1415
|
+
f"return (function(){{{escaped_expr}}}).call(el);}})()"
|
|
1416
|
+
)
|
|
1417
|
+
result = await session.runtime.evaluate(js, await_promise=await_promise)
|
|
1418
|
+
return result.get("result", {}).get("value")
|
|
1419
|
+
|
|
1420
|
+
async def _wait_for_element_in_shadow(
|
|
1421
|
+
self, selectors: list[str], timeout_ms: int = 30000
|
|
1422
|
+
) -> None:
|
|
1423
|
+
"""Wait for an element inside a shadow DOM tree to exist and be visible.
|
|
1424
|
+
|
|
1425
|
+
Args:
|
|
1426
|
+
selectors: List of CSS selectors piercing shadow boundaries.
|
|
1427
|
+
timeout_ms: Maximum wait time in milliseconds.
|
|
1428
|
+
|
|
1429
|
+
Raises:
|
|
1430
|
+
WaitTimeoutError: If the element is not found within the timeout.
|
|
1431
|
+
"""
|
|
1432
|
+
session = self._require_session()
|
|
1433
|
+
pierce_js = self._build_shadow_pierce_js(selectors)
|
|
1434
|
+
js = (
|
|
1435
|
+
f"(function(){{var el=({pierce_js});"
|
|
1436
|
+
f"if(!el)return false;"
|
|
1437
|
+
f"var rect=el.getBoundingClientRect();"
|
|
1438
|
+
f"return rect.width>0&&rect.height>0;}})()"
|
|
1439
|
+
)
|
|
1440
|
+
deadline = time.monotonic() + timeout_ms / 1000
|
|
1441
|
+
while time.monotonic() < deadline:
|
|
1442
|
+
result = await session.runtime.evaluate(js)
|
|
1443
|
+
if result.get("result", {}).get("value") is True:
|
|
1444
|
+
return
|
|
1445
|
+
await asyncio.sleep(0.1)
|
|
1446
|
+
raise WaitTimeoutError("selector", timeout_ms)
|
|
1447
|
+
|
|
1448
|
+
async def shadow_click(
|
|
1449
|
+
self, selectors: list[str], auto_wait: bool = True
|
|
1450
|
+
) -> None:
|
|
1451
|
+
"""Click an element inside a shadow DOM tree.
|
|
1452
|
+
|
|
1453
|
+
Args:
|
|
1454
|
+
selectors: List of CSS selectors piercing shadow boundaries.
|
|
1455
|
+
auto_wait: If True, wait for element to be visible before clicking.
|
|
1456
|
+
"""
|
|
1457
|
+
session = self._require_session()
|
|
1458
|
+
if auto_wait:
|
|
1459
|
+
await self._wait_for_element_in_shadow(selectors)
|
|
1460
|
+
pierce_js = self._build_shadow_pierce_js(selectors)
|
|
1461
|
+
js = (
|
|
1462
|
+
f"(function(){{var el=({pierce_js});"
|
|
1463
|
+
f"if(!el)return false;"
|
|
1464
|
+
f"el.scrollIntoView({{block:'center',behavior:'instant'}});"
|
|
1465
|
+
f"el.dispatchEvent(new MouseEvent('click',{{bubbles:true,composed:true}}));"
|
|
1466
|
+
f"return true;}})()"
|
|
1467
|
+
)
|
|
1468
|
+
result = await session.runtime.evaluate(js)
|
|
1469
|
+
if not result.get("result", {}).get("value"):
|
|
1470
|
+
raise ElementNotFoundError(" -> ".join(selectors))
|
|
1471
|
+
|
|
1472
|
+
async def shadow_fill(
|
|
1473
|
+
self, selectors: list[str], value: str, auto_wait: bool = True
|
|
1474
|
+
) -> None:
|
|
1475
|
+
"""Fill an input element inside a shadow DOM tree.
|
|
1476
|
+
|
|
1477
|
+
Args:
|
|
1478
|
+
selectors: List of CSS selectors piercing shadow boundaries.
|
|
1479
|
+
value: Value to set in the input field.
|
|
1480
|
+
auto_wait: If True, wait for element to be visible before filling.
|
|
1481
|
+
"""
|
|
1482
|
+
session = self._require_session()
|
|
1483
|
+
if auto_wait:
|
|
1484
|
+
await self._wait_for_element_in_shadow(selectors)
|
|
1485
|
+
pierce_js = self._build_shadow_pierce_js(selectors)
|
|
1486
|
+
escaped_val = value.replace("\\", "\\\\").replace("'", "\\'")
|
|
1487
|
+
js = (
|
|
1488
|
+
f"(function(){{var el=({pierce_js});"
|
|
1489
|
+
f"if(!el)return false;"
|
|
1490
|
+
f"el.focus();el.value='{escaped_val}';"
|
|
1491
|
+
f"el.dispatchEvent(new Event('input',{{bubbles:true,composed:true}}));"
|
|
1492
|
+
f"el.dispatchEvent(new Event('change',{{bubbles:true,composed:true}}));"
|
|
1493
|
+
f"return true;}})()"
|
|
1494
|
+
)
|
|
1495
|
+
result = await session.runtime.evaluate(js)
|
|
1496
|
+
if not result.get("result", {}).get("value"):
|
|
1497
|
+
raise ElementNotFoundError(" -> ".join(selectors))
|
|
1498
|
+
|
|
1372
1499
|
# ── Network advanced ───────────────────────────────────
|
|
1373
1500
|
|
|
1374
1501
|
async def block_requests(self, patterns: list[str]) -> None:
|