wavexis 2.3.4__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.4 → wavexis-2.3.5}/CHANGELOG.md +12 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/PKG-INFO +1 -1
- {wavexis-2.3.4 → wavexis-2.3.5}/pyproject.toml +1 -1
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_backend_manager.py +16 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_manager.py +7 -0
- wavexis-2.3.5/tests/unit/test_nl.py +180 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/__init__.py +1 -1
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/backend/base.py +43 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/backend/bidi.py +108 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/backend/cdp.py +108 -0
- wavexis-2.3.5/wavexis/cli/_nl.py +102 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/cli/app.py +1 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/.dockerignore +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/.github/workflows/ci.yml +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/.github/workflows/docs.yml +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/.github/workflows/release.yml +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/.gitignore +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/CODE_OF_CONDUCT.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/CONTRIBUTING.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/Dockerfile +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/LICENSE +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/README.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/SECURITY.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/docs/api/actions.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/docs/api/backends.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/docs/api/cli.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/docs/api/config.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/docs/api/exceptions.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/docs/api/multi.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/docs/api/plugins.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/docs/api/serve.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/docs/cookbook/auth.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/docs/cookbook/ci-cd.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/docs/cookbook/css.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/docs/cookbook/debug.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/docs/cookbook/record-replay.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/docs/cookbook/scraping.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/docs/cookbook/screenshots.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/docs/cookbook/serve-mode.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/docs/guide/assert.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/docs/guide/backends.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/docs/guide/commands.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/docs/guide/init.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/docs/guide/installation.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/docs/guide/multi.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/docs/guide/perf.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/docs/guide/plugins.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/docs/guide/quickstart.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/docs/guide/raw.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/docs/guide/repl.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/docs/guide/troubleshooting.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/docs/index.md +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/mkdocs.yml +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/stubs/bidiwave/__init__.pyi +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/stubs/cdpwave/__init__.pyi +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/__init__.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/conftest.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/__init__.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_a11y.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_animation.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_backend_selection.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_browser.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_console.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_css.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_debug.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_dialog.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_dom.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_dom_snapshot.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_emulation.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_emulation_advanced.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_eval.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_har.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_input.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_media.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_multi.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_navigate.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_network.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_network_advanced.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_overlay.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_pdf.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_perf.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_permissions.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_raw.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_record.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_scrape.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_screenshot.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_security.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_serve.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_service_worker.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_storage.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_tabs.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_webaudio.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/integration/test_webauthn.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/__init__.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_abstract_backend_phase5.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_actions.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_actions_phase5.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_animation_action.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_assert_multi.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_auth.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_auto_wait.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_bidi_backend.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_bidi_phase5.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_bluetooth_action.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_cast_action.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_cleanup.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_cli_phase5.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_config.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_config_phase5.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_cookies_headers_actions.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_css_action.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_debug_action.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_dom_snapshot_action.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_download_screencast.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_emulation_action.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_error_handling.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_exceptions.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_format_record.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_global_options.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_iframe.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_init.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_input_action.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_interactions_crawl.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_media_action.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_multi.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_multi_improvements.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_new_features.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_output.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_overlay_action.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_perf_action.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_perf_console.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_plugins.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_raw.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_record.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_repl.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_serve.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_service_worker_action.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_shadow.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_storage_action.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_suggest_locator.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_webaudio_action.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/tests/unit/test_webauthn_action.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/__main__.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/__init__.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/accessibility.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/animation.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/base.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/bluetooth.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/browser.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/cast.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/console.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/cookies.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/crawl.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/css.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/debug.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/dialog.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/dom.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/dom_snapshot.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/download.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/emulation.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/eval.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/extract.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/form.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/har.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/headers.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/input.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/lighthouse.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/media.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/multi.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/navigate.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/network.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/overlay.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/pdf.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/performance.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/permissions.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/record.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/scrape.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/screencast.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/screenshot.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/security.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/service_worker.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/session.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/storage.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/tabs.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/wait.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/webaudio.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/webauthn.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/actions/websocket.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/auth.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/backend/__init__.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/backend/manager.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/cleanup.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/cli/__init__.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/cli/_advanced.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/cli/_capture.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/cli/_config.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/cli/_debug.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/cli/_emulation.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/cli/_experimental.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/cli/_iframe.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/cli/_input.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/cli/_navigation.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/cli/_network.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/cli/_perf.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/cli/_serve.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/cli/_session.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/cli/_shadow.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/cli/_shared.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/cli/_workflow.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/config.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/exceptions.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/init.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/multi.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/output.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/plugins.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/record.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/repl.py +0 -0
- {wavexis-2.3.4 → wavexis-2.3.5}/wavexis/serve.py +0 -0
|
@@ -2,6 +2,18 @@
|
|
|
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
|
+
|
|
5
17
|
## v2.3.4 — 2026-07-07
|
|
6
18
|
|
|
7
19
|
### 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
|
|
@@ -138,6 +138,22 @@ class DummyBackend(AbstractBackend):
|
|
|
138
138
|
"""Suggest locator."""
|
|
139
139
|
return selector
|
|
140
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
|
+
|
|
141
157
|
async def capture_har(self, params: object) -> dict:
|
|
142
158
|
"""Capture har."""
|
|
143
159
|
return {}
|
|
@@ -69,6 +69,13 @@ class FakeBackend(AbstractBackend):
|
|
|
69
69
|
async def suggest_locator(self, selector, all=False):
|
|
70
70
|
"""Suggest locator."""
|
|
71
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."""
|
|
72
79
|
async def capture_har(self, params):
|
|
73
80
|
"""Capture har."""
|
|
74
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
|
+
)
|
|
@@ -166,6 +166,49 @@ class AbstractBackend(ABC):
|
|
|
166
166
|
List of selector strings when all=True, single best selector when all=False.
|
|
167
167
|
"""
|
|
168
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
|
+
|
|
169
212
|
# ── Network ────────────────────────────────────────────
|
|
170
213
|
|
|
171
214
|
@abstractmethod
|
|
@@ -595,6 +595,114 @@ class BiDiBackend(AbstractBackend):
|
|
|
595
595
|
f"}})()"
|
|
596
596
|
)
|
|
597
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
|
+
|
|
598
706
|
async def capture_har(self, params: HarParams) -> dict[str, Any]:
|
|
599
707
|
"""Capture HAR data via CDP Network domain.
|
|
600
708
|
|
|
@@ -702,6 +702,114 @@ class CDPBackend(AbstractBackend):
|
|
|
702
702
|
f"}})()"
|
|
703
703
|
)
|
|
704
704
|
|
|
705
|
+
@staticmethod
|
|
706
|
+
def _find_by_text_js(query: str) -> str:
|
|
707
|
+
"""Build JS that finds elements by natural language text query."""
|
|
708
|
+
escaped = query.replace("\\", "\\\\").replace("'", "\\'")
|
|
709
|
+
return (
|
|
710
|
+
f"(function(){{"
|
|
711
|
+
f"var q='{escaped}'.toLowerCase().trim();"
|
|
712
|
+
f"var words=q.split(/\\s+/);"
|
|
713
|
+
f"var els=Array.from(document.querySelectorAll('*'));"
|
|
714
|
+
f"var results=[];"
|
|
715
|
+
f"for(var i=0;i<els.length;i++){{"
|
|
716
|
+
f"var el=els[i];"
|
|
717
|
+
f"var rect=el.getBoundingClientRect();"
|
|
718
|
+
f"if(rect.width===0||rect.height===0)continue;"
|
|
719
|
+
f"var texts=["
|
|
720
|
+
f"(el.textContent||'').trim(),"
|
|
721
|
+
f"el.getAttribute('aria-label')||'',"
|
|
722
|
+
f"el.getAttribute('placeholder')||'',"
|
|
723
|
+
f"el.getAttribute('title')||'',"
|
|
724
|
+
f"el.getAttribute('alt')||'',"
|
|
725
|
+
f"el.getAttribute('value')||''"
|
|
726
|
+
f"].map(function(t){{return t.toLowerCase()}});"
|
|
727
|
+
f"var bestScore=0;"
|
|
728
|
+
f"for(var j=0;j<texts.length;j++){{"
|
|
729
|
+
f"var t=texts[j];if(!t)continue;"
|
|
730
|
+
f"if(t===q){{bestScore=100;break;}}"
|
|
731
|
+
f"if(t.indexOf(q)>=0){{bestScore=Math.max(bestScore,80);}}"
|
|
732
|
+
f"if(q.indexOf(t)>=0&&t.length>3){{bestScore=Math.max(bestScore,60);}}"
|
|
733
|
+
f"var matched=0;"
|
|
734
|
+
f"for(var k=0;k<words.length;k++){{"
|
|
735
|
+
f"if(t.indexOf(words[k])>=0)matched++;"
|
|
736
|
+
f"}}"
|
|
737
|
+
f"if(matched>0)bestScore=Math.max(bestScore,"
|
|
738
|
+
f"Math.round(matched/words.length*50));"
|
|
739
|
+
f"}}"
|
|
740
|
+
f"if(bestScore>0){{"
|
|
741
|
+
f"var tag=el.tagName.toLowerCase();"
|
|
742
|
+
f"var sel=tag;"
|
|
743
|
+
f"if(el.id)sel='#'+CSS.escape(el.id);"
|
|
744
|
+
f"else if(el.getAttribute('data-testid'))"
|
|
745
|
+
f"sel='[data-testid=\"'+el.getAttribute('data-testid')+'\"]';"
|
|
746
|
+
f"else if(el.getAttribute('aria-label'))"
|
|
747
|
+
f"sel=tag+'[aria-label=\"'+el.getAttribute('aria-label')+'\"]';"
|
|
748
|
+
f"else if(el.classList.length>0)"
|
|
749
|
+
f"sel=tag+'.'+Array.from(el.classList).join('.');"
|
|
750
|
+
f"results.push({{score:bestScore,sel:sel}});"
|
|
751
|
+
f"}}"
|
|
752
|
+
f"}}"
|
|
753
|
+
f"results.sort(function(a,b){{return b.score-a.score}});"
|
|
754
|
+
f"return JSON.stringify(results.map(function(r){{return r.sel}}));"
|
|
755
|
+
f"}})()"
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
async def find_by_text(
|
|
759
|
+
self, query: str, all: bool = False
|
|
760
|
+
) -> list[str] | str:
|
|
761
|
+
"""Find elements by natural language text query.
|
|
762
|
+
|
|
763
|
+
Args:
|
|
764
|
+
query: Natural language query (e.g. "the login button").
|
|
765
|
+
all: If True, return all matches; otherwise just the best one.
|
|
766
|
+
|
|
767
|
+
Returns:
|
|
768
|
+
List of CSS selector strings when all=True, single best when all=False.
|
|
769
|
+
|
|
770
|
+
Raises:
|
|
771
|
+
ElementNotFoundError: If no element matches the query.
|
|
772
|
+
"""
|
|
773
|
+
session = self._require_session()
|
|
774
|
+
js = self._find_by_text_js(query)
|
|
775
|
+
result = await session.runtime.evaluate(js)
|
|
776
|
+
raw = result.get("result", {}).get("value")
|
|
777
|
+
if not raw:
|
|
778
|
+
raise ElementNotFoundError(query)
|
|
779
|
+
selectors: list[str] = json.loads(raw)
|
|
780
|
+
if not selectors:
|
|
781
|
+
raise ElementNotFoundError(query)
|
|
782
|
+
if all:
|
|
783
|
+
return selectors
|
|
784
|
+
return selectors[0]
|
|
785
|
+
|
|
786
|
+
async def nl_click(
|
|
787
|
+
self, query: str, auto_wait: bool = True
|
|
788
|
+
) -> None:
|
|
789
|
+
"""Click an element found by natural language text query.
|
|
790
|
+
|
|
791
|
+
Args:
|
|
792
|
+
query: Natural language query (e.g. "login button").
|
|
793
|
+
auto_wait: If True, wait for element to be visible before clicking.
|
|
794
|
+
"""
|
|
795
|
+
selector = await self.find_by_text(query)
|
|
796
|
+
assert isinstance(selector, str)
|
|
797
|
+
await self.click(selector, auto_wait=auto_wait)
|
|
798
|
+
|
|
799
|
+
async def nl_fill(
|
|
800
|
+
self, query: str, value: str, auto_wait: bool = True
|
|
801
|
+
) -> None:
|
|
802
|
+
"""Fill an input element found by natural language text query.
|
|
803
|
+
|
|
804
|
+
Args:
|
|
805
|
+
query: Natural language query (e.g. "email field").
|
|
806
|
+
value: Value to set in the input field.
|
|
807
|
+
auto_wait: If True, wait for element to be visible before filling.
|
|
808
|
+
"""
|
|
809
|
+
selector = await self.find_by_text(query)
|
|
810
|
+
assert isinstance(selector, str)
|
|
811
|
+
await self.fill(selector, value, auto_wait=auto_wait)
|
|
812
|
+
|
|
705
813
|
# ── Network ────────────────────────────────────────────
|
|
706
814
|
|
|
707
815
|
async def capture_har(self, params: HarParams) -> dict[str, Any]:
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Natural language selector commands for wavexis CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = ["nl"]
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from wavexis.cli._shared import (
|
|
10
|
+
_browser_options,
|
|
11
|
+
_get_backend,
|
|
12
|
+
_run_async,
|
|
13
|
+
app,
|
|
14
|
+
)
|
|
15
|
+
from wavexis.config import WaitStrategy
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.command()
|
|
19
|
+
def nl(
|
|
20
|
+
action: str = typer.Argument(
|
|
21
|
+
..., help="Natural language action: click, fill, find"
|
|
22
|
+
),
|
|
23
|
+
url: str = typer.Argument(..., help="URL to navigate to"),
|
|
24
|
+
query: str = typer.Argument(
|
|
25
|
+
..., help='Natural language query (e.g. "the login button")'
|
|
26
|
+
),
|
|
27
|
+
value: str = typer.Option(
|
|
28
|
+
"", "--value", "-v", help="Value to fill (for fill)"
|
|
29
|
+
),
|
|
30
|
+
no_wait: bool = typer.Option(
|
|
31
|
+
False, "--no-wait", help="Skip auto-waiting for element visibility"
|
|
32
|
+
),
|
|
33
|
+
all: bool = typer.Option(
|
|
34
|
+
False, "--all", help="Return all matches (for find)"
|
|
35
|
+
),
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Interact with elements using natural language text queries.
|
|
38
|
+
|
|
39
|
+
\b
|
|
40
|
+
Click: wavexis nl click https://example.com "the login button"
|
|
41
|
+
Fill: wavexis nl fill https://example.com "email field" -v "user@example.com"
|
|
42
|
+
Find: wavexis nl find https://example.com "submit button" --all
|
|
43
|
+
"""
|
|
44
|
+
if action == "click":
|
|
45
|
+
_run_async(_nl_click(url, query, not no_wait))
|
|
46
|
+
typer.echo(f"Clicked element matching '{query}'")
|
|
47
|
+
|
|
48
|
+
elif action == "fill":
|
|
49
|
+
_run_async(_nl_fill(url, query, value, not no_wait))
|
|
50
|
+
typer.echo(f"Filled element matching '{query}' with '{value}'")
|
|
51
|
+
|
|
52
|
+
elif action == "find":
|
|
53
|
+
result = _run_async(_nl_find(url, query, all))
|
|
54
|
+
if isinstance(result, list):
|
|
55
|
+
for sel in result:
|
|
56
|
+
typer.echo(sel)
|
|
57
|
+
else:
|
|
58
|
+
typer.echo(result)
|
|
59
|
+
|
|
60
|
+
else:
|
|
61
|
+
typer.echo(
|
|
62
|
+
f"Error: unknown nl action '{action}'. "
|
|
63
|
+
"Use click, fill, or find.",
|
|
64
|
+
err=True,
|
|
65
|
+
)
|
|
66
|
+
raise typer.Exit(1)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def _nl_click(url: str, query: str, auto_wait: bool) -> None:
|
|
70
|
+
"""Async helper for natural language click."""
|
|
71
|
+
backend = _get_backend()
|
|
72
|
+
try:
|
|
73
|
+
await backend.launch(_browser_options())
|
|
74
|
+
await backend.navigate(url, WaitStrategy(strategy="load"))
|
|
75
|
+
await backend.nl_click(query, auto_wait=auto_wait)
|
|
76
|
+
finally:
|
|
77
|
+
await backend.close()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def _nl_fill(
|
|
81
|
+
url: str, query: str, value: str, auto_wait: bool
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Async helper for natural language fill."""
|
|
84
|
+
backend = _get_backend()
|
|
85
|
+
try:
|
|
86
|
+
await backend.launch(_browser_options())
|
|
87
|
+
await backend.navigate(url, WaitStrategy(strategy="load"))
|
|
88
|
+
await backend.nl_fill(query, value, auto_wait=auto_wait)
|
|
89
|
+
finally:
|
|
90
|
+
await backend.close()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def _nl_find(url: str, query: str, all: bool) -> list[str] | str:
|
|
94
|
+
"""Async helper for natural language find."""
|
|
95
|
+
backend = _get_backend()
|
|
96
|
+
try:
|
|
97
|
+
await backend.launch(_browser_options())
|
|
98
|
+
await backend.navigate(url, WaitStrategy(strategy="load"))
|
|
99
|
+
result: list[str] | str = await backend.find_by_text(query, all=all)
|
|
100
|
+
return result
|
|
101
|
+
finally:
|
|
102
|
+
await backend.close()
|
|
@@ -10,6 +10,7 @@ from wavexis.cli._iframe import * # noqa: F401, F403
|
|
|
10
10
|
from wavexis.cli._input import * # noqa: F401, F403
|
|
11
11
|
from wavexis.cli._navigation import * # noqa: F401, F403
|
|
12
12
|
from wavexis.cli._network import * # noqa: F401, F403
|
|
13
|
+
from wavexis.cli._nl import * # noqa: F401, F403
|
|
13
14
|
from wavexis.cli._perf import * # noqa: F401, F403
|
|
14
15
|
from wavexis.cli._serve import * # noqa: F401, F403
|
|
15
16
|
from wavexis.cli._session import * # noqa: F401, F403
|
|
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
|