From 7d5e7cff07255a196df67f9f69f75ede65481e76 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 21 May 2026 16:14:27 +0800 Subject: [PATCH 1/3] Add advanced WebDriverWrapper APIs and split into themed mixins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebDriverWrapper additions for stealth / anti-bot / advanced scenarios: * set_driver gains experimental_options, extension_paths, enable_bidi * attach_to_existing_browser for sessions started with --remote-debugging-port * execute_cdp_cmd plus convenience shortcuts: set_timezone / locale / device_metrics / user_agent / extra_http_headers / geolocation / network_conditions, block_urls, set_cache_disabled, set_download_directory, add_script_to_evaluate_on_new_document * CDP Fetch interception primitives: enable_fetch_interception, continue_request, fulfill_request, fail_request * W3C BiDi listeners: add_console_listener, add_js_error_listener and their remove counterparts (Selenium 4.16+) * Session persistence: save_cookies / load_cookies / clear_origin_storage * Screenshots / PDF: save_screenshot, save_full_page_screenshot, print_page * Page / window: reload(ignore_cache), bring_to_front, switch_to_window_by_url|title, get_current_url / title / page_source / window_handles / current_window_handle, new_window, close_window Split webdriver_wrapper.py (2340 lines) into themed mixins under webdriver/_wrapper_mixins/ so every file stays under the 750-line limit: _scripting_mixin (script / CDP / BiDi / Fetch), _navigation_mixin (navigation + window), _cookie_mixin, _actions_mixin (ActionChains), _media_mixin (screenshots + print + log). Main file retains lifecycle, element finding, waits, and the module-level _options_dict / _webdriver_dict / _webdriver_manager_dict so existing test patch paths work unchanged. Independent modules: * utils/cdp/event_loop.py — CDPEventListener (background WebSocket loop with sync send/on/context manager; lazy websocket-client import) * utils/cdp/tracing.py — record_trace producing Chrome DevTools- loadable JSON via the event loop * utils/bidi/network.py — cross-browser W3C BiDi network handlers (add_request_handler / add_response_handler / add_auth_handler / clear_network_handlers) wrapping driver.network Executor: register 41 new WR_* aliases for the additions; gate WR_execute_cdp_cmd and WR_add_script_to_evaluate_on_new_document under set_allow_arbitrary_script. MCP server: webrunner_run_actions description lists the advanced commands so MCP clients can discover them. Documentation: English + zh-TW + zh-CN READMEs gain an Advanced WebDriverWrapper section; Sphinx docs (Eng + Zh) extended with 11 new chapters covering launch options, page metadata, CDP shortcuts, Fetch primitives, BiDi listeners, the standalone modules, and the mixin layout. Tests: 109 new mock-based unit tests added across 4 files; all 119 related tests green. --- README.md | 171 ++- README/README_zh-CN.md | 87 ++ README/README_zh-TW.md | 87 ++ .../webdriver_wrapper_doc.rst | 241 ++++ .../webdriver_wrapper_doc.rst | 215 +++ je_web_runner/__init__.py | 21 + je_web_runner/mcp_server/browser_tools.py | 20 +- je_web_runner/utils/bidi/__init__.py | 0 je_web_runner/utils/bidi/network.py | 108 ++ je_web_runner/utils/cdp/event_loop.py | 272 ++++ je_web_runner/utils/cdp/tracing.py | 95 ++ .../utils/executor/action_executor.py | 45 + .../webdriver/_wrapper_mixins/__init__.py | 18 + .../_wrapper_mixins/_actions_mixin.py | 652 +++++++++ .../_wrapper_mixins/_cookie_mixin.py | 169 +++ .../webdriver/_wrapper_mixins/_media_mixin.py | 151 +++ .../_wrapper_mixins/_navigation_mixin.py | 444 ++++++ .../_wrapper_mixins/_scripting_mixin.py | 514 +++++++ je_web_runner/webdriver/webdriver_wrapper.py | 1208 ++--------------- test/unit_test/test_bidi_network.py | 84 ++ test/unit_test/test_cdp_event_loop.py | 279 ++++ test/unit_test/test_cdp_tracing.py | 121 ++ .../test_webdriver_wrapper_extensions.py | 853 ++++++++++++ 23 files changed, 4778 insertions(+), 1077 deletions(-) create mode 100644 je_web_runner/utils/bidi/__init__.py create mode 100644 je_web_runner/utils/bidi/network.py create mode 100644 je_web_runner/utils/cdp/event_loop.py create mode 100644 je_web_runner/utils/cdp/tracing.py create mode 100644 je_web_runner/webdriver/_wrapper_mixins/__init__.py create mode 100644 je_web_runner/webdriver/_wrapper_mixins/_actions_mixin.py create mode 100644 je_web_runner/webdriver/_wrapper_mixins/_cookie_mixin.py create mode 100644 je_web_runner/webdriver/_wrapper_mixins/_media_mixin.py create mode 100644 je_web_runner/webdriver/_wrapper_mixins/_navigation_mixin.py create mode 100644 je_web_runner/webdriver/_wrapper_mixins/_scripting_mixin.py create mode 100644 test/unit_test/test_bidi_network.py create mode 100644 test/unit_test/test_cdp_event_loop.py create mode 100644 test/unit_test/test_cdp_tracing.py create mode 100644 test/unit_test/test_webdriver_wrapper_extensions.py diff --git a/README.md b/README.md index 7e2241d..4b93ee0 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ WebRunner (`je_web_runner`) started as a Selenium wrapper and grew into a full a - [Observability](#observability) - [Test Orchestration](#test-orchestration) - [Quality & Security](#quality--security) +- [Advanced WebDriverWrapper](#advanced-webdriverwrapper) - [Browser Internals](#browser-internals) - [Test Data](#test-data) - [Auth & APIs](#auth--apis) @@ -66,6 +67,8 @@ WebRunner (`je_web_runner`) started as a Selenium wrapper and grew into a full a - **Observability without extra plumbing.** Auto-screenshot on failure, retry policy, OpenTelemetry hook, live HTTP dashboard, replay studio (HTML timeline), HAR capture + diff. - **Quality & security guards.** Action linter, migration helper, hard-coded secrets scanner, HTTP security headers audit, axe-core accessibility audit, Lighthouse runner, perf metrics (FCP/LCP/CLS), visual regression, snapshot testing, network throttling, arbitrary-script gate. - **Browser internals.** Raw CDP, console + network event capture, localStorage / sessionStorage / IndexedDB, service worker / cache control, Shadow DOM piercing, multi-iframe, file upload / download, browser extension loaders. +- **Advanced WebDriverWrapper surface.** `set_driver(experimental_options=, extension_paths=, enable_bidi=)`, `attach_to_existing_browser`, native CDP shortcuts (`set_timezone` / `set_locale` / `set_device_metrics` / `set_user_agent` / `set_extra_http_headers` / `set_geolocation` / `set_network_conditions` / `block_urls` / `set_cache_disabled` / `set_download_directory`), Fetch interception primitives (`enable_fetch_interception` / `continue_request` / `fulfill_request` / `fail_request`), W3C BiDi listeners (`add_console_listener` / `add_js_error_listener`), `save_cookies` / `load_cookies` for session reuse, `save_full_page_screenshot`, `print_page` (PDF), `reload(ignore_cache)`, `bring_to_front`, `switch_to_window_by_url|title`, page metadata getters (`get_current_url` / `get_title` / `get_page_source` / `get_window_handles` / `new_window` / `close_window`). All exposed via `WR_*` aliases too. +- **Standalone CDP / BiDi modules.** Background `CDPEventListener` (WebSocket loop + sync `send` / `on` / context manager), `record_trace(driver, path)` for Chrome DevTools-loadable performance traces, and a `bidi_network` module wrapping `driver.network.add_request_handler` / `add_response_handler` / `add_auth_handler` for cross-browser request interception. - **Test data & fixtures.** Faker integration, factory pattern, testcontainers (Postgres / Redis / generic), per-environment `.env` loader with `${ENV.X}` placeholder expansion, CSV/JSON data-driven runner with `${ROW.x}`. - **Auth, API, DB.** OAuth2 / OIDC client-credentials / password / refresh-token flows with token cache, HTTP API testing commands with JSON assertions, SQLAlchemy-backed database validation. - **Integrations.** TCP socket server with token + TLS, BrowserStack / Sauce Labs / LambdaTest cloud Grid, Appium mobile, JIRA + TestRail, Slack / generic webhook notifier, GitHub Actions inline annotations, Locust load testing. @@ -687,7 +690,14 @@ WebRunner ships a [Model Context Protocol](https://modelcontextprotocol.io/) ser python -m je_web_runner.mcp_server ``` -The default tool list (19 tools) exposes: +The default tool list (22 tools) exposes: + +Live browser execution: +- `webrunner_run_actions` — execute any `WR_*` action list. Covers the full ~280-command surface including the advanced WebDriverWrapper additions: `WR_attach_to_existing_browser`, `WR_execute_cdp_cmd`, `WR_set_timezone` / `_locale` / `_device_metrics` / `_user_agent` / `_extra_http_headers` / `_geolocation` / `_network_conditions`, `WR_block_urls` / `_set_cache_disabled` / `_set_download_directory`, `WR_save_cookies` / `_load_cookies` / `_clear_origin_storage`, `WR_save_full_page_screenshot` / `_print_page`, `WR_reload(ignore_cache=True)`, `WR_bring_to_front`, `WR_switch_to_window_by_url|title`, `WR_new_window` / `_close_window`, page metadata getters, Fetch interception primitives, `WR_add_script_to_evaluate_on_new_document`, … +- `webrunner_run_action_files` — batch-run JSON files on disk +- `webrunner_list_commands` — discover the full `WR_*` surface + +Plus the utility tools below (no live browser): Action JSON authoring & linting: - `webrunner_lint_action`, `webrunner_score_action_locators`, `webrunner_locator_strength` @@ -835,6 +845,165 @@ Test orchestration: - **Test impact analysis** — `impact_analysis.build_index("./actions")` walks every action JSON file and projects locator names, URLs, template names, and `WR_*` commands into a reverse index; `affected_action_files(index, locators=["primary_cta"])` answers "which tests touch this?" so diff-aware shards can go beyond filename matching. +## Advanced WebDriverWrapper + +The Selenium wrapper is now composed via mixins under +`je_web_runner/webdriver/_wrapper_mixins/` (lifecycle / element / wait stay in +`webdriver_wrapper.py`; cookies / actions / media / navigation / scripting are +the mixin themes). External imports — `webdriver_wrapper_instance`, +`WebDriverWrapper`, the `_options_dict` / `_webdriver_dict` / +`_webdriver_manager_dict` patch targets — are unchanged. + +### Stealth / anti-bot launch + +```python +from je_web_runner import webdriver_wrapper_instance + +webdriver_wrapper_instance.set_driver( + "chrome", + options=[ + "--disable-blink-features=AutomationControlled", + f"--user-data-dir={profile_dir}", + ], + experimental_options={ + "excludeSwitches": ["enable-automation"], + "useAutomationExtension": False, + }, + extension_paths=["/path/to/extension.crx"], # optional + enable_bidi=True, # for add_console_listener etc. +) +webdriver_wrapper_instance.add_script_to_evaluate_on_new_document( + "Object.defineProperty(navigator, 'webdriver', {get: () => undefined});" +) +``` + +### Attach to a manually started Chrome (session reuse) + +```python +# Step 1 — user starts Chrome themselves: +# chrome.exe --remote-debugging-port=9222 --user-data-dir="C:/temp/profile" +webdriver_wrapper_instance.attach_to_existing_browser("127.0.0.1:9222") +``` + +### CDP shortcuts (emulation, network, downloads) + +```python +w = webdriver_wrapper_instance +w.set_timezone("Asia/Tokyo") +w.set_locale("ja-JP") +w.set_device_metrics(390, 844, device_scale_factor=3, mobile=True) +w.set_user_agent("Mozilla/5.0 (custom)") +w.set_extra_http_headers({"X-Test-Run": "ci-123"}) +w.set_geolocation(35.68, 139.69, accuracy=50) +w.set_network_conditions(offline=False, latency=200, + download_throughput=50_000, upload_throughput=10_000) +w.block_urls(["*.doubleclick.net/*", "*.googletagmanager.com/*"]) +w.set_cache_disabled(True) +w.set_download_directory("./downloads") +w.clear_origin_storage("https://example.com") # cookies + localStorage + IDB + cache +``` + +### Session persistence + +```python +w.to_url("https://example.com/") +# … log in, etc. … +w.save_cookies("./cookies.json") + +# Later (after browser restart): +w.to_url("https://example.com/") +added = w.load_cookies("./cookies.json") # → number of cookies applied +``` + +### Fetch interception primitives + +```python +w.enable_fetch_interception(patterns=["*/api/*"]) +# In a Fetch.requestPaused event callback (subscribe via CDPEventListener): +w.fulfill_request(req_id, response_code=200, + body=b'{"ok": true}', + response_headers={"Content-Type": "application/json"}) +# Or: w.continue_request(req_id, url=rewritten_url) +# Or: w.fail_request(req_id, error_reason="AccessDenied") +``` + +### Page metadata + multi-tab navigation + +```python +w.get_current_url(); w.get_title(); w.get_page_source() +w.get_window_handles(); w.get_current_window_handle() +w.new_window("tab") +w.switch_to_window_by_url("checkout") # restores original if no match +w.close_window() # vs quit() which terminates the driver +w.reload(ignore_cache=True) # CDP Page.reload — Ctrl+Shift+R equivalent +w.bring_to_front() +w.save_full_page_screenshot("./shot.png") # full page, beyond viewport +w.print_page("./page.pdf") +``` + +### W3C BiDi listeners (Selenium 4.16+) + +```python +webdriver_wrapper_instance.set_driver("chrome", enable_bidi=True) +sub_id = webdriver_wrapper_instance.add_console_listener( + lambda entry: print(entry.text) +) +err_id = webdriver_wrapper_instance.add_js_error_listener( + lambda err: print("page exception:", err) +) +# …later… +webdriver_wrapper_instance.remove_console_listener(sub_id) +webdriver_wrapper_instance.remove_js_error_listener(err_id) +``` + +### Background CDP event loop (independent module) + +`CDPEventListener` opens its own CDP WebSocket on a worker thread so commands +and events share the same target session — required because Selenium's +`execute_cdp_cmd` cannot subscribe to events. + +```python +from je_web_runner import CDPEventListener + +with CDPEventListener.from_driver(driver) as listener: + listener.on("Fetch.requestPaused", handle_paused) + listener.send("Fetch.enable", {"patterns": [{"urlPattern": "*"}]}) + # … drive the browser … +``` + +Requires `pip install websocket-client` (lazy-loaded; a clear `CDPEventLoopError` +is raised if missing). + +### Performance tracing + +```python +from je_web_runner import record_trace + +record_trace( + driver, "perf.json", + categories=["devtools.timeline", "loading"], + duration=10.0, +) +# Open perf.json in chrome://tracing or DevTools "Performance". +``` + +### Cross-browser BiDi network (Selenium 4.16+, Firefox-compatible) + +```python +from je_web_runner import ( + bidi_add_request_handler, + bidi_add_response_handler, + bidi_clear_network_handlers, +) + +sub = bidi_add_request_handler(driver, lambda req: print(req.url)) +bidi_clear_network_handlers(driver) +``` + +Every method above is also reachable from action JSON via `WR_*` aliases +(`WR_set_timezone`, `WR_save_cookies`, `WR_enable_fetch_interception`, …) so +the same surface drives the MCP server too. + ## Browser Internals ```python diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index ce001a2..388f1bb 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -300,6 +300,93 @@ wrapper.switch("default_content") wrapper.get_log("browser") ``` +#### 高级 API(mixin 模块) + +`WebDriverWrapper` 现以 mixin 组合,主题分散在 +`je_web_runner/webdriver/_wrapper_mixins/`(cookies / actions / media / +navigation / scripting);对外 import 不变。以下 API 同时都有对应的 +`WR_*` 别名,可在 action JSON / MCP server 直接调用。 + +**启动参数(stealth / extension / BiDi):** + +```python +webdriver_wrapper_instance.set_driver( + "chrome", + options=["--disable-blink-features=AutomationControlled"], + experimental_options={ + "excludeSwitches": ["enable-automation"], + "useAutomationExtension": False, + }, + extension_paths=["/path/to/extension.crx"], + enable_bidi=True, # 开启 W3C BiDi 事件支持 +) +# 连接到用户手动启动的 Chrome (--remote-debugging-port=9222): +webdriver_wrapper_instance.attach_to_existing_browser("127.0.0.1:9222") +``` + +**CDP / Fetch / BiDi:** + +```python +w = webdriver_wrapper_instance +w.execute_cdp_cmd("Page.bringToFront") +w.add_script_to_evaluate_on_new_document("/* stealth JS */") +w.set_timezone("Asia/Tokyo"); w.set_locale("ja-JP") +w.set_device_metrics(390, 844, device_scale_factor=3, mobile=True) +w.set_user_agent("Mozilla/5.0 (custom)") +w.set_extra_http_headers({"X-Run": "ci-123"}) +w.set_geolocation(35.68, 139.69) +w.set_network_conditions(offline=False, latency=200, + download_throughput=50_000, upload_throughput=10_000) +w.block_urls(["*.doubleclick.net/*"]); w.set_cache_disabled(True) +w.set_download_directory("./downloads") +w.clear_origin_storage("https://example.com") + +# CDP Fetch 拦截(需配合 CDPEventListener 才能实际接收事件): +w.enable_fetch_interception(patterns=["*/api/*"]) +# 在 Fetch.requestPaused callback 中: +# w.fulfill_request(rid, 200, body=b'{"ok":true}', +# response_headers={"Content-Type": "application/json"}) +# w.continue_request(rid, url=rewritten) +# w.fail_request(rid, "AccessDenied") + +# Selenium 4.16+ BiDi listener: +sub = w.add_console_listener(lambda msg: print(msg.text)) +err = w.add_js_error_listener(lambda e: print("page exception:", e)) +w.remove_console_listener(sub); w.remove_js_error_listener(err) +``` + +**页面 metadata / session 重用 / 截图:** + +```python +w.get_current_url(); w.get_title(); w.get_page_source() +w.get_window_handles(); w.new_window("tab"); w.close_window() +w.switch_to_window_by_url("checkout"); w.switch_to_window_by_title("结账") +w.reload(ignore_cache=True) +w.save_cookies("./cookies.json"); w.load_cookies("./cookies.json") +w.save_full_page_screenshot("./shot.png") # 全页截图(含可视范围外) +w.print_page("./page.pdf") +``` + +**独立模块(CDP 事件循环 / 跨浏览器 BiDi network / performance trace):** + +```python +from je_web_runner import CDPEventListener, record_trace, bidi_add_request_handler + +# 后台 CDP WebSocket,命令 + 事件共享同一 session +with CDPEventListener.from_driver(driver) as listener: + listener.on("Fetch.requestPaused", handle_paused) + listener.send("Fetch.enable", {"patterns": [{"urlPattern": "*"}]}) + +# 录制可载入 Chrome DevTools 的 performance trace +record_trace(driver, "perf.json", + categories=["devtools.timeline", "loading"], duration=10.0) + +# W3C BiDi network(跨浏览器,Selenium 4.16+,需 enable_bidi=True 启动) +sub_id = bidi_add_request_handler(driver, lambda req: print(req.url)) +``` + +`CDPEventListener` 需 `pip install websocket-client`(lazy-import;缺包会抛 `CDPEventLoopError`)。 + ### 网页元素包装器 `WebElementWrapper` 提供与已定位元素交互的方法。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index c9f74bd..4fe1fd3 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -300,6 +300,93 @@ wrapper.switch("default_content") wrapper.get_log("browser") ``` +#### 進階 API(mixin 模組) + +`WebDriverWrapper` 本體現以 mixin 組合,主題分散在 +`je_web_runner/webdriver/_wrapper_mixins/`(cookies / actions / media / +navigation / scripting);對外 import 不變。以下 API 都同時有對應的 +`WR_*` 別名,可在 action JSON / MCP server 直接呼叫。 + +**啟動參數(stealth / extension / BiDi):** + +```python +webdriver_wrapper_instance.set_driver( + "chrome", + options=["--disable-blink-features=AutomationControlled"], + experimental_options={ + "excludeSwitches": ["enable-automation"], + "useAutomationExtension": False, + }, + extension_paths=["/path/to/extension.crx"], + enable_bidi=True, # 開啟 W3C BiDi 事件支援 +) +# 連到使用者手動啟動的 Chrome (--remote-debugging-port=9222): +webdriver_wrapper_instance.attach_to_existing_browser("127.0.0.1:9222") +``` + +**CDP / Fetch / BiDi:** + +```python +w = webdriver_wrapper_instance +w.execute_cdp_cmd("Page.bringToFront") +w.add_script_to_evaluate_on_new_document("/* stealth JS */") +w.set_timezone("Asia/Tokyo"); w.set_locale("ja-JP") +w.set_device_metrics(390, 844, device_scale_factor=3, mobile=True) +w.set_user_agent("Mozilla/5.0 (custom)") +w.set_extra_http_headers({"X-Run": "ci-123"}) +w.set_geolocation(35.68, 139.69) +w.set_network_conditions(offline=False, latency=200, + download_throughput=50_000, upload_throughput=10_000) +w.block_urls(["*.doubleclick.net/*"]); w.set_cache_disabled(True) +w.set_download_directory("./downloads") +w.clear_origin_storage("https://example.com") + +# CDP Fetch 攔截(要實際收事件請搭配 CDPEventListener): +w.enable_fetch_interception(patterns=["*/api/*"]) +# 於 Fetch.requestPaused callback 中: +# w.fulfill_request(rid, 200, body=b'{"ok":true}', +# response_headers={"Content-Type": "application/json"}) +# w.continue_request(rid, url=rewritten) +# w.fail_request(rid, "AccessDenied") + +# Selenium 4.16+ BiDi listener: +sub = w.add_console_listener(lambda msg: print(msg.text)) +err = w.add_js_error_listener(lambda e: print("page exception:", e)) +w.remove_console_listener(sub); w.remove_js_error_listener(err) +``` + +**頁面 metadata / session reuse / 截圖:** + +```python +w.get_current_url(); w.get_title(); w.get_page_source() +w.get_window_handles(); w.new_window("tab"); w.close_window() +w.switch_to_window_by_url("checkout"); w.switch_to_window_by_title("結帳") +w.reload(ignore_cache=True) +w.save_cookies("./cookies.json"); w.load_cookies("./cookies.json") +w.save_full_page_screenshot("./shot.png") # 全頁截圖 (含可視範圍外) +w.print_page("./page.pdf") +``` + +**獨立模組(CDP 事件迴圈 / 跨瀏覽器 BiDi network / performance trace):** + +```python +from je_web_runner import CDPEventListener, record_trace, bidi_add_request_handler + +# 背景 CDP WebSocket,命令 + 事件共用同一 session +with CDPEventListener.from_driver(driver) as listener: + listener.on("Fetch.requestPaused", handle_paused) + listener.send("Fetch.enable", {"patterns": [{"urlPattern": "*"}]}) + +# 錄製 Chrome DevTools 可載入的 performance trace +record_trace(driver, "perf.json", + categories=["devtools.timeline", "loading"], duration=10.0) + +# W3C BiDi network (跨瀏覽器,Selenium 4.16+,需 enable_bidi=True 啟動) +sub_id = bidi_add_request_handler(driver, lambda req: print(req.url)) +``` + +`CDPEventListener` 需 `pip install websocket-client`(lazy-import;缺套件會丟 `CDPEventLoopError`)。 + ### 網頁元素包裝器 `WebElementWrapper` 提供與已定位元素互動的方法。 diff --git a/docs/source/Eng/doc/webdriver_wrapper/webdriver_wrapper_doc.rst b/docs/source/Eng/doc/webdriver_wrapper/webdriver_wrapper_doc.rst index fa2c61b..fab762b 100644 --- a/docs/source/Eng/doc/webdriver_wrapper/webdriver_wrapper_doc.rst +++ b/docs/source/Eng/doc/webdriver_wrapper/webdriver_wrapper_doc.rst @@ -196,3 +196,244 @@ WebDriver Validation .. code-block:: python wrapper.check_current_webdriver({"name": "chrome"}) + +Advanced Launch Options +----------------------- + +``set_driver`` accepts three optional parameters beyond CLI args: + +.. code-block:: python + + wrapper.set_driver( + "chrome", + options=["--disable-blink-features=AutomationControlled"], + experimental_options={ # Chromium-only + "excludeSwitches": ["enable-automation"], + "useAutomationExtension": False, + "prefs": {"download.default_directory": "/tmp"}, + }, + extension_paths=["/path/to/extension.crx"], # add_extension + enable_bidi=True, # webSocketUrl capability + ) + +Attaching to an already-running browser started with +``--remote-debugging-port=9222``: + +.. code-block:: python + + wrapper.attach_to_existing_browser("127.0.0.1:9222") + +Page / Window Metadata +---------------------- + +.. code-block:: python + + wrapper.get_current_url() # → str + wrapper.get_title() + wrapper.get_page_source() + wrapper.get_window_handles() # → list[str] + wrapper.get_current_window_handle() + wrapper.new_window("tab") # or "window" + wrapper.close_window() # closes current tab; quit() ends driver + +Substring-based tab switching (restores original tab on no match): + +.. code-block:: python + + wrapper.switch_to_window_by_url("checkout") + wrapper.switch_to_window_by_title("Order") + +Page Reload / Scroll / Foreground +--------------------------------- + +.. code-block:: python + + wrapper.reload(ignore_cache=False) # ignore_cache=True → CDP Page.reload (Ctrl+Shift+R) + wrapper.scroll_to_element(element) # JS scrollIntoView({block:'center'}) + wrapper.scroll_to_top(); wrapper.scroll_to_bottom() + wrapper.bring_to_front() # CDP Page.bringToFront + +Screenshots & PDF +----------------- + +.. code-block:: python + + wrapper.save_screenshot("./shot.png") # → bool + wrapper.save_full_page_screenshot("./full.png") # CDP captureBeyondViewport + wrapper.print_page("./page.pdf") # Selenium 4 print_page + wrapper.get_screenshot_as_png() # → bytes (existing) + wrapper.get_screenshot_as_base64() # → str (existing) + +Session Persistence +------------------- + +.. code-block:: python + + wrapper.to_url("https://example.com/") + # … log in … + wrapper.save_cookies("./cookies.json") # → bool + + # later, after restart: + wrapper.to_url("https://example.com/") + added = wrapper.load_cookies("./cookies.json") # → int (count applied) + + wrapper.clear_origin_storage("https://example.com") # cookies + localStorage + IDB + cache + +CDP Shortcuts (Chromium only) +----------------------------- + +Direct command: + +.. code-block:: python + + wrapper.execute_cdp_cmd("Page.bringToFront") + wrapper.execute_cdp_cmd("Page.captureScreenshot", {"format": "png"}) + +Stealth / fingerprint overrides: + +.. code-block:: python + + wrapper.add_script_to_evaluate_on_new_document( + "Object.defineProperty(navigator, 'webdriver', {get:()=>undefined});" + ) + wrapper.set_user_agent("Mozilla/5.0 (custom)") + wrapper.set_extra_http_headers({"X-Run": "ci-123"}) + wrapper.set_geolocation(35.68, 139.69, accuracy=50) + wrapper.clear_geolocation_override() + +Emulation: + +.. code-block:: python + + wrapper.set_timezone("Asia/Tokyo") + wrapper.set_locale("ja-JP") + wrapper.set_device_metrics(390, 844, device_scale_factor=3, mobile=True) + wrapper.clear_device_metrics() + +Network: + +.. code-block:: python + + wrapper.set_network_conditions( + offline=False, latency=200, + download_throughput=50_000, upload_throughput=10_000, + ) + wrapper.block_urls(["*.doubleclick.net/*", "*.googletagmanager.com/*"]) + wrapper.unblock_urls() + wrapper.set_cache_disabled(True) + +Downloads (required for headless): + +.. code-block:: python + + wrapper.set_download_directory("./downloads") + +CDP Fetch Interception +---------------------- + +Thin wrappers around ``Fetch.*`` CDP commands. To receive +``Fetch.requestPaused`` events you must subscribe via ``CDPEventListener`` +(or Selenium's trio-based devtools listener) on your own: + +.. code-block:: python + + wrapper.enable_fetch_interception(patterns=["*/api/*"]) + # In your event handler: + wrapper.continue_request(req_id, url=rewritten_url, method="POST", + post_data="...", headers={"X-Test": "1"}) + wrapper.fulfill_request(req_id, response_code=200, + body=b'{"ok": true}', + response_headers={"Content-Type": "application/json"}) + wrapper.fail_request(req_id, error_reason="AccessDenied") + wrapper.disable_fetch_interception() + +W3C BiDi Event Listeners +------------------------ + +Selenium 4.16+ required; launch with ``enable_bidi=True``: + +.. code-block:: python + + wrapper.set_driver("chrome", enable_bidi=True) + + sub = wrapper.add_console_listener(lambda entry: print(entry.text)) + err = wrapper.add_js_error_listener(lambda e: print("exception:", e)) + wrapper.remove_console_listener(sub) + wrapper.remove_js_error_listener(err) + +A ``WebRunnerException`` with a clear instruction is raised when BiDi is +unavailable. + +Standalone CDP / BiDi Modules +----------------------------- + +``CDPEventListener`` (in ``je_web_runner.utils.cdp.event_loop``) opens a +background CDP WebSocket so commands and events share one target session: + +.. code-block:: python + + from je_web_runner import CDPEventListener + + with CDPEventListener.from_driver(driver) as listener: + listener.on("Fetch.requestPaused", on_paused) + listener.send("Fetch.enable", {"patterns": [{"urlPattern": "*"}]}) + # …drive the browser… + +Requires ``pip install websocket-client``; raises ``CDPEventLoopError`` if missing. + +Performance tracing: + +.. code-block:: python + + from je_web_runner import record_trace + + record_trace( + driver, "perf.json", + categories=["devtools.timeline", "loading"], + duration=10.0, + ) + # Open perf.json in chrome://tracing or DevTools "Performance". + +Cross-browser BiDi network (Chrome / Edge / Firefox): + +.. code-block:: python + + from je_web_runner import ( + bidi_add_request_handler, + bidi_add_response_handler, + bidi_add_auth_handler, + bidi_clear_network_handlers, + ) + + sub = bidi_add_request_handler(driver, lambda req: print(req.url)) + bidi_clear_network_handlers(driver) + +Action JSON Aliases +------------------- + +Every method above is also reachable from action JSON via a ``WR_*`` alias — +e.g. ``WR_set_timezone``, ``WR_save_cookies``, ``WR_enable_fetch_interception``, +``WR_save_full_page_screenshot``, ``WR_attach_to_existing_browser`` — so the +MCP server's ``webrunner_run_actions`` tool drives them too. + +Internal Mixin Layout +--------------------- + +``WebDriverWrapper`` is composed via mixins under +``je_web_runner/webdriver/_wrapper_mixins/`` to keep each file under the +750-line project limit: + +* ``_scripting_mixin.py`` — ``execute``, ``execute_script``, + ``execute_async_script``, ``execute_cdp_cmd``, all CDP shortcuts, Fetch + primitives, BiDi listeners +* ``_navigation_mixin.py`` — navigation, scroll, ``switch``, window/tab + management, window geometry +* ``_cookie_mixin.py`` — cookies + ``save_cookies`` / ``load_cookies`` / + ``clear_origin_storage`` +* ``_actions_mixin.py`` — ActionChains +* ``_media_mixin.py`` — screenshots, ``print_page``, ``get_log`` + +The composed class, lifecycle (``set_driver``, ``attach_to_existing_browser``, +``quit``), element finding, and waits stay in ``webdriver_wrapper.py``. +Public imports (``webdriver_wrapper_instance``, ``WebDriverWrapper``) are +unchanged. diff --git a/docs/source/Zh/doc/webdriver_wrapper/webdriver_wrapper_doc.rst b/docs/source/Zh/doc/webdriver_wrapper/webdriver_wrapper_doc.rst index 0e49c96..827d0b2 100644 --- a/docs/source/Zh/doc/webdriver_wrapper/webdriver_wrapper_doc.rst +++ b/docs/source/Zh/doc/webdriver_wrapper/webdriver_wrapper_doc.rst @@ -190,3 +190,218 @@ WebDriver 驗證 .. code-block:: python wrapper.check_current_webdriver({"name": "chrome"}) + +進階啟動參數 +------------ + +``set_driver`` 除了 CLI 參數外還支援三個選用參數: + +.. code-block:: python + + wrapper.set_driver( + "chrome", + options=["--disable-blink-features=AutomationControlled"], + experimental_options={ # 僅 Chromium 系 + "excludeSwitches": ["enable-automation"], + "useAutomationExtension": False, + "prefs": {"download.default_directory": "/tmp"}, + }, + extension_paths=["/path/to/extension.crx"], # add_extension + enable_bidi=True, # webSocketUrl capability + ) + +附加到已啟動的瀏覽器(以 ``--remote-debugging-port=9222`` 開啟): + +.. code-block:: python + + wrapper.attach_to_existing_browser("127.0.0.1:9222") + +頁面 / 視窗 metadata +-------------------- + +.. code-block:: python + + wrapper.get_current_url() # → str + wrapper.get_title() + wrapper.get_page_source() + wrapper.get_window_handles() # → list[str] + wrapper.get_current_window_handle() + wrapper.new_window("tab") # 或 "window" + wrapper.close_window() # 僅關當前 tab;要結束整個 driver 用 quit() + +依子字串切換 tab(找不到時自動還原原視窗): + +.. code-block:: python + + wrapper.switch_to_window_by_url("checkout") + wrapper.switch_to_window_by_title("結帳") + +重整 / 捲動 / 視窗置頂 +---------------------- + +.. code-block:: python + + wrapper.reload(ignore_cache=False) # ignore_cache=True → CDP Page.reload (Ctrl+Shift+R) + wrapper.scroll_to_element(element) # JS scrollIntoView({block:'center'}) + wrapper.scroll_to_top(); wrapper.scroll_to_bottom() + wrapper.bring_to_front() # CDP Page.bringToFront + +截圖與 PDF +---------- + +.. code-block:: python + + wrapper.save_screenshot("./shot.png") # → bool + wrapper.save_full_page_screenshot("./full.png") # CDP captureBeyondViewport + wrapper.print_page("./page.pdf") # Selenium 4 print_page + wrapper.get_screenshot_as_png() # → bytes (沿用) + wrapper.get_screenshot_as_base64() # → str (沿用) + +Session 持久化 +-------------- + +.. code-block:: python + + wrapper.to_url("https://example.com/") + # … 完成登入 … + wrapper.save_cookies("./cookies.json") # → bool + + # 重啟瀏覽器後: + wrapper.to_url("https://example.com/") + added = wrapper.load_cookies("./cookies.json") # → int (成功套用的數量) + + wrapper.clear_origin_storage("https://example.com") # cookies + localStorage + IDB + cache + +CDP 便利方法(僅 Chromium 系) +------------------------------ + +.. code-block:: python + + wrapper.execute_cdp_cmd("Page.bringToFront") + wrapper.add_script_to_evaluate_on_new_document( + "Object.defineProperty(navigator, 'webdriver', {get:()=>undefined});" + ) + wrapper.set_user_agent("Mozilla/5.0 (custom)") + wrapper.set_extra_http_headers({"X-Run": "ci-123"}) + wrapper.set_geolocation(35.68, 139.69, accuracy=50) + wrapper.clear_geolocation_override() + + wrapper.set_timezone("Asia/Tokyo") + wrapper.set_locale("ja-JP") + wrapper.set_device_metrics(390, 844, device_scale_factor=3, mobile=True) + wrapper.clear_device_metrics() + + wrapper.set_network_conditions( + offline=False, latency=200, + download_throughput=50_000, upload_throughput=10_000, + ) + wrapper.block_urls(["*.doubleclick.net/*"]) + wrapper.unblock_urls() + wrapper.set_cache_disabled(True) + wrapper.set_download_directory("./downloads") # headless 下載必備 + +CDP Fetch 攔截 +-------------- + +``Fetch.*`` CDP 命令薄包裝。要實際收事件 (``Fetch.requestPaused``) 請使用 +``CDPEventListener``(或 Selenium trio-based devtools listener)自行訂閱: + +.. code-block:: python + + wrapper.enable_fetch_interception(patterns=["*/api/*"]) + # 在事件 callback 中: + wrapper.continue_request(req_id, url=rewritten, method="POST", + post_data="...", headers={"X-Test": "1"}) + wrapper.fulfill_request(req_id, response_code=200, + body=b'{"ok": true}', + response_headers={"Content-Type": "application/json"}) + wrapper.fail_request(req_id, error_reason="AccessDenied") + wrapper.disable_fetch_interception() + +W3C BiDi 事件 listener +---------------------- + +需 Selenium 4.16+ 且以 ``enable_bidi=True`` 啟動: + +.. code-block:: python + + wrapper.set_driver("chrome", enable_bidi=True) + sub = wrapper.add_console_listener(lambda entry: print(entry.text)) + err = wrapper.add_js_error_listener(lambda e: print("exception:", e)) + wrapper.remove_console_listener(sub) + wrapper.remove_js_error_listener(err) + +未啟用 BiDi 時呼叫會丟出 ``WebRunnerException`` 並附上修復指引。 + +獨立 CDP / BiDi 模組 +-------------------- + +``CDPEventListener`` (``je_web_runner.utils.cdp.event_loop``) 在背景開 +WebSocket,讓命令與事件共用同一個 target session: + +.. code-block:: python + + from je_web_runner import CDPEventListener + + with CDPEventListener.from_driver(driver) as listener: + listener.on("Fetch.requestPaused", on_paused) + listener.send("Fetch.enable", {"patterns": [{"urlPattern": "*"}]}) + +需 ``pip install websocket-client``;缺套件會丟 ``CDPEventLoopError``。 + +Performance tracing: + +.. code-block:: python + + from je_web_runner import record_trace + + record_trace( + driver, "perf.json", + categories=["devtools.timeline", "loading"], + duration=10.0, + ) + # 用 chrome://tracing 或 DevTools「Performance」面板開啟 perf.json。 + +跨瀏覽器 BiDi network (Chrome / Edge / Firefox): + +.. code-block:: python + + from je_web_runner import ( + bidi_add_request_handler, + bidi_add_response_handler, + bidi_add_auth_handler, + bidi_clear_network_handlers, + ) + + sub = bidi_add_request_handler(driver, lambda req: print(req.url)) + bidi_clear_network_handlers(driver) + +Action JSON 別名 +---------------- + +以上所有方法都有對應的 ``WR_*`` 別名(``WR_set_timezone`` / +``WR_save_cookies`` / ``WR_enable_fetch_interception`` / +``WR_save_full_page_screenshot`` / ``WR_attach_to_existing_browser`` ……), +所以 MCP server 的 ``webrunner_run_actions`` 工具也能直接驅動。 + +內部 mixin 拆分 +--------------- + +``WebDriverWrapper`` 現以 mixin 組合,位於 +``je_web_runner/webdriver/_wrapper_mixins/`` 之下,確保每個檔案不超過專案 +規範的 750 行: + +* ``_scripting_mixin.py`` — ``execute`` / ``execute_script`` / + ``execute_async_script`` / ``execute_cdp_cmd``、全部 CDP 便利方法、 + Fetch 原語、BiDi listener +* ``_navigation_mixin.py`` — 導航、scroll、``switch``、視窗 / tab 管理、 + 視窗大小位置 +* ``_cookie_mixin.py`` — cookies + ``save_cookies`` / ``load_cookies`` / + ``clear_origin_storage`` +* ``_actions_mixin.py`` — ActionChains 全集 +* ``_media_mixin.py`` — 截圖、``print_page``、``get_log`` + +組合後的類別本體、driver 生命週期 (``set_driver``、 +``attach_to_existing_browser``、``quit``)、元素查找、等待都留在 +``webdriver_wrapper.py``。對外的 import (``webdriver_wrapper_instance``、 +``WebDriverWrapper``) 完全不變。 diff --git a/je_web_runner/__init__.py b/je_web_runner/__init__.py index 330fe50..9a23bff 100644 --- a/je_web_runner/__init__.py +++ b/je_web_runner/__init__.py @@ -43,6 +43,22 @@ reset_playwright_cdp_sessions, selenium_cdp, ) +from je_web_runner.utils.cdp.event_loop import ( + CDPEventListener, + CDPEventLoopError, + resolve_cdp_ws_url, +) +from je_web_runner.utils.cdp.tracing import ( + TracingError, + record_trace, +) +from je_web_runner.utils.bidi.network import ( + BidiNetworkError, + add_auth_handler as bidi_add_auth_handler, + add_request_handler as bidi_add_request_handler, + add_response_handler as bidi_add_response_handler, + clear_network_handlers as bidi_clear_network_handlers, +) from je_web_runner.utils.api.http_client import ( HttpAssertionError, get_last_response, @@ -186,6 +202,11 @@ "AccessibilityError", "load_axe_source", "selenium_run_audit", "playwright_run_audit", "summarise_violations", "CDPError", "selenium_cdp", "playwright_cdp", "reset_playwright_cdp_sessions", + "CDPEventListener", "CDPEventLoopError", "resolve_cdp_ws_url", + "TracingError", "record_trace", + "BidiNetworkError", + "bidi_add_request_handler", "bidi_add_response_handler", + "bidi_add_auth_handler", "bidi_clear_network_handlers", "summarise_run", "notify_webhook", "notify_slack", "notify_run_summary", "NotifierError", "POMGeneratorError", "extract_elements_from_html", "generate_pom_class", diff --git a/je_web_runner/mcp_server/browser_tools.py b/je_web_runner/mcp_server/browser_tools.py index 11bfcff..6c1ce67 100644 --- a/je_web_runner/mcp_server/browser_tools.py +++ b/je_web_runner/mcp_server/browser_tools.py @@ -79,9 +79,23 @@ def build_browser_tools() -> List[Tool]: "Execute a WebRunner action list against a real browser. Each" " entry is [command_name, params] where params is a dict of" " kwargs or a list of positional args. Common commands:" - " WR_get_webdriver_manager, WR_to_url, WR_send_keys," - " WR_click_element, WR_pw_launch, WR_pw_to_url, WR_quit." - " Returns {'stdout': str, 'record': {action_repr: result}}." + " WR_get_webdriver_manager, WR_set_driver, WR_to_url," + " WR_send_keys, WR_click_element, WR_pw_launch, WR_pw_to_url," + " WR_quit. Advanced (mixin) commands include:" + " WR_attach_to_existing_browser, WR_execute_cdp_cmd," + " WR_add_script_to_evaluate_on_new_document," + " WR_set_timezone/locale/device_metrics/user_agent/" + "extra_http_headers/geolocation/network_conditions," + " WR_block_urls/set_cache_disabled/set_download_directory," + " WR_save_cookies/load_cookies/clear_origin_storage," + " WR_save_full_page_screenshot/print_page, WR_reload," + " WR_bring_to_front, WR_switch_to_window_by_url|title," + " WR_get_current_url/title/page_source/window_handles/" + "current_window_handle, WR_new_window/close_window," + " WR_enable_fetch_interception/disable_fetch_interception/" + "fetch_continue_request/fetch_fulfill_request/fetch_fail_request." + " Call webrunner_list_commands for the full list. Returns" + " {'stdout': str, 'record': {action_repr: result}}." ), input_schema={ "type": "object", diff --git a/je_web_runner/utils/bidi/__init__.py b/je_web_runner/utils/bidi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/je_web_runner/utils/bidi/network.py b/je_web_runner/utils/bidi/network.py new file mode 100644 index 0000000..607877b --- /dev/null +++ b/je_web_runner/utils/bidi/network.py @@ -0,0 +1,108 @@ +""" +W3C WebDriver BiDi Network 模組薄包裝 (Selenium 4.16+)。 +Thin wrappers around the W3C WebDriver BiDi Network surface (Selenium 4.16+). + +優於 CDP Fetch 之處 / Why prefer this over CDP Fetch +-------------------------------------------------- +* **跨瀏覽器** — BiDi 是 W3C 標準,Chromium 與 Firefox 都支援;CDP Fetch 僅 + Chromium 系。 + **Cross-browser** — BiDi is a W3C standard supported by Chromium and Firefox; + CDP Fetch is Chromium-only. +* **同步 callback 由 Selenium 內部派發** — 不必自行建 WebSocket 事件迴圈。 + **Sync callbacks dispatched by Selenium** — no need to build your own + WebSocket event loop. + +需求 / Requirements +------------------ +* Selenium 4.16+ (BiDi Network surface 從這版開始穩定) +* driver 啟動時需開 BiDi:``set_driver(..., enable_bidi=True)`` +* Firefox 也支援,但需要 ``geckodriver`` 0.34+ +""" +from __future__ import annotations + +from typing import Any, Callable + +from je_web_runner.utils.exception.exceptions import WebRunnerException +from je_web_runner.utils.logging.loggin_instance import web_runner_logger + + +class BidiNetworkError(WebRunnerException): + """Raised when BiDi network API is unavailable or fails.""" + + +def _resolve_network(driver) -> Any: + """取得並驗證 ``driver.network`` 服務可用 / Resolve and validate driver.network.""" + network = getattr(driver, "network", None) + if network is None: + raise BidiNetworkError( + "BiDi network unavailable: driver has no 'network' attribute. " + "Use set_driver(enable_bidi=True) and Selenium >= 4.16." + ) + return network + + +def add_request_handler(driver, callback: Callable[[Any], None]) -> int: + """ + 註冊「請求送出前」事件 handler,回傳訂閱 id。 + Register a handler for the BiDi ``network.beforeRequestSent`` event; + returns a subscription id. + + :param callback: 接收事件物件的可呼叫物 / callable taking an event object + """ + web_runner_logger.info("bidi network add_request_handler") + try: + return _resolve_network(driver).add_request_handler(callback) + except AttributeError as error: + raise BidiNetworkError( + "driver.network.add_request_handler missing; needs Selenium 4.23+" + ) from error + + +def add_response_handler(driver, callback: Callable[[Any], None]) -> int: + """ + 註冊「收到回應」事件 handler,回傳訂閱 id。 + Register a handler for the BiDi ``network.responseCompleted`` event; + returns a subscription id. + """ + web_runner_logger.info("bidi network add_response_handler") + try: + return _resolve_network(driver).add_response_handler(callback) + except AttributeError as error: + raise BidiNetworkError( + "driver.network.add_response_handler missing; needs Selenium 4.23+" + ) from error + + +def add_auth_handler(driver, callback: Callable[[Any], None]) -> int: + """ + 註冊 HTTP 401 / 407 認證挑戰 handler。 + Register a handler for the BiDi ``network.authRequired`` event. + """ + web_runner_logger.info("bidi network add_auth_handler") + try: + return _resolve_network(driver).add_auth_handler(callback) + except AttributeError as error: + raise BidiNetworkError( + "driver.network.add_auth_handler missing; needs Selenium 4.23+" + ) from error + + +def clear_network_handlers(driver) -> bool: + """ + 一次清除所有透過本模組註冊的 network handlers。 + Clear every network handler previously registered through this module. + """ + web_runner_logger.info("bidi network clear_network_handlers") + network = _resolve_network(driver) + clear = getattr(network, "clear_handlers", None) or getattr(network, "clear", None) + if clear is None: + raise BidiNetworkError( + "driver.network has neither clear_handlers nor clear; " + "your Selenium version may not expose handler clearing" + ) + try: + clear() + return True + except Exception as error: # noqa: BLE001 — surface a friendlier wrapper + web_runner_logger.error(f"clear_network_handlers failed: {error!r}") + return False diff --git a/je_web_runner/utils/cdp/event_loop.py b/je_web_runner/utils/cdp/event_loop.py new file mode 100644 index 0000000..12480b7 --- /dev/null +++ b/je_web_runner/utils/cdp/event_loop.py @@ -0,0 +1,272 @@ +""" +CDP 事件迴圈:背景執行緒從 Chrome DevTools Protocol WebSocket 接收事件, +並以同步 callback 派發給使用者。 +Background CDP event loop: reads events from the Chrome DevTools Protocol +WebSocket on a worker thread and dispatches them to user callbacks +synchronously. + +為什麼存在 / Why this module exists +---------------------------------- +``webdriver_wrapper_instance.execute_cdp_cmd`` 走 Selenium 內部 session, +是一次性命令,不會把後續 CDP 事件 (例如 ``Fetch.requestPaused`` / +``Tracing.dataCollected``) 推給使用者。要訂閱事件,唯一可靠的同步路徑是 +**獨立開一條 CDP WebSocket** — 本模組就是做這件事。 + +``execute_cdp_cmd`` issues one-shot CDP commands through Selenium's internal +session but never surfaces subsequent CDP events (e.g. +``Fetch.requestPaused`` / ``Tracing.dataCollected``) back to user code. +The only reliable synchronous path to subscribe to events is to open a +**separate CDP WebSocket connection** — that's what this module does. + +限制 / Limitations +----------------- +* 需要 ``websocket-client`` 套件 (``pip install websocket-client``)。 + Requires the ``websocket-client`` package. +* 僅 Chromium 系瀏覽器 (Chrome / Chromium / Edge)。 + Chromium-family browsers only. +* 為了讓事件與命令在同一個 CDP target session 上,命令請改用 + ``listener.send(method, params)`` 而不是 ``execute_cdp_cmd`` — 不同 session + 發出的命令不會把事件路由到本 listener。 + Use ``listener.send(method, params)`` to issue commands so they share the + same target session as event subscriptions; ``execute_cdp_cmd`` lives on a + different Selenium-managed session and its events won't reach this listener. +""" +from __future__ import annotations + +import json +import queue +import threading +from typing import Any, Callable, Dict, List, Optional + +from je_web_runner.utils.exception.exceptions import WebRunnerException +from je_web_runner.utils.logging.loggin_instance import web_runner_logger + + +class CDPEventLoopError(WebRunnerException): + """Raised when the CDP event loop cannot operate.""" + + +def _require_websocket_client(): + """惰性匯入 websocket-client;缺少時拋出友善錯誤。""" + try: + import websocket # type: ignore[import-not-found] + except ImportError as error: + raise CDPEventLoopError( + "websocket-client is required for CDPEventListener; " + "install with: pip install websocket-client" + ) from error + return websocket + + +def _query_page_ws_url(debugger_address: str) -> str: + """從 ``http://host:port/json`` 取出第一個 page target 的 WebSocket URL。""" + import urllib.request + + url = f"http://{debugger_address}/json" + with urllib.request.urlopen(url, timeout=5) as response: # noqa: S310 — local devtools endpoint + targets = json.loads(response.read()) + pages = [t for t in targets if t.get("type") == "page"] + if not pages: + raise CDPEventLoopError(f"no page targets at {debugger_address}") + ws_url = pages[0].get("webSocketDebuggerUrl") + if not ws_url: + raise CDPEventLoopError(f"no webSocketDebuggerUrl in {pages[0]!r}") + return ws_url + + +def resolve_cdp_ws_url(driver) -> str: + """ + 從 Selenium driver 解析出可用的 CDP WebSocket URL。 + Resolve a usable CDP WebSocket URL from a Selenium driver. + + 優先順序 / Order of preference: + 1. ``driver.capabilities['goog:chromeOptions'].debuggerAddress`` 對應的 page WS + 2. ``driver.capabilities['ms:edgeOptions'].debuggerAddress`` + 3. ``driver.capabilities['se:cdp']`` (Selenium 內建,通常為 browser-level) + """ + capabilities = getattr(driver, "capabilities", None) or {} + chrome_opts = capabilities.get("goog:chromeOptions") or {} + edge_opts = capabilities.get("ms:edgeOptions") or {} + debugger_address = ( + chrome_opts.get("debuggerAddress") or edge_opts.get("debuggerAddress") + ) + if debugger_address: + return _query_page_ws_url(debugger_address) + se_cdp = capabilities.get("se:cdp") + if se_cdp: + return se_cdp + raise CDPEventLoopError( + "cannot resolve CDP WebSocket URL from driver capabilities" + ) + + +class CDPEventListener: + """ + 背景 CDP 事件迴圈,搭配同一條 WebSocket 同時送命令 / 收事件。 + Background CDP event loop sharing one WebSocket for sending commands + and receiving events. + + 典型用法 / Typical usage:: + + with CDPEventListener.from_driver(driver) as listener: + listener.on("Fetch.requestPaused", on_paused) + listener.send("Fetch.enable", {"patterns": [{"urlPattern": "*"}]}) + # ... do work ... + """ + + def __init__(self, ws_url: str): + self._ws_url = ws_url + self._ws = None + self._thread: Optional[threading.Thread] = None + self._stop_flag = threading.Event() + self._handlers: Dict[str, List[Callable[[dict], None]]] = {} + self._handlers_lock = threading.Lock() + self._pending: Dict[int, queue.Queue] = {} + self._pending_lock = threading.Lock() + self._next_id_lock = threading.Lock() + self._next_id = 1 + + @classmethod + def from_driver(cls, driver) -> "CDPEventListener": + """從現有 driver 自動解析 WebSocket URL 並建立 listener。""" + return cls(resolve_cdp_ws_url(driver)) + + def __enter__(self) -> "CDPEventListener": + self.start() + return self + + def __exit__(self, *_args) -> None: + self.stop() + + def on(self, method: str, callback: Callable[[dict], None]) -> None: + """訂閱指定 CDP 事件 / Subscribe to a CDP event.""" + with self._handlers_lock: + self._handlers.setdefault(method, []).append(callback) + + def off(self, method: str, callback: Callable[[dict], None]) -> bool: + """取消單一訂閱 (回傳是否成功移除)。""" + with self._handlers_lock: + handlers = self._handlers.get(method) + if not handlers: + return False + try: + handlers.remove(callback) + return True + except ValueError: + return False + + def start(self) -> None: + """開啟 WebSocket 並啟動背景接收執行緒 (重複呼叫安全)。""" + if self._thread is not None and self._thread.is_alive(): + return + websocket = _require_websocket_client() + try: + self._ws = websocket.create_connection(self._ws_url, timeout=10) + except Exception as error: + raise CDPEventLoopError( + f"failed to open CDP WebSocket at {self._ws_url}: {error!r}" + ) from error + self._stop_flag.clear() + self._thread = threading.Thread( + target=self._run, name="CDPEventListener", daemon=True + ) + self._thread.start() + + def stop(self, join_timeout: float = 2.0) -> None: + """停止背景執行緒並關閉 WebSocket。""" + self._stop_flag.set() + ws = self._ws + self._ws = None + if ws is not None: + try: + ws.close() + except Exception: # noqa: BLE001 — best-effort + pass + thread = self._thread + self._thread = None + if thread is not None and thread.is_alive(): + thread.join(timeout=join_timeout) + + def send( + self, + method: str, + params: Optional[dict] = None, + timeout: float = 5.0, + ) -> Any: + """ + 發送 CDP 命令並等待回應 (與事件共用同一個 session)。 + Send a CDP command and block for the response (same session as events). + """ + if self._ws is None: + raise CDPEventLoopError("listener not started; call start() first") + msg_id = self._allocate_id() + reply_queue: queue.Queue = queue.Queue(maxsize=1) + with self._pending_lock: + self._pending[msg_id] = reply_queue + payload = json.dumps({"id": msg_id, "method": method, "params": params or {}}) + try: + self._ws.send(payload) + except Exception as error: + with self._pending_lock: + self._pending.pop(msg_id, None) + raise CDPEventLoopError(f"failed to send {method!r}: {error!r}") from error + try: + reply = reply_queue.get(timeout=timeout) + except queue.Empty as error: + with self._pending_lock: + self._pending.pop(msg_id, None) + raise CDPEventLoopError( + f"timeout ({timeout}s) waiting for reply to {method!r}" + ) from error + if "error" in reply: + raise CDPEventLoopError(f"CDP error for {method!r}: {reply['error']!r}") + return reply.get("result", {}) + + def _allocate_id(self) -> int: + with self._next_id_lock: + value = self._next_id + self._next_id += 1 + return value + + def _run(self) -> None: + """背景執行緒主迴圈:讀 WS → 解析 → 派發。""" + while not self._stop_flag.is_set(): + ws = self._ws + if ws is None: + break + try: + raw = ws.recv() + except Exception as error: # noqa: BLE001 + if not self._stop_flag.is_set(): + web_runner_logger.error(f"CDPEventListener recv failed: {error!r}") + break + if not raw: + continue + try: + message = json.loads(raw) + except Exception as error: # noqa: BLE001 + web_runner_logger.warning(f"CDPEventListener bad JSON: {error!r}") + continue + self._dispatch(message) + + def _dispatch(self, message: dict) -> None: + msg_id = message.get("id") + if msg_id is not None: + with self._pending_lock: + reply_queue = self._pending.pop(msg_id, None) + if reply_queue is not None: + reply_queue.put(message) + return + method = message.get("method") + if not method: + return + with self._handlers_lock: + handlers = list(self._handlers.get(method, [])) + params = message.get("params") or {} + for callback in handlers: + try: + callback(params) + except Exception as error: # noqa: BLE001 — never let a handler kill the loop + web_runner_logger.error( + f"CDPEventListener handler for {method!r} raised: {error!r}" + ) diff --git a/je_web_runner/utils/cdp/tracing.py b/je_web_runner/utils/cdp/tracing.py new file mode 100644 index 0000000..d607c81 --- /dev/null +++ b/je_web_runner/utils/cdp/tracing.py @@ -0,0 +1,95 @@ +""" +CDP performance tracing 包裝:在 Chromium 系瀏覽器上錄製效能追蹤, +存成可載入 Chrome DevTools 「Performance」面板的 JSON 檔。 +CDP performance tracing helper: record a perf trace on Chromium-family +browsers and save the JSON for loading into Chrome DevTools "Performance". + +為何要走獨立 WebSocket / Why a dedicated WebSocket +------------------------------------------------ +``Tracing.start`` 與 ``Tracing.dataCollected`` / ``Tracing.tracingComplete`` +事件必須在同一個 CDP target session 才能配對。Selenium 的 +``execute_cdp_cmd`` 走的是內部 session,事件不會傳出來;本模組改用 +``CDPEventListener`` 在同一條 WebSocket 上送命令、收事件,確保完整收齊。 +``Tracing.start`` and its corresponding ``Tracing.dataCollected`` / +``Tracing.tracingComplete`` events must share the same CDP target session. +Selenium's ``execute_cdp_cmd`` uses an internal session whose events never +reach user code; this module uses ``CDPEventListener`` so commands and +events live on the same WebSocket. +""" +from __future__ import annotations + +import json +import threading +import time +from typing import List, Optional + +from je_web_runner.utils.cdp.event_loop import CDPEventListener, CDPEventLoopError +from je_web_runner.utils.exception.exceptions import WebRunnerException +from je_web_runner.utils.logging.loggin_instance import web_runner_logger + + +class TracingError(WebRunnerException): + """Raised when a CDP tracing session fails.""" + + +def record_trace( + driver, + file_path: str, + categories: Optional[List[str]] = None, + duration: Optional[float] = None, + completion_timeout: float = 30.0, +) -> str: + """ + 錄製一段 CDP performance trace 並存成 JSON 檔。 + Record a CDP performance trace session and save it as JSON. + + :param driver: Selenium WebDriver (Chromium 系) + :param file_path: 輸出 JSON 路徑 / output JSON path + :param categories: 要追蹤的 CDP categories;``None`` 表示使用 CDP 預設 + Trace categories; ``None`` uses the CDP default set + :param duration: 自動 sleep 多少秒再 ``Tracing.end``;``None`` 表示**等到使用者 + 另行呼叫 ``Tracing.end``**,但本函式僅做同步版本,``None`` + 會用 ``completion_timeout`` 等事件直接結束。 + If set, sleep this many seconds before issuing ``Tracing.end``. + If ``None``, end immediately and rely on ``completion_timeout``. + :param completion_timeout: 等 ``Tracing.tracingComplete`` 的最長秒數 + Max seconds to wait for ``Tracing.tracingComplete`` + :return: 寫入的檔案路徑 / written file path + """ + web_runner_logger.info( + f"record_trace, file_path: {file_path}, categories: {categories}, " + f"duration: {duration}" + ) + events: List[dict] = [] + done = threading.Event() + + def _on_data(params: dict) -> None: + events.extend(params.get("value") or []) + + def _on_complete(_params: dict) -> None: + done.set() + + try: + with CDPEventListener.from_driver(driver) as listener: + listener.on("Tracing.dataCollected", _on_data) + listener.on("Tracing.tracingComplete", _on_complete) + + start_params: dict = {"transferMode": "ReportEvents"} + if categories: + start_params["categories"] = ",".join(categories) + listener.send("Tracing.start", start_params) + + if duration is not None and duration > 0: + time.sleep(duration) + + listener.send("Tracing.end") + if not done.wait(timeout=completion_timeout): + raise TracingError( + f"Tracing.tracingComplete not received within {completion_timeout}s" + ) + except CDPEventLoopError as error: + raise TracingError(f"CDP event loop failed: {error!r}") from error + + with open(file_path, "w", encoding="utf-8") as fh: + json.dump(events, fh, ensure_ascii=False) + return file_path diff --git a/je_web_runner/utils/executor/action_executor.py b/je_web_runner/utils/executor/action_executor.py index 2e875e5..18ec14b 100644 --- a/je_web_runner/utils/executor/action_executor.py +++ b/je_web_runner/utils/executor/action_executor.py @@ -28,6 +28,8 @@ "WR_pw_evaluate", "WR_cdp", "WR_pw_cdp", + "WR_execute_cdp_cmd", + "WR_add_script_to_evaluate_on_new_document", }) from je_web_runner.manager.webrunner_manager import web_runner @@ -267,9 +269,52 @@ def __init__(self): "WR_set_window_rect": webdriver_wrapper_instance.set_window_rect, "WR_get_screenshot_as_png": webdriver_wrapper_instance.get_screenshot_as_png, "WR_get_screenshot_as_base64": webdriver_wrapper_instance.get_screenshot_as_base64, + "WR_save_screenshot": webdriver_wrapper_instance.save_screenshot, + "WR_save_full_page_screenshot": webdriver_wrapper_instance.save_full_page_screenshot, + "WR_print_page": webdriver_wrapper_instance.print_page, "WR_get_log": webdriver_wrapper_instance.get_log, "WR_single_quit": webdriver_wrapper_instance.quit, + # webdriver wrapper — advanced (mixin additions) + "WR_attach_to_existing_browser": webdriver_wrapper_instance.attach_to_existing_browser, + "WR_execute_cdp_cmd": webdriver_wrapper_instance.execute_cdp_cmd, + "WR_reload": webdriver_wrapper_instance.reload, + "WR_bring_to_front": webdriver_wrapper_instance.bring_to_front, + "WR_scroll_to_top": webdriver_wrapper_instance.scroll_to_top, + "WR_scroll_to_bottom": webdriver_wrapper_instance.scroll_to_bottom, + "WR_switch_to_window_by_url": webdriver_wrapper_instance.switch_to_window_by_url, + "WR_switch_to_window_by_title": webdriver_wrapper_instance.switch_to_window_by_title, + "WR_get_current_url": webdriver_wrapper_instance.get_current_url, + "WR_get_title": webdriver_wrapper_instance.get_title, + "WR_get_page_source": webdriver_wrapper_instance.get_page_source, + "WR_get_window_handles": webdriver_wrapper_instance.get_window_handles, + "WR_get_current_window_handle": webdriver_wrapper_instance.get_current_window_handle, + "WR_new_window": webdriver_wrapper_instance.new_window, + "WR_close_window": webdriver_wrapper_instance.close_window, + "WR_save_cookies": webdriver_wrapper_instance.save_cookies, + "WR_load_cookies": webdriver_wrapper_instance.load_cookies, + "WR_clear_origin_storage": webdriver_wrapper_instance.clear_origin_storage, + "WR_add_script_to_evaluate_on_new_document": + webdriver_wrapper_instance.add_script_to_evaluate_on_new_document, + "WR_set_user_agent": webdriver_wrapper_instance.set_user_agent, + "WR_set_extra_http_headers": webdriver_wrapper_instance.set_extra_http_headers, + "WR_set_geolocation": webdriver_wrapper_instance.set_geolocation, + "WR_set_timezone": webdriver_wrapper_instance.set_timezone, + "WR_set_locale": webdriver_wrapper_instance.set_locale, + "WR_set_device_metrics": webdriver_wrapper_instance.set_device_metrics, + "WR_clear_device_metrics": webdriver_wrapper_instance.clear_device_metrics, + "WR_clear_geolocation_override": webdriver_wrapper_instance.clear_geolocation_override, + "WR_set_network_conditions": webdriver_wrapper_instance.set_network_conditions, + "WR_block_urls": webdriver_wrapper_instance.block_urls, + "WR_unblock_urls": webdriver_wrapper_instance.unblock_urls, + "WR_set_cache_disabled": webdriver_wrapper_instance.set_cache_disabled, + "WR_set_download_directory": webdriver_wrapper_instance.set_download_directory, + "WR_enable_fetch_interception": webdriver_wrapper_instance.enable_fetch_interception, + "WR_disable_fetch_interception": webdriver_wrapper_instance.disable_fetch_interception, + "WR_fetch_continue_request": webdriver_wrapper_instance.continue_request, + "WR_fetch_fulfill_request": webdriver_wrapper_instance.fulfill_request, + "WR_fetch_fail_request": webdriver_wrapper_instance.fail_request, + # web element "WR_element_submit": web_runner.webdriver_element.submit, "WR_element_clear": web_runner.webdriver_element.clear, diff --git a/je_web_runner/webdriver/_wrapper_mixins/__init__.py b/je_web_runner/webdriver/_wrapper_mixins/__init__.py new file mode 100644 index 0000000..61712a9 --- /dev/null +++ b/je_web_runner/webdriver/_wrapper_mixins/__init__.py @@ -0,0 +1,18 @@ +"""WebDriverWrapper mixin 子套件,依主題拆分以避免單檔過長。 + +Mixin submodules grouping WebDriverWrapper methods by theme so no single file +exceeds the project's 750-line limit. +""" +from je_web_runner.webdriver._wrapper_mixins._actions_mixin import _ActionsMixin +from je_web_runner.webdriver._wrapper_mixins._cookie_mixin import _CookieMixin +from je_web_runner.webdriver._wrapper_mixins._media_mixin import _MediaMixin +from je_web_runner.webdriver._wrapper_mixins._navigation_mixin import _NavigationMixin +from je_web_runner.webdriver._wrapper_mixins._scripting_mixin import _ScriptingMixin + +__all__ = [ + "_ActionsMixin", + "_CookieMixin", + "_MediaMixin", + "_NavigationMixin", + "_ScriptingMixin", +] diff --git a/je_web_runner/webdriver/_wrapper_mixins/_actions_mixin.py b/je_web_runner/webdriver/_wrapper_mixins/_actions_mixin.py new file mode 100644 index 0000000..3393e74 --- /dev/null +++ b/je_web_runner/webdriver/_wrapper_mixins/_actions_mixin.py @@ -0,0 +1,652 @@ +"""Selenium ActionChains 包裝 / Selenium ActionChains wrappers.""" +from __future__ import annotations + +from selenium.webdriver.remote.webelement import WebElement + +from je_web_runner.element.web_element_wrapper import web_element_wrapper +from je_web_runner.utils.exception.exceptions import WebRunnerException +from je_web_runner.utils.logging.loggin_instance import web_runner_logger +from je_web_runner.utils.test_object.test_object_record.test_object_record_class import test_object_record +from je_web_runner.utils.test_record.test_record_class import record_action_to_list + + +class _ActionsMixin: + """所有 ActionChains-based 滑鼠 / 鍵盤 / 拖曳動作。 + + All ActionChains-based mouse, keyboard, and drag-and-drop operations. + 依賴 ``self._action_chain`` (由主類別在 ``__init__`` / ``set_driver`` 設定)。 + Depends on ``self._action_chain`` set by the host class. + """ + + def move_to_element(self, target_element: WebElement) -> None: + """ + 將滑鼠移動到指定元素 + Move mouse to target web element + + :param target_element: 目標 WebElement / target web element + """ + web_runner_logger.info(f"WebDriverWrapper move_to_element, target_element: {target_element}") + param = locals() + try: + self._action_chain.move_to_element(target_element) + record_action_to_list("webdriver wrapper move_to_element", param, None) + except Exception as error: + web_runner_logger.error( + f"WebDriverWrapper move_to_element, target_element: {target_element}, failed: {repr(error)}" + ) + record_action_to_list("webdriver wrapper move_to_element", param, error) + + def move_to_element_with_test_object(self, element_name: str): + """ + 使用 TestObjectRecord 中的元素名稱,將滑鼠移動到指定元素 + Move mouse to target element using TestObjectRecord + + :param element_name: 測試物件名稱 / test object name + """ + web_runner_logger.info(f"WebDriverWrapper move_to_element_with_test_object, element_name: {element_name}") + param = locals() + try: + record = test_object_record.test_object_record_dict.get(element_name) + if record is None: + raise WebRunnerException(f"TestObject '{element_name}' not found") + element = self.current_webdriver.find_element(record.test_object_type, record.test_object_name) + self._action_chain.move_to_element(element) + record_action_to_list("webdriver wrapper move_to_element_with_test_object", param, None) + except Exception as error: + web_runner_logger.error( + f"WebDriverWrapper move_to_element_with_test_object, element_name: {element_name}, failed: {repr(error)}" + ) + record_action_to_list("webdriver wrapper move_to_element_with_test_object", param, error) + + def move_to_element_with_offset(self, target_element: WebElement, offset_x: int, offset_y: int) -> None: + """ + 將滑鼠移動到指定元素,並加上偏移量 + Move mouse to target element with offset + + :param target_element: 目標 WebElement / target web element + :param offset_x: X 軸偏移量 / offset on X axis + :param offset_y: Y 軸偏移量 / offset on Y axis + """ + web_runner_logger.info( + f"WebDriverWrapper move_to_element_with_offset, target_element: {target_element}, " + f"offset_x: {offset_x}, offset_y: {offset_y}" + ) + param = locals() + try: + self._action_chain.move_to_element_with_offset(target_element, offset_x, offset_y) + record_action_to_list("webdriver wrapper move_to_element_with_offset", param, None) + except Exception as error: + web_runner_logger.error( + f"WebDriverWrapper move_to_element_with_offset failed: {repr(error)}" + ) + record_action_to_list("webdriver wrapper move_to_element_with_offset", param, error) + + def move_to_element_with_offset_and_test_object(self, element_name: str, offset_x: int, offset_y: int) -> None: + """ + 使用 TestObjectRecord 中的元素名稱,將滑鼠移動到指定元素並加上偏移量 + Move mouse to target element with offset using TestObjectRecord + + :param element_name: 測試物件名稱 / test object name + :param offset_x: X 軸偏移量 / offset on X axis + :param offset_y: Y 軸偏移量 / offset on Y axis + """ + web_runner_logger.info( + f"WebDriverWrapper move_to_element_with_offset_and_test_object, element_name: {element_name}, " + f"offset_x: {offset_x}, offset_y: {offset_y}" + ) + param = locals() + try: + record = test_object_record.test_object_record_dict.get(element_name) + if record is None: + raise WebRunnerException(f"TestObject '{element_name}' not found") + element = self.current_webdriver.find_element(record.test_object_type, record.test_object_name) + self._action_chain.move_to_element_with_offset(element, offset_x, offset_y) + record_action_to_list("webdriver wrapper move_to_element_with_offset_and_test_object", param, None) + except Exception as error: + web_runner_logger.error( + f"WebDriverWrapper move_to_element_with_offset_and_test_object failed: {repr(error)}" + ) + record_action_to_list("webdriver wrapper move_to_element_with_offset_and_test_object", param, error) + + def drag_and_drop(self, web_element: WebElement, target_element: WebElement) -> None: + """ + 拖曳元素到另一個元素上並釋放 + Drag a web element to another target element and drop + + :param web_element: 要拖曳的元素 / element to drag + :param target_element: 目標元素 / target element to drop onto + """ + web_runner_logger.info( + f"WebDriverWrapper drag_and_drop, web_element: {web_element}, target_element: {target_element}" + ) + param = locals() + try: + self._action_chain.drag_and_drop(web_element, target_element) + record_action_to_list("webdriver wrapper drag_and_drop", param, None) + except Exception as error: + web_runner_logger.error( + f"WebDriverWrapper drag_and_drop failed: {repr(error)}" + ) + record_action_to_list("webdriver wrapper drag_and_drop", param, error) + + def drag_and_drop_with_test_object(self, element_name: str, target_element_name: str) -> None: + """ + 使用 TestObjectRecord 中的元素名稱,拖曳元素到另一個元素上 + Drag a web element to another target element using TestObjectRecord + + :param element_name: 要拖曳的元素名稱 / name of element to drag + :param target_element_name: 目標元素名稱 / name of target element + """ + web_runner_logger.info( + f"WebDriverWrapper drag_and_drop_with_test_object, element_name: {element_name}, " + f"target_element_name: {target_element_name}" + ) + param = locals() + try: + element_record = test_object_record.test_object_record_dict.get(element_name) + target_record = test_object_record.test_object_record_dict.get(target_element_name) + if element_record is None or target_record is None: + raise WebRunnerException(f"TestObject not found: {element_name} or {target_element_name}") + + element = self.current_webdriver.find_element(element_record.test_object_type, + element_record.test_object_name) + another_element = self.current_webdriver.find_element(target_record.test_object_type, + target_record.test_object_name) + + self._action_chain.drag_and_drop(element, another_element) + record_action_to_list("webdriver wrapper drag_and_drop_with_test_object", param, None) + except Exception as error: + web_runner_logger.error( + f"WebDriverWrapper drag_and_drop_with_test_object failed: {repr(error)}" + ) + record_action_to_list("webdriver wrapper drag_and_drop_with_test_object", param, error) + + def drag_and_drop_offset(self, web_element: WebElement, target_x: int, target_y: int) -> None: + """ + 拖曳元素到指定偏移位置 + Drag a web element to a position with offset + + :param web_element: 要拖曳的元素 / element to drag + :param target_x: X 軸偏移量 / offset on X axis + :param target_y: Y 軸偏移量 / offset on Y axis + """ + web_runner_logger.info( + f"WebDriverWrapper drag_and_drop_offset, web_element: {web_element}, " + f"target_x: {target_x}, target_y: {target_y}" + ) + param = locals() + try: + self._action_chain.drag_and_drop_by_offset(web_element, target_x, target_y) + record_action_to_list("webdriver wrapper drag_and_drop_offset", param, None) + except Exception as error: + web_runner_logger.error( + f"WebDriverWrapper drag_and_drop_offset failed: {repr(error)}" + ) + record_action_to_list("webdriver wrapper drag_and_drop_offset", param, error) + + def drag_and_drop_offset_with_test_object(self, element_name: str, offset_x: int, offset_y: int) -> None: + """ + 使用 TestObjectRecord 中的元素名稱,拖曳元素到指定偏移位置 + Drag a web element with offset using TestObjectRecord + + :param element_name: 測試物件名稱 / test object name + :param offset_x: X 軸偏移量 / offset on X axis + :param offset_y: Y 軸偏移量 / offset on Y axis + """ + web_runner_logger.info( + f"WebDriverWrapper drag_and_drop_offset_with_test_object, element_name: {element_name}, " + f"offset_x: {offset_x}, offset_y: {offset_y}" + ) + param = locals() + try: + record = test_object_record.test_object_record_dict.get(element_name) + if record is None: + raise WebRunnerException(f"TestObject not found: {element_name}") + + element = self.current_webdriver.find_element(record.test_object_type, record.test_object_name) + self._action_chain.drag_and_drop_by_offset(element, offset_x, offset_y) + record_action_to_list("webdriver wrapper drag_and_drop_offset_with_test_object", param, None) + except Exception as error: + web_runner_logger.error( + f"WebDriverWrapper drag_and_drop_offset_with_test_object failed: {repr(error)}" + ) + record_action_to_list("webdriver wrapper drag_and_drop_offset_with_test_object", param, error) + + def perform(self) -> None: + """ + 執行累積的 ActionChains 動作 + Perform all queued ActionChains actions. + + Selenium 的 ActionChains 是「先排隊、後一次執行」模型。 + ``WR_left_click_and_hold`` / ``WR_move_to_element`` / + ``WR_release`` / ``WR_press_key`` 等命令只是把動作排入佇列, + 必須最後呼叫 ``WR_perform`` 才會真的觸發;中途要清除請用 + ``WR_reset_actions``。對單純點擊或輸入請改用 + ``WR_element_click`` / ``WR_element_input`` 直接執行,免用 + ActionChains。 + + Selenium ActionChains is a queue-then-execute model. Commands like + ``WR_left_click_and_hold`` / ``WR_move_to_element`` / + ``WR_release`` / ``WR_press_key`` only enqueue the action; you must + call ``WR_perform`` at the end to actually fire them, and + ``WR_reset_actions`` to drop the queue mid-flow. For simple clicks + or text input prefer ``WR_element_click`` / ``WR_element_input``, + which run synchronously. + """ + web_runner_logger.info("WebDriverWrapper perform") + try: + self._action_chain.perform() + record_action_to_list("webdriver wrapper perform", None, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper perform failed: {repr(error)}") + record_action_to_list("webdriver wrapper perform", None, error) + + def reset_actions(self) -> None: + """ + 清除目前累積的 ActionChains 動作(搭配 ``WR_perform`` 使用) + Clear all queued ActionChains actions. + + Use this together with ``WR_perform`` when you want to abort an + ActionChains sequence partway through. See ``perform`` above for + the queue-then-execute model. + """ + web_runner_logger.info("WebDriverWrapper reset_actions") + try: + self._action_chain.reset_actions() + record_action_to_list("webdriver wrapper reset_actions", None, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper reset_actions failed: {repr(error)}") + record_action_to_list("webdriver wrapper reset_actions", None, error) + + def left_click(self, on_element: WebElement = None) -> None: + """ + 滑鼠左鍵點擊 (可指定元素或當前位置) + Left click mouse at current position or on a given element + + :param on_element: WebElement 或 None + """ + web_runner_logger.info(f"WebDriverWrapper left_click, on_element: {on_element}") + param = locals() + try: + self._action_chain.click(on_element) + record_action_to_list("webdriver wrapper left_click", param, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper left_click failed: {repr(error)}") + record_action_to_list("webdriver wrapper left_click", param, error) + + def left_click_with_test_object(self, element_name: str = None) -> None: + """ + 使用 TestObject 名稱找到元素並左鍵點擊 + Left click using a TestObject name + + :param element_name: 測試物件名稱 / test object name + """ + web_runner_logger.info(f"WebDriverWrapper left_click_with_test_object, element_name: {element_name}") + param = locals() + try: + if element_name is None: + self._action_chain.click(None) + else: + record = test_object_record.test_object_record_dict.get(element_name) + if record is None: + raise WebRunnerException(f"TestObject '{element_name}' not found") + element = self.current_webdriver.find_element(record.test_object_type, record.test_object_name) + self._action_chain.click(element) + record_action_to_list("webdriver wrapper left_click_with_test_object", param, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper left_click_with_test_object failed: {repr(error)}") + record_action_to_list("webdriver wrapper left_click_with_test_object", param, error) + + def left_click_and_hold(self, on_element: WebElement = None) -> None: + """ + 滑鼠左鍵按住 (可指定元素或當前位置) + Left click and hold mouse at current position or on a given element + """ + web_runner_logger.info(f"WebDriverWrapper left_click_and_hold, on_element: {on_element}") + param = locals() + try: + self._action_chain.click_and_hold(on_element) + record_action_to_list("webdriver wrapper left_click_and_hold", param, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper left_click_and_hold failed: {repr(error)}") + record_action_to_list("webdriver wrapper left_click_and_hold", param, error) + + def left_click_and_hold_with_test_object(self, element_name: str = None) -> None: + """ + 使用 TestObject 名稱找到元素並左鍵按住 + Left click and hold using a TestObject name + """ + web_runner_logger.info(f"WebDriverWrapper left_click_and_hold_with_test_object, element_name: {element_name}") + param = locals() + try: + if element_name is None: + self._action_chain.click_and_hold(None) + else: + record = test_object_record.test_object_record_dict.get(element_name) + if record is None: + raise WebRunnerException(f"TestObject '{element_name}' not found") + element = self.current_webdriver.find_element(record.test_object_type, record.test_object_name) + self._action_chain.click_and_hold(element) + record_action_to_list("webdriver wrapper left_click_and_hold_with_test_object", param, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper left_click_and_hold_with_test_object failed: {repr(error)}") + record_action_to_list("webdriver wrapper left_click_and_hold_with_test_object", param, error) + + def right_click(self, on_element: WebElement = None) -> None: + """ + 滑鼠右鍵點擊 (可指定元素或當前位置) + Right click mouse at current position or on a given element + """ + web_runner_logger.info(f"WebDriverWrapper right_click, on_element: {on_element}") + param = locals() + try: + self._action_chain.context_click(on_element) + record_action_to_list("webdriver wrapper right_click", param, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper right_click failed: {repr(error)}") + record_action_to_list("webdriver wrapper right_click", param, error) + + def right_click_with_test_object(self, element_name: str = None) -> None: + """ + 使用 TestObject 名稱找到元素並右鍵點擊 + Right click using a TestObject name + """ + web_runner_logger.info(f"WebDriverWrapper right_click_with_test_object, element_name: {element_name}") + param = locals() + try: + if element_name is None: + self._action_chain.context_click(None) + else: + record = test_object_record.test_object_record_dict.get(element_name) + if record is None: + raise WebRunnerException(f"TestObject '{element_name}' not found") + element = self.current_webdriver.find_element(record.test_object_type, record.test_object_name) + self._action_chain.context_click(element) + record_action_to_list("webdriver wrapper right_click_with_test_object", param, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper right_click_with_test_object failed: {repr(error)}") + record_action_to_list("webdriver wrapper right_click_with_test_object", param, error) + + def left_double_click(self, on_element: WebElement = None) -> None: + """ + 滑鼠左鍵雙擊 (可指定元素或當前位置) + Double left click mouse at current position or on a given element + + :param on_element: WebElement 或 None + """ + web_runner_logger.info(f"WebDriverWrapper left_double_click, on_element: {on_element}") + param = locals() + try: + self._action_chain.double_click(on_element) + record_action_to_list("webdriver wrapper left_double_click", param, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper left_double_click failed: {repr(error)}") + record_action_to_list("webdriver wrapper left_double_click", param, error) + + def left_double_click_with_test_object(self, element_name: str = None) -> None: + """ + 使用 TestObject 名稱找到元素並左鍵雙擊 + Double left click using a TestObject name + + :param element_name: 測試物件名稱 / test object name + """ + web_runner_logger.info(f"WebDriverWrapper left_double_click_with_test_object, element_name: {element_name}") + param = locals() + try: + if element_name is None: + self._action_chain.double_click(None) + else: + record = test_object_record.test_object_record_dict.get(element_name) + if record is None: + raise WebRunnerException(f"TestObject '{element_name}' not found") + web_element_wrapper.current_web_element = self.current_webdriver.find_element( + record.test_object_type, record.test_object_name + ) + self._action_chain.double_click(web_element_wrapper.current_web_element) + record_action_to_list("webdriver wrapper left_double_click_with_test_object", param, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper left_double_click_with_test_object failed: {repr(error)}") + record_action_to_list("webdriver wrapper left_double_click_with_test_object", param, error) + + def release(self, on_element: WebElement = None) -> None: + """ + 釋放滑鼠 (可指定元素或當前位置) + Release mouse button at current position or on a given element + """ + web_runner_logger.info(f"WebDriverWrapper release, on_element: {on_element}") + param = locals() + try: + self._action_chain.release(on_element) + record_action_to_list("webdriver wrapper release", param, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper release failed: {repr(error)}") + record_action_to_list("webdriver wrapper release", param, error) + + def release_with_test_object(self, element_name: str = None) -> None: + """ + 使用 TestObject 名稱找到元素並釋放滑鼠 + Release mouse button using a TestObject name + """ + web_runner_logger.info(f"WebDriverWrapper release_with_test_object, element_name: {element_name}") + param = locals() + try: + if element_name is None: + self._action_chain.release(None) + else: + record = test_object_record.test_object_record_dict.get(element_name) + if record is None: + raise WebRunnerException(f"TestObject '{element_name}' not found") + web_element_wrapper.current_web_element = self.current_webdriver.find_element( + record.test_object_type, record.test_object_name + ) + self._action_chain.release(web_element_wrapper.current_web_element) + record_action_to_list("webdriver wrapper release_with_test_object", param, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper release_with_test_object failed: {repr(error)}") + record_action_to_list("webdriver wrapper release_with_test_object", param, error) + + def press_key(self, keycode_on_key_class, on_element: WebElement = None) -> None: + """ + 按下鍵盤按鍵 (可指定元素或當前位置) + Press a key on keyboard, optionally on a given element + + :param keycode_on_key_class: 要按下的鍵 (來自 selenium.webdriver.common.keys.Keys) + key to press (from selenium.webdriver.common.keys.Keys) + :param on_element: WebElement 或 None + """ + web_runner_logger.info( + f"WebDriverWrapper press_key, keycode_on_key_class: {keycode_on_key_class}, on_element: {on_element}" + ) + param = locals() + try: + self._action_chain.key_down(keycode_on_key_class, on_element) + record_action_to_list("webdriver wrapper press_key", param, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper press_key failed: {repr(error)}") + record_action_to_list("webdriver wrapper press_key", param, error) + + def press_key_with_test_object(self, keycode_on_key_class, element_name: str = None) -> None: + """ + 使用 TestObject 名稱找到元素並按下鍵盤按鍵 + Press a key on keyboard using a TestObject name + + :param keycode_on_key_class: 要按下的鍵 (selenium Keys) + :param element_name: 測試物件名稱 / test object name + """ + web_runner_logger.info( + f"WebDriverWrapper press_key_with_test_object, keycode_on_key_class: {keycode_on_key_class}, element_name: {element_name}" + ) + param = locals() + try: + if element_name is None: + self._action_chain.key_down(keycode_on_key_class, None) + else: + record = test_object_record.test_object_record_dict.get(element_name) + if record is None: + raise WebRunnerException(f"TestObject '{element_name}' not found") + web_element_wrapper.current_web_element = self.current_webdriver.find_element( + record.test_object_type, record.test_object_name + ) + self._action_chain.key_down(keycode_on_key_class, web_element_wrapper.current_web_element) + record_action_to_list("webdriver wrapper press_key_with_test_object", param, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper press_key_with_test_object failed: {repr(error)}") + record_action_to_list("webdriver wrapper press_key_with_test_object", param, error) + + def release_key(self, keycode_on_key_class, on_element: WebElement = None) -> None: + """ + 釋放鍵盤按鍵 (可指定元素或當前位置) + Release a key on keyboard, optionally on a given element + + :param keycode_on_key_class: 要釋放的鍵 (selenium Keys) + :param on_element: WebElement 或 None + """ + web_runner_logger.info( + f"WebDriverWrapper release_key, keycode_on_key_class: {keycode_on_key_class}, on_element: {on_element}" + ) + param = locals() + try: + self._action_chain.key_up(keycode_on_key_class, on_element) + record_action_to_list("webdriver wrapper release_key", param, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper release_key failed: {repr(error)}") + record_action_to_list("webdriver wrapper release_key", param, error) + + def release_key_with_test_object(self, keycode_on_key_class, element_name: str = None) -> None: + """ + 使用 TestObject 名稱找到元素並釋放鍵盤按鍵 + Release a key on keyboard using a TestObject name + + :param keycode_on_key_class: 要釋放的鍵 (selenium Keys) + :param element_name: 測試物件名稱 / test object name + """ + web_runner_logger.info( + f"WebDriverWrapper release_key_with_test_object, keycode_on_key_class: {keycode_on_key_class}, element_name: {element_name}" + ) + param = locals() + try: + if element_name is None: + self._action_chain.key_up(keycode_on_key_class, None) + else: + record = test_object_record.test_object_record_dict.get(element_name) + if record is None: + raise WebRunnerException(f"TestObject '{element_name}' not found") + web_element_wrapper.current_web_element = self.current_webdriver.find_element( + record.test_object_type, record.test_object_name + ) + self._action_chain.key_up(keycode_on_key_class, web_element_wrapper.current_web_element) + record_action_to_list("webdriver wrapper release_key_with_test_object", param, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper release_key_with_test_object failed: {repr(error)}") + record_action_to_list("webdriver wrapper release_key_with_test_object", param, error) + + def move_by_offset(self, offset_x: int, offset_y: int) -> None: + """ + 滑鼠移動指定偏移量 + Move mouse by offset + + :param offset_x: X 軸偏移量 / offset on X axis + :param offset_y: Y 軸偏移量 / offset on Y axis + """ + web_runner_logger.info(f"WebDriverWrapper move_by_offset, offset_x: {offset_x}, offset_y: {offset_y}") + param = locals() + try: + self._action_chain.move_by_offset(offset_x, offset_y) + record_action_to_list("webdriver wrapper move_by_offset", param, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper move_by_offset failed: {repr(error)}") + record_action_to_list("webdriver wrapper move_by_offset", param, error) + + def pause(self, seconds: int) -> None: + """ + 暫停指定秒數 (注意:可能導致 Selenium 拋出例外) + Pause for a number of seconds (may cause Selenium exceptions) + + :param seconds: 暫停秒數 / seconds to pause + """ + web_runner_logger.info(f"WebDriverWrapper pause, seconds: {seconds}") + param = locals() + try: + self._action_chain.pause(seconds) + record_action_to_list("webdriver wrapper pause", param, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper pause failed: {repr(error)}") + record_action_to_list("webdriver wrapper pause", param, error) + + def send_keys(self, keys_to_send) -> None: + """ + 發送鍵盤按鍵 (按下並釋放) + Send (press and release) keyboard keys + + :param keys_to_send: 要發送的鍵 (可多個) / keys to send (can be multiple) + """ + web_runner_logger.info(f"WebDriverWrapper send_keys, keys_to_send: {keys_to_send}") + param = locals() + try: + self._action_chain.send_keys(*keys_to_send) + record_action_to_list("webdriver wrapper send_keys", param, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper send_keys failed: {repr(error)}") + record_action_to_list("webdriver wrapper send_keys", param, error) + + def send_keys_to_element(self, element: WebElement, keys_to_send) -> None: + """ + 發送鍵盤按鍵到指定元素 + Send keyboard keys to a given element + + :param element: 目標元素 / target element + :param keys_to_send: 要發送的鍵 / keys to send + """ + web_runner_logger.info( + f"WebDriverWrapper send_keys_to_element, element: {element}, keys_to_send: {keys_to_send}") + param = locals() + try: + self._action_chain.send_keys_to_element(element, keys_to_send) + record_action_to_list("webdriver wrapper send_keys_to_element", param, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper send_keys_to_element failed: {repr(error)}") + record_action_to_list("webdriver wrapper send_keys_to_element", param, error) + + def send_keys_to_element_with_test_object(self, element_name: str, keys_to_send) -> None: + """ + 使用 TestObject 名稱找到元素並發送鍵盤按鍵 + Send keyboard keys to an element using a TestObject name + + :param element_name: 測試物件名稱 / test object name + :param keys_to_send: 要發送的鍵 / keys to send + """ + web_runner_logger.info( + f"WebDriverWrapper send_keys_to_element_with_test_object, element_name: {element_name}, keys_to_send: {keys_to_send}" + ) + param = locals() + try: + record = test_object_record.test_object_record_dict.get(element_name) + if record is None: + raise WebRunnerException(f"TestObject '{element_name}' not found") + web_element_wrapper.current_web_element = self.current_webdriver.find_element( + record.test_object_type, record.test_object_name + ) + self._action_chain.send_keys_to_element(web_element_wrapper.current_web_element, *keys_to_send) + record_action_to_list("webdriver wrapper send_keys_to_element_with_test_object", param, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper send_keys_to_element_with_test_object failed: {repr(error)}") + record_action_to_list("webdriver wrapper send_keys_to_element_with_test_object", param, error) + + def scroll(self, scroll_x: int, scroll_y: int) -> None: + """ + 滾動頁面 + Scroll the page + + :param scroll_x: 滾動的 X 軸距離 / distance to scroll on X axis + :param scroll_y: 滾動的 Y 軸距離 / distance to scroll on Y axis + """ + web_runner_logger.info( + f"WebDriverWrapper scroll, scroll_x: {scroll_x}, scroll_y: {scroll_y}" + ) + param = locals() + try: + self._action_chain.scroll_by_amount(scroll_x, scroll_y) + record_action_to_list("webdriver wrapper scroll", param, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper scroll failed: {repr(error)}") + record_action_to_list("webdriver wrapper scroll", param, error) diff --git a/je_web_runner/webdriver/_wrapper_mixins/_cookie_mixin.py b/je_web_runner/webdriver/_wrapper_mixins/_cookie_mixin.py new file mode 100644 index 0000000..31520bc --- /dev/null +++ b/je_web_runner/webdriver/_wrapper_mixins/_cookie_mixin.py @@ -0,0 +1,169 @@ +"""Cookies / origin-storage 相關方法 / Cookie and origin storage methods.""" +from __future__ import annotations + +import json + +from je_web_runner.utils.logging.loggin_instance import web_runner_logger +from je_web_runner.utils.test_record.test_record_class import record_action_to_list + + +class _CookieMixin: + """Cookie 操作與 session 持久化 / Cookie operations and session persistence.""" + + def get_cookies(self) -> list[dict] | None: + """ + 取得當前頁面的所有 cookies + Get all cookies from the current page + + :return: cookies 清單,每個 cookie 是 dict + list of cookies, each cookie is a dict + """ + web_runner_logger.info("WebDriverWrapper get_cookies") + try: + record_action_to_list("webdriver wrapper get_cookies", None, None) + return self.current_webdriver.get_cookies() + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper get_cookies, failed: {repr(error)}") + record_action_to_list("webdriver wrapper get_cookies", None, error) + + def get_cookie(self, name: str) -> dict | None: + """ + 取得指定名稱的 cookie + Get a cookie by name + + :param name: cookie 名稱 / cookie name + :return: cookie dict + """ + web_runner_logger.info(f"WebDriverWrapper get_cookie, name: {name}") + param = locals() + try: + record_action_to_list("webdriver wrapper get_cookie", param, None) + return self.current_webdriver.get_cookie(name) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper get_cookie, name: {name}, failed: {repr(error)}") + record_action_to_list("webdriver wrapper get_cookie", param, error) + + def add_cookie(self, cookie_dict: dict) -> None: + """ + 新增 cookie 到當前頁面 + Add a cookie to the current page + + :param cookie_dict: cookie dict,例如 {"name": "session", "value": "12345"} + """ + web_runner_logger.info(f"WebDriverWrapper add_cookie, cookie_dict: {cookie_dict}") + param = locals() + try: + self.current_webdriver.add_cookie(cookie_dict) + record_action_to_list("webdriver wrapper add_cookie", param, None) + except Exception as error: + web_runner_logger.error( + f"WebDriverWrapper add_cookie, cookie_dict: {cookie_dict}, failed: {repr(error)}" + ) + record_action_to_list("webdriver wrapper add_cookie", param, error) + + def delete_cookie(self, name: str) -> None: + """ + 刪除指定名稱的 cookie + Delete a cookie by name + + :param name: cookie 名稱 / cookie name + """ + web_runner_logger.info(f"WebDriverWrapper delete_cookie, name: {name}") + param = locals() + try: + self.current_webdriver.delete_cookie(name) + record_action_to_list("webdriver wrapper delete_cookie", param, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper delete_cookie, name: {name}, failed: {repr(error)}") + record_action_to_list("webdriver wrapper delete_cookie", param, error) + + def delete_all_cookies(self) -> None: + """ + 刪除當前頁面的所有 cookies + Delete all cookies from the current page + """ + web_runner_logger.info("WebDriverWrapper delete_all_cookies") + try: + self.current_webdriver.delete_all_cookies() + record_action_to_list("webdriver wrapper delete_all_cookies", None, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper delete_all_cookies, failed: {repr(error)}") + record_action_to_list("webdriver wrapper delete_all_cookies", None, error) + + def save_cookies(self, file_path: str) -> bool: + """ + 將當前頁面的 cookies 序列化為 JSON 並寫入檔案 (用於 session reuse)。 + Serialize current-page cookies to JSON and write to file (for session reuse). + + 注意 / Note: 僅儲存當前頁面 domain 的 cookies;若要跨 domain,需先導航或改用 + ``execute_cdp_cmd("Network.getAllCookies")``。 + Only stores cookies for the current page domain; for cross-domain dumps, + navigate first or use ``execute_cdp_cmd("Network.getAllCookies")``. + + :param file_path: 目標 JSON 檔案路徑 / target JSON path + :return: 是否成功 / True if saved + """ + web_runner_logger.info(f"WebDriverWrapper save_cookies, file_path: {file_path}") + param = locals() + try: + cookies = self.current_webdriver.get_cookies() or [] + with open(file_path, "w", encoding="utf-8") as fh: + json.dump(cookies, fh, ensure_ascii=False, indent=2) + record_action_to_list("webdriver wrapper save_cookies", param, None) + return True + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper save_cookies failed: {repr(error)}") + record_action_to_list("webdriver wrapper save_cookies", param, error) + return False + + def load_cookies(self, file_path: str) -> int: + """ + 從 JSON 檔讀回 cookies 並逐筆套用 (恢復先前的登入態)。 + Load cookies from a JSON file and add them one by one (restore login state). + + 注意 / Note: 必須先 ``to_url`` 到對應 domain,否則 ``add_cookie`` 會拋出。 + Must navigate (``to_url``) to a matching domain first; ``add_cookie`` raises otherwise. + + :param file_path: 來源 JSON 檔案路徑 / source JSON path + :return: 成功套用的 cookie 數量;失敗 (例如 domain 不符) 會略過該筆 + Number of cookies successfully added; mismatches are skipped + """ + web_runner_logger.info(f"WebDriverWrapper load_cookies, file_path: {file_path}") + param = locals() + try: + with open(file_path, "r", encoding="utf-8") as fh: + cookies = json.load(fh) + added = 0 + for cookie in cookies: + try: + self.current_webdriver.add_cookie(cookie) + added += 1 + except Exception as add_error: # noqa: BLE001 — per-cookie failures are tolerated + web_runner_logger.warning( + f"WebDriverWrapper load_cookies skipped cookie " + f"{cookie.get('name')!r}: {repr(add_error)}" + ) + record_action_to_list("webdriver wrapper load_cookies", param, None) + return added + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper load_cookies failed: {repr(error)}") + record_action_to_list("webdriver wrapper load_cookies", param, error) + return 0 + + def clear_origin_storage(self, origin: str) -> None: + """ + 透過 CDP ``Storage.clearDataForOrigin`` 一次清掉指定 origin 的所有儲存 + (cookies + localStorage + IndexedDB + cache + service workers...)。 + Clear every storage type for an origin via CDP ``Storage.clearDataForOrigin`` + (cookies + localStorage + IndexedDB + cache + service workers + ...). + + :param origin: 完整 origin,例如 ``"https://example.com"`` + Full origin, e.g. ``"https://example.com"`` + """ + self.execute_cdp_cmd( + "Storage.clearDataForOrigin", + { + "origin": origin, + "storageTypes": "all", + }, + ) diff --git a/je_web_runner/webdriver/_wrapper_mixins/_media_mixin.py b/je_web_runner/webdriver/_wrapper_mixins/_media_mixin.py new file mode 100644 index 0000000..264c27a --- /dev/null +++ b/je_web_runner/webdriver/_wrapper_mixins/_media_mixin.py @@ -0,0 +1,151 @@ +"""截圖、PDF 列印、driver log / Screenshots, PDF printing, driver log.""" +from __future__ import annotations + +import base64 + +from je_web_runner.utils.logging.loggin_instance import web_runner_logger +from je_web_runner.utils.test_record.test_record_class import record_action_to_list + + +class _MediaMixin: + """截圖 / 列印 / log 取得 / Screenshots, printing, and driver log retrieval.""" + + def save_screenshot(self, file_path: str) -> bool: + """ + 將當前頁面截圖儲存至指定路徑 (PNG) + Save current page screenshot to the given file path (PNG) + + :param file_path: 目標檔案路徑 / target file path + :return: 截圖是否成功儲存 / True if saved + """ + web_runner_logger.info(f"WebDriverWrapper save_screenshot, file_path: {file_path}") + param = locals() + try: + result = self.current_webdriver.save_screenshot(file_path) + record_action_to_list("webdriver wrapper save_screenshot", param, None) + return bool(result) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper save_screenshot failed: {repr(error)}") + record_action_to_list("webdriver wrapper save_screenshot", param, error) + return False + + def get_screenshot_as_png(self) -> bytes | None: + """ + 取得當前頁面截圖 (PNG 格式) + Get current page screenshot as PNG + + :return: PNG 截圖的 bytes + """ + web_runner_logger.info("WebDriverWrapper get_screenshot_as_png") + try: + record_action_to_list("webdriver wrapper get_screenshot_as_png", None, None) + return self.current_webdriver.get_screenshot_as_png() + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper get_screenshot_as_png failed: {repr(error)}") + record_action_to_list("webdriver wrapper get_screenshot_as_png", None, error) + + def save_full_page_screenshot(self, file_path: str) -> bool: + """ + 將整個頁面 (含可視範圍外) 截圖儲存為 PNG,透過 CDP + ``Page.captureScreenshot`` 並啟用 ``captureBeyondViewport``。 + Save a full-page screenshot (beyond the visible viewport) as PNG, + via CDP ``Page.captureScreenshot`` with ``captureBeyondViewport``. + + 僅 Chromium 系瀏覽器支援。 + Chromium-family browsers only. + + :param file_path: 目標檔案路徑 / target file path + :return: 是否成功儲存 / True if saved + """ + web_runner_logger.info(f"WebDriverWrapper save_full_page_screenshot, file_path: {file_path}") + param = locals() + try: + result = self.execute_cdp_cmd( + "Page.captureScreenshot", + {"format": "png", "captureBeyondViewport": True, "fromSurface": True}, + ) + data_b64 = (result or {}).get("data") + if not data_b64: + record_action_to_list("webdriver wrapper save_full_page_screenshot", param, None) + return False + with open(file_path, "wb") as fh: + fh.write(base64.b64decode(data_b64)) + record_action_to_list("webdriver wrapper save_full_page_screenshot", param, None) + return True + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper save_full_page_screenshot failed: {repr(error)}") + record_action_to_list("webdriver wrapper save_full_page_screenshot", param, error) + return False + + def print_page(self, file_path: str, print_options=None) -> bool: + """ + 將當前頁面列印為 PDF 並存檔 (Selenium 4 內建 ``print_page``)。 + Print the current page to PDF and save (uses Selenium 4 ``print_page``). + + :param file_path: 目標 PDF 檔案路徑 / target PDF path + :param print_options: 選填,Selenium 的 ``PrintOptions`` 實例 + Optional Selenium ``PrintOptions`` instance + :return: 是否成功儲存 / True if saved + """ + web_runner_logger.info(f"WebDriverWrapper print_page, file_path: {file_path}") + param = locals() + try: + data_b64 = ( + self.current_webdriver.print_page(print_options) + if print_options is not None + else self.current_webdriver.print_page() + ) + if not data_b64: + record_action_to_list("webdriver wrapper print_page", param, None) + return False + with open(file_path, "wb") as fh: + fh.write(base64.b64decode(data_b64)) + record_action_to_list("webdriver wrapper print_page", param, None) + return True + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper print_page failed: {repr(error)}") + record_action_to_list("webdriver wrapper print_page", param, error) + return False + + def get_screenshot_as_base64(self) -> str | None: + """ + 取得當前頁面截圖 (Base64 字串) + Get current page screenshot as Base64 string + + :return: Base64 字串 + """ + web_runner_logger.info("WebDriverWrapper get_screenshot_as_base64") + try: + record_action_to_list("webdriver wrapper get_screenshot_as_base64", None, None) + return self.current_webdriver.get_screenshot_as_base64() + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper get_screenshot_as_base64 failed: {repr(error)}") + record_action_to_list("webdriver wrapper get_screenshot_as_base64", None, error) + + def get_log(self, log_type: str): + """ + 取得 WebDriver 日誌(``log_type`` 為必填) + Get WebDriver logs (``log_type`` is required). + + :param log_type: 必填,需為下列之一: + Required; one of: + + - ``"browser"`` — JS console output (Chrome/Edge) + - ``"driver"`` — driver-side messages + - ``"client"`` — client-side bindings logs + - ``"server"`` — Selenium server logs + - ``"performance"`` — perf log (only when enabled in capabilities) + + 不同瀏覽器支援的子集不同;Firefox 自 GeckoDriver 後幾乎不再 + 提供,多數情況請改用 Playwright 的 console-event capture。 + Browser support varies; modern Firefox no longer exposes most + of these, prefer Playwright's console-event capture instead. + :return: log 資料 (list of dict) / log entries + """ + web_runner_logger.info(f"WebDriverWrapper get_log, log_type: {log_type}") + try: + record_action_to_list("webdriver wrapper get_log", None, None) + return self.current_webdriver.get_log(log_type) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper get_log failed: {repr(error)}") + record_action_to_list("webdriver wrapper get_log", None, error) diff --git a/je_web_runner/webdriver/_wrapper_mixins/_navigation_mixin.py b/je_web_runner/webdriver/_wrapper_mixins/_navigation_mixin.py new file mode 100644 index 0000000..2b6f652 --- /dev/null +++ b/je_web_runner/webdriver/_wrapper_mixins/_navigation_mixin.py @@ -0,0 +1,444 @@ +"""導航、捲動、視窗 / Page navigation, scrolling, window management.""" +from __future__ import annotations + +from selenium.common import NoAlertPresentException + +from je_web_runner.utils.logging.loggin_instance import web_runner_logger +from je_web_runner.utils.test_record.test_record_class import record_action_to_list + + +class _NavigationMixin: + """URL 導航、捲動、tab / window 切換、視窗大小位置。 + + URL navigation, scrolling, tab/window switching, and window geometry. + """ + + # webdriver url redirect + def to_url(self, url: str) -> None: + """ + 導航到指定 URL + Navigate to a given URL + """ + web_runner_logger.info(f"WebDriverWrapper to_url, url: {url}") + param = locals() + try: + self.current_webdriver.get(url) + record_action_to_list("webdriver wrapper to_url", param, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper to_url failed: {repr(error)}") + record_action_to_list("webdriver wrapper to_url", param, error) + + def forward(self) -> None: + """前進到下一頁 / Navigate forward""" + web_runner_logger.info("WebDriverWrapper forward") + try: + self.current_webdriver.forward() + record_action_to_list("webdriver wrapper forward", None, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper forward failed: {repr(error)}") + record_action_to_list("webdriver wrapper forward", None, error) + + def back(self) -> None: + """返回上一頁 / Navigate back""" + web_runner_logger.info("WebDriverWrapper back") + try: + self.current_webdriver.back() + record_action_to_list("webdriver wrapper back", None, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper back failed: {repr(error)}") + record_action_to_list("webdriver wrapper back", None, error) + + def refresh(self) -> None: + """重新整理頁面 / Refresh current page""" + web_runner_logger.info("WebDriverWrapper refresh") + try: + self.current_webdriver.refresh() + record_action_to_list("webdriver wrapper refresh", None, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper refresh failed: {repr(error)}") + record_action_to_list("webdriver wrapper refresh", None, error) + + def reload(self, ignore_cache: bool = False) -> None: + """ + 重新整理頁面,可選擇是否旁路快取 (透過 CDP ``Page.reload``)。 + Reload the current page, optionally bypassing the cache (via CDP ``Page.reload``). + + :param ignore_cache: True 表示忽略 HTTP cache,等同瀏覽器的 Ctrl+Shift+R + True bypasses HTTP cache (equivalent to Ctrl+Shift+R) + """ + if ignore_cache: + self.execute_cdp_cmd("Page.reload", {"ignoreCache": True}) + else: + self.refresh() + + def scroll_to_element(self, element) -> None: + """ + 將指定元素捲動到可視範圍 (JS ``scrollIntoView({block: 'center'})``)。 + Scroll a given element into view via JS ``scrollIntoView({block: 'center'})``. + """ + web_runner_logger.info("WebDriverWrapper scroll_to_element") + try: + self.current_webdriver.execute_script( + "arguments[0].scrollIntoView({block: 'center', inline: 'center'});", + element, + ) + record_action_to_list("webdriver wrapper scroll_to_element", None, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper scroll_to_element failed: {repr(error)}") + record_action_to_list("webdriver wrapper scroll_to_element", None, error) + + def scroll_to_top(self) -> None: + """捲動到頁面最上方 / Scroll to the top of the page""" + web_runner_logger.info("WebDriverWrapper scroll_to_top") + try: + self.current_webdriver.execute_script("window.scrollTo(0, 0);") + record_action_to_list("webdriver wrapper scroll_to_top", None, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper scroll_to_top failed: {repr(error)}") + record_action_to_list("webdriver wrapper scroll_to_top", None, error) + + def scroll_to_bottom(self) -> None: + """捲動到頁面最下方 / Scroll to the bottom of the page""" + web_runner_logger.info("WebDriverWrapper scroll_to_bottom") + try: + self.current_webdriver.execute_script( + "window.scrollTo(0, document.body.scrollHeight);" + ) + record_action_to_list("webdriver wrapper scroll_to_bottom", None, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper scroll_to_bottom failed: {repr(error)}") + record_action_to_list("webdriver wrapper scroll_to_bottom", None, error) + + def bring_to_front(self) -> None: + """ + 將瀏覽器視窗叫到最上層 (透過 CDP ``Page.bringToFront``)。 + Bring the browser window to the foreground via CDP ``Page.bringToFront``. + """ + self.execute_cdp_cmd("Page.bringToFront") + + def switch_to_window_by_url(self, pattern: str) -> bool: + """ + 遍歷所有 window handle,切換到第一個 URL 含 ``pattern`` 子字串的視窗。 + Iterate window handles and switch to the first whose URL contains ``pattern``. + + :param pattern: 子字串比對 (大小寫敏感) / case-sensitive substring match + :return: 是否找到並切換 / True if matched and switched + """ + return self._switch_to_window_by_attr("current_url", pattern) + + def switch_to_window_by_title(self, pattern: str) -> bool: + """ + 遍歷所有 window handle,切換到第一個 title 含 ``pattern`` 子字串的視窗。 + Iterate window handles and switch to the first whose title contains ``pattern``. + + :param pattern: 子字串比對 (大小寫敏感) / case-sensitive substring match + :return: 是否找到並切換 / True if matched and switched + """ + return self._switch_to_window_by_attr("title", pattern) + + def _switch_to_window_by_attr(self, attr_name: str, pattern: str) -> bool: + """共用實作:依 driver 屬性 (current_url / title) 子字串比對切換視窗。""" + web_runner_logger.info( + f"WebDriverWrapper _switch_to_window_by_attr, attr: {attr_name}, pattern: {pattern}" + ) + original = None + try: + original = self.current_webdriver.current_window_handle + for handle in self.current_webdriver.window_handles: + self.current_webdriver.switch_to.window(handle) + if pattern in (getattr(self.current_webdriver, attr_name) or ""): + return True + if original is not None: + self.current_webdriver.switch_to.window(original) + return False + except Exception as error: + web_runner_logger.error( + f"WebDriverWrapper _switch_to_window_by_attr failed: {repr(error)}" + ) + if original is not None: + try: + self.current_webdriver.switch_to.window(original) + except Exception: # noqa: BLE001 — best-effort restore + pass + return False + + # page / window metadata + def get_current_url(self) -> str | None: + """ + 取得當前頁面的 URL + Get the current page URL + """ + web_runner_logger.info("WebDriverWrapper get_current_url") + try: + record_action_to_list("webdriver wrapper get_current_url", None, None) + return self.current_webdriver.current_url + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper get_current_url failed: {repr(error)}") + record_action_to_list("webdriver wrapper get_current_url", None, error) + return None + + def get_title(self) -> str | None: + """ + 取得當前頁面的 + Get the current page title + """ + web_runner_logger.info("WebDriverWrapper get_title") + try: + record_action_to_list("webdriver wrapper get_title", None, None) + return self.current_webdriver.title + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper get_title failed: {repr(error)}") + record_action_to_list("webdriver wrapper get_title", None, error) + return None + + def get_page_source(self) -> str | None: + """ + 取得當前頁面 HTML 原始碼 + Get the current page HTML source + """ + web_runner_logger.info("WebDriverWrapper get_page_source") + try: + record_action_to_list("webdriver wrapper get_page_source", None, None) + return self.current_webdriver.page_source + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper get_page_source failed: {repr(error)}") + record_action_to_list("webdriver wrapper get_page_source", None, error) + return None + + def get_window_handles(self) -> list[str] | None: + """ + 取得所有開啟中的視窗 / tab handle + Get all open window / tab handles + """ + web_runner_logger.info("WebDriverWrapper get_window_handles") + try: + record_action_to_list("webdriver wrapper get_window_handles", None, None) + return self.current_webdriver.window_handles + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper get_window_handles failed: {repr(error)}") + record_action_to_list("webdriver wrapper get_window_handles", None, error) + return None + + def get_current_window_handle(self) -> str | None: + """ + 取得當前 driver 操作的視窗 handle + Get the handle of the window currently driven + """ + web_runner_logger.info("WebDriverWrapper get_current_window_handle") + try: + record_action_to_list("webdriver wrapper get_current_window_handle", None, None) + return self.current_webdriver.current_window_handle + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper get_current_window_handle failed: {repr(error)}") + record_action_to_list("webdriver wrapper get_current_window_handle", None, error) + return None + + def new_window(self, type_hint: str = "tab") -> None: + """ + 開啟新的 tab 或 window,並自動切換到該視窗 + Open a new tab or window and switch focus to it. + + :param type_hint: ``"tab"`` (預設) 或 ``"window"`` / ``"tab"`` (default) or ``"window"`` + """ + web_runner_logger.info(f"WebDriverWrapper new_window, type_hint: {type_hint}") + param = locals() + try: + self.current_webdriver.switch_to.new_window(type_hint) + record_action_to_list("webdriver wrapper new_window", param, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper new_window failed: {repr(error)}") + record_action_to_list("webdriver wrapper new_window", param, error) + + def close_window(self) -> None: + """ + 關閉當前 tab / window (不會結束整個 driver;要結束請用 ``quit``) + Close the current tab / window (does not quit the whole driver; use ``quit`` for that) + """ + web_runner_logger.info("WebDriverWrapper close_window") + try: + self.current_webdriver.close() + record_action_to_list("webdriver wrapper close_window", None, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper close_window failed: {repr(error)}") + record_action_to_list("webdriver wrapper close_window", None, error) + + # webdriver new page + def switch(self, switch_type: str, switch_target_name: str = None): + """ + 切換 WebDriver 的上下文 (frame, window, alert...) + Switch WebDriver context (frame, window, alert...) + + :param switch_type: [active_element, default_content, frame, parent_frame, window, alert] + :param switch_target_name: 目標名稱 (frame 名稱或 window handle) + :return: 切換後的目標物件 / switched target + """ + web_runner_logger.info( + f"WebDriverWrapper switch, switch_type: {switch_type}, switch_target_name: {switch_target_name}" + ) + param = locals() + try: + switch_type = switch_type.lower() + switch_type_dict = { + "active_element": self.current_webdriver.switch_to.active_element, + "default_content": self.current_webdriver.switch_to.default_content, + "frame": self.current_webdriver.switch_to.frame, + "parent_frame": self.current_webdriver.switch_to.parent_frame, + "window": self.current_webdriver.switch_to.window, + } + try: + switch_type_dict.update({"alert": self.current_webdriver.switch_to.alert}) + except NoAlertPresentException as error: + switch_type_dict.update({"alert": None}) + web_runner_logger.error(f"WebDriverWrapper switch alert failed: {repr(error)}") + + record_action_to_list("webdriver wrapper switch", param, None) + if switch_type in ["active_element", "alert"]: + return switch_type_dict.get(switch_type) + elif switch_type in ["default_content", "parent_frame"]: + return switch_type_dict.get(switch_type)() + else: + return switch_type_dict.get(switch_type)(switch_target_name) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper switch failed: {repr(error)}") + record_action_to_list("webdriver wrapper switch", param, error) + + # window geometry + def maximize_window(self) -> None: + """ + 最大化當前視窗 + Maximize the current browser window + """ + web_runner_logger.info("WebDriverWrapper maximize_window") + try: + self.current_webdriver.maximize_window() + record_action_to_list("webdriver wrapper maximize_window", None, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper maximize_window failed: {repr(error)}") + record_action_to_list("webdriver wrapper maximize_window", None, error) + + def fullscreen_window(self) -> None: + """ + 全螢幕顯示當前視窗 + Fullscreen the current browser window + """ + web_runner_logger.info("WebDriverWrapper fullscreen_window") + try: + self.current_webdriver.fullscreen_window() + record_action_to_list("webdriver wrapper fullscreen_window", None, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper fullscreen_window failed: {repr(error)}") + record_action_to_list("webdriver wrapper fullscreen_window", None, error) + + def minimize_window(self) -> None: + """ + 最小化當前視窗 + Minimize the current browser window + """ + web_runner_logger.info("WebDriverWrapper minimize_window") + try: + self.current_webdriver.minimize_window() + record_action_to_list("webdriver wrapper minimize_window", None, None) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper minimize_window failed: {repr(error)}") + record_action_to_list("webdriver wrapper minimize_window", None, error) + + def set_window_size(self, width: int, height: int, window_handle: str = 'current') -> None: + """ + 設定視窗大小 + Set the window size + + :param width: 視窗寬度 (像素) / window width in pixels + :param height: 視窗高度 (像素) / window height in pixels + :param window_handle: 預設為 "current" (w3c 標準),若非 "current" 可能會拋出例外 + normally "current" (w3c), otherwise may raise exception + :return: 視窗大小資訊 (dict) / window size info (dict) + """ + web_runner_logger.info( + f"WebDriverWrapper set_window_size, width: {width}, height: {height}, window_handle: {window_handle}" + ) + param = locals() + try: + record_action_to_list("webdriver wrapper set_window_size", param, None) + self.current_webdriver.set_window_size(width, height, window_handle) + except Exception as error: + web_runner_logger.error( + f"WebDriverWrapper set_window_size failed: {repr(error)}" + ) + record_action_to_list("webdriver wrapper set_window_size", param, error) + + def set_window_position(self, x: int, y: int, window_handle: str = 'current') -> dict | None: + """ + 設定視窗位置 + Set the window position + + :param x: 視窗左上角的 X 座標 / X coordinate of the window + :param y: 視窗左上角的 Y 座標 / Y coordinate of the window + :param window_handle: 預設為 "current" (w3c 標準),若非 "current" 可能會拋出例外 + normally "current" (w3c), otherwise may raise exception + :return: 視窗位置與大小資訊 (dict) / window rect info (dict) + """ + web_runner_logger.info( + f"WebDriverWrapper set_window_position, x: {x}, y: {y}, window_handle: {window_handle}" + ) + param = locals() + try: + record_action_to_list("webdriver wrapper set_window_position", param, None) + return self.current_webdriver.set_window_position(x, y, window_handle) + except Exception as error: + web_runner_logger.error( + f"WebDriverWrapper set_window_position failed: {repr(error)}" + ) + record_action_to_list("webdriver wrapper set_window_position", param, error) + + def get_window_position(self, window_handle='current') -> dict | None: + """ + 取得視窗位置 + Get window position + + :param window_handle: 預設為 "current" (w3c 標準),若非 "current" 可能會拋出例外 + :return: 視窗位置 dict,例如 {"x": 100, "y": 200} + """ + web_runner_logger.info(f"WebDriverWrapper get_window_position, window_handle: {window_handle}") + param = locals() + try: + record_action_to_list("webdriver wrapper get_window_position", param, None) + return self.current_webdriver.get_window_position(window_handle) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper get_window_position failed: {repr(error)}") + record_action_to_list("webdriver wrapper get_window_position", param, error) + + def get_window_rect(self) -> dict | None: + """ + 取得視窗矩形資訊 (位置與大小) + Get window rect (position and size) + + :return: dict, e.g. {"x": 100, "y": 200, "width": 1280, "height": 720} + """ + web_runner_logger.info("WebDriverWrapper get_window_rect") + try: + record_action_to_list("webdriver wrapper get_window_rect", None, None) + return self.current_webdriver.get_window_rect() + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper get_window_rect failed: {repr(error)}") + record_action_to_list("webdriver wrapper get_window_rect", None, error) + + def set_window_rect(self, x: int = None, y: int = None, width: int = None, height: int = None) -> dict | None: + """ + 設定視窗矩形 (位置與大小),僅支援 W3C 相容瀏覽器 + Set window rect (position and size), only supported for W3C compatible browsers + + :param x: X 座標 + :param y: Y 座標 + :param width: 視窗寬度 + :param height: 視窗高度 + :return: dict, e.g. {"x": 100, "y": 200, "width": 1280, "height": 720} + """ + web_runner_logger.info( + f"WebDriverWrapper set_window_rect, x: {x}, y: {y}, width: {width}, height: {height}") + param = locals() + try: + record_action_to_list("webdriver wrapper set_window_rect", param, None) + return self.current_webdriver.set_window_rect(x, y, width, height) + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper set_window_rect failed: {repr(error)}") + record_action_to_list("webdriver wrapper set_window_rect", param, error) diff --git a/je_web_runner/webdriver/_wrapper_mixins/_scripting_mixin.py b/je_web_runner/webdriver/_wrapper_mixins/_scripting_mixin.py new file mode 100644 index 0000000..6048618 --- /dev/null +++ b/je_web_runner/webdriver/_wrapper_mixins/_scripting_mixin.py @@ -0,0 +1,514 @@ +"""執行 script / CDP / BiDi listener / Fetch interception。 + +JavaScript execution, Chrome DevTools Protocol commands, W3C BiDi listeners, +and CDP Fetch interception primitives. +""" +from __future__ import annotations + +import base64 +from typing import List + +from je_web_runner.utils.exception.exceptions import WebRunnerException +from je_web_runner.utils.logging.loggin_instance import web_runner_logger +from je_web_runner.utils.test_record.test_record_class import record_action_to_list + + +class _ScriptingMixin: + """提供同步 / 非同步 JS、CDP、BiDi、Fetch 攔截原語。 + + Synchronous and asynchronous JavaScript, CDP commands, BiDi listeners, + and CDP Fetch interception primitives. + """ + + # exec selenium command + def execute(self, driver_command: str, params: dict = None) -> dict | None: + """ + 執行 Selenium WebDriver 的底層命令 + Execute a raw WebDriver command + + :param driver_command: WebDriver 指令名稱 / WebDriver command name + :param params: 指令參數 / command parameters + :return: 執行結果 (dict) / execution result as dict + """ + web_runner_logger.info(f"WebDriverWrapper execute, driver_command: {driver_command}, params: {params}") + param = locals() + try: + record_action_to_list("webdriver wrapper execute", param, None) + return self.current_webdriver.execute(driver_command, params) + except Exception as error: + web_runner_logger.error( + f"WebDriverWrapper execute, driver_command: {driver_command}, params: {params}, failed: {repr(error)}" + ) + record_action_to_list("webdriver wrapper execute", param, error) + + def execute_script(self, script: str, *args): + """ + 在當前頁面執行 JavaScript,回傳 JS 的回傳值。 + Execute JavaScript on the current page and return the result. + + :param script: JavaScript 程式碼 / JavaScript code + :param args: 傳入 JS 的參數 / arguments passed to JS + :return: JS 回傳值(dict / list / 字面值 / None) + The value returned by the script (dict / list / literal / None) + """ + web_runner_logger.info(f"WebDriverWrapper execute_script, script: {script}") + param = locals() + try: + value = self.current_webdriver.execute_script(script, *args) + record_action_to_list("webdriver wrapper execute_script", param, None) + return value + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper execute_script, script: {script}, failed: {repr(error)}") + record_action_to_list("webdriver wrapper execute_script", param, error) + return None + + def execute_cdp_cmd(self, cmd: str, cmd_args: dict = None): + """ + 在當前 driver 上執行 Chrome DevTools Protocol 命令 (僅 Chromium 系)。 + Issue a Chrome DevTools Protocol command on the current driver (Chromium-only). + + 典型用途:在頁面腳本之前注入 stealth JavaScript,例如 + ``execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", {"source": js})``。 + + Typical use case: inject stealth JavaScript before any page script runs, e.g. + ``execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", {"source": js})``. + + :param cmd: CDP 方法名稱,例如 "Page.addScriptToEvaluateOnNewDocument" + CDP method name, e.g. "Page.addScriptToEvaluateOnNewDocument" + :param cmd_args: CDP 參數 dict / CDP params dict + :return: CDP 回傳 dict / CDP response dict + """ + # 延遲匯入避免循環依賴 / Lazy import to avoid circular dependency + from je_web_runner.utils.cdp.cdp_commands import CDPError + + web_runner_logger.info(f"WebDriverWrapper execute_cdp_cmd, cmd: {cmd}") + param = locals() + try: + if self.current_webdriver is None: + raise CDPError("no Selenium driver active") + if not hasattr(self.current_webdriver, "execute_cdp_cmd"): + raise CDPError("active driver does not support CDP (non-Chromium browser?)") + result = self.current_webdriver.execute_cdp_cmd(cmd, cmd_args or {}) + record_action_to_list("webdriver wrapper execute_cdp_cmd", param, None) + return result + except Exception as error: + web_runner_logger.error( + f"WebDriverWrapper execute_cdp_cmd, cmd: {cmd}, failed: {repr(error)}" + ) + record_action_to_list("webdriver wrapper execute_cdp_cmd", param, error) + raise + + def add_script_to_evaluate_on_new_document(self, source: str) -> str | None: + """ + 在每次新文件載入前注入一段 JavaScript (常用於 anti-bot / stealth)。 + Inject a JavaScript snippet that will run before any page script on every + new document. Commonly used for anti-bot / stealth setups. + + 包裝 CDP ``Page.addScriptToEvaluateOnNewDocument``。 + Wraps CDP ``Page.addScriptToEvaluateOnNewDocument``. + + :param source: 要注入的 JavaScript 原始碼 / JS source to inject + :return: CDP 回傳的 script identifier,可用於後續 ``removeScriptToEvaluateOnNewDocument`` + The script identifier returned by CDP + """ + result = self.execute_cdp_cmd( + "Page.addScriptToEvaluateOnNewDocument", {"source": source} + ) + if isinstance(result, dict): + return result.get("identifier") + return None + + def set_user_agent(self, user_agent: str) -> None: + """ + 以 CDP ``Network.setUserAgentOverride`` 動態覆寫 User-Agent。 + Override User-Agent at runtime via CDP ``Network.setUserAgentOverride``. + + 比 ``--user-agent`` 啟動參數彈性,可在 driver 啟動後任意時刻切換。 + More flexible than the ``--user-agent`` launch arg — can be switched after start. + """ + self.execute_cdp_cmd("Network.setUserAgentOverride", {"userAgent": user_agent}) + + def set_extra_http_headers(self, headers: dict) -> None: + """ + 以 CDP ``Network.setExtraHTTPHeaders`` 為所有後續請求附加 header。 + Attach extra HTTP headers to all subsequent requests via CDP + ``Network.setExtraHTTPHeaders``. + + :param headers: header 名稱對應到值 (皆為字串) / header name → string value + """ + self.execute_cdp_cmd("Network.setExtraHTTPHeaders", {"headers": headers}) + + def set_geolocation(self, latitude: float, longitude: float, accuracy: float = 100) -> None: + """ + 以 CDP ``Emulation.setGeolocationOverride`` 覆寫地理位置。 + Override geolocation via CDP ``Emulation.setGeolocationOverride``. + + :param latitude: 緯度 / latitude + :param longitude: 經度 / longitude + :param accuracy: 精準度 (公尺) / accuracy in meters + """ + self.execute_cdp_cmd( + "Emulation.setGeolocationOverride", + {"latitude": latitude, "longitude": longitude, "accuracy": accuracy}, + ) + + def set_timezone(self, timezone_id: str) -> None: + """ + 以 CDP ``Emulation.setTimezoneOverride`` 覆寫時區。 + Override timezone via CDP ``Emulation.setTimezoneOverride``. + + :param timezone_id: IANA 時區字串,例如 ``"Asia/Tokyo"`` / IANA timezone, e.g. ``"Asia/Tokyo"`` + """ + self.execute_cdp_cmd("Emulation.setTimezoneOverride", {"timezoneId": timezone_id}) + + def set_locale(self, locale: str) -> None: + """ + 以 CDP ``Emulation.setLocaleOverride`` 覆寫語系。 + Override locale via CDP ``Emulation.setLocaleOverride``. + + :param locale: BCP47 語系字串,例如 ``"ja-JP"`` / BCP47 locale, e.g. ``"ja-JP"`` + """ + self.execute_cdp_cmd("Emulation.setLocaleOverride", {"locale": locale}) + + def set_device_metrics( + self, + width: int, + height: int, + device_scale_factor: float = 1, + mobile: bool = False, + ) -> None: + """ + 以 CDP ``Emulation.setDeviceMetricsOverride`` 覆寫裝置外觀 (viewport / DPR / mobile)。 + Override device metrics (viewport / DPR / mobile flag) via CDP + ``Emulation.setDeviceMetricsOverride``. + + :param width: viewport 寬 / viewport width + :param height: viewport 高 / viewport height + :param device_scale_factor: DPR,預設 1 / device pixel ratio + :param mobile: 是否啟用手機模式 / mobile mode flag + """ + self.execute_cdp_cmd( + "Emulation.setDeviceMetricsOverride", + { + "width": width, + "height": height, + "deviceScaleFactor": device_scale_factor, + "mobile": mobile, + }, + ) + + def clear_device_metrics(self) -> None: + """ + 清除 ``set_device_metrics`` 設定。 + Clear device metrics override. + """ + self.execute_cdp_cmd("Emulation.clearDeviceMetricsOverride") + + def clear_geolocation_override(self) -> None: + """ + 清除 ``set_geolocation`` 設定。 + Clear geolocation override. + """ + self.execute_cdp_cmd("Emulation.clearGeolocationOverride") + + def set_network_conditions( + self, + offline: bool = False, + latency: float = 0, + download_throughput: float = -1, + upload_throughput: float = -1, + ) -> None: + """ + 以 CDP ``Network.emulateNetworkConditions`` 模擬網路條件 (離線、節流)。 + Emulate network conditions via CDP ``Network.emulateNetworkConditions``. + + :param offline: 是否離線 / True for offline + :param latency: 延遲毫秒數 / latency in ms + :param download_throughput: 下載速度 bytes/s,``-1`` 表示不限制 + download speed in bytes/s; ``-1`` for unlimited + :param upload_throughput: 上傳速度 bytes/s,``-1`` 表示不限制 + upload speed in bytes/s; ``-1`` for unlimited + """ + self.execute_cdp_cmd( + "Network.emulateNetworkConditions", + { + "offline": offline, + "latency": latency, + "downloadThroughput": download_throughput, + "uploadThroughput": upload_throughput, + }, + ) + + def block_urls(self, patterns: List[str]) -> None: + """ + 透過 CDP ``Network.setBlockedURLs`` 阻擋符合任一 pattern 的請求。 + Block requests matching any pattern via CDP ``Network.setBlockedURLs``. + + Pattern 支援 ``*`` wildcard,例如 ``"*.doubleclick.net/*"``。 + Patterns support ``*`` wildcards, e.g. ``"*.doubleclick.net/*"``. + """ + self.execute_cdp_cmd("Network.setBlockedURLs", {"urls": list(patterns)}) + + def unblock_urls(self) -> None: + """ + 清空 ``block_urls`` 列表。 + Clear all blocked URL patterns. + """ + self.execute_cdp_cmd("Network.setBlockedURLs", {"urls": []}) + + def set_cache_disabled(self, disabled: bool = True) -> None: + """ + 透過 CDP ``Network.setCacheDisabled`` 切換 HTTP 快取。 + Toggle HTTP cache via CDP ``Network.setCacheDisabled``. + """ + self.execute_cdp_cmd("Network.setCacheDisabled", {"cacheDisabled": disabled}) + + # --- BiDi event listeners (Selenium 4.16+ required) --- + def _bidi_script(self): + """取得 ``driver.script`` 服務並驗證可用性 / Resolve driver.script and validate.""" + script = getattr(self.current_webdriver, "script", None) + if script is None: + raise WebRunnerException( + "BiDi unavailable: driver has no 'script' service. " + "Use set_driver(enable_bidi=True) and Selenium >= 4.16." + ) + return script + + def add_console_listener(self, callback) -> int | None: + """ + 訂閱瀏覽器 console 訊息事件 (透過 W3C BiDi)。 + Subscribe to browser console message events via W3C BiDi. + + :param callback: 接收單一 ``ConsoleLogEntry`` 參數的可呼叫物 + Callable taking a single ``ConsoleLogEntry`` argument + :return: 訂閱 id,用於 ``remove_console_listener`` / subscription id for removal + """ + web_runner_logger.info("WebDriverWrapper add_console_listener") + param = locals() + try: + subscription_id = self._bidi_script().add_console_message_handler(callback) + record_action_to_list("webdriver wrapper add_console_listener", param, None) + return subscription_id + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper add_console_listener failed: {repr(error)}") + record_action_to_list("webdriver wrapper add_console_listener", param, error) + raise + + def add_js_error_listener(self, callback) -> int | None: + """ + 訂閱頁面 JavaScript 例外事件 (透過 W3C BiDi)。 + Subscribe to JavaScript exception events via W3C BiDi. + """ + web_runner_logger.info("WebDriverWrapper add_js_error_listener") + param = locals() + try: + subscription_id = self._bidi_script().add_javascript_error_handler(callback) + record_action_to_list("webdriver wrapper add_js_error_listener", param, None) + return subscription_id + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper add_js_error_listener failed: {repr(error)}") + record_action_to_list("webdriver wrapper add_js_error_listener", param, error) + raise + + def remove_console_listener(self, subscription_id: int) -> bool: + """ + 移除 ``add_console_listener`` 註冊的訂閱。 + Remove a console listener subscription. + + :return: 是否成功移除 / True if removed without error + """ + web_runner_logger.info(f"WebDriverWrapper remove_console_listener, id: {subscription_id}") + param = locals() + try: + self._bidi_script().remove_console_message_handler(subscription_id) + record_action_to_list("webdriver wrapper remove_console_listener", param, None) + return True + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper remove_console_listener failed: {repr(error)}") + record_action_to_list("webdriver wrapper remove_console_listener", param, error) + return False + + def remove_js_error_listener(self, subscription_id: int) -> bool: + """ + 移除 ``add_js_error_listener`` 註冊的訂閱。 + Remove a JS error listener subscription. + """ + web_runner_logger.info(f"WebDriverWrapper remove_js_error_listener, id: {subscription_id}") + param = locals() + try: + self._bidi_script().remove_javascript_error_handler(subscription_id) + record_action_to_list("webdriver wrapper remove_js_error_listener", param, None) + return True + except Exception as error: + web_runner_logger.error(f"WebDriverWrapper remove_js_error_listener failed: {repr(error)}") + record_action_to_list("webdriver wrapper remove_js_error_listener", param, error) + return False + + def set_download_directory(self, download_path: str, behavior: str = "allow") -> None: + """ + 透過 CDP ``Browser.setDownloadBehavior`` 指定下載資料夾 (headless 必備)。 + Set the download directory via CDP ``Browser.setDownloadBehavior``; + required to receive downloads in headless mode. + + :param download_path: 下載資料夾路徑 / download directory + :param behavior: ``"allow"`` / ``"deny"`` / ``"default"``,預設 ``"allow"`` + CDP behavior; defaults to ``"allow"`` + """ + self.execute_cdp_cmd( + "Browser.setDownloadBehavior", + {"behavior": behavior, "downloadPath": download_path}, + ) + + # --- CDP Fetch interception primitives --- + # 這些方法只是 Fetch.* CDP 命令的薄包裝;要實際攔截事件 (Fetch.requestPaused) + # 仍需使用者自行透過 Selenium BiDi 或 trio-based devtools listener 訂閱事件。 + # These methods are thin wrappers around Fetch.* CDP commands. To actually + # receive Fetch.requestPaused events, the caller must subscribe via + # Selenium BiDi or trio-based devtools listeners on their own. + @staticmethod + def _headers_dict_to_list(headers) -> list: + """將 ``{name: value}`` dict 轉成 CDP 要求的 ``[{"name":..., "value":...}]``。""" + if isinstance(headers, dict): + return [{"name": str(k), "value": str(v)} for k, v in headers.items()] + return list(headers) + + @staticmethod + def _normalize_fetch_patterns(patterns) -> list: + """str → ``{"urlPattern": str}``;dict → 原樣;None → 攔截所有 URL。""" + if patterns is None: + return [{"urlPattern": "*"}] + normalized = [] + for pattern in patterns: + if isinstance(pattern, str): + normalized.append({"urlPattern": pattern}) + elif isinstance(pattern, dict): + normalized.append(pattern) + else: + raise WebRunnerException( + f"unsupported fetch pattern type: {type(pattern).__name__}" + ) + return normalized + + def enable_fetch_interception( + self, + patterns: list = None, + handle_auth: bool = False, + ) -> None: + """ + 啟動 CDP ``Fetch.enable`` 開始攔截請求。 + Enable CDP ``Fetch.enable`` to start intercepting requests. + + :param patterns: ``None`` 表示攔截所有;可傳 ``List[str]`` (每個視為 ``urlPattern``) + 或 ``List[dict]`` (完整 CDP RequestPattern 結構)。 + ``None`` intercepts everything. Accepts ``List[str]`` (each treated + as a ``urlPattern``) or full ``List[RequestPattern dict]``. + :param handle_auth: 是否同時攔截 401 / 407 auth challenge + Whether to also intercept 401 / 407 auth challenges + """ + self.execute_cdp_cmd( + "Fetch.enable", + { + "patterns": self._normalize_fetch_patterns(patterns), + "handleAuthRequests": handle_auth, + }, + ) + + def disable_fetch_interception(self) -> None: + """停止 Fetch 攔截 / Disable Fetch interception.""" + self.execute_cdp_cmd("Fetch.disable") + + def continue_request( + self, + request_id: str, + url: str = None, + method: str = None, + post_data=None, + headers=None, + ) -> None: + """ + 放行 (或改寫後放行) 一個被 ``Fetch.requestPaused`` 暫停的請求。 + Continue (optionally modifying) a request paused by ``Fetch.requestPaused``. + + :param request_id: ``Fetch.requestPaused`` 事件提供的 ``requestId`` + :param url: 改寫後的 URL;``None`` 表示維持原本 + :param method: 改寫後的 HTTP method;``None`` 表示維持原本 + :param post_data: ``str`` 或 ``bytes``;會自動 base64 編碼 + :param headers: ``dict`` 或 ``List[{"name", "value"}]`` + """ + params: dict = {"requestId": request_id} + if url is not None: + params["url"] = url + if method is not None: + params["method"] = method + if post_data is not None: + if isinstance(post_data, str): + post_data = post_data.encode("utf-8") + params["postData"] = base64.b64encode(post_data).decode("ascii") + if headers is not None: + params["headers"] = self._headers_dict_to_list(headers) + self.execute_cdp_cmd("Fetch.continueRequest", params) + + def fulfill_request( + self, + request_id: str, + response_code: int, + body=None, + response_headers=None, + response_phrase: str = None, + ) -> None: + """ + 以指定 response 回應一個被攔截的請求 (不再送出到原伺服器)。 + Fulfill an intercepted request with a synthetic response (no network call). + + :param request_id: ``Fetch.requestPaused`` 提供的 ``requestId`` + :param response_code: HTTP 狀態碼 / HTTP status code + :param body: ``str`` 或 ``bytes``;會自動 base64 編碼 + :param response_headers: ``dict`` 或 ``List[{"name", "value"}]`` + :param response_phrase: HTTP reason phrase (例如 ``"OK"``) + """ + params: dict = {"requestId": request_id, "responseCode": response_code} + if response_headers is not None: + params["responseHeaders"] = self._headers_dict_to_list(response_headers) + if body is not None: + if isinstance(body, str): + body = body.encode("utf-8") + params["body"] = base64.b64encode(body).decode("ascii") + if response_phrase is not None: + params["responsePhrase"] = response_phrase + self.execute_cdp_cmd("Fetch.fulfillRequest", params) + + def fail_request(self, request_id: str, error_reason: str = "Aborted") -> None: + """ + 以指定錯誤理由讓被攔截的請求失敗 (用於阻擋 / 模擬網路錯誤)。 + Fail an intercepted request with the given reason (block / simulate network error). + + :param request_id: ``Fetch.requestPaused`` 提供的 ``requestId`` + :param error_reason: CDP ``ErrorReason`` 列舉,常見值:``"Aborted"`` / + ``"AccessDenied"`` / ``"TimedOut"`` / ``"Failed"`` / + ``"NameNotResolved"`` / ``"InternetDisconnected"`` + """ + self.execute_cdp_cmd( + "Fetch.failRequest", + {"requestId": request_id, "errorReason": error_reason}, + ) + + def execute_async_script(self, script: str, *args): + """ + 執行非同步 JavaScript + Execute asynchronous JavaScript + + :param script: 要執行的 JS 程式碼 / JavaScript code to execute + :param args: 傳入 JS 的參數 / arguments passed to JS + :return: JS 執行結果 (非同步回傳) / result of async JS execution + """ + web_runner_logger.info(f"WebDriverWrapper execute_async_script, script: {script}") + param = locals() + try: + result = self.current_webdriver.execute_async_script(script, *args) + record_action_to_list("webdriver wrapper execute_async_script", param, None) + return result + except Exception as error: + web_runner_logger.error( + f"WebDriverWrapper execute_async_script, script: {script}, failed: {repr(error)}" + ) + record_action_to_list("webdriver wrapper execute_async_script", param, error) diff --git a/je_web_runner/webdriver/webdriver_wrapper.py b/je_web_runner/webdriver/webdriver_wrapper.py index e562f88..937ed3c 100644 --- a/je_web_runner/webdriver/webdriver_wrapper.py +++ b/je_web_runner/webdriver/webdriver_wrapper.py @@ -1,3 +1,16 @@ +""" +WebDriverWrapper:以 mixin 組合的 Selenium 包裝器入口。 +WebDriverWrapper: Selenium wrapper assembled from theme-specific mixins. + +各主題方法分散在 ``_wrapper_mixins/`` 下,本檔保留: +This file keeps only: + +* 瀏覽器 / Options / webdriver_manager 對應表 (測試會 ``patch.dict`` 這幾個名稱) + Browser / Options / webdriver_manager dicts (test code ``patch.dict``s these) +* ``WebDriverWrapper`` 類別本體 (__init__ + 生命週期 + 元素 + 等待 + quit) + The ``WebDriverWrapper`` class itself (lifecycle + element finding + waits + quit) +* ``webdriver_wrapper_instance`` 全域單例 / module-level singleton +""" from __future__ import annotations import typing @@ -5,7 +18,6 @@ from typing import List, Union from selenium import webdriver -from selenium.common import NoAlertPresentException from selenium.webdriver import ActionChains from selenium.webdriver.chrome.options import Options as ChromeOptions from selenium.webdriver.chromium.options import ArgOptions as ChromiumOptions @@ -30,6 +42,13 @@ from je_web_runner.utils.test_object.test_object_class import TestObject from je_web_runner.utils.test_object.test_object_record.test_object_record_class import test_object_record from je_web_runner.utils.test_record.test_record_class import record_action_to_list +from je_web_runner.webdriver._wrapper_mixins import ( + _ActionsMixin, + _CookieMixin, + _MediaMixin, + _NavigationMixin, + _ScriptingMixin, +) from je_web_runner.webdriver.webdriver_with_options import set_webdriver_options_capability_wrapper # 瀏覽器名稱對應到 WebDriver 類別 @@ -65,10 +84,29 @@ } -class WebDriverWrapper(object): +class WebDriverWrapper( + _ScriptingMixin, + _NavigationMixin, + _CookieMixin, + _ActionsMixin, + _MediaMixin, +): """ WebDriver 包裝器 - WebDriver wrapper to manage browser drivers and options + WebDriver wrapper to manage browser drivers and options. + + Mixin 組成 / Mixin composition:: + + _ScriptingMixin — execute / execute_script / execute_cdp_cmd / + BiDi listener / Fetch 攔截 (放最前以提供 execute_cdp_cmd + 給其他 mixin 使用) + _NavigationMixin — to_url / forward / back / scroll / switch / window + _CookieMixin — cookies + clear_origin_storage + _ActionsMixin — ActionChains 全集 + _MediaMixin — 截圖 / PDF 列印 / get_log + + 本類別本身保留:driver 生命週期、元素查找、等待、check / quit。 + This class itself keeps: driver lifecycle, element finding, waits, check / quit. """ def __init__(self): @@ -81,6 +119,9 @@ def set_driver( webdriver_name: str, webdriver_manager_option_dict: dict = None, options: List[str] = None, + experimental_options: dict = None, + extension_paths: List[str] = None, + enable_bidi: bool = False, **kwargs ) -> Union[ webdriver.Chrome, @@ -99,6 +140,26 @@ def set_driver( Extra options for webdriver_manager (currently unused) :param options: 瀏覽器啟動參數 (例如 ["--headless", "--disable-gpu"]) Browser startup arguments + :param experimental_options: Chromium 系瀏覽器專屬實驗性參數 (Chrome / Chromium / Edge), + 會逐項經由 ``add_experimental_option`` 傳入。例如 + ``{"excludeSwitches": ["enable-automation"], + "useAutomationExtension": False, + "prefs": {"download.default_directory": "/tmp"}}``。 + 非 Chromium 系瀏覽器若傳入會拋出例外。 + Browser-specific experimental options for Chromium-family + browsers (Chrome / Chromium / Edge), each forwarded via + ``add_experimental_option``. Raises on non-Chromium browsers. + :param extension_paths: 要載入的瀏覽器擴充功能檔案路徑 (.crx)。會逐一呼叫 + ``Options.add_extension(path)``。僅 Chromium 系與 Firefox 支援; + 其他瀏覽器若傳入會拋出例外。 + List of browser extension file paths (.crx) to load via + ``Options.add_extension(path)``. Chromium-family and Firefox only. + :param enable_bidi: 啟用 W3C WebDriver BiDi (``webSocketUrl=True`` capability), + 供 ``add_console_listener`` / ``add_js_error_listener`` 等 BiDi + 事件 API 使用。需 Selenium 4.16+。 + Enable W3C WebDriver BiDi (``webSocketUrl=True`` capability) so + ``add_console_listener`` / ``add_js_error_listener`` work. + Requires Selenium 4.16+. :param kwargs: 額外傳給 WebDriver 的參數 Extra kwargs passed to WebDriver :return: 啟動後的 WebDriver 實例 @@ -123,12 +184,37 @@ def set_driver( webdriver_install_manager = _webdriver_manager_dict.get(webdriver_name) webdriver_install_manager().install() - # 如果有傳入 options,則建立對應的 Options 並加入參數 - if options and len(options) > 0: - driver_options = _options_dict.get(webdriver_name)() - if driver_options: - for option in options: - driver_options.add_argument(argument=option) + # 如果有任一 option-like 參數,則建立對應的 Options 一次傳入 + # If any option-like arg provided, build a single Options object + has_options = bool(options) + has_experimental = bool(experimental_options) + has_extensions = bool(extension_paths) + if has_options or has_experimental or has_extensions or enable_bidi: + options_cls = _options_dict.get(webdriver_name) + driver_options = options_cls() if options_cls else None + if driver_options is None: + self.current_webdriver = webdriver_value(**kwargs) + else: + if has_options: + for option in options: + driver_options.add_argument(argument=option) + if has_experimental: + if not hasattr(driver_options, "add_experimental_option"): + raise WebRunnerException( + f"{webdriver_name!r} options do not support experimental_options " + f"(Chromium-family browsers only)" + ) + for exp_key, exp_value in experimental_options.items(): + driver_options.add_experimental_option(exp_key, exp_value) + if has_extensions: + if not hasattr(driver_options, "add_extension"): + raise WebRunnerException( + f"{webdriver_name!r} options do not support add_extension" + ) + for ext_path in extension_paths: + driver_options.add_extension(ext_path) + if enable_bidi: + driver_options.set_capability("webSocketUrl", True) self.current_webdriver = webdriver_value(options=driver_options, **kwargs) else: self.current_webdriver = webdriver_value(**kwargs) @@ -174,6 +260,46 @@ def set_webdriver_options_capability(self, key_and_vale_dict: dict) -> Options | record_action_to_list("webdriver wrapper set_webdriver_options_capability", param, error) raise WebRunnerException + def attach_to_existing_browser( + self, + debugger_address: str, + webdriver_name: str = "chrome", + options: List[str] = None, + experimental_options: dict = None, + **kwargs, + ): + """ + 附加到一個已啟動且開啟 remote debugging 埠的 Chrome / Edge 實例。 + Attach to an already-running Chrome / Edge that was started with + ``--remote-debugging-port``. + + 典型用法(使用者先手動啟動 Chrome):: + + chrome.exe --remote-debugging-port=9222 --user-data-dir="C:/temp/profile" + + 然後在腳本中:: + + webdriver_wrapper_instance.attach_to_existing_browser("127.0.0.1:9222") + + :param debugger_address: ``host:port``,例如 ``"127.0.0.1:9222"`` + :param webdriver_name: 預設 ``"chrome"``,亦可為 ``"edge"`` / ``"chromium"`` + :param options: 額外 CLI 啟動參數 (一般 attach 場景不需要) + :param experimental_options: 其他要合併的實驗性參數 + :return: 已連接的 WebDriver 實例 / attached WebDriver instance + """ + web_runner_logger.info( + f"WebDriverWrapper attach_to_existing_browser, debugger_address: {debugger_address}, " + f"webdriver_name: {webdriver_name}" + ) + merged_exp = dict(experimental_options or {}) + merged_exp["debuggerAddress"] = debugger_address + return self.set_driver( + webdriver_name, + options=options, + experimental_options=merged_exp, + **kwargs, + ) + # web element def find_element(self, test_object: TestObject) -> WebElement | None: """ @@ -330,91 +456,6 @@ def explict_wait(self, wait_time: int, method: typing.Callable = None, until_typ ) record_action_to_list("webdriver wrapper explict_wait", param, error) - # webdriver url redirect - def to_url(self, url: str) -> None: - """ - 導航到指定 URL - Navigate to a given URL - """ - web_runner_logger.info(f"WebDriverWrapper to_url, url: {url}") - param = locals() - try: - self.current_webdriver.get(url) - record_action_to_list("webdriver wrapper to_url", param, None) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper to_url failed: {repr(error)}") - record_action_to_list("webdriver wrapper to_url", param, error) - - def forward(self) -> None: - """前進到下一頁 / Navigate forward""" - web_runner_logger.info("WebDriverWrapper forward") - try: - self.current_webdriver.forward() - record_action_to_list("webdriver wrapper forward", None, None) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper forward failed: {repr(error)}") - record_action_to_list("webdriver wrapper forward", None, error) - - def back(self) -> None: - """返回上一頁 / Navigate back""" - web_runner_logger.info("WebDriverWrapper back") - try: - self.current_webdriver.back() - record_action_to_list("webdriver wrapper back", None, None) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper back failed: {repr(error)}") - record_action_to_list("webdriver wrapper back", None, error) - - def refresh(self) -> None: - """重新整理頁面 / Refresh current page""" - web_runner_logger.info("WebDriverWrapper refresh") - try: - self.current_webdriver.refresh() - record_action_to_list("webdriver wrapper refresh", None, None) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper refresh failed: {repr(error)}") - record_action_to_list("webdriver wrapper refresh", None, error) - - # webdriver new page - def switch(self, switch_type: str, switch_target_name: str = None): - """ - 切換 WebDriver 的上下文 (frame, window, alert...) - Switch WebDriver context (frame, window, alert...) - - :param switch_type: [active_element, default_content, frame, parent_frame, window, alert] - :param switch_target_name: 目標名稱 (frame 名稱或 window handle) - :return: 切換後的目標物件 / switched target - """ - web_runner_logger.info( - f"WebDriverWrapper switch, switch_type: {switch_type}, switch_target_name: {switch_target_name}" - ) - param = locals() - try: - switch_type = switch_type.lower() - switch_type_dict = { - "active_element": self.current_webdriver.switch_to.active_element, - "default_content": self.current_webdriver.switch_to.default_content, - "frame": self.current_webdriver.switch_to.frame, - "parent_frame": self.current_webdriver.switch_to.parent_frame, - "window": self.current_webdriver.switch_to.window, - } - try: - switch_type_dict.update({"alert": self.current_webdriver.switch_to.alert}) - except NoAlertPresentException as error: - switch_type_dict.update({"alert": None}) - web_runner_logger.error(f"WebDriverWrapper switch alert failed: {repr(error)}") - - record_action_to_list("webdriver wrapper switch", param, None) - if switch_type in ["active_element", "alert"]: - return switch_type_dict.get(switch_type) - elif switch_type in ["default_content", "parent_frame"]: - return switch_type_dict.get(switch_type)() - else: - return switch_type_dict.get(switch_type)(switch_target_name) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper switch failed: {repr(error)}") - record_action_to_list("webdriver wrapper switch", param, error) - # timeout def set_script_timeout(self, time_to_wait: int) -> None: """設定 script 最大執行時間 / Set max script execution time""" @@ -438,985 +479,6 @@ def set_page_load_timeout(self, time_to_wait: int) -> None: web_runner_logger.error(f"WebDriverWrapper set_page_load_timeout failed: {repr(error)}") record_action_to_list("webdriver wrapper set_page_load_timeout", param, error) - # cookie - def get_cookies(self) -> list[dict] | None: - """ - 取得當前頁面的所有 cookies - Get all cookies from the current page - - :return: cookies 清單,每個 cookie 是 dict - list of cookies, each cookie is a dict - """ - web_runner_logger.info("WebDriverWrapper get_cookies") - try: - record_action_to_list("webdriver wrapper get_cookies", None, None) - return self.current_webdriver.get_cookies() - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper get_cookies, failed: {repr(error)}") - record_action_to_list("webdriver wrapper get_cookies", None, error) - - def get_cookie(self, name: str) -> dict | None: - """ - 取得指定名稱的 cookie - Get a cookie by name - - :param name: cookie 名稱 / cookie name - :return: cookie dict - """ - web_runner_logger.info(f"WebDriverWrapper get_cookie, name: {name}") - param = locals() - try: - record_action_to_list("webdriver wrapper get_cookie", param, None) - return self.current_webdriver.get_cookie(name) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper get_cookie, name: {name}, failed: {repr(error)}") - record_action_to_list("webdriver wrapper get_cookie", param, error) - - def add_cookie(self, cookie_dict: dict) -> None: - """ - 新增 cookie 到當前頁面 - Add a cookie to the current page - - :param cookie_dict: cookie dict,例如 {"name": "session", "value": "12345"} - """ - web_runner_logger.info(f"WebDriverWrapper add_cookie, cookie_dict: {cookie_dict}") - param = locals() - try: - self.current_webdriver.add_cookie(cookie_dict) - record_action_to_list("webdriver wrapper add_cookie", param, None) - except Exception as error: - web_runner_logger.error( - f"WebDriverWrapper add_cookie, cookie_dict: {cookie_dict}, failed: {repr(error)}" - ) - record_action_to_list("webdriver wrapper add_cookie", param, error) - - def delete_cookie(self, name: str) -> None: - """ - 刪除指定名稱的 cookie - Delete a cookie by name - - :param name: cookie 名稱 / cookie name - """ - web_runner_logger.info(f"WebDriverWrapper delete_cookie, name: {name}") - param = locals() - try: - self.current_webdriver.delete_cookie(name) - record_action_to_list("webdriver wrapper delete_cookie", param, None) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper delete_cookie, name: {name}, failed: {repr(error)}") - record_action_to_list("webdriver wrapper delete_cookie", param, error) - - def delete_all_cookies(self) -> None: - """ - 刪除當前頁面的所有 cookies - Delete all cookies from the current page - """ - web_runner_logger.info("WebDriverWrapper delete_all_cookies") - try: - self.current_webdriver.delete_all_cookies() - record_action_to_list("webdriver wrapper delete_all_cookies", None, None) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper delete_all_cookies, failed: {repr(error)}") - record_action_to_list("webdriver wrapper delete_all_cookies", None, error) - - # exec selenium command - def execute(self, driver_command: str, params: dict = None) -> dict | None: - """ - 執行 Selenium WebDriver 的底層命令 - Execute a raw WebDriver command - - :param driver_command: WebDriver 指令名稱 / WebDriver command name - :param params: 指令參數 / command parameters - :return: 執行結果 (dict) / execution result as dict - """ - web_runner_logger.info(f"WebDriverWrapper execute, driver_command: {driver_command}, params: {params}") - param = locals() - try: - record_action_to_list("webdriver wrapper execute", param, None) - return self.current_webdriver.execute(driver_command, params) - except Exception as error: - web_runner_logger.error( - f"WebDriverWrapper execute, driver_command: {driver_command}, params: {params}, failed: {repr(error)}" - ) - record_action_to_list("webdriver wrapper execute", param, error) - - def execute_script(self, script: str, *args): - """ - 在當前頁面執行 JavaScript,回傳 JS 的回傳值。 - Execute JavaScript on the current page and return the result. - - :param script: JavaScript 程式碼 / JavaScript code - :param args: 傳入 JS 的參數 / arguments passed to JS - :return: JS 回傳值(dict / list / 字面值 / None) - The value returned by the script (dict / list / literal / None) - """ - web_runner_logger.info(f"WebDriverWrapper execute_script, script: {script}") - param = locals() - try: - value = self.current_webdriver.execute_script(script, *args) - record_action_to_list("webdriver wrapper execute_script", param, None) - return value - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper execute_script, script: {script}, failed: {repr(error)}") - record_action_to_list("webdriver wrapper execute_script", param, error) - return None - - def execute_async_script(self, script: str, *args): - """ - 執行非同步 JavaScript - Execute asynchronous JavaScript - - :param script: 要執行的 JS 程式碼 / JavaScript code to execute - :param args: 傳入 JS 的參數 / arguments passed to JS - :return: JS 執行結果 (非同步回傳) / result of async JS execution - """ - web_runner_logger.info(f"WebDriverWrapper execute_async_script, script: {script}") - param = locals() - try: - result = self.current_webdriver.execute_async_script(script, *args) - record_action_to_list("webdriver wrapper execute_async_script", param, None) - return result - except Exception as error: - web_runner_logger.error( - f"WebDriverWrapper execute_async_script, script: {script}, failed: {repr(error)}" - ) - record_action_to_list("webdriver wrapper execute_async_script", param, error) - - # ActionChains - def move_to_element(self, target_element: WebElement) -> None: - """ - 將滑鼠移動到指定元素 - Move mouse to target web element - - :param target_element: 目標 WebElement / target web element - """ - web_runner_logger.info(f"WebDriverWrapper move_to_element, target_element: {target_element}") - param = locals() - try: - self._action_chain.move_to_element(target_element) - record_action_to_list("webdriver wrapper move_to_element", param, None) - except Exception as error: - web_runner_logger.error( - f"WebDriverWrapper move_to_element, target_element: {target_element}, failed: {repr(error)}" - ) - record_action_to_list("webdriver wrapper move_to_element", param, error) - - def move_to_element_with_test_object(self, element_name: str): - """ - 使用 TestObjectRecord 中的元素名稱,將滑鼠移動到指定元素 - Move mouse to target element using TestObjectRecord - - :param element_name: 測試物件名稱 / test object name - """ - web_runner_logger.info(f"WebDriverWrapper move_to_element_with_test_object, element_name: {element_name}") - param = locals() - try: - record = test_object_record.test_object_record_dict.get(element_name) - if record is None: - raise WebRunnerException(f"TestObject '{element_name}' not found") - element = self.current_webdriver.find_element(record.test_object_type, record.test_object_name) - self._action_chain.move_to_element(element) - record_action_to_list("webdriver wrapper move_to_element_with_test_object", param, None) - except Exception as error: - web_runner_logger.error( - f"WebDriverWrapper move_to_element_with_test_object, element_name: {element_name}, failed: {repr(error)}" - ) - record_action_to_list("webdriver wrapper move_to_element_with_test_object", param, error) - - def move_to_element_with_offset(self, target_element: WebElement, offset_x: int, offset_y: int) -> None: - """ - 將滑鼠移動到指定元素,並加上偏移量 - Move mouse to target element with offset - - :param target_element: 目標 WebElement / target web element - :param offset_x: X 軸偏移量 / offset on X axis - :param offset_y: Y 軸偏移量 / offset on Y axis - """ - web_runner_logger.info( - f"WebDriverWrapper move_to_element_with_offset, target_element: {target_element}, " - f"offset_x: {offset_x}, offset_y: {offset_y}" - ) - param = locals() - try: - self._action_chain.move_to_element_with_offset(target_element, offset_x, offset_y) - record_action_to_list("webdriver wrapper move_to_element_with_offset", param, None) - except Exception as error: - web_runner_logger.error( - f"WebDriverWrapper move_to_element_with_offset failed: {repr(error)}" - ) - record_action_to_list("webdriver wrapper move_to_element_with_offset", param, error) - - def move_to_element_with_offset_and_test_object(self, element_name: str, offset_x: int, offset_y: int) -> None: - """ - 使用 TestObjectRecord 中的元素名稱,將滑鼠移動到指定元素並加上偏移量 - Move mouse to target element with offset using TestObjectRecord - - :param element_name: 測試物件名稱 / test object name - :param offset_x: X 軸偏移量 / offset on X axis - :param offset_y: Y 軸偏移量 / offset on Y axis - """ - web_runner_logger.info( - f"WebDriverWrapper move_to_element_with_offset_and_test_object, element_name: {element_name}, " - f"offset_x: {offset_x}, offset_y: {offset_y}" - ) - param = locals() - try: - record = test_object_record.test_object_record_dict.get(element_name) - if record is None: - raise WebRunnerException(f"TestObject '{element_name}' not found") - element = self.current_webdriver.find_element(record.test_object_type, record.test_object_name) - self._action_chain.move_to_element_with_offset(element, offset_x, offset_y) - record_action_to_list("webdriver wrapper move_to_element_with_offset_and_test_object", param, None) - except Exception as error: - web_runner_logger.error( - f"WebDriverWrapper move_to_element_with_offset_and_test_object failed: {repr(error)}" - ) - record_action_to_list("webdriver wrapper move_to_element_with_offset_and_test_object", param, error) - - def drag_and_drop(self, web_element: WebElement, target_element: WebElement) -> None: - """ - 拖曳元素到另一個元素上並釋放 - Drag a web element to another target element and drop - - :param web_element: 要拖曳的元素 / element to drag - :param target_element: 目標元素 / target element to drop onto - """ - web_runner_logger.info( - f"WebDriverWrapper drag_and_drop, web_element: {web_element}, target_element: {target_element}" - ) - param = locals() - try: - self._action_chain.drag_and_drop(web_element, target_element) - record_action_to_list("webdriver wrapper drag_and_drop", param, None) - except Exception as error: - web_runner_logger.error( - f"WebDriverWrapper drag_and_drop failed: {repr(error)}" - ) - record_action_to_list("webdriver wrapper drag_and_drop", param, error) - - def drag_and_drop_with_test_object(self, element_name: str, target_element_name: str) -> None: - """ - 使用 TestObjectRecord 中的元素名稱,拖曳元素到另一個元素上 - Drag a web element to another target element using TestObjectRecord - - :param element_name: 要拖曳的元素名稱 / name of element to drag - :param target_element_name: 目標元素名稱 / name of target element - """ - web_runner_logger.info( - f"WebDriverWrapper drag_and_drop_with_test_object, element_name: {element_name}, " - f"target_element_name: {target_element_name}" - ) - param = locals() - try: - element_record = test_object_record.test_object_record_dict.get(element_name) - target_record = test_object_record.test_object_record_dict.get(target_element_name) - if element_record is None or target_record is None: - raise WebRunnerException(f"TestObject not found: {element_name} or {target_element_name}") - - element = self.current_webdriver.find_element(element_record.test_object_type, - element_record.test_object_name) - another_element = self.current_webdriver.find_element(target_record.test_object_type, - target_record.test_object_name) - - self._action_chain.drag_and_drop(element, another_element) - record_action_to_list("webdriver wrapper drag_and_drop_with_test_object", param, None) - except Exception as error: - web_runner_logger.error( - f"WebDriverWrapper drag_and_drop_with_test_object failed: {repr(error)}" - ) - record_action_to_list("webdriver wrapper drag_and_drop_with_test_object", param, error) - - def drag_and_drop_offset(self, web_element: WebElement, target_x: int, target_y: int) -> None: - """ - 拖曳元素到指定偏移位置 - Drag a web element to a position with offset - - :param web_element: 要拖曳的元素 / element to drag - :param target_x: X 軸偏移量 / offset on X axis - :param target_y: Y 軸偏移量 / offset on Y axis - """ - web_runner_logger.info( - f"WebDriverWrapper drag_and_drop_offset, web_element: {web_element}, " - f"target_x: {target_x}, target_y: {target_y}" - ) - param = locals() - try: - self._action_chain.drag_and_drop_by_offset(web_element, target_x, target_y) - record_action_to_list("webdriver wrapper drag_and_drop_offset", param, None) - except Exception as error: - web_runner_logger.error( - f"WebDriverWrapper drag_and_drop_offset failed: {repr(error)}" - ) - record_action_to_list("webdriver wrapper drag_and_drop_offset", param, error) - - def drag_and_drop_offset_with_test_object(self, element_name: str, offset_x: int, offset_y: int) -> None: - """ - 使用 TestObjectRecord 中的元素名稱,拖曳元素到指定偏移位置 - Drag a web element with offset using TestObjectRecord - - :param element_name: 測試物件名稱 / test object name - :param offset_x: X 軸偏移量 / offset on X axis - :param offset_y: Y 軸偏移量 / offset on Y axis - """ - web_runner_logger.info( - f"WebDriverWrapper drag_and_drop_offset_with_test_object, element_name: {element_name}, " - f"offset_x: {offset_x}, offset_y: {offset_y}" - ) - param = locals() - try: - record = test_object_record.test_object_record_dict.get(element_name) - if record is None: - raise WebRunnerException(f"TestObject not found: {element_name}") - - element = self.current_webdriver.find_element(record.test_object_type, record.test_object_name) - self._action_chain.drag_and_drop_by_offset(element, offset_x, offset_y) - record_action_to_list("webdriver wrapper drag_and_drop_offset_with_test_object", param, None) - except Exception as error: - web_runner_logger.error( - f"WebDriverWrapper drag_and_drop_offset_with_test_object failed: {repr(error)}" - ) - record_action_to_list("webdriver wrapper drag_and_drop_offset_with_test_object", param, error) - - def perform(self) -> None: - """ - 執行累積的 ActionChains 動作 - Perform all queued ActionChains actions. - - Selenium 的 ActionChains 是「先排隊、後一次執行」模型。 - ``WR_left_click_and_hold`` / ``WR_move_to_element`` / - ``WR_release`` / ``WR_press_key`` 等命令只是把動作排入佇列, - 必須最後呼叫 ``WR_perform`` 才會真的觸發;中途要清除請用 - ``WR_reset_actions``。對單純點擊或輸入請改用 - ``WR_element_click`` / ``WR_element_input`` 直接執行,免用 - ActionChains。 - - Selenium ActionChains is a queue-then-execute model. Commands like - ``WR_left_click_and_hold`` / ``WR_move_to_element`` / - ``WR_release`` / ``WR_press_key`` only enqueue the action; you must - call ``WR_perform`` at the end to actually fire them, and - ``WR_reset_actions`` to drop the queue mid-flow. For simple clicks - or text input prefer ``WR_element_click`` / ``WR_element_input``, - which run synchronously. - """ - web_runner_logger.info("WebDriverWrapper perform") - try: - self._action_chain.perform() - record_action_to_list("webdriver wrapper perform", None, None) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper perform failed: {repr(error)}") - record_action_to_list("webdriver wrapper perform", None, error) - - def reset_actions(self) -> None: - """ - 清除目前累積的 ActionChains 動作(搭配 ``WR_perform`` 使用) - Clear all queued ActionChains actions. - - Use this together with ``WR_perform`` when you want to abort an - ActionChains sequence partway through. See ``perform`` above for - the queue-then-execute model. - """ - web_runner_logger.info("WebDriverWrapper reset_actions") - try: - self._action_chain.reset_actions() - record_action_to_list("webdriver wrapper reset_actions", None, None) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper reset_actions failed: {repr(error)}") - record_action_to_list("webdriver wrapper reset_actions", None, error) - - def left_click(self, on_element: WebElement = None) -> None: - """ - 滑鼠左鍵點擊 (可指定元素或當前位置) - Left click mouse at current position or on a given element - - :param on_element: WebElement 或 None - """ - web_runner_logger.info(f"WebDriverWrapper left_click, on_element: {on_element}") - param = locals() - try: - self._action_chain.click(on_element) - record_action_to_list("webdriver wrapper left_click", param, None) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper left_click failed: {repr(error)}") - record_action_to_list("webdriver wrapper left_click", param, error) - - def left_click_with_test_object(self, element_name: str = None) -> None: - """ - 使用 TestObject 名稱找到元素並左鍵點擊 - Left click using a TestObject name - - :param element_name: 測試物件名稱 / test object name - """ - web_runner_logger.info(f"WebDriverWrapper left_click_with_test_object, element_name: {element_name}") - param = locals() - try: - if element_name is None: - self._action_chain.click(None) - else: - record = test_object_record.test_object_record_dict.get(element_name) - if record is None: - raise WebRunnerException(f"TestObject '{element_name}' not found") - element = self.current_webdriver.find_element(record.test_object_type, record.test_object_name) - self._action_chain.click(element) - record_action_to_list("webdriver wrapper left_click_with_test_object", param, None) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper left_click_with_test_object failed: {repr(error)}") - record_action_to_list("webdriver wrapper left_click_with_test_object", param, error) - - def left_click_and_hold(self, on_element: WebElement = None) -> None: - """ - 滑鼠左鍵按住 (可指定元素或當前位置) - Left click and hold mouse at current position or on a given element - """ - web_runner_logger.info(f"WebDriverWrapper left_click_and_hold, on_element: {on_element}") - param = locals() - try: - self._action_chain.click_and_hold(on_element) - record_action_to_list("webdriver wrapper left_click_and_hold", param, None) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper left_click_and_hold failed: {repr(error)}") - record_action_to_list("webdriver wrapper left_click_and_hold", param, error) - - def left_click_and_hold_with_test_object(self, element_name: str = None) -> None: - """ - 使用 TestObject 名稱找到元素並左鍵按住 - Left click and hold using a TestObject name - """ - web_runner_logger.info(f"WebDriverWrapper left_click_and_hold_with_test_object, element_name: {element_name}") - param = locals() - try: - if element_name is None: - self._action_chain.click_and_hold(None) - else: - record = test_object_record.test_object_record_dict.get(element_name) - if record is None: - raise WebRunnerException(f"TestObject '{element_name}' not found") - element = self.current_webdriver.find_element(record.test_object_type, record.test_object_name) - self._action_chain.click_and_hold(element) - record_action_to_list("webdriver wrapper left_click_and_hold_with_test_object", param, None) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper left_click_and_hold_with_test_object failed: {repr(error)}") - record_action_to_list("webdriver wrapper left_click_and_hold_with_test_object", param, error) - - def right_click(self, on_element: WebElement = None) -> None: - """ - 滑鼠右鍵點擊 (可指定元素或當前位置) - Right click mouse at current position or on a given element - """ - web_runner_logger.info(f"WebDriverWrapper right_click, on_element: {on_element}") - param = locals() - try: - self._action_chain.context_click(on_element) - record_action_to_list("webdriver wrapper right_click", param, None) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper right_click failed: {repr(error)}") - record_action_to_list("webdriver wrapper right_click", param, error) - - def right_click_with_test_object(self, element_name: str = None) -> None: - """ - 使用 TestObject 名稱找到元素並右鍵點擊 - Right click using a TestObject name - """ - web_runner_logger.info(f"WebDriverWrapper right_click_with_test_object, element_name: {element_name}") - param = locals() - try: - if element_name is None: - self._action_chain.context_click(None) - else: - record = test_object_record.test_object_record_dict.get(element_name) - if record is None: - raise WebRunnerException(f"TestObject '{element_name}' not found") - element = self.current_webdriver.find_element(record.test_object_type, record.test_object_name) - self._action_chain.context_click(element) - record_action_to_list("webdriver wrapper right_click_with_test_object", param, None) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper right_click_with_test_object failed: {repr(error)}") - record_action_to_list("webdriver wrapper right_click_with_test_object", param, error) - - def left_double_click(self, on_element: WebElement = None) -> None: - """ - 滑鼠左鍵雙擊 (可指定元素或當前位置) - Double left click mouse at current position or on a given element - - :param on_element: WebElement 或 None - """ - web_runner_logger.info(f"WebDriverWrapper left_double_click, on_element: {on_element}") - param = locals() - try: - self._action_chain.double_click(on_element) - record_action_to_list("webdriver wrapper left_double_click", param, None) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper left_double_click failed: {repr(error)}") - record_action_to_list("webdriver wrapper left_double_click", param, error) - - def left_double_click_with_test_object(self, element_name: str = None) -> None: - """ - 使用 TestObject 名稱找到元素並左鍵雙擊 - Double left click using a TestObject name - - :param element_name: 測試物件名稱 / test object name - """ - web_runner_logger.info(f"WebDriverWrapper left_double_click_with_test_object, element_name: {element_name}") - param = locals() - try: - if element_name is None: - self._action_chain.double_click(None) - else: - record = test_object_record.test_object_record_dict.get(element_name) - if record is None: - raise WebRunnerException(f"TestObject '{element_name}' not found") - web_element_wrapper.current_web_element = self.current_webdriver.find_element( - record.test_object_type, record.test_object_name - ) - self._action_chain.double_click(web_element_wrapper.current_web_element) - record_action_to_list("webdriver wrapper left_double_click_with_test_object", param, None) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper left_double_click_with_test_object failed: {repr(error)}") - record_action_to_list("webdriver wrapper left_double_click_with_test_object", param, error) - - def release(self, on_element: WebElement = None) -> None: - """ - 釋放滑鼠 (可指定元素或當前位置) - Release mouse button at current position or on a given element - """ - web_runner_logger.info(f"WebDriverWrapper release, on_element: {on_element}") - param = locals() - try: - self._action_chain.release(on_element) - record_action_to_list("webdriver wrapper release", param, None) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper release failed: {repr(error)}") - record_action_to_list("webdriver wrapper release", param, error) - - def release_with_test_object(self, element_name: str = None) -> None: - """ - 使用 TestObject 名稱找到元素並釋放滑鼠 - Release mouse button using a TestObject name - """ - web_runner_logger.info(f"WebDriverWrapper release_with_test_object, element_name: {element_name}") - param = locals() - try: - if element_name is None: - self._action_chain.release(None) - else: - record = test_object_record.test_object_record_dict.get(element_name) - if record is None: - raise WebRunnerException(f"TestObject '{element_name}' not found") - web_element_wrapper.current_web_element = self.current_webdriver.find_element( - record.test_object_type, record.test_object_name - ) - self._action_chain.release(web_element_wrapper.current_web_element) - record_action_to_list("webdriver wrapper release_with_test_object", param, None) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper release_with_test_object failed: {repr(error)}") - record_action_to_list("webdriver wrapper release_with_test_object", param, error) - - def press_key(self, keycode_on_key_class, on_element: WebElement = None) -> None: - """ - 按下鍵盤按鍵 (可指定元素或當前位置) - Press a key on keyboard, optionally on a given element - - :param keycode_on_key_class: 要按下的鍵 (來自 selenium.webdriver.common.keys.Keys) - key to press (from selenium.webdriver.common.keys.Keys) - :param on_element: WebElement 或 None - """ - web_runner_logger.info( - f"WebDriverWrapper press_key, keycode_on_key_class: {keycode_on_key_class}, on_element: {on_element}" - ) - param = locals() - try: - self._action_chain.key_down(keycode_on_key_class, on_element) - record_action_to_list("webdriver wrapper press_key", param, None) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper press_key failed: {repr(error)}") - record_action_to_list("webdriver wrapper press_key", param, error) - - def press_key_with_test_object(self, keycode_on_key_class, element_name: str = None) -> None: - """ - 使用 TestObject 名稱找到元素並按下鍵盤按鍵 - Press a key on keyboard using a TestObject name - - :param keycode_on_key_class: 要按下的鍵 (selenium Keys) - :param element_name: 測試物件名稱 / test object name - """ - web_runner_logger.info( - f"WebDriverWrapper press_key_with_test_object, keycode_on_key_class: {keycode_on_key_class}, element_name: {element_name}" - ) - param = locals() - try: - if element_name is None: - self._action_chain.key_down(keycode_on_key_class, None) - else: - record = test_object_record.test_object_record_dict.get(element_name) - if record is None: - raise WebRunnerException(f"TestObject '{element_name}' not found") - web_element_wrapper.current_web_element = self.current_webdriver.find_element( - record.test_object_type, record.test_object_name - ) - self._action_chain.key_down(keycode_on_key_class, web_element_wrapper.current_web_element) - record_action_to_list("webdriver wrapper press_key_with_test_object", param, None) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper press_key_with_test_object failed: {repr(error)}") - record_action_to_list("webdriver wrapper press_key_with_test_object", param, error) - - def release_key(self, keycode_on_key_class, on_element: WebElement = None) -> None: - """ - 釋放鍵盤按鍵 (可指定元素或當前位置) - Release a key on keyboard, optionally on a given element - - :param keycode_on_key_class: 要釋放的鍵 (selenium Keys) - :param on_element: WebElement 或 None - """ - web_runner_logger.info( - f"WebDriverWrapper release_key, keycode_on_key_class: {keycode_on_key_class}, on_element: {on_element}" - ) - param = locals() - try: - self._action_chain.key_up(keycode_on_key_class, on_element) - record_action_to_list("webdriver wrapper release_key", param, None) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper release_key failed: {repr(error)}") - record_action_to_list("webdriver wrapper release_key", param, error) - - def release_key_with_test_object(self, keycode_on_key_class, element_name: str = None) -> None: - """ - 使用 TestObject 名稱找到元素並釋放鍵盤按鍵 - Release a key on keyboard using a TestObject name - - :param keycode_on_key_class: 要釋放的鍵 (selenium Keys) - :param element_name: 測試物件名稱 / test object name - """ - web_runner_logger.info( - f"WebDriverWrapper release_key_with_test_object, keycode_on_key_class: {keycode_on_key_class}, element_name: {element_name}" - ) - param = locals() - try: - if element_name is None: - self._action_chain.key_up(keycode_on_key_class, None) - else: - record = test_object_record.test_object_record_dict.get(element_name) - if record is None: - raise WebRunnerException(f"TestObject '{element_name}' not found") - web_element_wrapper.current_web_element = self.current_webdriver.find_element( - record.test_object_type, record.test_object_name - ) - self._action_chain.key_up(keycode_on_key_class, web_element_wrapper.current_web_element) - record_action_to_list("webdriver wrapper release_key_with_test_object", param, None) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper release_key_with_test_object failed: {repr(error)}") - record_action_to_list("webdriver wrapper release_key_with_test_object", param, error) - - def move_by_offset(self, offset_x: int, offset_y: int) -> None: - """ - 滑鼠移動指定偏移量 - Move mouse by offset - - :param offset_x: X 軸偏移量 / offset on X axis - :param offset_y: Y 軸偏移量 / offset on Y axis - """ - web_runner_logger.info(f"WebDriverWrapper move_by_offset, offset_x: {offset_x}, offset_y: {offset_y}") - param = locals() - try: - self._action_chain.move_by_offset(offset_x, offset_y) - record_action_to_list("webdriver wrapper move_by_offset", param, None) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper move_by_offset failed: {repr(error)}") - record_action_to_list("webdriver wrapper move_by_offset", param, error) - - def pause(self, seconds: int) -> None: - """ - 暫停指定秒數 (注意:可能導致 Selenium 拋出例外) - Pause for a number of seconds (may cause Selenium exceptions) - - :param seconds: 暫停秒數 / seconds to pause - """ - web_runner_logger.info(f"WebDriverWrapper pause, seconds: {seconds}") - param = locals() - try: - self._action_chain.pause(seconds) - record_action_to_list("webdriver wrapper pause", param, None) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper pause failed: {repr(error)}") - record_action_to_list("webdriver wrapper pause", param, error) - - def send_keys(self, keys_to_send) -> None: - """ - 發送鍵盤按鍵 (按下並釋放) - Send (press and release) keyboard keys - - :param keys_to_send: 要發送的鍵 (可多個) / keys to send (can be multiple) - """ - web_runner_logger.info(f"WebDriverWrapper send_keys, keys_to_send: {keys_to_send}") - param = locals() - try: - self._action_chain.send_keys(*keys_to_send) - record_action_to_list("webdriver wrapper send_keys", param, None) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper send_keys failed: {repr(error)}") - record_action_to_list("webdriver wrapper send_keys", param, error) - - def send_keys_to_element(self, element: WebElement, keys_to_send) -> None: - """ - 發送鍵盤按鍵到指定元素 - Send keyboard keys to a given element - - :param element: 目標元素 / target element - :param keys_to_send: 要發送的鍵 / keys to send - """ - web_runner_logger.info( - f"WebDriverWrapper send_keys_to_element, element: {element}, keys_to_send: {keys_to_send}") - param = locals() - try: - self._action_chain.send_keys_to_element(element, keys_to_send) - record_action_to_list("webdriver wrapper send_keys_to_element", param, None) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper send_keys_to_element failed: {repr(error)}") - record_action_to_list("webdriver wrapper send_keys_to_element", param, error) - - def send_keys_to_element_with_test_object(self, element_name: str, keys_to_send) -> None: - """ - 使用 TestObject 名稱找到元素並發送鍵盤按鍵 - Send keyboard keys to an element using a TestObject name - - :param element_name: 測試物件名稱 / test object name - :param keys_to_send: 要發送的鍵 / keys to send - """ - web_runner_logger.info( - f"WebDriverWrapper send_keys_to_element_with_test_object, element_name: {element_name}, keys_to_send: {keys_to_send}" - ) - param = locals() - try: - record = test_object_record.test_object_record_dict.get(element_name) - if record is None: - raise WebRunnerException(f"TestObject '{element_name}' not found") - web_element_wrapper.current_web_element = self.current_webdriver.find_element( - record.test_object_type, record.test_object_name - ) - self._action_chain.send_keys_to_element(web_element_wrapper.current_web_element, *keys_to_send) - record_action_to_list("webdriver wrapper send_keys_to_element_with_test_object", param, None) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper send_keys_to_element_with_test_object failed: {repr(error)}") - record_action_to_list("webdriver wrapper send_keys_to_element_with_test_object", param, error) - - def scroll(self, scroll_x: int, scroll_y: int) -> None: - """ - 滾動頁面 - Scroll the page - - :param scroll_x: 滾動的 X 軸距離 / distance to scroll on X axis - :param scroll_y: 滾動的 Y 軸距離 / distance to scroll on Y axis - """ - web_runner_logger.info( - f"WebDriverWrapper scroll, scroll_x: {scroll_x}, scroll_y: {scroll_y}" - ) - param = locals() - try: - self._action_chain.scroll_by_amount(scroll_x, scroll_y) - record_action_to_list("webdriver wrapper scroll", param, None) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper scroll failed: {repr(error)}") - record_action_to_list("webdriver wrapper scroll", param, error) - - # window - def maximize_window(self) -> None: - """ - 最大化當前視窗 - Maximize the current browser window - """ - web_runner_logger.info("WebDriverWrapper maximize_window") - try: - self.current_webdriver.maximize_window() - record_action_to_list("webdriver wrapper maximize_window", None, None) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper maximize_window failed: {repr(error)}") - record_action_to_list("webdriver wrapper maximize_window", None, error) - - def fullscreen_window(self) -> None: - """ - 全螢幕顯示當前視窗 - Fullscreen the current browser window - """ - web_runner_logger.info("WebDriverWrapper fullscreen_window") - try: - self.current_webdriver.fullscreen_window() - record_action_to_list("webdriver wrapper fullscreen_window", None, None) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper fullscreen_window failed: {repr(error)}") - record_action_to_list("webdriver wrapper fullscreen_window", None, error) - - def minimize_window(self) -> None: - """ - 最小化當前視窗 - Minimize the current browser window - """ - web_runner_logger.info("WebDriverWrapper minimize_window") - try: - self.current_webdriver.minimize_window() - record_action_to_list("webdriver wrapper minimize_window", None, None) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper minimize_window failed: {repr(error)}") - record_action_to_list("webdriver wrapper minimize_window", None, error) - - def set_window_size(self, width: int, height: int, window_handle: str = 'current') -> None: - """ - 設定視窗大小 - Set the window size - - :param width: 視窗寬度 (像素) / window width in pixels - :param height: 視窗高度 (像素) / window height in pixels - :param window_handle: 預設為 "current" (w3c 標準),若非 "current" 可能會拋出例外 - normally "current" (w3c), otherwise may raise exception - :return: 視窗大小資訊 (dict) / window size info (dict) - """ - web_runner_logger.info( - f"WebDriverWrapper set_window_size, width: {width}, height: {height}, window_handle: {window_handle}" - ) - param = locals() - try: - record_action_to_list("webdriver wrapper set_window_size", param, None) - self.current_webdriver.set_window_size(width, height, window_handle) - except Exception as error: - web_runner_logger.error( - f"WebDriverWrapper set_window_size failed: {repr(error)}" - ) - record_action_to_list("webdriver wrapper set_window_size", param, error) - - def set_window_position(self, x: int, y: int, window_handle: str = 'current') -> dict | None: - """ - 設定視窗位置 - Set the window position - - :param x: 視窗左上角的 X 座標 / X coordinate of the window - :param y: 視窗左上角的 Y 座標 / Y coordinate of the window - :param window_handle: 預設為 "current" (w3c 標準),若非 "current" 可能會拋出例外 - normally "current" (w3c), otherwise may raise exception - :return: 視窗位置與大小資訊 (dict) / window rect info (dict) - """ - web_runner_logger.info( - f"WebDriverWrapper set_window_position, x: {x}, y: {y}, window_handle: {window_handle}" - ) - param = locals() - try: - record_action_to_list("webdriver wrapper set_window_position", param, None) - return self.current_webdriver.set_window_position(x, y, window_handle) - except Exception as error: - web_runner_logger.error( - f"WebDriverWrapper set_window_position failed: {repr(error)}" - ) - record_action_to_list("webdriver wrapper set_window_position", param, error) - - def get_window_position(self, window_handle='current') -> dict | None: - """ - 取得視窗位置 - Get window position - - :param window_handle: 預設為 "current" (w3c 標準),若非 "current" 可能會拋出例外 - :return: 視窗位置 dict,例如 {"x": 100, "y": 200} - """ - web_runner_logger.info(f"WebDriverWrapper get_window_position, window_handle: {window_handle}") - param = locals() - try: - record_action_to_list("webdriver wrapper get_window_position", param, None) - return self.current_webdriver.get_window_position(window_handle) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper get_window_position failed: {repr(error)}") - record_action_to_list("webdriver wrapper get_window_position", param, error) - - def get_window_rect(self) -> dict | None: - """ - 取得視窗矩形資訊 (位置與大小) - Get window rect (position and size) - - :return: dict, e.g. {"x": 100, "y": 200, "width": 1280, "height": 720} - """ - web_runner_logger.info("WebDriverWrapper get_window_rect") - try: - record_action_to_list("webdriver wrapper get_window_rect", None, None) - return self.current_webdriver.get_window_rect() - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper get_window_rect failed: {repr(error)}") - record_action_to_list("webdriver wrapper get_window_rect", None, error) - - def set_window_rect(self, x: int = None, y: int = None, width: int = None, height: int = None) -> dict | None: - """ - 設定視窗矩形 (位置與大小),僅支援 W3C 相容瀏覽器 - Set window rect (position and size), only supported for W3C compatible browsers - - :param x: X 座標 - :param y: Y 座標 - :param width: 視窗寬度 - :param height: 視窗高度 - :return: dict, e.g. {"x": 100, "y": 200, "width": 1280, "height": 720} - """ - web_runner_logger.info( - f"WebDriverWrapper set_window_rect, x: {x}, y: {y}, width: {width}, height: {height}") - param = locals() - try: - record_action_to_list("webdriver wrapper set_window_rect", param, None) - return self.current_webdriver.set_window_rect(x, y, width, height) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper set_window_rect failed: {repr(error)}") - record_action_to_list("webdriver wrapper set_window_rect", param, error) - - # save as file - def get_screenshot_as_png(self) -> bytes | None: - """ - 取得當前頁面截圖 (PNG 格式) - Get current page screenshot as PNG - - :return: PNG 截圖的 bytes - """ - web_runner_logger.info("WebDriverWrapper get_screenshot_as_png") - try: - record_action_to_list("webdriver wrapper get_screenshot_as_png", None, None) - return self.current_webdriver.get_screenshot_as_png() - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper get_screenshot_as_png failed: {repr(error)}") - record_action_to_list("webdriver wrapper get_screenshot_as_png", None, error) - - def get_screenshot_as_base64(self) -> str | None: - """ - 取得當前頁面截圖 (Base64 字串) - Get current page screenshot as Base64 string - - :return: Base64 字串 - """ - web_runner_logger.info("WebDriverWrapper get_screenshot_as_base64") - try: - record_action_to_list("webdriver wrapper get_screenshot_as_base64", None, None) - return self.current_webdriver.get_screenshot_as_base64() - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper get_screenshot_as_base64 failed: {repr(error)}") - record_action_to_list("webdriver wrapper get_screenshot_as_base64", None, error) - - # log - def get_log(self, log_type: str): - """ - 取得 WebDriver 日誌(``log_type`` 為必填) - Get WebDriver logs (``log_type`` is required). - - :param log_type: 必填,需為下列之一: - Required; one of: - - - ``"browser"`` — JS console output (Chrome/Edge) - - ``"driver"`` — driver-side messages - - ``"client"`` — client-side bindings logs - - ``"server"`` — Selenium server logs - - ``"performance"`` — perf log (only when enabled in capabilities) - - 不同瀏覽器支援的子集不同;Firefox 自 GeckoDriver 後幾乎不再 - 提供,多數情況請改用 Playwright 的 console-event capture。 - Browser support varies; modern Firefox no longer exposes most - of these, prefer Playwright's console-event capture instead. - :return: log 資料 (list of dict) / log entries - """ - web_runner_logger.info(f"WebDriverWrapper get_log, log_type: {log_type}") - try: - record_action_to_list("webdriver wrapper get_log", None, None) - return self.current_webdriver.get_log(log_type) - except Exception as error: - web_runner_logger.error(f"WebDriverWrapper get_log failed: {repr(error)}") - record_action_to_list("webdriver wrapper get_log", None, error) - # webdriver wrapper add function def check_current_webdriver(self, check_dict: dict) -> None: """ diff --git a/test/unit_test/test_bidi_network.py b/test/unit_test/test_bidi_network.py new file mode 100644 index 0000000..081088d --- /dev/null +++ b/test/unit_test/test_bidi_network.py @@ -0,0 +1,84 @@ +"""BiDi network 模組的 mock-based 測試。""" +from __future__ import annotations + +import unittest +from unittest.mock import MagicMock + +from je_web_runner.utils.bidi.network import ( + BidiNetworkError, + add_auth_handler, + add_request_handler, + add_response_handler, + clear_network_handlers, +) + + +class TestBidiNetwork(unittest.TestCase): + + def _driver_with_network(self, network=None): + driver = MagicMock(spec=["network"]) + driver.network = network if network is not None else MagicMock() + return driver + + def test_add_request_handler_returns_id(self): + network = MagicMock() + network.add_request_handler.return_value = 11 + driver = self._driver_with_network(network) + cb = lambda e: None # noqa: E731 + self.assertEqual(add_request_handler(driver, cb), 11) + network.add_request_handler.assert_called_once_with(cb) + + def test_add_response_handler_returns_id(self): + network = MagicMock() + network.add_response_handler.return_value = 22 + driver = self._driver_with_network(network) + self.assertEqual(add_response_handler(driver, lambda e: None), 22) + + def test_add_auth_handler_returns_id(self): + network = MagicMock() + network.add_auth_handler.return_value = 33 + driver = self._driver_with_network(network) + self.assertEqual(add_auth_handler(driver, lambda e: None), 33) + + def test_no_network_attribute_raises(self): + driver = MagicMock(spec=["execute"]) # 沒有 network 屬性 + with self.assertRaises(BidiNetworkError): + add_request_handler(driver, lambda e: None) + + def test_missing_method_wrapped(self): + # network 存在但缺方法 (例如 Selenium 4.16-4.22) + network = MagicMock(spec=["unrelated_method"]) + driver = self._driver_with_network(network) + with self.assertRaises(BidiNetworkError): + add_request_handler(driver, lambda e: None) + + def test_clear_uses_clear_handlers_if_available(self): + network = MagicMock() + network.clear_handlers = MagicMock() + driver = self._driver_with_network(network) + self.assertTrue(clear_network_handlers(driver)) + network.clear_handlers.assert_called_once() + + def test_clear_falls_back_to_clear_method(self): + network = MagicMock(spec=["clear"]) + network.clear = MagicMock() + driver = self._driver_with_network(network) + self.assertTrue(clear_network_handlers(driver)) + network.clear.assert_called_once() + + def test_clear_returns_false_on_exception(self): + network = MagicMock() + network.clear_handlers = MagicMock(side_effect=RuntimeError("boom")) + driver = self._driver_with_network(network) + self.assertFalse(clear_network_handlers(driver)) + + def test_clear_without_method_raises(self): + # 兩個方法都沒有 + network = MagicMock(spec=["unrelated"]) + driver = self._driver_with_network(network) + with self.assertRaises(BidiNetworkError): + clear_network_handlers(driver) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unit_test/test_cdp_event_loop.py b/test/unit_test/test_cdp_event_loop.py new file mode 100644 index 0000000..fad5028 --- /dev/null +++ b/test/unit_test/test_cdp_event_loop.py @@ -0,0 +1,279 @@ +""" +CDPEventListener / resolve_cdp_ws_url 的 mock-based 測試。 +Mock-based tests for CDPEventListener and resolve_cdp_ws_url. + +不需要真的瀏覽器;我們替換 ``_require_websocket_client`` 來注入 FakeWebSocket。 +No real browser required; ``_require_websocket_client`` is patched to inject +a FakeWebSocket. +""" +from __future__ import annotations + +import json +import threading +import time +import unittest +from collections import deque +from unittest.mock import MagicMock, patch + +from je_web_runner.utils.cdp.event_loop import ( + CDPEventListener, + CDPEventLoopError, + resolve_cdp_ws_url, +) + + +class FakeWebSocket: + """同步、執行緒安全的 fake WebSocket,給 listener 用。""" + + def __init__(self): + self._inbox: deque = deque() + self._lock = threading.Lock() + self._not_empty = threading.Condition(self._lock) + self.sent: list = [] + self.closed = False + + def push_message(self, message: str) -> None: + with self._not_empty: + self._inbox.append(message) + self._not_empty.notify() + + def push_dict(self, data: dict) -> None: + self.push_message(json.dumps(data)) + + def send(self, data: str) -> None: + if self.closed: + raise OSError("closed") + self.sent.append(data) + + def recv(self) -> str: + with self._not_empty: + while not self._inbox and not self.closed: + self._not_empty.wait(timeout=0.05) + if self._inbox: + return self._inbox.popleft() + raise OSError("closed") + + def close(self) -> None: + with self._not_empty: + self.closed = True + self._not_empty.notify_all() + + +def _patch_websocket(fake_ws: FakeWebSocket): + """回傳一個 patch context manager,將 _require_websocket_client 改為 fake。""" + fake_module = MagicMock() + fake_module.create_connection.return_value = fake_ws + return patch( + "je_web_runner.utils.cdp.event_loop._require_websocket_client", + return_value=fake_module, + ) + + +def _wait_until(predicate, timeout=1.0, step=0.01) -> bool: + """忙等 predicate 為真,timeout 後回 False。""" + end = time.time() + timeout + while time.time() < end: + if predicate(): + return True + time.sleep(step) + return predicate() + + +class TestCDPEventListenerLifecycle(unittest.TestCase): + + def test_context_manager_starts_and_stops(self): + fake_ws = FakeWebSocket() + with _patch_websocket(fake_ws): + with CDPEventListener("ws://fake") as listener: + self.assertIsNotNone(listener._thread) + self.assertTrue(listener._thread.is_alive()) + # 退出 context 後 thread 應收尾 + self.assertTrue(fake_ws.closed) + + def test_start_is_idempotent(self): + fake_ws = FakeWebSocket() + with _patch_websocket(fake_ws): + listener = CDPEventListener("ws://fake") + listener.start() + first_thread = listener._thread + listener.start() + self.assertIs(listener._thread, first_thread) + listener.stop() + + def test_missing_websocket_client_raises(self): + def fake_require(): + raise CDPEventLoopError("websocket-client is required ...") + + with patch( + "je_web_runner.utils.cdp.event_loop._require_websocket_client", + side_effect=fake_require, + ): + listener = CDPEventListener("ws://fake") + with self.assertRaises(CDPEventLoopError): + listener.start() + + def test_create_connection_failure_wrapped(self): + fake_module = MagicMock() + fake_module.create_connection.side_effect = OSError("refused") + with patch( + "je_web_runner.utils.cdp.event_loop._require_websocket_client", + return_value=fake_module, + ): + listener = CDPEventListener("ws://fake") + with self.assertRaises(CDPEventLoopError): + listener.start() + + +class TestCDPEventListenerDispatch(unittest.TestCase): + + def test_event_dispatched_to_handler(self): + fake_ws = FakeWebSocket() + received = [] + with _patch_websocket(fake_ws): + with CDPEventListener("ws://fake") as listener: + listener.on( + "Fetch.requestPaused", + lambda params: received.append(params), + ) + fake_ws.push_dict({ + "method": "Fetch.requestPaused", + "params": {"requestId": "abc"}, + }) + self.assertTrue(_wait_until(lambda: bool(received))) + self.assertEqual(received, [{"requestId": "abc"}]) + + def test_off_removes_handler(self): + fake_ws = FakeWebSocket() + received = [] + with _patch_websocket(fake_ws): + with CDPEventListener("ws://fake") as listener: + cb = received.append + listener.on("Page.loadEventFired", cb) + self.assertTrue(listener.off("Page.loadEventFired", cb)) + # 再 off 一次 (找不到) 應回 False + self.assertFalse(listener.off("Page.loadEventFired", cb)) + fake_ws.push_dict({"method": "Page.loadEventFired", "params": {}}) + # 給點時間讓 _run 走過去;應該不會被派發 + time.sleep(0.1) + self.assertEqual(received, []) + + def test_handler_exception_does_not_kill_loop(self): + fake_ws = FakeWebSocket() + good = [] + with _patch_websocket(fake_ws): + with CDPEventListener("ws://fake") as listener: + listener.on("X", lambda p: (_ for _ in ()).throw(RuntimeError("boom"))) + listener.on("X", lambda p: good.append(p)) + fake_ws.push_dict({"method": "X", "params": {"n": 1}}) + self.assertTrue(_wait_until(lambda: bool(good))) + # 第二個事件也要照常派發 + fake_ws.push_dict({"method": "X", "params": {"n": 2}}) + self.assertTrue(_wait_until(lambda: len(good) >= 2)) + + +class TestCDPEventListenerSend(unittest.TestCase): + + def test_send_round_trip(self): + fake_ws = FakeWebSocket() + with _patch_websocket(fake_ws): + with CDPEventListener("ws://fake") as listener: + # 在另一條 thread 上 send,主 thread 模擬伺服器回覆 + result_holder: dict = {} + + def call_send(): + result_holder["value"] = listener.send( + "Network.enable", {"maxTotalBufferSize": 1024}, timeout=1.0 + ) + + t = threading.Thread(target=call_send, daemon=True) + t.start() + # 等到對方真的 send 到 ws 才回覆 + self.assertTrue(_wait_until(lambda: bool(fake_ws.sent))) + sent_payload = json.loads(fake_ws.sent[-1]) + fake_ws.push_dict( + {"id": sent_payload["id"], "result": {"ok": True}} + ) + t.join(timeout=1.0) + self.assertEqual(result_holder["value"], {"ok": True}) + + def test_send_propagates_cdp_error(self): + fake_ws = FakeWebSocket() + with _patch_websocket(fake_ws): + with CDPEventListener("ws://fake") as listener: + exc_holder: dict = {} + + def call_send(): + try: + listener.send("Bad.method", timeout=1.0) + except CDPEventLoopError as e: + exc_holder["error"] = e + + t = threading.Thread(target=call_send, daemon=True) + t.start() + self.assertTrue(_wait_until(lambda: bool(fake_ws.sent))) + sent_payload = json.loads(fake_ws.sent[-1]) + fake_ws.push_dict({ + "id": sent_payload["id"], + "error": {"code": -32601, "message": "method not found"}, + }) + t.join(timeout=1.0) + self.assertIn("error", exc_holder) + + def test_send_times_out(self): + fake_ws = FakeWebSocket() + with _patch_websocket(fake_ws): + with CDPEventListener("ws://fake") as listener: + with self.assertRaises(CDPEventLoopError): + listener.send("Slow.method", timeout=0.1) + + def test_send_before_start_raises(self): + listener = CDPEventListener("ws://fake") + with self.assertRaises(CDPEventLoopError): + listener.send("Network.enable") + + +class TestResolveCdpWsUrl(unittest.TestCase): + + def test_prefers_debugger_address_via_json(self): + driver = MagicMock() + driver.capabilities = { + "goog:chromeOptions": {"debuggerAddress": "127.0.0.1:9222"}, + "se:cdp": "ws://wrong", + } + fake_response = MagicMock() + fake_response.read.return_value = json.dumps([ + {"type": "background_page", "webSocketDebuggerUrl": "ws://bg"}, + {"type": "page", "webSocketDebuggerUrl": "ws://page-1"}, + ]).encode("utf-8") + fake_response.__enter__ = lambda self_=fake_response: self_ + fake_response.__exit__ = lambda *a: None + with patch("urllib.request.urlopen", return_value=fake_response): + self.assertEqual(resolve_cdp_ws_url(driver), "ws://page-1") + + def test_falls_back_to_se_cdp(self): + driver = MagicMock() + driver.capabilities = {"se:cdp": "ws://browser-level"} + self.assertEqual(resolve_cdp_ws_url(driver), "ws://browser-level") + + def test_no_source_raises(self): + driver = MagicMock() + driver.capabilities = {} + with self.assertRaises(CDPEventLoopError): + resolve_cdp_ws_url(driver) + + def test_no_page_target_raises(self): + driver = MagicMock() + driver.capabilities = { + "ms:edgeOptions": {"debuggerAddress": "127.0.0.1:9333"}, + } + fake_response = MagicMock() + fake_response.read.return_value = b"[]" + fake_response.__enter__ = lambda self_=fake_response: self_ + fake_response.__exit__ = lambda *a: None + with patch("urllib.request.urlopen", return_value=fake_response): + with self.assertRaises(CDPEventLoopError): + resolve_cdp_ws_url(driver) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unit_test/test_cdp_tracing.py b/test/unit_test/test_cdp_tracing.py new file mode 100644 index 0000000..813755f --- /dev/null +++ b/test/unit_test/test_cdp_tracing.py @@ -0,0 +1,121 @@ +""" +CDP tracing 模組的 mock-based 測試。 +透過 mock CDPEventListener 模擬 ``Tracing.dataCollected`` / ``Tracing.tracingComplete``。 +""" +from __future__ import annotations + +import json +import os +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +from je_web_runner.utils.cdp.event_loop import CDPEventLoopError +from je_web_runner.utils.cdp.tracing import TracingError, record_trace + + +def _make_fake_listener(trace_events, complete=True): + """建一個 fake CDPEventListener:呼叫 send('Tracing.end') 時觸發收到的事件。""" + listener = MagicMock() + handlers: dict = {} + + def fake_on(method, callback): + handlers[method] = callback + + def fake_send(method, params=None, timeout=5.0): + if method == "Tracing.end": + data_handler = handlers.get("Tracing.dataCollected") + if data_handler is not None: + data_handler({"value": trace_events}) + if complete: + complete_handler = handlers.get("Tracing.tracingComplete") + if complete_handler is not None: + complete_handler({}) + return {} + + listener.on.side_effect = fake_on + listener.send.side_effect = fake_send + listener.__enter__ = MagicMock(return_value=listener) + listener.__exit__ = MagicMock(return_value=None) + return listener, handlers + + +class TestRecordTrace(unittest.TestCase): + + def test_happy_path_writes_events(self): + events = [{"name": "a"}, {"name": "b"}] + fake_listener, handlers = _make_fake_listener(events) + with patch( + "je_web_runner.utils.cdp.tracing.CDPEventListener.from_driver", + return_value=fake_listener, + ): + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "trace.json") + returned = record_trace(MagicMock(), path) + self.assertEqual(returned, path) + with open(path, "r", encoding="utf-8") as fh: + self.assertEqual(json.load(fh), events) + # Tracing.start / Tracing.end 都應送出 + sent_methods = [c.args[0] for c in fake_listener.send.call_args_list] + self.assertIn("Tracing.start", sent_methods) + self.assertIn("Tracing.end", sent_methods) + + def test_passes_categories_in_start(self): + fake_listener, _ = _make_fake_listener([]) + with patch( + "je_web_runner.utils.cdp.tracing.CDPEventListener.from_driver", + return_value=fake_listener, + ): + with tempfile.TemporaryDirectory() as tmpdir: + record_trace( + MagicMock(), + os.path.join(tmpdir, "trace.json"), + categories=["devtools.timeline", "loading"], + ) + start_call = next( + c for c in fake_listener.send.call_args_list if c.args[0] == "Tracing.start" + ) + params = start_call.args[1] + self.assertEqual(params["categories"], "devtools.timeline,loading") + self.assertEqual(params["transferMode"], "ReportEvents") + + def test_no_complete_event_times_out(self): + fake_listener, _ = _make_fake_listener([{"x": 1}], complete=False) + with patch( + "je_web_runner.utils.cdp.tracing.CDPEventListener.from_driver", + return_value=fake_listener, + ): + with tempfile.TemporaryDirectory() as tmpdir: + with self.assertRaises(TracingError): + record_trace( + MagicMock(), + os.path.join(tmpdir, "trace.json"), + completion_timeout=0.1, + ) + + def test_event_loop_error_wrapped_as_tracing_error(self): + with patch( + "je_web_runner.utils.cdp.tracing.CDPEventListener.from_driver", + side_effect=CDPEventLoopError("ws-client missing"), + ): + with tempfile.TemporaryDirectory() as tmpdir: + with self.assertRaises(TracingError): + record_trace(MagicMock(), os.path.join(tmpdir, "trace.json")) + + def test_duration_sleeps_between_start_and_end(self): + fake_listener, _ = _make_fake_listener([{"x": 1}]) + with patch( + "je_web_runner.utils.cdp.tracing.CDPEventListener.from_driver", + return_value=fake_listener, + ), patch("je_web_runner.utils.cdp.tracing.time.sleep") as sleep_mock: + with tempfile.TemporaryDirectory() as tmpdir: + record_trace( + MagicMock(), + os.path.join(tmpdir, "trace.json"), + duration=2.5, + ) + sleep_mock.assert_called_once_with(2.5) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/unit_test/test_webdriver_wrapper_extensions.py b/test/unit_test/test_webdriver_wrapper_extensions.py new file mode 100644 index 0000000..1848b8e --- /dev/null +++ b/test/unit_test/test_webdriver_wrapper_extensions.py @@ -0,0 +1,853 @@ +""" +驗證 WebDriverWrapper 為支援 anti-bot / stealth / 進階情境新增的 API。 +Tests for WebDriverWrapper APIs added for anti-bot / stealth / advanced scenarios. +""" +from __future__ import annotations + +import base64 +import os +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +from je_web_runner.utils.cdp.cdp_commands import CDPError +from je_web_runner.utils.exception.exceptions import WebRunnerException +from je_web_runner.webdriver.webdriver_wrapper import WebDriverWrapper + + +class TestSetDriverExperimentalOptions(unittest.TestCase): + """``set_driver`` 應將 experimental_options 透過 add_experimental_option 傳入 ChromeOptions。""" + + def _patched_set_driver(self, **set_driver_kwargs): + """執行 set_driver 並回傳實際傳給 webdriver.Chrome 的 Options 物件。""" + fake_options = MagicMock(name="ChromeOptions") + # 由於 hasattr(driver_options, "add_experimental_option") 會被檢查, + # MagicMock 預設會有任何屬性,因此天然滿足條件。 + fake_options_cls = MagicMock(return_value=fake_options) + fake_driver_cls = MagicMock(name="ChromeDriverClass") + fake_manager_cls = MagicMock(name="ChromeDriverManager") + + with patch.dict( + "je_web_runner.webdriver.webdriver_wrapper._options_dict", + {"chrome": fake_options_cls}, + clear=False, + ), patch.dict( + "je_web_runner.webdriver.webdriver_wrapper._webdriver_dict", + {"chrome": fake_driver_cls}, + clear=False, + ), patch.dict( + "je_web_runner.webdriver.webdriver_wrapper._webdriver_manager_dict", + {"chrome": fake_manager_cls}, + clear=False, + ): + wrapper = WebDriverWrapper() + wrapper.set_driver("chrome", **set_driver_kwargs) + return fake_options, fake_driver_cls + + def test_experimental_options_forwarded(self): + exp = { + "excludeSwitches": ["enable-automation"], + "useAutomationExtension": False, + } + fake_options, fake_driver_cls = self._patched_set_driver( + options=["--start-maximized"], + experimental_options=exp, + ) + fake_options.add_argument.assert_called_once_with(argument="--start-maximized") + fake_options.add_experimental_option.assert_any_call( + "excludeSwitches", ["enable-automation"] + ) + fake_options.add_experimental_option.assert_any_call( + "useAutomationExtension", False + ) + # 確認 Options 物件最終被傳給 webdriver.Chrome + fake_driver_cls.assert_called_once() + _, kwargs = fake_driver_cls.call_args + self.assertIs(kwargs.get("options"), fake_options) + + def test_experimental_options_without_args_still_builds_options(self): + fake_options, fake_driver_cls = self._patched_set_driver( + experimental_options={"prefs": {"download.default_directory": "/tmp"}}, + ) + fake_options.add_argument.assert_not_called() + fake_options.add_experimental_option.assert_called_once_with( + "prefs", {"download.default_directory": "/tmp"} + ) + _, kwargs = fake_driver_cls.call_args + self.assertIs(kwargs.get("options"), fake_options) + + def test_unsupported_browser_raises(self): + # FirefoxOptions 不支援 add_experimental_option。 + class FakeFirefoxOptions: + def add_argument(self, argument): # noqa: D401 — fake + pass + # 故意沒有 add_experimental_option + + fake_options_cls = MagicMock(return_value=FakeFirefoxOptions()) + fake_driver_cls = MagicMock(name="FirefoxDriverClass") + fake_manager_cls = MagicMock(name="FirefoxDriverManager") + + with patch.dict( + "je_web_runner.webdriver.webdriver_wrapper._options_dict", + {"firefox": fake_options_cls}, + clear=False, + ), patch.dict( + "je_web_runner.webdriver.webdriver_wrapper._webdriver_dict", + {"firefox": fake_driver_cls}, + clear=False, + ), patch.dict( + "je_web_runner.webdriver.webdriver_wrapper._webdriver_manager_dict", + {"firefox": fake_manager_cls}, + clear=False, + ): + wrapper = WebDriverWrapper() + with self.assertRaises(WebRunnerException): + wrapper.set_driver( + "firefox", + experimental_options={"excludeSwitches": ["enable-automation"]}, + ) + + +class TestExecuteCdpCmd(unittest.TestCase): + + def test_dispatches_to_selenium_cdp(self): + wrapper = WebDriverWrapper() + fake_driver = MagicMock() + fake_driver.execute_cdp_cmd.return_value = {"identifier": "abc123"} + wrapper.current_webdriver = fake_driver + + result = wrapper.execute_cdp_cmd( + "Page.addScriptToEvaluateOnNewDocument", + {"source": "console.log('hi');"}, + ) + self.assertEqual(result, {"identifier": "abc123"}) + fake_driver.execute_cdp_cmd.assert_called_once_with( + "Page.addScriptToEvaluateOnNewDocument", + {"source": "console.log('hi');"}, + ) + + def test_empty_args_default_to_dict(self): + wrapper = WebDriverWrapper() + fake_driver = MagicMock() + fake_driver.execute_cdp_cmd.return_value = None + wrapper.current_webdriver = fake_driver + + wrapper.execute_cdp_cmd("Network.enable") + fake_driver.execute_cdp_cmd.assert_called_once_with("Network.enable", {}) + + def test_no_driver_raises(self): + wrapper = WebDriverWrapper() + wrapper.current_webdriver = None + with self.assertRaises(CDPError): + wrapper.execute_cdp_cmd("Network.enable") + + def test_non_chromium_driver_raises(self): + wrapper = WebDriverWrapper() + # object() 沒有 execute_cdp_cmd 屬性,模擬 Firefox / Safari driver。 + wrapper.current_webdriver = object() + with self.assertRaises(CDPError): + wrapper.execute_cdp_cmd("Network.enable") + + +class TestSaveScreenshot(unittest.TestCase): + + def test_delegates_to_driver(self): + wrapper = WebDriverWrapper() + fake_driver = MagicMock() + fake_driver.save_screenshot.return_value = True + wrapper.current_webdriver = fake_driver + + self.assertTrue(wrapper.save_screenshot("/tmp/out.png")) + fake_driver.save_screenshot.assert_called_once_with("/tmp/out.png") + + def test_returns_false_on_exception(self): + wrapper = WebDriverWrapper() + fake_driver = MagicMock() + fake_driver.save_screenshot.side_effect = RuntimeError("disk full") + wrapper.current_webdriver = fake_driver + + self.assertFalse(wrapper.save_screenshot("/tmp/out.png")) + + +# --- Group A: page / window metadata -------------------------------------- + +class TestPageWindowMetadata(unittest.TestCase): + + def setUp(self): + self.wrapper = WebDriverWrapper() + self.fake_driver = MagicMock() + self.wrapper.current_webdriver = self.fake_driver + + def test_get_current_url(self): + self.fake_driver.current_url = "https://example.com/" + self.assertEqual(self.wrapper.get_current_url(), "https://example.com/") + + def test_get_title(self): + self.fake_driver.title = "Example Domain" + self.assertEqual(self.wrapper.get_title(), "Example Domain") + + def test_get_page_source(self): + self.fake_driver.page_source = "<html></html>" + self.assertEqual(self.wrapper.get_page_source(), "<html></html>") + + def test_get_window_handles(self): + self.fake_driver.window_handles = ["w1", "w2"] + self.assertEqual(self.wrapper.get_window_handles(), ["w1", "w2"]) + + def test_get_current_window_handle(self): + self.fake_driver.current_window_handle = "w1" + self.assertEqual(self.wrapper.get_current_window_handle(), "w1") + + def test_new_window_default_tab(self): + self.wrapper.new_window() + self.fake_driver.switch_to.new_window.assert_called_once_with("tab") + + def test_new_window_explicit_window(self): + self.wrapper.new_window("window") + self.fake_driver.switch_to.new_window.assert_called_once_with("window") + + def test_close_window(self): + self.wrapper.close_window() + self.fake_driver.close.assert_called_once() + + def test_getters_return_none_on_exception(self): + # 模擬 driver 拋例外的情況:所有 getter 應回 None 而非崩潰 + broken_driver = MagicMock() + type(broken_driver).current_url = unittest.mock.PropertyMock( + side_effect=RuntimeError("no session") + ) + self.wrapper.current_webdriver = broken_driver + self.assertIsNone(self.wrapper.get_current_url()) + + +# --- Group B: add_extension + attach_to_existing_browser ------------------ + +class TestExtensionsAndAttach(unittest.TestCase): + + def _patched_chrome(self, fake_options): + fake_options_cls = MagicMock(return_value=fake_options) + fake_driver_cls = MagicMock(name="ChromeDriverClass") + fake_manager_cls = MagicMock(name="ChromeDriverManager") + return patch.dict( + "je_web_runner.webdriver.webdriver_wrapper._options_dict", + {"chrome": fake_options_cls}, + clear=False, + ), patch.dict( + "je_web_runner.webdriver.webdriver_wrapper._webdriver_dict", + {"chrome": fake_driver_cls}, + clear=False, + ), patch.dict( + "je_web_runner.webdriver.webdriver_wrapper._webdriver_manager_dict", + {"chrome": fake_manager_cls}, + clear=False, + ), fake_driver_cls + + def test_extension_paths_forwarded(self): + fake_options = MagicMock(name="ChromeOptions") + p1, p2, p3, fake_driver_cls = self._patched_chrome(fake_options) + with p1, p2, p3: + wrapper = WebDriverWrapper() + wrapper.set_driver( + "chrome", + extension_paths=["/tmp/a.crx", "/tmp/b.crx"], + ) + fake_options.add_extension.assert_any_call("/tmp/a.crx") + fake_options.add_extension.assert_any_call("/tmp/b.crx") + _, kwargs = fake_driver_cls.call_args + self.assertIs(kwargs.get("options"), fake_options) + + def test_extension_unsupported_browser_raises(self): + class FakeIeOptions: + def add_argument(self, argument): # noqa: D401 + pass + # 故意沒有 add_extension + + fake_options_cls = MagicMock(return_value=FakeIeOptions()) + fake_driver_cls = MagicMock(name="IeDriverClass") + fake_manager_cls = MagicMock(name="IeDriverManager") + with patch.dict( + "je_web_runner.webdriver.webdriver_wrapper._options_dict", + {"ie": fake_options_cls}, + clear=False, + ), patch.dict( + "je_web_runner.webdriver.webdriver_wrapper._webdriver_dict", + {"ie": fake_driver_cls}, + clear=False, + ), patch.dict( + "je_web_runner.webdriver.webdriver_wrapper._webdriver_manager_dict", + {"ie": fake_manager_cls}, + clear=False, + ): + wrapper = WebDriverWrapper() + with self.assertRaises(WebRunnerException): + wrapper.set_driver("ie", extension_paths=["/tmp/a.crx"]) + + def test_attach_to_existing_browser_merges_debugger_address(self): + wrapper = WebDriverWrapper() + with patch.object(wrapper, "set_driver", return_value=MagicMock()) as set_drv: + wrapper.attach_to_existing_browser( + "127.0.0.1:9222", + experimental_options={"detach": True}, + ) + set_drv.assert_called_once() + args, kwargs = set_drv.call_args + self.assertEqual(args[0], "chrome") + merged = kwargs["experimental_options"] + self.assertEqual(merged["debuggerAddress"], "127.0.0.1:9222") + self.assertEqual(merged["detach"], True) + + +# --- Group C: CDP convenience methods ------------------------------------- + +class TestCdpConvenience(unittest.TestCase): + + def setUp(self): + self.wrapper = WebDriverWrapper() + self.fake_driver = MagicMock() + self.wrapper.current_webdriver = self.fake_driver + + def test_add_script_to_evaluate_on_new_document(self): + self.fake_driver.execute_cdp_cmd.return_value = {"identifier": "1"} + ident = self.wrapper.add_script_to_evaluate_on_new_document( + "console.log('hi');" + ) + self.assertEqual(ident, "1") + self.fake_driver.execute_cdp_cmd.assert_called_once_with( + "Page.addScriptToEvaluateOnNewDocument", + {"source": "console.log('hi');"}, + ) + + def test_set_user_agent(self): + self.wrapper.set_user_agent("Mozilla/5.0 (Custom)") + self.fake_driver.execute_cdp_cmd.assert_called_once_with( + "Network.setUserAgentOverride", + {"userAgent": "Mozilla/5.0 (Custom)"}, + ) + + def test_set_extra_http_headers(self): + self.wrapper.set_extra_http_headers({"X-Test": "1"}) + self.fake_driver.execute_cdp_cmd.assert_called_once_with( + "Network.setExtraHTTPHeaders", + {"headers": {"X-Test": "1"}}, + ) + + def test_set_geolocation_defaults(self): + self.wrapper.set_geolocation(25.03, 121.56) + self.fake_driver.execute_cdp_cmd.assert_called_once_with( + "Emulation.setGeolocationOverride", + {"latitude": 25.03, "longitude": 121.56, "accuracy": 100}, + ) + + +# --- Group D: print_page + full-page screenshot --------------------------- + +class TestPrintAndFullPageScreenshot(unittest.TestCase): + + def setUp(self): + self.wrapper = WebDriverWrapper() + self.fake_driver = MagicMock() + self.wrapper.current_webdriver = self.fake_driver + + def test_save_full_page_screenshot_writes_decoded_png(self): + png_bytes = b"\x89PNG\r\n\x1a\nFAKE" + self.fake_driver.execute_cdp_cmd.return_value = { + "data": base64.b64encode(png_bytes).decode("ascii"), + } + with tempfile.TemporaryDirectory() as tmpdir: + target = os.path.join(tmpdir, "shot.png") + self.assertTrue(self.wrapper.save_full_page_screenshot(target)) + with open(target, "rb") as fh: + self.assertEqual(fh.read(), png_bytes) + # 驗證確實呼叫 CDP 並帶 captureBeyondViewport + self.fake_driver.execute_cdp_cmd.assert_called_once_with( + "Page.captureScreenshot", + {"format": "png", "captureBeyondViewport": True, "fromSurface": True}, + ) + + def test_save_full_page_screenshot_returns_false_on_empty_data(self): + self.fake_driver.execute_cdp_cmd.return_value = {"data": ""} + with tempfile.TemporaryDirectory() as tmpdir: + target = os.path.join(tmpdir, "shot.png") + self.assertFalse(self.wrapper.save_full_page_screenshot(target)) + self.assertFalse(os.path.exists(target)) + + def test_print_page_writes_decoded_pdf(self): + pdf_bytes = b"%PDF-1.4\nfake" + self.fake_driver.print_page.return_value = base64.b64encode(pdf_bytes).decode("ascii") + with tempfile.TemporaryDirectory() as tmpdir: + target = os.path.join(tmpdir, "out.pdf") + self.assertTrue(self.wrapper.print_page(target)) + with open(target, "rb") as fh: + self.assertEqual(fh.read(), pdf_bytes) + self.fake_driver.print_page.assert_called_once_with() + + def test_print_page_with_options(self): + sentinel_options = object() + self.fake_driver.print_page.return_value = base64.b64encode(b"%PDF-").decode("ascii") + with tempfile.TemporaryDirectory() as tmpdir: + target = os.path.join(tmpdir, "out.pdf") + self.wrapper.print_page(target, print_options=sentinel_options) + self.fake_driver.print_page.assert_called_once_with(sentinel_options) + + def test_print_page_returns_false_on_exception(self): + self.fake_driver.print_page.side_effect = RuntimeError("boom") + with tempfile.TemporaryDirectory() as tmpdir: + target = os.path.join(tmpdir, "out.pdf") + self.assertFalse(self.wrapper.print_page(target)) + + +# --- Group E: CDP emulation overrides ------------------------------------- + +class TestEmulationOverrides(unittest.TestCase): + + def setUp(self): + self.wrapper = WebDriverWrapper() + self.fake_driver = MagicMock() + self.wrapper.current_webdriver = self.fake_driver + + def test_set_timezone(self): + self.wrapper.set_timezone("Asia/Tokyo") + self.fake_driver.execute_cdp_cmd.assert_called_once_with( + "Emulation.setTimezoneOverride", {"timezoneId": "Asia/Tokyo"} + ) + + def test_set_locale(self): + self.wrapper.set_locale("ja-JP") + self.fake_driver.execute_cdp_cmd.assert_called_once_with( + "Emulation.setLocaleOverride", {"locale": "ja-JP"} + ) + + def test_set_device_metrics(self): + self.wrapper.set_device_metrics(390, 844, device_scale_factor=3, mobile=True) + self.fake_driver.execute_cdp_cmd.assert_called_once_with( + "Emulation.setDeviceMetricsOverride", + {"width": 390, "height": 844, "deviceScaleFactor": 3, "mobile": True}, + ) + + def test_clear_device_metrics(self): + self.wrapper.clear_device_metrics() + self.fake_driver.execute_cdp_cmd.assert_called_once_with( + "Emulation.clearDeviceMetricsOverride", {} + ) + + def test_clear_geolocation_override(self): + self.wrapper.clear_geolocation_override() + self.fake_driver.execute_cdp_cmd.assert_called_once_with( + "Emulation.clearGeolocationOverride", {} + ) + + def test_set_network_conditions_offline(self): + self.wrapper.set_network_conditions(offline=True) + self.fake_driver.execute_cdp_cmd.assert_called_once_with( + "Network.emulateNetworkConditions", + { + "offline": True, + "latency": 0, + "downloadThroughput": -1, + "uploadThroughput": -1, + }, + ) + + def test_set_network_conditions_throttled(self): + self.wrapper.set_network_conditions( + offline=False, latency=200, download_throughput=50_000, upload_throughput=10_000 + ) + self.fake_driver.execute_cdp_cmd.assert_called_once_with( + "Network.emulateNetworkConditions", + { + "offline": False, + "latency": 200, + "downloadThroughput": 50_000, + "uploadThroughput": 10_000, + }, + ) + + +# --- Group F: cookie persistence + origin storage ------------------------- + +class TestCookiePersistence(unittest.TestCase): + + def setUp(self): + self.wrapper = WebDriverWrapper() + self.fake_driver = MagicMock() + self.wrapper.current_webdriver = self.fake_driver + + def test_save_and_load_roundtrip(self): + cookies = [ + {"name": "session", "value": "abc", "domain": "example.com"}, + {"name": "lang", "value": "en", "domain": "example.com"}, + ] + self.fake_driver.get_cookies.return_value = cookies + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "cookies.json") + self.assertTrue(self.wrapper.save_cookies(path)) + + # 載回時清掉 mock 並驗證 add_cookie 被逐筆呼叫 + self.fake_driver.add_cookie.reset_mock() + added = self.wrapper.load_cookies(path) + self.assertEqual(added, 2) + self.assertEqual(self.fake_driver.add_cookie.call_count, 2) + self.fake_driver.add_cookie.assert_any_call(cookies[0]) + self.fake_driver.add_cookie.assert_any_call(cookies[1]) + + def test_load_cookies_skips_failing_entries(self): + import json as _json + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "cookies.json") + with open(path, "w", encoding="utf-8") as fh: + _json.dump( + [ + {"name": "ok", "value": "1", "domain": "example.com"}, + {"name": "bad", "value": "2", "domain": "wrong.example.com"}, + ], + fh, + ) + # 第一個成功,第二個 raise + self.fake_driver.add_cookie.side_effect = [None, RuntimeError("domain mismatch")] + added = self.wrapper.load_cookies(path) + self.assertEqual(added, 1) + + def test_save_cookies_returns_false_on_exception(self): + self.fake_driver.get_cookies.side_effect = RuntimeError("no driver") + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "cookies.json") + self.assertFalse(self.wrapper.save_cookies(path)) + + def test_clear_origin_storage(self): + self.wrapper.clear_origin_storage("https://example.com") + self.fake_driver.execute_cdp_cmd.assert_called_once_with( + "Storage.clearDataForOrigin", + {"origin": "https://example.com", "storageTypes": "all"}, + ) + + +# --- Group G: network blocking / cache ------------------------------------ + +class TestNetworkBlocking(unittest.TestCase): + + def setUp(self): + self.wrapper = WebDriverWrapper() + self.fake_driver = MagicMock() + self.wrapper.current_webdriver = self.fake_driver + + def test_block_urls(self): + self.wrapper.block_urls(["*.doubleclick.net/*", "*.googletagmanager.com/*"]) + self.fake_driver.execute_cdp_cmd.assert_called_once_with( + "Network.setBlockedURLs", + {"urls": ["*.doubleclick.net/*", "*.googletagmanager.com/*"]}, + ) + + def test_unblock_urls(self): + self.wrapper.unblock_urls() + self.fake_driver.execute_cdp_cmd.assert_called_once_with( + "Network.setBlockedURLs", {"urls": []} + ) + + def test_set_cache_disabled_true(self): + self.wrapper.set_cache_disabled(True) + self.fake_driver.execute_cdp_cmd.assert_called_once_with( + "Network.setCacheDisabled", {"cacheDisabled": True} + ) + + def test_set_cache_disabled_default_true(self): + self.wrapper.set_cache_disabled() + self.fake_driver.execute_cdp_cmd.assert_called_once_with( + "Network.setCacheDisabled", {"cacheDisabled": True} + ) + + +# --- Group H: download directory ------------------------------------------ + +class TestDownloadDirectory(unittest.TestCase): + + def test_set_download_directory(self): + wrapper = WebDriverWrapper() + fake_driver = MagicMock() + wrapper.current_webdriver = fake_driver + wrapper.set_download_directory("/tmp/downloads") + fake_driver.execute_cdp_cmd.assert_called_once_with( + "Browser.setDownloadBehavior", + {"behavior": "allow", "downloadPath": "/tmp/downloads"}, + ) + + def test_set_download_directory_deny(self): + wrapper = WebDriverWrapper() + fake_driver = MagicMock() + wrapper.current_webdriver = fake_driver + wrapper.set_download_directory("/tmp/downloads", behavior="deny") + fake_driver.execute_cdp_cmd.assert_called_once_with( + "Browser.setDownloadBehavior", + {"behavior": "deny", "downloadPath": "/tmp/downloads"}, + ) + + +# --- Group I: page convenience -------------------------------------------- + +class TestPageConvenience(unittest.TestCase): + + def setUp(self): + self.wrapper = WebDriverWrapper() + self.fake_driver = MagicMock() + self.wrapper.current_webdriver = self.fake_driver + + def test_reload_with_cache(self): + self.wrapper.reload(ignore_cache=False) + self.fake_driver.refresh.assert_called_once() + self.fake_driver.execute_cdp_cmd.assert_not_called() + + def test_reload_ignore_cache(self): + self.wrapper.reload(ignore_cache=True) + self.fake_driver.execute_cdp_cmd.assert_called_once_with( + "Page.reload", {"ignoreCache": True} + ) + self.fake_driver.refresh.assert_not_called() + + def test_scroll_to_element(self): + element = MagicMock(name="element") + self.wrapper.scroll_to_element(element) + args, _ = self.fake_driver.execute_script.call_args + self.assertIn("scrollIntoView", args[0]) + self.assertIs(args[1], element) + + def test_scroll_to_top(self): + self.wrapper.scroll_to_top() + self.fake_driver.execute_script.assert_called_once_with("window.scrollTo(0, 0);") + + def test_scroll_to_bottom(self): + self.wrapper.scroll_to_bottom() + self.fake_driver.execute_script.assert_called_once_with( + "window.scrollTo(0, document.body.scrollHeight);" + ) + + def test_bring_to_front(self): + self.wrapper.bring_to_front() + self.fake_driver.execute_cdp_cmd.assert_called_once_with("Page.bringToFront", {}) + + def _setup_two_windows(self, urls=("https://a.com/", "https://b.com/"), titles=("Alpha", "Beta")): + self.fake_driver.current_window_handle = "w1" + self.fake_driver.window_handles = ["w1", "w2"] + # 切換到不同 handle 時,current_url / title 取對應值 + urls_iter = iter(urls) + titles_iter = iter(titles) + + def _switch_window(handle): + # 模擬切窗:每次切窗後 current_url / title 換成下一筆 + type(self.fake_driver).current_url = unittest.mock.PropertyMock( + return_value=next(urls_iter, urls[-1]) + ) + type(self.fake_driver).title = unittest.mock.PropertyMock( + return_value=next(titles_iter, titles[-1]) + ) + + self.fake_driver.switch_to.window.side_effect = _switch_window + + def test_switch_to_window_by_url_matches_second(self): + self._setup_two_windows(urls=("https://a.com/", "https://target.com/page")) + self.assertTrue(self.wrapper.switch_to_window_by_url("target")) + # switch_to.window 應被呼叫到第二個 handle 為止 + called_handles = [c.args[0] for c in self.fake_driver.switch_to.window.call_args_list] + self.assertEqual(called_handles[:2], ["w1", "w2"]) + + def test_switch_to_window_by_url_no_match_restores_original(self): + self._setup_two_windows(urls=("https://a.com/", "https://b.com/")) + self.assertFalse(self.wrapper.switch_to_window_by_url("not-present")) + # 最後一次切窗應該是切回原視窗 "w1" + last_handle = self.fake_driver.switch_to.window.call_args_list[-1].args[0] + self.assertEqual(last_handle, "w1") + + def test_switch_to_window_by_title_matches(self): + self._setup_two_windows(titles=("Alpha", "Beta Target")) + self.assertTrue(self.wrapper.switch_to_window_by_title("Target")) + + +# --- Group J: BiDi event listeners ---------------------------------------- + +class TestBidiListeners(unittest.TestCase): + + def setUp(self): + self.wrapper = WebDriverWrapper() + self.fake_driver = MagicMock() + self.fake_script = MagicMock() + self.fake_driver.script = self.fake_script + self.wrapper.current_webdriver = self.fake_driver + + def test_add_console_listener_returns_subscription_id(self): + self.fake_script.add_console_message_handler.return_value = 42 + cb = lambda msg: None # noqa: E731 + self.assertEqual(self.wrapper.add_console_listener(cb), 42) + self.fake_script.add_console_message_handler.assert_called_once_with(cb) + + def test_add_js_error_listener_returns_subscription_id(self): + self.fake_script.add_javascript_error_handler.return_value = 7 + cb = lambda err: None # noqa: E731 + self.assertEqual(self.wrapper.add_js_error_listener(cb), 7) + self.fake_script.add_javascript_error_handler.assert_called_once_with(cb) + + def test_remove_console_listener_success(self): + self.assertTrue(self.wrapper.remove_console_listener(42)) + self.fake_script.remove_console_message_handler.assert_called_once_with(42) + + def test_remove_js_error_listener_success(self): + self.assertTrue(self.wrapper.remove_js_error_listener(7)) + self.fake_script.remove_javascript_error_handler.assert_called_once_with(7) + + def test_remove_returns_false_when_underlying_fails(self): + self.fake_script.remove_console_message_handler.side_effect = RuntimeError("gone") + self.assertFalse(self.wrapper.remove_console_listener(42)) + + def test_listener_raises_without_bidi_support(self): + # Driver 沒有 script 屬性,模擬 Selenium < 4.16 或未啟用 BiDi + driver_without_bidi = MagicMock(spec=["execute"]) + self.wrapper.current_webdriver = driver_without_bidi + with self.assertRaises(WebRunnerException): + self.wrapper.add_console_listener(lambda m: None) + + +class TestSetDriverEnableBidi(unittest.TestCase): + + def test_enable_bidi_sets_capability(self): + fake_options = MagicMock(name="ChromeOptions") + fake_options_cls = MagicMock(return_value=fake_options) + fake_driver_cls = MagicMock(name="ChromeDriverClass") + fake_manager_cls = MagicMock(name="ChromeDriverManager") + with patch.dict( + "je_web_runner.webdriver.webdriver_wrapper._options_dict", + {"chrome": fake_options_cls}, + clear=False, + ), patch.dict( + "je_web_runner.webdriver.webdriver_wrapper._webdriver_dict", + {"chrome": fake_driver_cls}, + clear=False, + ), patch.dict( + "je_web_runner.webdriver.webdriver_wrapper._webdriver_manager_dict", + {"chrome": fake_manager_cls}, + clear=False, + ): + wrapper = WebDriverWrapper() + wrapper.set_driver("chrome", enable_bidi=True) + fake_options.set_capability.assert_called_once_with("webSocketUrl", True) + + +# --- Group K: Fetch interception primitives ------------------------------- + +class TestFetchInterception(unittest.TestCase): + + def setUp(self): + self.wrapper = WebDriverWrapper() + self.fake_driver = MagicMock() + self.wrapper.current_webdriver = self.fake_driver + + def test_enable_default_intercepts_everything(self): + self.wrapper.enable_fetch_interception() + self.fake_driver.execute_cdp_cmd.assert_called_once_with( + "Fetch.enable", + {"patterns": [{"urlPattern": "*"}], "handleAuthRequests": False}, + ) + + def test_enable_with_string_patterns(self): + self.wrapper.enable_fetch_interception( + patterns=["*.doubleclick.net/*", "https://api.example.com/*"], + handle_auth=True, + ) + self.fake_driver.execute_cdp_cmd.assert_called_once_with( + "Fetch.enable", + { + "patterns": [ + {"urlPattern": "*.doubleclick.net/*"}, + {"urlPattern": "https://api.example.com/*"}, + ], + "handleAuthRequests": True, + }, + ) + + def test_enable_with_dict_patterns_passes_through(self): + rp = {"urlPattern": "*/api/*", "resourceType": "XHR", "requestStage": "Response"} + self.wrapper.enable_fetch_interception(patterns=[rp]) + args, _ = self.fake_driver.execute_cdp_cmd.call_args + self.assertEqual(args[1]["patterns"], [rp]) + + def test_enable_rejects_bad_pattern_type(self): + with self.assertRaises(WebRunnerException): + self.wrapper.enable_fetch_interception(patterns=[123]) + + def test_disable(self): + self.wrapper.disable_fetch_interception() + self.fake_driver.execute_cdp_cmd.assert_called_once_with("Fetch.disable", {}) + + def test_continue_request_minimal(self): + self.wrapper.continue_request("req-123") + self.fake_driver.execute_cdp_cmd.assert_called_once_with( + "Fetch.continueRequest", {"requestId": "req-123"} + ) + + def test_continue_request_with_overrides(self): + self.wrapper.continue_request( + "req-123", + url="https://override.example.com/", + method="POST", + post_data="hello", + headers={"X-Test": "1", "X-Two": 2}, + ) + _, kwargs_or_args = self.fake_driver.execute_cdp_cmd.call_args + args = self.fake_driver.execute_cdp_cmd.call_args.args + self.assertEqual(args[0], "Fetch.continueRequest") + params = args[1] + self.assertEqual(params["url"], "https://override.example.com/") + self.assertEqual(params["method"], "POST") + self.assertEqual( + base64.b64decode(params["postData"]).decode("utf-8"), "hello" + ) + self.assertEqual( + params["headers"], + [{"name": "X-Test", "value": "1"}, {"name": "X-Two", "value": "2"}], + ) + + def test_continue_request_post_data_bytes_passthrough(self): + self.wrapper.continue_request("req-1", post_data=b"\x00\x01raw") + params = self.fake_driver.execute_cdp_cmd.call_args.args[1] + self.assertEqual(base64.b64decode(params["postData"]), b"\x00\x01raw") + + def test_fulfill_request_full(self): + self.wrapper.fulfill_request( + "req-1", + response_code=200, + body="hello world", + response_headers={"Content-Type": "text/plain"}, + response_phrase="OK", + ) + args = self.fake_driver.execute_cdp_cmd.call_args.args + self.assertEqual(args[0], "Fetch.fulfillRequest") + params = args[1] + self.assertEqual(params["requestId"], "req-1") + self.assertEqual(params["responseCode"], 200) + self.assertEqual( + params["responseHeaders"], + [{"name": "Content-Type", "value": "text/plain"}], + ) + self.assertEqual( + base64.b64decode(params["body"]).decode("utf-8"), "hello world" + ) + self.assertEqual(params["responsePhrase"], "OK") + + def test_fulfill_request_minimal(self): + self.wrapper.fulfill_request("req-2", response_code=204) + params = self.fake_driver.execute_cdp_cmd.call_args.args[1] + self.assertEqual(params, {"requestId": "req-2", "responseCode": 204}) + + def test_fail_request_default(self): + self.wrapper.fail_request("req-9") + self.fake_driver.execute_cdp_cmd.assert_called_once_with( + "Fetch.failRequest", + {"requestId": "req-9", "errorReason": "Aborted"}, + ) + + def test_fail_request_explicit_reason(self): + self.wrapper.fail_request("req-10", error_reason="AccessDenied") + self.fake_driver.execute_cdp_cmd.assert_called_once_with( + "Fetch.failRequest", + {"requestId": "req-10", "errorReason": "AccessDenied"}, + ) + + +if __name__ == "__main__": + unittest.main() From 22cb37763ef94599c3654993b77e2745359c1ede Mon Sep 17 00:00:00 2001 From: JeffreyChen <zenxcvwait@gmail.com> Date: Thu, 21 May 2026 16:23:55 +0800 Subject: [PATCH 2/3] Address SonarCloud findings on PR #96 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * S1192 — extract _FULL_PAGE_SCREENSHOT_LOG / _PRINT_PAGE_LOG constants in _media_mixin.py so the literals are defined once each. * S3776 — split set_driver into _build_driver_options / _apply_experimental_options / _apply_extension_paths helpers; main function cognitive complexity drops from 42 to well under 15. * S7500 — replace the (_ for _ in ()).throw(...) lambda hack in test_cdp_event_loop.py with a named _explode helper. * S1481 — drop unused 'handlers' from test_cdp_tracing.py and unused 'kwargs_or_args' from test_webdriver_wrapper_extensions.py. * S5906 — use assertTrue for the merged["detach"] bool check. * S5443 — rename hard-coded /tmp/ test fixtures to /opt/ paths (mock-only strings, never written) and update the docstring example in set_driver. * S5332 hotspot — Chrome DevTools /json discovery endpoint is HTTP-only by design; add NOSONAR with a comment block explaining why and that the endpoint binds to localhost. All 119 unit tests still pass. --- je_web_runner/utils/cdp/event_loop.py | 8 +- .../webdriver/_wrapper_mixins/_media_mixin.py | 15 +-- je_web_runner/webdriver/webdriver_wrapper.py | 92 ++++++++++++------- test/unit_test/test_cdp_event_loop.py | 6 +- test/unit_test/test_cdp_tracing.py | 2 +- .../test_webdriver_wrapper_extensions.py | 29 +++--- 6 files changed, 95 insertions(+), 57 deletions(-) diff --git a/je_web_runner/utils/cdp/event_loop.py b/je_web_runner/utils/cdp/event_loop.py index 12480b7..48e9e3d 100644 --- a/je_web_runner/utils/cdp/event_loop.py +++ b/je_web_runner/utils/cdp/event_loop.py @@ -62,7 +62,13 @@ def _query_page_ws_url(debugger_address: str) -> str: """從 ``http://host:port/json`` 取出第一個 page target 的 WebSocket URL。""" import urllib.request - url = f"http://{debugger_address}/json" + # Chrome / Edge DevTools 的 ``/json`` discovery endpoint 只跑在 + # ``--remote-debugging-port`` 上,協議固定為 HTTP 且只 bind localhost。 + # 不支援 HTTPS — 換 https 會直接連線失敗。 + # The Chrome / Edge DevTools ``/json`` discovery endpoint runs only on + # the ``--remote-debugging-port``, is HTTP-only by browser design, and + # binds to localhost. Forcing https here would simply fail to connect. + url = f"http://{debugger_address}/json" # NOSONAR python:S5332 — DevTools endpoint is HTTP-only by design with urllib.request.urlopen(url, timeout=5) as response: # noqa: S310 — local devtools endpoint targets = json.loads(response.read()) pages = [t for t in targets if t.get("type") == "page"] diff --git a/je_web_runner/webdriver/_wrapper_mixins/_media_mixin.py b/je_web_runner/webdriver/_wrapper_mixins/_media_mixin.py index 264c27a..433e4df 100644 --- a/je_web_runner/webdriver/_wrapper_mixins/_media_mixin.py +++ b/je_web_runner/webdriver/_wrapper_mixins/_media_mixin.py @@ -6,6 +6,9 @@ from je_web_runner.utils.logging.loggin_instance import web_runner_logger from je_web_runner.utils.test_record.test_record_class import record_action_to_list +_FULL_PAGE_SCREENSHOT_LOG = "webdriver wrapper save_full_page_screenshot" +_PRINT_PAGE_LOG = "webdriver wrapper print_page" + class _MediaMixin: """截圖 / 列印 / log 取得 / Screenshots, printing, and driver log retrieval.""" @@ -66,15 +69,15 @@ def save_full_page_screenshot(self, file_path: str) -> bool: ) data_b64 = (result or {}).get("data") if not data_b64: - record_action_to_list("webdriver wrapper save_full_page_screenshot", param, None) + record_action_to_list(_FULL_PAGE_SCREENSHOT_LOG, param, None) return False with open(file_path, "wb") as fh: fh.write(base64.b64decode(data_b64)) - record_action_to_list("webdriver wrapper save_full_page_screenshot", param, None) + record_action_to_list(_FULL_PAGE_SCREENSHOT_LOG, param, None) return True except Exception as error: web_runner_logger.error(f"WebDriverWrapper save_full_page_screenshot failed: {repr(error)}") - record_action_to_list("webdriver wrapper save_full_page_screenshot", param, error) + record_action_to_list(_FULL_PAGE_SCREENSHOT_LOG, param, error) return False def print_page(self, file_path: str, print_options=None) -> bool: @@ -96,15 +99,15 @@ def print_page(self, file_path: str, print_options=None) -> bool: else self.current_webdriver.print_page() ) if not data_b64: - record_action_to_list("webdriver wrapper print_page", param, None) + record_action_to_list(_PRINT_PAGE_LOG, param, None) return False with open(file_path, "wb") as fh: fh.write(base64.b64decode(data_b64)) - record_action_to_list("webdriver wrapper print_page", param, None) + record_action_to_list(_PRINT_PAGE_LOG, param, None) return True except Exception as error: web_runner_logger.error(f"WebDriverWrapper print_page failed: {repr(error)}") - record_action_to_list("webdriver wrapper print_page", param, error) + record_action_to_list(_PRINT_PAGE_LOG, param, error) return False def get_screenshot_as_base64(self) -> str | None: diff --git a/je_web_runner/webdriver/webdriver_wrapper.py b/je_web_runner/webdriver/webdriver_wrapper.py index 937ed3c..4a1a79a 100644 --- a/je_web_runner/webdriver/webdriver_wrapper.py +++ b/je_web_runner/webdriver/webdriver_wrapper.py @@ -84,6 +84,59 @@ } +def _apply_experimental_options(driver_options, webdriver_name, experimental_options) -> None: + """逐項套用 Chromium 系實驗性參數,若 Options 不支援則拋出。""" + if not hasattr(driver_options, "add_experimental_option"): + raise WebRunnerException( + f"{webdriver_name!r} options do not support experimental_options " + f"(Chromium-family browsers only)" + ) + for exp_key, exp_value in experimental_options.items(): + driver_options.add_experimental_option(exp_key, exp_value) + + +def _apply_extension_paths(driver_options, webdriver_name, extension_paths) -> None: + """逐項載入瀏覽器擴充功能 (.crx),若 Options 不支援則拋出。""" + if not hasattr(driver_options, "add_extension"): + raise WebRunnerException( + f"{webdriver_name!r} options do not support add_extension" + ) + for ext_path in extension_paths: + driver_options.add_extension(ext_path) + + +def _build_driver_options( + webdriver_name: str, + options: List[str] | None, + experimental_options: dict | None, + extension_paths: List[str] | None, + enable_bidi: bool, +): + """ + 依旗標組裝 Options 物件;若所有參數都空或瀏覽器沒有對應 Options 類別,回傳 None + 讓呼叫端走 ``webdriver_value(**kwargs)`` 路徑。 + Build the Options object based on the flag set; return ``None`` when nothing + needs to be configured (or the browser has no Options class), so the caller + can take the ``webdriver_value(**kwargs)`` path. + """ + if not (options or experimental_options or extension_paths or enable_bidi): + return None + options_cls = _options_dict.get(webdriver_name) + if options_cls is None: + return None + driver_options = options_cls() + if options: + for option in options: + driver_options.add_argument(argument=option) + if experimental_options: + _apply_experimental_options(driver_options, webdriver_name, experimental_options) + if extension_paths: + _apply_extension_paths(driver_options, webdriver_name, extension_paths) + if enable_bidi: + driver_options.set_capability("webSocketUrl", True) + return driver_options + + class WebDriverWrapper( _ScriptingMixin, _NavigationMixin, @@ -144,7 +197,7 @@ def set_driver( 會逐項經由 ``add_experimental_option`` 傳入。例如 ``{"excludeSwitches": ["enable-automation"], "useAutomationExtension": False, - "prefs": {"download.default_directory": "/tmp"}}``。 + "prefs": {"download.default_directory": "./downloads"}}``。 非 Chromium 系瀏覽器若傳入會拋出例外。 Browser-specific experimental options for Chromium-family browsers (Chrome / Chromium / Edge), each forwarded via @@ -184,38 +237,11 @@ def set_driver( webdriver_install_manager = _webdriver_manager_dict.get(webdriver_name) webdriver_install_manager().install() - # 如果有任一 option-like 參數,則建立對應的 Options 一次傳入 - # If any option-like arg provided, build a single Options object - has_options = bool(options) - has_experimental = bool(experimental_options) - has_extensions = bool(extension_paths) - if has_options or has_experimental or has_extensions or enable_bidi: - options_cls = _options_dict.get(webdriver_name) - driver_options = options_cls() if options_cls else None - if driver_options is None: - self.current_webdriver = webdriver_value(**kwargs) - else: - if has_options: - for option in options: - driver_options.add_argument(argument=option) - if has_experimental: - if not hasattr(driver_options, "add_experimental_option"): - raise WebRunnerException( - f"{webdriver_name!r} options do not support experimental_options " - f"(Chromium-family browsers only)" - ) - for exp_key, exp_value in experimental_options.items(): - driver_options.add_experimental_option(exp_key, exp_value) - if has_extensions: - if not hasattr(driver_options, "add_extension"): - raise WebRunnerException( - f"{webdriver_name!r} options do not support add_extension" - ) - for ext_path in extension_paths: - driver_options.add_extension(ext_path) - if enable_bidi: - driver_options.set_capability("webSocketUrl", True) - self.current_webdriver = webdriver_value(options=driver_options, **kwargs) + driver_options = _build_driver_options( + webdriver_name, options, experimental_options, extension_paths, enable_bidi, + ) + if driver_options is not None: + self.current_webdriver = webdriver_value(options=driver_options, **kwargs) else: self.current_webdriver = webdriver_value(**kwargs) diff --git a/test/unit_test/test_cdp_event_loop.py b/test/unit_test/test_cdp_event_loop.py index fad5028..0ed782b 100644 --- a/test/unit_test/test_cdp_event_loop.py +++ b/test/unit_test/test_cdp_event_loop.py @@ -160,9 +160,13 @@ def test_off_removes_handler(self): def test_handler_exception_does_not_kill_loop(self): fake_ws = FakeWebSocket() good = [] + + def _explode(_params): + raise RuntimeError("boom") + with _patch_websocket(fake_ws): with CDPEventListener("ws://fake") as listener: - listener.on("X", lambda p: (_ for _ in ()).throw(RuntimeError("boom"))) + listener.on("X", _explode) listener.on("X", lambda p: good.append(p)) fake_ws.push_dict({"method": "X", "params": {"n": 1}}) self.assertTrue(_wait_until(lambda: bool(good))) diff --git a/test/unit_test/test_cdp_tracing.py b/test/unit_test/test_cdp_tracing.py index 813755f..63c6a95 100644 --- a/test/unit_test/test_cdp_tracing.py +++ b/test/unit_test/test_cdp_tracing.py @@ -44,7 +44,7 @@ class TestRecordTrace(unittest.TestCase): def test_happy_path_writes_events(self): events = [{"name": "a"}, {"name": "b"}] - fake_listener, handlers = _make_fake_listener(events) + fake_listener, _ = _make_fake_listener(events) with patch( "je_web_runner.utils.cdp.tracing.CDPEventListener.from_driver", return_value=fake_listener, diff --git a/test/unit_test/test_webdriver_wrapper_extensions.py b/test/unit_test/test_webdriver_wrapper_extensions.py index 1848b8e..96b0bbd 100644 --- a/test/unit_test/test_webdriver_wrapper_extensions.py +++ b/test/unit_test/test_webdriver_wrapper_extensions.py @@ -67,11 +67,11 @@ def test_experimental_options_forwarded(self): def test_experimental_options_without_args_still_builds_options(self): fake_options, fake_driver_cls = self._patched_set_driver( - experimental_options={"prefs": {"download.default_directory": "/tmp"}}, + experimental_options={"prefs": {"download.default_directory": "/opt/dl"}}, ) fake_options.add_argument.assert_not_called() fake_options.add_experimental_option.assert_called_once_with( - "prefs", {"download.default_directory": "/tmp"} + "prefs", {"download.default_directory": "/opt/dl"} ) _, kwargs = fake_driver_cls.call_args self.assertIs(kwargs.get("options"), fake_options) @@ -157,8 +157,8 @@ def test_delegates_to_driver(self): fake_driver.save_screenshot.return_value = True wrapper.current_webdriver = fake_driver - self.assertTrue(wrapper.save_screenshot("/tmp/out.png")) - fake_driver.save_screenshot.assert_called_once_with("/tmp/out.png") + self.assertTrue(wrapper.save_screenshot("/opt/out.png")) + fake_driver.save_screenshot.assert_called_once_with("/opt/out.png") def test_returns_false_on_exception(self): wrapper = WebDriverWrapper() @@ -166,7 +166,7 @@ def test_returns_false_on_exception(self): fake_driver.save_screenshot.side_effect = RuntimeError("disk full") wrapper.current_webdriver = fake_driver - self.assertFalse(wrapper.save_screenshot("/tmp/out.png")) + self.assertFalse(wrapper.save_screenshot("/opt/out.png")) # --- Group A: page / window metadata -------------------------------------- @@ -249,10 +249,10 @@ def test_extension_paths_forwarded(self): wrapper = WebDriverWrapper() wrapper.set_driver( "chrome", - extension_paths=["/tmp/a.crx", "/tmp/b.crx"], + extension_paths=["/opt/a.crx", "/opt/b.crx"], ) - fake_options.add_extension.assert_any_call("/tmp/a.crx") - fake_options.add_extension.assert_any_call("/tmp/b.crx") + fake_options.add_extension.assert_any_call("/opt/a.crx") + fake_options.add_extension.assert_any_call("/opt/b.crx") _, kwargs = fake_driver_cls.call_args self.assertIs(kwargs.get("options"), fake_options) @@ -280,7 +280,7 @@ def add_argument(self, argument): # noqa: D401 ): wrapper = WebDriverWrapper() with self.assertRaises(WebRunnerException): - wrapper.set_driver("ie", extension_paths=["/tmp/a.crx"]) + wrapper.set_driver("ie", extension_paths=["/opt/a.crx"]) def test_attach_to_existing_browser_merges_debugger_address(self): wrapper = WebDriverWrapper() @@ -294,7 +294,7 @@ def test_attach_to_existing_browser_merges_debugger_address(self): self.assertEqual(args[0], "chrome") merged = kwargs["experimental_options"] self.assertEqual(merged["debuggerAddress"], "127.0.0.1:9222") - self.assertEqual(merged["detach"], True) + self.assertTrue(merged["detach"]) # --- Group C: CDP convenience methods ------------------------------------- @@ -564,20 +564,20 @@ def test_set_download_directory(self): wrapper = WebDriverWrapper() fake_driver = MagicMock() wrapper.current_webdriver = fake_driver - wrapper.set_download_directory("/tmp/downloads") + wrapper.set_download_directory("/opt/downloads") fake_driver.execute_cdp_cmd.assert_called_once_with( "Browser.setDownloadBehavior", - {"behavior": "allow", "downloadPath": "/tmp/downloads"}, + {"behavior": "allow", "downloadPath": "/opt/downloads"}, ) def test_set_download_directory_deny(self): wrapper = WebDriverWrapper() fake_driver = MagicMock() wrapper.current_webdriver = fake_driver - wrapper.set_download_directory("/tmp/downloads", behavior="deny") + wrapper.set_download_directory("/opt/downloads", behavior="deny") fake_driver.execute_cdp_cmd.assert_called_once_with( "Browser.setDownloadBehavior", - {"behavior": "deny", "downloadPath": "/tmp/downloads"}, + {"behavior": "deny", "downloadPath": "/opt/downloads"}, ) @@ -788,7 +788,6 @@ def test_continue_request_with_overrides(self): post_data="hello", headers={"X-Test": "1", "X-Two": 2}, ) - _, kwargs_or_args = self.fake_driver.execute_cdp_cmd.call_args args = self.fake_driver.execute_cdp_cmd.call_args.args self.assertEqual(args[0], "Fetch.continueRequest") params = args[1] From fbf6e49ec37262503f63503a41abb24d4a3634e4 Mon Sep 17 00:00:00 2001 From: JeffreyChen <zenxcvwait@gmail.com> Date: Thu, 21 May 2026 17:19:35 +0800 Subject: [PATCH 3/3] Address Codacy findings on PR #96 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Bandit B110 (event_loop.py:189) — replace bare pass in CDPEventListener.stop()'s ws.close() except with a debug-log line so the cleanup failure is at least visible in WEBRunner.log. * Bandit B110 (_navigation_mixin.py:161) — same fix for the best-effort window-restore branch in _switch_to_window_by_attr. * Bandit B310 (event_loop.py:72) — annotate the urlopen call with '# nosec B310' plus a comment explaining the scheme is a fixed-literal http:// (only debugger_address host:port varies, no user-controlled scheme so no file:// risk). --- je_web_runner/utils/cdp/event_loop.py | 8 +++++--- .../webdriver/_wrapper_mixins/_navigation_mixin.py | 7 +++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/je_web_runner/utils/cdp/event_loop.py b/je_web_runner/utils/cdp/event_loop.py index 48e9e3d..5b83998 100644 --- a/je_web_runner/utils/cdp/event_loop.py +++ b/je_web_runner/utils/cdp/event_loop.py @@ -69,7 +69,9 @@ def _query_page_ws_url(debugger_address: str) -> str: # the ``--remote-debugging-port``, is HTTP-only by browser design, and # binds to localhost. Forcing https here would simply fail to connect. url = f"http://{debugger_address}/json" # NOSONAR python:S5332 — DevTools endpoint is HTTP-only by design - with urllib.request.urlopen(url, timeout=5) as response: # noqa: S310 — local devtools endpoint + # Bandit B310: scheme is fixed-literal ``http://`` above, not user-controlled — only + # ``debugger_address`` (host:port) varies, so no file:// or custom-scheme risk. + with urllib.request.urlopen(url, timeout=5) as response: # nosec B310 # noqa: S310 — local devtools endpoint targets = json.loads(response.read()) pages = [t for t in targets if t.get("type") == "page"] if not pages: @@ -186,8 +188,8 @@ def stop(self, join_timeout: float = 2.0) -> None: if ws is not None: try: ws.close() - except Exception: # noqa: BLE001 — best-effort - pass + except Exception as error: # noqa: BLE001 — best-effort cleanup + web_runner_logger.debug(f"CDPEventListener ws.close failed: {error!r}") thread = self._thread self._thread = None if thread is not None and thread.is_alive(): diff --git a/je_web_runner/webdriver/_wrapper_mixins/_navigation_mixin.py b/je_web_runner/webdriver/_wrapper_mixins/_navigation_mixin.py index 2b6f652..800a87d 100644 --- a/je_web_runner/webdriver/_wrapper_mixins/_navigation_mixin.py +++ b/je_web_runner/webdriver/_wrapper_mixins/_navigation_mixin.py @@ -158,8 +158,11 @@ def _switch_to_window_by_attr(self, attr_name: str, pattern: str) -> bool: if original is not None: try: self.current_webdriver.switch_to.window(original) - except Exception: # noqa: BLE001 — best-effort restore - pass + except Exception as restore_error: # noqa: BLE001 — best-effort restore + web_runner_logger.debug( + f"WebDriverWrapper _switch_to_window_by_attr restore failed: " + f"{repr(restore_error)}" + ) return False # page / window metadata