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..5b83998
--- /dev/null
+++ b/je_web_runner/utils/cdp/event_loop.py
@@ -0,0 +1,280 @@
+"""
+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
+
+ # 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
+ # 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:
+ 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 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():
+ 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..433e4df
--- /dev/null
+++ b/je_web_runner/webdriver/_wrapper_mixins/_media_mixin.py
@@ -0,0 +1,154 @@
+"""截圖、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
+
+_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."""
+
+ 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(_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(_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(_FULL_PAGE_SCREENSHOT_LOG, 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(_PRINT_PAGE_LOG, param, None)
+ return False
+ with open(file_path, "wb") as fh:
+ fh.write(base64.b64decode(data_b64))
+ 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(_PRINT_PAGE_LOG, 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..800a87d
--- /dev/null
+++ b/je_web_runner/webdriver/_wrapper_mixins/_navigation_mixin.py
@@ -0,0 +1,447 @@
+"""導航、捲動、視窗 / 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 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
+ 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..4a1a79a 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,82 @@
}
-class WebDriverWrapper(object):
+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,
+ _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 +172,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 +193,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": "./downloads"}}``。
+ 非 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,13 +237,11 @@ 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)
- 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)
@@ -174,6 +286,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 +482,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 +505,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..0ed782b
--- /dev/null
+++ b/test/unit_test/test_cdp_event_loop.py
@@ -0,0 +1,283 @@
+"""
+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 = []
+
+ def _explode(_params):
+ raise RuntimeError("boom")
+
+ with _patch_websocket(fake_ws):
+ with CDPEventListener("ws://fake") as listener:
+ 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)))
+ # 第二個事件也要照常派發
+ 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..63c6a95
--- /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, _ = _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..96b0bbd
--- /dev/null
+++ b/test/unit_test/test_webdriver_wrapper_extensions.py
@@ -0,0 +1,852 @@
+"""
+驗證 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": "/opt/dl"}},
+ )
+ fake_options.add_argument.assert_not_called()
+ fake_options.add_experimental_option.assert_called_once_with(
+ "prefs", {"download.default_directory": "/opt/dl"}
+ )
+ _, 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("/opt/out.png"))
+ fake_driver.save_screenshot.assert_called_once_with("/opt/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("/opt/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 = ""
+ self.assertEqual(self.wrapper.get_page_source(), "")
+
+ 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=["/opt/a.crx", "/opt/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)
+
+ 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=["/opt/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.assertTrue(merged["detach"])
+
+
+# --- 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("/opt/downloads")
+ fake_driver.execute_cdp_cmd.assert_called_once_with(
+ "Browser.setDownloadBehavior",
+ {"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("/opt/downloads", behavior="deny")
+ fake_driver.execute_cdp_cmd.assert_called_once_with(
+ "Browser.setDownloadBehavior",
+ {"behavior": "deny", "downloadPath": "/opt/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},
+ )
+ 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()