wavexis 2.3.3__tar.gz → 2.3.5__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.3 → wavexis-2.3.5}/CHANGELOG.md +22 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/PKG-INFO +1 -1
- {wavexis-2.3.3 → wavexis-2.3.5}/pyproject.toml +1 -1
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_backend_manager.py +22 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_manager.py +10 -0
- wavexis-2.3.5/tests/unit/test_nl.py +180 -0
- wavexis-2.3.5/tests/unit/test_suggest_locator.py +110 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/__init__.py +1 -1
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/backend/base.py +60 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/backend/bidi.py +166 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/backend/cdp.py +166 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/cli/_capture.py +18 -2
- wavexis-2.3.5/wavexis/cli/_nl.py +102 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/cli/app.py +1 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/.dockerignore +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/.github/workflows/ci.yml +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/.github/workflows/docs.yml +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/.github/workflows/release.yml +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/.gitignore +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/CODE_OF_CONDUCT.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/CONTRIBUTING.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/Dockerfile +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/LICENSE +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/README.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/SECURITY.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/docs/api/actions.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/docs/api/backends.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/docs/api/cli.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/docs/api/config.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/docs/api/exceptions.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/docs/api/multi.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/docs/api/plugins.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/docs/api/serve.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/docs/cookbook/auth.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/docs/cookbook/ci-cd.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/docs/cookbook/css.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/docs/cookbook/debug.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/docs/cookbook/record-replay.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/docs/cookbook/scraping.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/docs/cookbook/screenshots.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/docs/cookbook/serve-mode.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/docs/guide/assert.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/docs/guide/backends.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/docs/guide/commands.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/docs/guide/init.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/docs/guide/installation.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/docs/guide/multi.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/docs/guide/perf.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/docs/guide/plugins.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/docs/guide/quickstart.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/docs/guide/raw.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/docs/guide/repl.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/docs/guide/troubleshooting.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/docs/index.md +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/mkdocs.yml +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/stubs/bidiwave/__init__.pyi +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/stubs/cdpwave/__init__.pyi +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/__init__.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/conftest.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/__init__.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_a11y.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_animation.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_backend_selection.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_browser.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_console.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_css.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_debug.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_dialog.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_dom.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_dom_snapshot.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_emulation.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_emulation_advanced.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_eval.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_har.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_input.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_media.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_multi.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_navigate.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_network.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_network_advanced.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_overlay.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_pdf.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_perf.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_permissions.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_raw.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_record.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_scrape.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_screenshot.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_security.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_serve.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_service_worker.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_storage.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_tabs.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_webaudio.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/integration/test_webauthn.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/__init__.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_abstract_backend_phase5.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_actions.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_actions_phase5.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_animation_action.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_assert_multi.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_auth.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_auto_wait.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_bidi_backend.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_bidi_phase5.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_bluetooth_action.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_cast_action.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_cleanup.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_cli_phase5.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_config.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_config_phase5.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_cookies_headers_actions.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_css_action.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_debug_action.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_dom_snapshot_action.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_download_screencast.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_emulation_action.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_error_handling.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_exceptions.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_format_record.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_global_options.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_iframe.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_init.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_input_action.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_interactions_crawl.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_media_action.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_multi.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_multi_improvements.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_new_features.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_output.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_overlay_action.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_perf_action.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_perf_console.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_plugins.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_raw.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_record.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_repl.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_serve.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_service_worker_action.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_shadow.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_storage_action.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_webaudio_action.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/tests/unit/test_webauthn_action.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/__main__.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/__init__.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/accessibility.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/animation.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/base.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/bluetooth.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/browser.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/cast.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/console.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/cookies.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/crawl.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/css.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/debug.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/dialog.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/dom.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/dom_snapshot.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/download.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/emulation.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/eval.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/extract.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/form.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/har.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/headers.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/input.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/lighthouse.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/media.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/multi.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/navigate.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/network.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/overlay.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/pdf.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/performance.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/permissions.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/record.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/scrape.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/screencast.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/screenshot.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/security.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/service_worker.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/session.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/storage.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/tabs.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/wait.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/webaudio.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/webauthn.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/actions/websocket.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/auth.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/backend/__init__.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/backend/manager.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/cleanup.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/cli/__init__.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/cli/_advanced.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/cli/_config.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/cli/_debug.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/cli/_emulation.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/cli/_experimental.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/cli/_iframe.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/cli/_input.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/cli/_navigation.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/cli/_network.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/cli/_perf.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/cli/_serve.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/cli/_session.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/cli/_shadow.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/cli/_shared.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/cli/_workflow.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/config.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/exceptions.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/init.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/multi.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/output.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/plugins.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/record.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/repl.py +0 -0
- {wavexis-2.3.3 → wavexis-2.3.5}/wavexis/serve.py +0 -0
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to wavexis are documented in this file.
|
|
4
4
|
|
|
5
|
+
## v2.3.5 — 2026-07-07
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Natural language selector: `find_by_text`, `nl_click`, `nl_fill` in both CDP and BiDi backends
|
|
10
|
+
- `wavexis nl click/fill/find` CLI command with natural language queries
|
|
11
|
+
- Fuzzy text matching scoring: exact match (100), contains query (80), query contains text (60), word overlap (up to 50)
|
|
12
|
+
- Searches textContent, aria-label, placeholder, title, alt, and value attributes
|
|
13
|
+
- Returns best CSS selector for the matched element
|
|
14
|
+
- `--all` flag on `nl find` returns all matches ranked by score
|
|
15
|
+
- `--no-wait` flag to skip auto-waiting
|
|
16
|
+
|
|
17
|
+
## v2.3.4 — 2026-07-07
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- Generate locator: `suggest_locator` method in both CDP and BiDi backends
|
|
22
|
+
- `wavexis dom <url> --action suggest_locator -s <selector>` CLI command
|
|
23
|
+
- `--all` flag to return all suggestions ranked by specificity
|
|
24
|
+
- Priority order: `#id` > `[data-testid]` > `[aria-label]` > `[role]` > `:has-text()` > `tag.classes` > `parent > tag` > `:nth-of-type()` > `tag`
|
|
25
|
+
- Uses `CSS.escape()` for safe selector generation
|
|
26
|
+
|
|
5
27
|
## v2.3.3 — 2026-07-07
|
|
6
28
|
|
|
7
29
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wavexis
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.5
|
|
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
|
|
@@ -132,6 +132,28 @@ class DummyBackend(AbstractBackend):
|
|
|
132
132
|
"""Dom scroll."""
|
|
133
133
|
pass
|
|
134
134
|
|
|
135
|
+
async def suggest_locator(
|
|
136
|
+
self, selector: str, all: bool = False
|
|
137
|
+
) -> list[str] | str:
|
|
138
|
+
"""Suggest locator."""
|
|
139
|
+
return selector
|
|
140
|
+
|
|
141
|
+
async def find_by_text(
|
|
142
|
+
self, query: str, all: bool = False
|
|
143
|
+
) -> list[str] | str:
|
|
144
|
+
"""Find by text."""
|
|
145
|
+
return query
|
|
146
|
+
|
|
147
|
+
async def nl_click(self, query: str, auto_wait: bool = True) -> None:
|
|
148
|
+
"""NL click."""
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
async def nl_fill(
|
|
152
|
+
self, query: str, value: str, auto_wait: bool = True
|
|
153
|
+
) -> None:
|
|
154
|
+
"""NL fill."""
|
|
155
|
+
pass
|
|
156
|
+
|
|
135
157
|
async def capture_har(self, params: object) -> dict:
|
|
136
158
|
"""Capture har."""
|
|
137
159
|
return {}
|
|
@@ -66,6 +66,16 @@ class FakeBackend(AbstractBackend):
|
|
|
66
66
|
"""Dom focus."""
|
|
67
67
|
async def dom_scroll(self, selector=None, x=0, y=0):
|
|
68
68
|
"""Dom scroll."""
|
|
69
|
+
async def suggest_locator(self, selector, all=False):
|
|
70
|
+
"""Suggest locator."""
|
|
71
|
+
return selector
|
|
72
|
+
async def find_by_text(self, query, all=False):
|
|
73
|
+
"""Find by text."""
|
|
74
|
+
return query
|
|
75
|
+
async def nl_click(self, query, auto_wait=True):
|
|
76
|
+
"""NL click."""
|
|
77
|
+
async def nl_fill(self, query, value, auto_wait=True):
|
|
78
|
+
"""NL fill."""
|
|
69
79
|
async def capture_har(self, params):
|
|
70
80
|
"""Capture har."""
|
|
71
81
|
async def get_cookies(self):
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Unit tests for natural language selector — find_by_text, nl_click, nl_fill."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from wavexis.exceptions import ElementNotFoundError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _make_cdp_backend() -> Any:
|
|
16
|
+
"""Create a CDPBackend with mocked session."""
|
|
17
|
+
from wavexis.backend.cdp import CDPBackend
|
|
18
|
+
|
|
19
|
+
backend = CDPBackend()
|
|
20
|
+
backend._session = MagicMock()
|
|
21
|
+
backend._session.runtime = MagicMock()
|
|
22
|
+
backend._session.runtime.evaluate = AsyncMock(
|
|
23
|
+
return_value={"result": {"value": None}}
|
|
24
|
+
)
|
|
25
|
+
return backend
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _make_bidi_backend() -> Any:
|
|
29
|
+
"""Create a BiDiBackend with mocked client."""
|
|
30
|
+
from wavexis.backend.bidi import BiDiBackend
|
|
31
|
+
|
|
32
|
+
backend = BiDiBackend()
|
|
33
|
+
backend._client = MagicMock()
|
|
34
|
+
backend._context = MagicMock()
|
|
35
|
+
backend._client.script = MagicMock()
|
|
36
|
+
result = MagicMock()
|
|
37
|
+
result.value = None
|
|
38
|
+
backend._client.script.evaluate = AsyncMock(return_value=result)
|
|
39
|
+
return backend
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.unit
|
|
43
|
+
class TestFindByTextCDP:
|
|
44
|
+
"""Tests for find_by_text in CDP backend."""
|
|
45
|
+
|
|
46
|
+
def test_returns_best_selector(self) -> None:
|
|
47
|
+
"""Test that find_by_text returns the best match."""
|
|
48
|
+
backend = _make_cdp_backend()
|
|
49
|
+
selectors = ["#login-btn", "button.primary"]
|
|
50
|
+
backend._session.runtime.evaluate = AsyncMock(
|
|
51
|
+
return_value={"result": {"value": json.dumps(selectors)}}
|
|
52
|
+
)
|
|
53
|
+
result = asyncio.run(backend.find_by_text("login button"))
|
|
54
|
+
assert result == "#login-btn"
|
|
55
|
+
|
|
56
|
+
def test_returns_all_selectors(self) -> None:
|
|
57
|
+
"""Test that find_by_text with all=True returns all matches."""
|
|
58
|
+
backend = _make_cdp_backend()
|
|
59
|
+
selectors = ["#login-btn", "button.primary", "button"]
|
|
60
|
+
backend._session.runtime.evaluate = AsyncMock(
|
|
61
|
+
return_value={"result": {"value": json.dumps(selectors)}}
|
|
62
|
+
)
|
|
63
|
+
result = asyncio.run(backend.find_by_text("login", all=True))
|
|
64
|
+
assert isinstance(result, list)
|
|
65
|
+
assert len(result) == 3
|
|
66
|
+
|
|
67
|
+
def test_raises_on_no_match(self) -> None:
|
|
68
|
+
"""Test that find_by_text raises ElementNotFoundError when no match."""
|
|
69
|
+
backend = _make_cdp_backend()
|
|
70
|
+
backend._session.runtime.evaluate = AsyncMock(
|
|
71
|
+
return_value={"result": {"value": json.dumps([])}}
|
|
72
|
+
)
|
|
73
|
+
with pytest.raises(ElementNotFoundError):
|
|
74
|
+
asyncio.run(backend.find_by_text("nonexistent"))
|
|
75
|
+
|
|
76
|
+
def test_raises_on_null_result(self) -> None:
|
|
77
|
+
"""Test that find_by_text raises when JS returns null."""
|
|
78
|
+
backend = _make_cdp_backend()
|
|
79
|
+
backend._session.runtime.evaluate = AsyncMock(
|
|
80
|
+
return_value={"result": {"value": None}}
|
|
81
|
+
)
|
|
82
|
+
with pytest.raises(ElementNotFoundError):
|
|
83
|
+
asyncio.run(backend.find_by_text("anything"))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@pytest.mark.unit
|
|
87
|
+
class TestFindByTextBiDi:
|
|
88
|
+
"""Tests for find_by_text in BiDi backend."""
|
|
89
|
+
|
|
90
|
+
def test_returns_best_selector(self) -> None:
|
|
91
|
+
"""Test that find_by_text returns the best match."""
|
|
92
|
+
backend = _make_bidi_backend()
|
|
93
|
+
selectors = ["#email-input", "input.email"]
|
|
94
|
+
result_mock = MagicMock()
|
|
95
|
+
result_mock.value = json.dumps(selectors)
|
|
96
|
+
backend._client.script.evaluate = AsyncMock(return_value=result_mock)
|
|
97
|
+
result = asyncio.run(backend.find_by_text("email field"))
|
|
98
|
+
assert result == "#email-input"
|
|
99
|
+
|
|
100
|
+
def test_returns_all_selectors(self) -> None:
|
|
101
|
+
"""Test that find_by_text with all=True returns all matches."""
|
|
102
|
+
backend = _make_bidi_backend()
|
|
103
|
+
selectors = ["#email", "input[placeholder='email']", "input"]
|
|
104
|
+
result_mock = MagicMock()
|
|
105
|
+
result_mock.value = json.dumps(selectors)
|
|
106
|
+
backend._client.script.evaluate = AsyncMock(return_value=result_mock)
|
|
107
|
+
result = asyncio.run(backend.find_by_text("email", all=True))
|
|
108
|
+
assert isinstance(result, list)
|
|
109
|
+
assert len(result) == 3
|
|
110
|
+
|
|
111
|
+
def test_raises_on_no_match(self) -> None:
|
|
112
|
+
"""Test that find_by_text raises ElementNotFoundError when no match."""
|
|
113
|
+
backend = _make_bidi_backend()
|
|
114
|
+
result_mock = MagicMock()
|
|
115
|
+
result_mock.value = json.dumps([])
|
|
116
|
+
backend._client.script.evaluate = AsyncMock(return_value=result_mock)
|
|
117
|
+
with pytest.raises(ElementNotFoundError):
|
|
118
|
+
asyncio.run(backend.find_by_text("nonexistent"))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@pytest.mark.unit
|
|
122
|
+
class TestNlClick:
|
|
123
|
+
"""Tests for nl_click in both backends."""
|
|
124
|
+
|
|
125
|
+
def test_cdp_nl_click_calls_click(self) -> None:
|
|
126
|
+
"""Test that nl_click finds element and calls click."""
|
|
127
|
+
backend = _make_cdp_backend()
|
|
128
|
+
selectors = ["#submit-btn"]
|
|
129
|
+
backend._session.runtime.evaluate = AsyncMock(
|
|
130
|
+
return_value={"result": {"value": json.dumps(selectors)}}
|
|
131
|
+
)
|
|
132
|
+
backend.click = AsyncMock()
|
|
133
|
+
asyncio.run(backend.nl_click("submit button", auto_wait=False))
|
|
134
|
+
backend.click.assert_called_once_with(
|
|
135
|
+
"#submit-btn", auto_wait=False
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def test_bidi_nl_click_calls_click(self) -> None:
|
|
139
|
+
"""Test that nl_click finds element and calls click."""
|
|
140
|
+
backend = _make_bidi_backend()
|
|
141
|
+
selectors = ["#submit-btn"]
|
|
142
|
+
result_mock = MagicMock()
|
|
143
|
+
result_mock.value = json.dumps(selectors)
|
|
144
|
+
backend._client.script.evaluate = AsyncMock(return_value=result_mock)
|
|
145
|
+
backend.click = AsyncMock()
|
|
146
|
+
asyncio.run(backend.nl_click("submit button", auto_wait=False))
|
|
147
|
+
backend.click.assert_called_once_with(
|
|
148
|
+
"#submit-btn", auto_wait=False
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@pytest.mark.unit
|
|
153
|
+
class TestNlFill:
|
|
154
|
+
"""Tests for nl_fill in both backends."""
|
|
155
|
+
|
|
156
|
+
def test_cdp_nl_fill_calls_fill(self) -> None:
|
|
157
|
+
"""Test that nl_fill finds element and calls fill."""
|
|
158
|
+
backend = _make_cdp_backend()
|
|
159
|
+
selectors = ["#email-field"]
|
|
160
|
+
backend._session.runtime.evaluate = AsyncMock(
|
|
161
|
+
return_value={"result": {"value": json.dumps(selectors)}}
|
|
162
|
+
)
|
|
163
|
+
backend.fill = AsyncMock()
|
|
164
|
+
asyncio.run(backend.nl_fill("email field", "test@test.com", auto_wait=False))
|
|
165
|
+
backend.fill.assert_called_once_with(
|
|
166
|
+
"#email-field", "test@test.com", auto_wait=False
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def test_bidi_nl_fill_calls_fill(self) -> None:
|
|
170
|
+
"""Test that nl_fill finds element and calls fill."""
|
|
171
|
+
backend = _make_bidi_backend()
|
|
172
|
+
selectors = ["#email-field"]
|
|
173
|
+
result_mock = MagicMock()
|
|
174
|
+
result_mock.value = json.dumps(selectors)
|
|
175
|
+
backend._client.script.evaluate = AsyncMock(return_value=result_mock)
|
|
176
|
+
backend.fill = AsyncMock()
|
|
177
|
+
asyncio.run(backend.nl_fill("email field", "test@test.com", auto_wait=False))
|
|
178
|
+
backend.fill.assert_called_once_with(
|
|
179
|
+
"#email-field", "test@test.com", auto_wait=False
|
|
180
|
+
)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Unit tests for suggest_locator in both backends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from wavexis.exceptions import ElementNotFoundError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _make_cdp_backend() -> Any:
|
|
16
|
+
"""Create a CDPBackend with mocked session."""
|
|
17
|
+
from wavexis.backend.cdp import CDPBackend
|
|
18
|
+
|
|
19
|
+
backend = CDPBackend()
|
|
20
|
+
backend._session = MagicMock()
|
|
21
|
+
backend._session.runtime = MagicMock()
|
|
22
|
+
backend._session.runtime.evaluate = AsyncMock(
|
|
23
|
+
return_value={"result": {"value": None}}
|
|
24
|
+
)
|
|
25
|
+
return backend
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _make_bidi_backend() -> Any:
|
|
29
|
+
"""Create a BiDiBackend with mocked client."""
|
|
30
|
+
from wavexis.backend.bidi import BiDiBackend
|
|
31
|
+
|
|
32
|
+
backend = BiDiBackend()
|
|
33
|
+
backend._client = MagicMock()
|
|
34
|
+
backend._context = MagicMock()
|
|
35
|
+
backend._client.script = MagicMock()
|
|
36
|
+
result = MagicMock()
|
|
37
|
+
result.value = None
|
|
38
|
+
backend._client.script.evaluate = AsyncMock(return_value=result)
|
|
39
|
+
return backend
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.mark.unit
|
|
43
|
+
class TestSuggestLocatorCDP:
|
|
44
|
+
"""Tests for suggest_locator in CDP backend."""
|
|
45
|
+
|
|
46
|
+
def test_returns_best_selector(self) -> None:
|
|
47
|
+
"""Test that suggest_locator returns the first (best) suggestion."""
|
|
48
|
+
backend = _make_cdp_backend()
|
|
49
|
+
suggestions = ["#login-btn", "button.primary", "button"]
|
|
50
|
+
backend._session.runtime.evaluate = AsyncMock(
|
|
51
|
+
return_value={"result": {"value": json.dumps(suggestions)}}
|
|
52
|
+
)
|
|
53
|
+
result = asyncio.run(backend.suggest_locator("#login-btn"))
|
|
54
|
+
assert result == "#login-btn"
|
|
55
|
+
|
|
56
|
+
def test_returns_all_suggestions(self) -> None:
|
|
57
|
+
"""Test that suggest_locator with all=True returns all suggestions."""
|
|
58
|
+
backend = _make_cdp_backend()
|
|
59
|
+
suggestions = ["#my-id", "[data-testid='btn']", "button.clickable", "button"]
|
|
60
|
+
backend._session.runtime.evaluate = AsyncMock(
|
|
61
|
+
return_value={"result": {"value": json.dumps(suggestions)}}
|
|
62
|
+
)
|
|
63
|
+
result = asyncio.run(backend.suggest_locator("button", all=True))
|
|
64
|
+
assert isinstance(result, list)
|
|
65
|
+
assert len(result) == 4
|
|
66
|
+
assert result[0] == "#my-id"
|
|
67
|
+
|
|
68
|
+
def test_raises_on_element_not_found(self) -> None:
|
|
69
|
+
"""Test that suggest_locator raises ElementNotFoundError when element missing."""
|
|
70
|
+
backend = _make_cdp_backend()
|
|
71
|
+
backend._session.runtime.evaluate = AsyncMock(
|
|
72
|
+
return_value={"result": {"value": None}}
|
|
73
|
+
)
|
|
74
|
+
with pytest.raises(ElementNotFoundError):
|
|
75
|
+
asyncio.run(backend.suggest_locator("#missing"))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@pytest.mark.unit
|
|
79
|
+
class TestSuggestLocatorBiDi:
|
|
80
|
+
"""Tests for suggest_locator in BiDi backend."""
|
|
81
|
+
|
|
82
|
+
def test_returns_best_selector(self) -> None:
|
|
83
|
+
"""Test that suggest_locator returns the first (best) suggestion."""
|
|
84
|
+
backend = _make_bidi_backend()
|
|
85
|
+
suggestions = ["#submit", "button[type='submit']", "button"]
|
|
86
|
+
result_mock = MagicMock()
|
|
87
|
+
result_mock.value = json.dumps(suggestions)
|
|
88
|
+
backend._client.script.evaluate = AsyncMock(return_value=result_mock)
|
|
89
|
+
result = asyncio.run(backend.suggest_locator("#submit"))
|
|
90
|
+
assert result == "#submit"
|
|
91
|
+
|
|
92
|
+
def test_returns_all_suggestions(self) -> None:
|
|
93
|
+
"""Test that suggest_locator with all=True returns all suggestions."""
|
|
94
|
+
backend = _make_bidi_backend()
|
|
95
|
+
suggestions = ["#email", "[data-testid='email']", "input.email", "input"]
|
|
96
|
+
result_mock = MagicMock()
|
|
97
|
+
result_mock.value = json.dumps(suggestions)
|
|
98
|
+
backend._client.script.evaluate = AsyncMock(return_value=result_mock)
|
|
99
|
+
result = asyncio.run(backend.suggest_locator("input", all=True))
|
|
100
|
+
assert isinstance(result, list)
|
|
101
|
+
assert len(result) == 4
|
|
102
|
+
|
|
103
|
+
def test_raises_on_element_not_found(self) -> None:
|
|
104
|
+
"""Test that suggest_locator raises ElementNotFoundError when element missing."""
|
|
105
|
+
backend = _make_bidi_backend()
|
|
106
|
+
result_mock = MagicMock()
|
|
107
|
+
result_mock.value = None
|
|
108
|
+
backend._client.script.evaluate = AsyncMock(return_value=result_mock)
|
|
109
|
+
with pytest.raises(ElementNotFoundError):
|
|
110
|
+
asyncio.run(backend.suggest_locator("#missing"))
|
|
@@ -149,6 +149,66 @@ class AbstractBackend(ABC):
|
|
|
149
149
|
) -> None:
|
|
150
150
|
"""Scroll to an element or by offset."""
|
|
151
151
|
|
|
152
|
+
@abstractmethod
|
|
153
|
+
async def suggest_locator(
|
|
154
|
+
self, selector: str, all: bool = False
|
|
155
|
+
) -> list[str] | str:
|
|
156
|
+
"""Suggest the best CSS selector for an element.
|
|
157
|
+
|
|
158
|
+
Analyzes the element and generates optimal selectors in priority order:
|
|
159
|
+
id > data-testid > aria-label > text > tag.classes > nth-of-type chain.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
selector: CSS selector for the target element.
|
|
163
|
+
all: If True, return multiple suggestions; otherwise just the best one.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
List of selector strings when all=True, single best selector when all=False.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
@abstractmethod
|
|
170
|
+
async def find_by_text(
|
|
171
|
+
self, query: str, all: bool = False
|
|
172
|
+
) -> list[str] | str:
|
|
173
|
+
"""Find elements by natural language text query.
|
|
174
|
+
|
|
175
|
+
Searches all visible elements by text content, aria-label,
|
|
176
|
+
placeholder, title, and alt text using fuzzy matching.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
query: Natural language query (e.g. "the login button").
|
|
180
|
+
all: If True, return all matches; otherwise just the best one.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
List of CSS selector strings when all=True, single best when all=False.
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
ElementNotFoundError: If no element matches the query.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
@abstractmethod
|
|
190
|
+
async def nl_click(
|
|
191
|
+
self, query: str, auto_wait: bool = True
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Click an element found by natural language text query.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
query: Natural language query (e.g. "login button").
|
|
197
|
+
auto_wait: If True, wait for element to be visible before clicking.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
@abstractmethod
|
|
201
|
+
async def nl_fill(
|
|
202
|
+
self, query: str, value: str, auto_wait: bool = True
|
|
203
|
+
) -> None:
|
|
204
|
+
"""Fill an input element found by natural language text query.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
query: Natural language query (e.g. "email field").
|
|
208
|
+
value: Value to set in the input field.
|
|
209
|
+
auto_wait: If True, wait for element to be visible before filling.
|
|
210
|
+
"""
|
|
211
|
+
|
|
152
212
|
# ── Network ────────────────────────────────────────────
|
|
153
213
|
|
|
154
214
|
@abstractmethod
|
|
@@ -537,6 +537,172 @@ class BiDiBackend(AbstractBackend):
|
|
|
537
537
|
js = f"window.scrollTo({x},{y})"
|
|
538
538
|
await self._client.script.evaluate(self._context, js)
|
|
539
539
|
|
|
540
|
+
async def suggest_locator(
|
|
541
|
+
self, selector: str, all: bool = False
|
|
542
|
+
) -> list[str] | str:
|
|
543
|
+
"""Suggest the best CSS selector for an element.
|
|
544
|
+
|
|
545
|
+
Args:
|
|
546
|
+
selector: CSS selector for the target element.
|
|
547
|
+
all: If True, return multiple suggestions; otherwise just the best one.
|
|
548
|
+
|
|
549
|
+
Returns:
|
|
550
|
+
List of selector strings when all=True, single best selector when all=False.
|
|
551
|
+
"""
|
|
552
|
+
client = self._require_client()
|
|
553
|
+
escaped = selector.replace("'", "\\'")
|
|
554
|
+
js = self._suggest_locator_js(escaped)
|
|
555
|
+
result = await client.script.evaluate(self._context, js)
|
|
556
|
+
raw = getattr(result, "value", None)
|
|
557
|
+
if not raw:
|
|
558
|
+
raise ElementNotFoundError(selector)
|
|
559
|
+
suggestions: list[str] = json.loads(raw)
|
|
560
|
+
if all:
|
|
561
|
+
return suggestions
|
|
562
|
+
return suggestions[0] if suggestions else selector
|
|
563
|
+
|
|
564
|
+
@staticmethod
|
|
565
|
+
def _suggest_locator_js(escaped: str) -> str:
|
|
566
|
+
"""Build JS that generates CSS selector suggestions for an element."""
|
|
567
|
+
return (
|
|
568
|
+
f"(function(){{"
|
|
569
|
+
f"var el=document.querySelector('{escaped}');"
|
|
570
|
+
f"if(!el)return null;"
|
|
571
|
+
f"var s=[];var t=el.tagName.toLowerCase();"
|
|
572
|
+
f"var id=el.id;"
|
|
573
|
+
f"var tid=el.getAttribute('data-testid')"
|
|
574
|
+
f"||el.getAttribute('data-test-id')"
|
|
575
|
+
f"||el.getAttribute('data-cy');"
|
|
576
|
+
f"var aria=el.getAttribute('aria-label');"
|
|
577
|
+
f"var role=el.getAttribute('role');"
|
|
578
|
+
f"var txt=(el.textContent||'').trim().substring(0,50);"
|
|
579
|
+
f"var cls=Array.from(el.classList);"
|
|
580
|
+
f"if(id)s.push('#'+CSS.escape(id));"
|
|
581
|
+
f"if(tid)s.push('[data-testid=\"'+tid+'\"]');"
|
|
582
|
+
f"if(aria)s.push(t+'[aria-label=\"'+aria+'\"]');"
|
|
583
|
+
f"if(role)s.push(t+'[role=\"'+role+'\"]');"
|
|
584
|
+
f"if(txt&&txt.length<30)s.push(t+':has-text(\"'+txt+'\"]');"
|
|
585
|
+
f"if(cls.length>0)s.push(t+'.'+cls.join('.'));"
|
|
586
|
+
f"var p=el.parentElement;"
|
|
587
|
+
f"if(p&&p.id)s.push('#'+CSS.escape(p.id)+' > '+t);"
|
|
588
|
+
f"var sib=p?Array.from(p.children)"
|
|
589
|
+
f".filter(function(c){{return c.tagName===el.tagName}}):[];"
|
|
590
|
+
f"if(sib.length>1){{"
|
|
591
|
+
f"var i=sib.indexOf(el)+1;"
|
|
592
|
+
f"s.push(t+':nth-of-type('+i+')');}}"
|
|
593
|
+
f"s.push(t);"
|
|
594
|
+
f"return JSON.stringify(s);"
|
|
595
|
+
f"}})()"
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
@staticmethod
|
|
599
|
+
def _find_by_text_js(query: str) -> str:
|
|
600
|
+
"""Build JS that finds elements by natural language text query."""
|
|
601
|
+
escaped = query.replace("\\", "\\\\").replace("'", "\\'")
|
|
602
|
+
return (
|
|
603
|
+
f"(function(){{"
|
|
604
|
+
f"var q='{escaped}'.toLowerCase().trim();"
|
|
605
|
+
f"var words=q.split(/\\s+/);"
|
|
606
|
+
f"var els=Array.from(document.querySelectorAll('*'));"
|
|
607
|
+
f"var results=[];"
|
|
608
|
+
f"for(var i=0;i<els.length;i++){{"
|
|
609
|
+
f"var el=els[i];"
|
|
610
|
+
f"var rect=el.getBoundingClientRect();"
|
|
611
|
+
f"if(rect.width===0||rect.height===0)continue;"
|
|
612
|
+
f"var texts=["
|
|
613
|
+
f"(el.textContent||'').trim(),"
|
|
614
|
+
f"el.getAttribute('aria-label')||'',"
|
|
615
|
+
f"el.getAttribute('placeholder')||'',"
|
|
616
|
+
f"el.getAttribute('title')||'',"
|
|
617
|
+
f"el.getAttribute('alt')||'',"
|
|
618
|
+
f"el.getAttribute('value')||''"
|
|
619
|
+
f"].map(function(t){{return t.toLowerCase()}});"
|
|
620
|
+
f"var bestScore=0;"
|
|
621
|
+
f"for(var j=0;j<texts.length;j++){{"
|
|
622
|
+
f"var t=texts[j];if(!t)continue;"
|
|
623
|
+
f"if(t===q){{bestScore=100;break;}}"
|
|
624
|
+
f"if(t.indexOf(q)>=0){{bestScore=Math.max(bestScore,80);}}"
|
|
625
|
+
f"if(q.indexOf(t)>=0&&t.length>3){{bestScore=Math.max(bestScore,60);}}"
|
|
626
|
+
f"var matched=0;"
|
|
627
|
+
f"for(var k=0;k<words.length;k++){{"
|
|
628
|
+
f"if(t.indexOf(words[k])>=0)matched++;"
|
|
629
|
+
f"}}"
|
|
630
|
+
f"if(matched>0)bestScore=Math.max(bestScore,"
|
|
631
|
+
f"Math.round(matched/words.length*50));"
|
|
632
|
+
f"}}"
|
|
633
|
+
f"if(bestScore>0){{"
|
|
634
|
+
f"var tag=el.tagName.toLowerCase();"
|
|
635
|
+
f"var sel=tag;"
|
|
636
|
+
f"if(el.id)sel='#'+CSS.escape(el.id);"
|
|
637
|
+
f"else if(el.getAttribute('data-testid'))"
|
|
638
|
+
f"sel='[data-testid=\"'+el.getAttribute('data-testid')+'\"]';"
|
|
639
|
+
f"else if(el.getAttribute('aria-label'))"
|
|
640
|
+
f"sel=tag+'[aria-label=\"'+el.getAttribute('aria-label')+'\"]';"
|
|
641
|
+
f"else if(el.classList.length>0)"
|
|
642
|
+
f"sel=tag+'.'+Array.from(el.classList).join('.');"
|
|
643
|
+
f"results.push({{score:bestScore,sel:sel}});"
|
|
644
|
+
f"}}"
|
|
645
|
+
f"}}"
|
|
646
|
+
f"results.sort(function(a,b){{return b.score-a.score}});"
|
|
647
|
+
f"return JSON.stringify(results.map(function(r){{return r.sel}}));"
|
|
648
|
+
f"}})()"
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
async def find_by_text(
|
|
652
|
+
self, query: str, all: bool = False
|
|
653
|
+
) -> list[str] | str:
|
|
654
|
+
"""Find elements by natural language text query.
|
|
655
|
+
|
|
656
|
+
Args:
|
|
657
|
+
query: Natural language query (e.g. "the login button").
|
|
658
|
+
all: If True, return all matches; otherwise just the best one.
|
|
659
|
+
|
|
660
|
+
Returns:
|
|
661
|
+
List of CSS selector strings when all=True, single best when all=False.
|
|
662
|
+
|
|
663
|
+
Raises:
|
|
664
|
+
ElementNotFoundError: If no element matches the query.
|
|
665
|
+
"""
|
|
666
|
+
client = self._require_client()
|
|
667
|
+
js = self._find_by_text_js(query)
|
|
668
|
+
result = await client.script.evaluate(self._context, js)
|
|
669
|
+
raw = getattr(result, "value", None)
|
|
670
|
+
if not raw:
|
|
671
|
+
raise ElementNotFoundError(query)
|
|
672
|
+
selectors: list[str] = json.loads(raw)
|
|
673
|
+
if not selectors:
|
|
674
|
+
raise ElementNotFoundError(query)
|
|
675
|
+
if all:
|
|
676
|
+
return selectors
|
|
677
|
+
return selectors[0]
|
|
678
|
+
|
|
679
|
+
async def nl_click(
|
|
680
|
+
self, query: str, auto_wait: bool = True
|
|
681
|
+
) -> None:
|
|
682
|
+
"""Click an element found by natural language text query.
|
|
683
|
+
|
|
684
|
+
Args:
|
|
685
|
+
query: Natural language query (e.g. "login button").
|
|
686
|
+
auto_wait: If True, wait for element to be visible before clicking.
|
|
687
|
+
"""
|
|
688
|
+
selector = await self.find_by_text(query)
|
|
689
|
+
assert isinstance(selector, str)
|
|
690
|
+
await self.click(selector, auto_wait=auto_wait)
|
|
691
|
+
|
|
692
|
+
async def nl_fill(
|
|
693
|
+
self, query: str, value: str, auto_wait: bool = True
|
|
694
|
+
) -> None:
|
|
695
|
+
"""Fill an input element found by natural language text query.
|
|
696
|
+
|
|
697
|
+
Args:
|
|
698
|
+
query: Natural language query (e.g. "email field").
|
|
699
|
+
value: Value to set in the input field.
|
|
700
|
+
auto_wait: If True, wait for element to be visible before filling.
|
|
701
|
+
"""
|
|
702
|
+
selector = await self.find_by_text(query)
|
|
703
|
+
assert isinstance(selector, str)
|
|
704
|
+
await self.fill(selector, value, auto_wait=auto_wait)
|
|
705
|
+
|
|
540
706
|
async def capture_har(self, params: HarParams) -> dict[str, Any]:
|
|
541
707
|
"""Capture HAR data via CDP Network domain.
|
|
542
708
|
|