From edd5bf943795a7f85d3d811ad528e3b98546f8e2 Mon Sep 17 00:00:00 2001
From: lanyue-llk <270302213+lanyue-llk@users.noreply.github.com>
Date: Tue, 22 Sep 2026 12:00:48 +0800
Subject: [PATCH] fix(input): preserve activation and navigation boundaries
---
.../moli_cdp_smoke/groups/dom_input.py | 21 +-
.../src/protocol_server/tests/classic.rs | 571 +++++++++++++++++-
.../src/protocol_server/webdriver_bidi.rs | 24 +
.../src/protocol_server/webdriver_classic.rs | 302 ++++++++-
.../webdriver_classic/helpers.rs | 3 +-
.../webdriver_classic/state.rs | 18 -
.../src/actions.rs | 22 +-
.../src/commands/elements.rs | 69 ++-
.../src/commands/mod.rs | 2 +-
moli-protocol-webdriver-classic/src/lib.rs | 16 +-
moli-protocol-webdriver-classic/src/tests.rs | 12 +-
moli-protocol-webdriver-classic/src/types.rs | 2 +
moli-protocol/src/conn/devtools_command.rs | 1 +
moli-protocol/src/domains/dom/resolve.rs | 16 +-
moli-protocol/src/domains/input.rs | 50 +-
moli-protocol/src/domains/input/tests.rs | 172 +++++-
.../domains/input/tests/keyboard_events.rs | 48 ++
moli-renderer-v8/src/native_bridge/element.rs | 4 +-
.../element/activation/default_action.rs | 63 +-
.../native_bridge/element/activation/mod.rs | 1 +
.../src/native_bridge/element/forms.rs | 3 +-
.../native_bridge/element/forms/submission.rs | 2 +-
.../src/runtime/owner_local_store/mod.rs | 11 +-
moli-renderer-v8/src/runtime/tests.rs | 278 +++++++++
moli-renderer-v8/src/script_vm.rs | 2 +
.../src/script_vm/input_dispatch.rs | 167 ++++-
.../src/script_vm/tests/dom_xhr/forms.rs | 455 ++++++++++++++
.../tests/dom_xhr/forms/event_activation.rs | 171 ++++++
28 files changed, 2372 insertions(+), 134 deletions(-)
diff --git a/moli-cdp-smoke/moli_cdp_smoke/groups/dom_input.py b/moli-cdp-smoke/moli_cdp_smoke/groups/dom_input.py
index 44dac500255..cc8d140091e 100644
--- a/moli-cdp-smoke/moli_cdp_smoke/groups/dom_input.py
+++ b/moli-cdp-smoke/moli_cdp_smoke/groups/dom_input.py
@@ -9,6 +9,7 @@
from . import SmokeState
from ..assertions import SmokeError, assert_equal, wait_until
+from ..progress import await_with_progress
async def run_dom_input_group(state: SmokeState) -> None:
@@ -192,7 +193,11 @@ async def run_dom_input_group(state: SmokeState) -> None:
await run_playwright_expect_matcher_workflows(state)
await run_locator_composition_workflows(state)
await run_keyboard_editing_workflows(state)
- await run_keypress_dispatch_workflow(state)
+ await await_with_progress(
+ "scenario/playwright-and-cdp-keypress",
+ run_keypress_dispatch_workflow(state),
+ timeout_seconds=30,
+ )
await run_cdp_control_key_name_workflow(state)
await run_cdp_input_navigation_replacement_workflows(state)
await run_mouse_event_workflows(state)
@@ -976,10 +981,10 @@ async def run_keyboard_editing_workflows(state: SmokeState) -> None:
async def run_keypress_dispatch_workflow(state: SmokeState) -> None:
- page = await state.context.new_page()
+ page = await await_with_progress("keypress/new-page", state.context.new_page())
session = None
try:
- await page.set_content("""
+ await await_with_progress("keypress/set-content", page.set_content("""
- """)
- session = await state.context.new_cdp_session(page)
+ """))
+ session = await await_with_progress(
+ "keypress/new-cdp-session", state.context.new_cdp_session(page),
+ )
full_sequence = ["keydown", "keypress", "beforeinput", "input", "keyup"]
async def reset(cancel_at: bool = False) -> None:
@@ -1045,8 +1052,8 @@ async def check(value: str, event_types: list[str], label: str) -> None:
state.record("playwright_and_cdp_keypress_dispatch")
finally:
if session is not None:
- await session.detach()
- await page.close()
+ await await_with_progress("keypress/detach", session.detach(), timeout_seconds=5)
+ await await_with_progress("keypress/close-page", page.close(), timeout_seconds=5)
async def run_cdp_control_key_name_workflow(state: SmokeState) -> None:
diff --git a/moli-protocol-server/src/protocol_server/tests/classic.rs b/moli-protocol-server/src/protocol_server/tests/classic.rs
index 84a3ea0be7d..69beb69ef98 100644
--- a/moli-protocol-server/src/protocol_server/tests/classic.rs
+++ b/moli-protocol-server/src/protocol_server/tests/classic.rs
@@ -11129,6 +11129,84 @@ async fn webdriver_classic_execute_async_honors_script_timeout() {
.await;
}
+#[tokio::test]
+async fn webdriver_classic_click_respects_dom_first_and_real_layout_policies() {
+ for policy in [LayoutPolicy::Mock, LayoutPolicy::OnDemand] {
+ let state = AppState::new_with_storage_partition_and_runtime_config(
+ "127.0.0.1:9222".parse().unwrap(),
+ Arc::new(StoragePartitionState::open(None).unwrap()),
+ NavigationRuntimeConfig::new(
+ protocol_server_test_fetch_config(FetchConfig::default()),
+ OptionalResourceFetchMask::NONE,
+ true,
+ policy,
+ ),
+ crate::config::DEFAULT_SCREENCAST_INTERVAL_MS,
+ )
+ .unwrap();
+ let app = build_router(state);
+ let session = classic_request_json(app.clone(), Method::POST, "/session").await;
+ let session_id = session["value"]["sessionId"].as_str().unwrap();
+ let navigated = classic_request_json_with_body(
+ app.clone(), Method::POST, &format!("/session/{session_id}/url"),
+ json!({"url": "data:text/html,"}),
+ ).await;
+ assert_eq!(navigated, json!({"value": null}));
+ let element_id = classic_find_css_element_id(app.clone(), session_id, "#target").await;
+ let clicked = classic_request_json(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{session_id}/element/{element_id}/click"),
+ )
+ .await;
+ assert_eq!(clicked, json!({"value": null}), "{policy:?}");
+ let observed = classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{session_id}/execute/sync"),
+ json!({"script": "return window.events;", "args": []}),
+ )
+ .await;
+ let expected = match policy {
+ LayoutPolicy::Mock => json!(["click"]),
+ LayoutPolicy::OnDemand => json!(["pointerdown", "mousedown", "mouseup", "click"]),
+ };
+ assert_eq!(observed["value"], expected, "{policy:?}");
+ classic_request_json_with_body(
+ app.clone(), Method::POST, &format!("/session/{session_id}/url"),
+ json!({"url": "data:text/html,"}),
+ ).await;
+ let target = classic_find_css_element_id(app.clone(), session_id, "#target").await;
+ assert_eq!(
+ classic_request_json(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{session_id}/element/{target}/click"),
+ )
+ .await,
+ json!({"value":null})
+ );
+ assert_eq!(
+ classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{session_id}/actions"),
+ json!({"actions":[{"type":"key","id":"keyboard","actions":[
+ {"type":"keyDown","value":"x"},{"type":"keyUp","value":"x"}
+ ]}]}),
+ )
+ .await,
+ json!({"value":null})
+ );
+ let focused = classic_request_json_with_body(
+ app.clone(), Method::POST, &format!("/session/{session_id}/execute/sync"),
+ json!({"script":"return [document.activeElement.id,document.getElementById('origin').value,document.getElementById('target').value];","args":[]}),
+ ).await;
+ assert_eq!(focused["value"], json!(["target", "", "x"]), "{policy:?}");
+ classic_request_json(app, Method::DELETE, &format!("/session/{session_id}")).await;
+ }
+}
+
#[tokio::test]
async fn webdriver_classic_element_click_uses_shared_dom_geometry_and_input() {
let app = build_router(test_state());
@@ -11137,7 +11215,7 @@ async fn webdriver_classic_element_click_uses_shared_dom_geometry_and_input() {
let session_id = session["value"]["sessionId"]
.as_str()
.expect("classic session id");
- let url = "data:text/html,";
+ let url = "data:text/html,";
let navigated = classic_request_json_with_body(
app.clone(),
@@ -11175,12 +11253,18 @@ async fn webdriver_classic_element_click_uses_shared_dom_geometry_and_input() {
Method::POST,
&format!("/session/{session_id}/execute/sync"),
json!({
- "script": "return Boolean(window.__clicked);",
+ "script": "return [Boolean(window.__clicked),document.activeElement.id,window.events];",
"args": []
}),
)
.await;
- assert_eq!(clicked_state, json!({ "value": true }));
+ assert_eq!(
+ clicked_state,
+ json!({ "value": [true, "target", [
+ ["pointerdown", true], ["mousedown", true], ["focus", true],
+ ["mouseup", true], ["click", true]
+ ]] })
+ );
let _ = classic_request_json(
app.clone(),
@@ -11190,6 +11274,414 @@ async fn webdriver_classic_element_click_uses_shared_dom_geometry_and_input() {
.await;
}
+#[tokio::test]
+async fn webdriver_classic_click_pointer_focus_and_interactability_controls() {
+ let app = build_router(test_state());
+ let session = classic_request_json(app.clone(), Method::POST, "/session").await;
+ let session_id = session["value"]["sessionId"].as_str().expect("session id");
+ for (name, target, setup, expected_focus, expected_clicks, expected_error) in [
+ ("ordinary", "", "", "target", 1, None),
+ (
+ "clipped fixed button",
+ "",
+ "",
+ "target",
+ 1,
+ None,
+ ),
+ (
+ "entirely outside viewport",
+ "",
+ "",
+ "origin",
+ 0,
+ Some("element not interactable"),
+ ),
+ (
+ "cancel mousedown",
+ "",
+ "target.onmousedown=e=>e.preventDefault();",
+ "origin",
+ 1,
+ None,
+ ),
+ (
+ "cancel pointerdown",
+ "",
+ "target.onpointerdown=e=>e.preventDefault();",
+ "origin",
+ 1,
+ None,
+ ),
+ (
+ "label",
+ "",
+ "",
+ "check",
+ 1,
+ None,
+ ),
+ (
+ "disabled",
+ "",
+ "",
+ "origin",
+ 0,
+ None,
+ ),
+ (
+ "scroll",
+ "",
+ "",
+ "target",
+ 1,
+ None,
+ ),
+ (
+ "file",
+ "",
+ "",
+ "origin",
+ 0,
+ Some("invalid argument"),
+ ),
+ (
+ "hidden",
+ "",
+ "",
+ "origin",
+ 0,
+ Some("element not interactable"),
+ ),
+ (
+ "obscured",
+ "
",
+ "",
+ "origin",
+ 0,
+ Some("element click intercepted"),
+ ),
+ ] {
+ let html = format!(
+ "{target}"
+ );
+ let navigated = classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{session_id}/url"),
+ json!({"url":format!("data:text/html,{html}")}),
+ )
+ .await;
+ assert_eq!(navigated, json!({"value":null}), "{name}");
+ let element_id = classic_find_css_element_id(app.clone(), session_id, "#target").await;
+ let (status, clicked) = classic_request_status_and_json(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{session_id}/element/{element_id}/click"),
+ )
+ .await;
+ if let Some(error) = expected_error {
+ assert_eq!(status, StatusCode::BAD_REQUEST, "{name}: {clicked}");
+ assert_eq!(clicked["value"]["error"], error, "{name}");
+ } else {
+ assert_eq!(clicked, json!({"value":null}), "{name}");
+ }
+ let observed = classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{session_id}/execute/sync"),
+ json!({
+ "script":"return [document.activeElement.id,window.clicks];", "args":[]
+ }),
+ )
+ .await;
+ assert_eq!(observed["value"][1], expected_clicks, "{name}");
+ // Disabled controls must not be rejected or activated. Their focus
+ // result is not the behavior this adapter correction changes.
+ if name != "disabled" {
+ assert_eq!(observed["value"][0], expected_focus, "{name}");
+ }
+ }
+ // execute/sync remains a JavaScript operation, even with user_gesture set.
+ classic_request_json_with_body(app.clone(), Method::POST,
+ &format!("/session/{session_id}/url"), json!({
+ "url":"data:text/html,"
+ })).await;
+ let synthetic = classic_request_json_with_body(app.clone(), Method::POST,
+ &format!("/session/{session_id}/execute/sync"), json!({
+ "script":"let events=[];for(const type of ['pointerdown','mousedown','focus','mouseup','click'])target.addEventListener(type,e=>events.push([e.type,e.isTrusted]));target.click();return [document.activeElement.id,events];", "args":[]
+ })).await;
+ assert_eq!(synthetic, json!({"value":["origin",[["click",false]]]}));
+ classic_request_json(app, Method::DELETE, &format!("/session/{session_id}")).await;
+}
+
+#[tokio::test]
+async fn webdriver_classic_click_uses_top_level_pointer_coordinates_inside_offset_frame() {
+ for transform in ["none", "scale(0.75)", "rotate(12deg)"] {
+ let app = build_router(test_state());
+ let session = classic_request_json(app.clone(), Method::POST, "/session").await;
+ let session_id = session["value"]["sessionId"].as_str().expect("session id");
+ let html = format!(
+ ""
+ );
+ classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{session_id}/url"),
+ json!({"url":format!("data:text/html,{html}")}),
+ )
+ .await;
+ assert_eq!(
+ classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{session_id}/frame"),
+ json!({"id":0})
+ )
+ .await,
+ json!({"value":null})
+ );
+ let element_id = classic_find_css_element_id(app.clone(), session_id, "#target").await;
+ assert_eq!(
+ classic_request_json(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{session_id}/element/{element_id}/click")
+ )
+ .await,
+ json!({"value":null})
+ );
+ let observed = classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{session_id}/execute/sync"),
+ json!({
+ "script":"return [document.activeElement.id,window.clicks,window.trusted];", "args":[]
+ }),
+ )
+ .await;
+ assert_eq!(observed, json!({"value":["target",1,true]}), "{transform}");
+ classic_request_json(app, Method::DELETE, &format!("/session/{session_id}")).await;
+ }
+}
+
+#[tokio::test]
+async fn webdriver_classic_frame_target_click_is_invariant_under_neutral_frame_wrapping() {
+ for depth in 0..=2 {
+ for selector in ["#target", "#container"] {
+ let app = build_router(test_state());
+ let session = classic_request_json(app.clone(), Method::POST, "/session").await;
+ let session_id = session["value"]["sessionId"].as_str().unwrap();
+ let mut html = "".to_owned();
+ for _ in 0..depth {
+ let child = html.replace('&', "&").replace('"', """);
+ html = format!(
+ ""
+ );
+ }
+ classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{session_id}/url"),
+ json!({"url":classic_data_url(&html)}),
+ )
+ .await;
+ for _ in 0..depth {
+ classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{session_id}/frame"),
+ json!({"id":0}),
+ )
+ .await;
+ }
+ let target = classic_find_css_element_id(app.clone(), session_id, selector).await;
+ let (status, clicked) = classic_request_status_and_json(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{session_id}/element/{target}/click"),
+ )
+ .await;
+ assert_eq!(
+ status,
+ StatusCode::OK,
+ "depth={depth} target={selector}: {clicked}"
+ );
+ assert_eq!(clicked, json!({"value":null}));
+ let observed = classic_request_json_with_body(
+ app.clone(), Method::POST, &format!("/session/{session_id}/execute/sync"),
+ json!({"script":"return document.getElementById('target').contentWindow.clicks;","args":[]}),
+ ).await;
+ assert_eq!(observed["value"], 1, "depth={depth} target={selector}");
+ // Another child of the same document is not a descendant of the
+ // requested target. It must not become an accepted deep hit.
+ classic_request_json_with_body(
+ app.clone(), Method::POST, &format!("/session/{session_id}/execute/sync"),
+ json!({"script":r#"
+ window.overlayClicks=0;
+ const veil=document.createElement('iframe');
+ veil.style.cssText='position:fixed;inset:0;width:100%;height:100%;border:0;z-index:999';
+ veil.srcdoc='overlay';
+ document.body.appendChild(veil);
+ "#,"args":[]}),
+ ).await;
+ let (status, blocked) = classic_request_status_and_json(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{session_id}/element/{target}/click"),
+ )
+ .await;
+ assert_eq!(
+ status,
+ StatusCode::BAD_REQUEST,
+ "depth={depth} target={selector}: {blocked}"
+ );
+ assert_eq!(blocked["value"]["error"], "element click intercepted");
+ let observed = classic_request_json_with_body(
+ app.clone(), Method::POST, &format!("/session/{session_id}/execute/sync"),
+ json!({"script":"return [document.getElementById('target').contentWindow.clicks,window.overlayClicks];","args":[]}),
+ ).await;
+ assert_eq!(
+ observed["value"],
+ json!([1, 0]),
+ "rejected clicks must have no effect"
+ );
+ classic_request_json(app, Method::DELETE, &format!("/session/{session_id}")).await;
+ }
+ }
+}
+
+#[tokio::test]
+async fn webdriver_classic_frame_click_rejects_ancestor_overlays_without_dispatch() {
+ for (depth, blocked_level) in [(1, 0), (2, 0), (2, 1)] {
+ let app = build_router(test_state());
+ let session = classic_request_json(app.clone(), Method::POST, "/session").await;
+ let session_id = session["value"]["sessionId"].as_str().unwrap();
+ let mut html = "".to_owned();
+ for _ in 0..depth {
+ let child = html.replace('&', "&").replace('"', """);
+ html = format!(
+ ""
+ );
+ }
+ classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{session_id}/url"),
+ json!({"url":classic_data_url(&html)}),
+ )
+ .await;
+ for _ in 0..depth {
+ assert_eq!(
+ classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{session_id}/frame"),
+ json!({"id":0}),
+ )
+ .await,
+ json!({"value":null})
+ );
+ }
+ let target = classic_find_css_element_id(app.clone(), session_id, "#target").await;
+ let overlay_script = format!(
+ "let w=window;for(let i=0;i<{};i++)w=w.parent;w.document.getElementById('veil').style.display=arguments[0];",
+ depth - blocked_level
+ );
+ let read_script = "let w=window,total=0;while(w!==w.parent){w=w.parent;total+=w.overlayClicks;}return [window.clicks,total];";
+ for blocked in [true, false] {
+ classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{session_id}/execute/sync"),
+ json!({"script":overlay_script,"args":[if blocked {"block"} else {"none"}]}),
+ )
+ .await;
+ let (status, clicked) = classic_request_status_and_json(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{session_id}/element/{target}/click"),
+ )
+ .await;
+ if blocked {
+ assert_eq!(status, StatusCode::BAD_REQUEST);
+ assert_eq!(
+ clicked["value"]["error"], "element click intercepted",
+ "depth={depth} level={blocked_level}: {clicked}"
+ );
+ } else {
+ assert_eq!(status, StatusCode::OK);
+ assert_eq!(clicked, json!({"value":null}));
+ }
+ let observed = classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{session_id}/execute/sync"),
+ json!({"script":read_script,"args":[]}),
+ )
+ .await;
+ assert_eq!(observed["value"], json!([if blocked { 0 } else { 1 }, 0]));
+ }
+ classic_request_json(app, Method::DELETE, &format!("/session/{session_id}")).await;
+ }
+}
+
+#[tokio::test]
+async fn webdriver_classic_click_mousedown_navigation_does_not_activate_successor() {
+ let app = build_router(test_state());
+ let session = classic_request_json(app.clone(), Method::POST, "/session").await;
+ let session_id = session["value"]["sessionId"].as_str().expect("session id");
+ let successor = classic_data_url(
+ "",
+ );
+ let source = classic_data_url(&format!(
+ ""
+ ));
+ classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{session_id}/url"),
+ json!({"url":source}),
+ )
+ .await;
+ let element_id = classic_find_css_element_id(app.clone(), session_id, "#target").await;
+ let clicked = classic_request_json(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{session_id}/element/{element_id}/click"),
+ )
+ .await;
+ // A complete physical sequence may navigate during mousedown; no input
+ // phase is retried against the successor document.
+ assert_eq!(clicked, json!({"value":null}));
+ let observed = classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{session_id}/execute/sync"),
+ json!({
+ "script":"return window.clicks;", "args":[]
+ }),
+ )
+ .await;
+ assert_eq!(observed, json!({"value":0}));
+ let successor_id = classic_find_css_element_id(app.clone(), session_id, "#target").await;
+ assert_eq!(
+ classic_request_json(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{session_id}/element/{successor_id}/click")
+ )
+ .await,
+ json!({"value":null})
+ );
+ let observed = classic_request_json_with_body(app.clone(), Method::POST,
+ &format!("/session/{session_id}/execute/sync"), json!({
+ "script":"return [window.clicks,document.activeElement.id,window.buttons.every(b=>b===0)];", "args":[]
+ })).await;
+ assert_eq!(observed, json!({"value":[1,"target",true]}));
+ classic_request_json(app, Method::DELETE, &format!("/session/{session_id}")).await;
+}
+
#[tokio::test]
async fn webdriver_classic_anchor_target_blank_click_opens_window_handle() {
// Mirrors ChromeDriver's link-click new window smoke:
@@ -11532,6 +12024,14 @@ async fn webdriver_classic_option_click_updates_select_state_ported_from_seleniu
)
.await;
assert_eq!(clicked_brie, json!({ "value": null }));
+ let focused_select = classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{session_id}/execute/sync"),
+ json!({"script":"return document.activeElement.id;", "args":[]}),
+ )
+ .await;
+ assert_eq!(focused_select, json!({"value":"single"}));
let clicked_ham = classic_request_json(
app.clone(),
Method::POST,
@@ -12012,6 +12512,71 @@ async fn webdriver_classic_option_click_edges_ported_from_chromium_wpt() {
.await;
}
+#[tokio::test]
+async fn webdriver_classic_enter_inserts_newline_and_submits_through_both_key_routes() {
+ let app = build_router(test_state());
+ let session = classic_request_json(app.clone(), Method::POST, "/session").await;
+ let session_id = session["value"]["sessionId"].as_str().unwrap();
+ for key in ["\u{E006}", "\u{E007}"] {
+ for use_actions in [false, true] {
+ for (target, expected) in [
+ ("", json!(["a\nb", 0])),
+ ("", json!(["ab", 1])),
+ ] {
+ let html = format!(
+ ""
+ );
+ let navigated = classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{session_id}/url"),
+ json!({"url": format!("data:text/html,{html}")}),
+ )
+ .await;
+ assert_eq!(navigated, json!({"value": null}));
+ let element_id =
+ classic_find_css_element_id(app.clone(), session_id, "#field").await;
+ let (route, body) = if use_actions {
+ (
+ format!("/session/{session_id}/actions"),
+ json!({"actions": [{
+ "type": "key", "id": "keyboard", "actions": [
+ {"type":"keyDown", "value":"a"}, {"type":"keyUp", "value":"a"},
+ {"type":"keyDown", "value":key}, {"type":"keyUp", "value":key},
+ {"type":"keyDown", "value":"b"}, {"type":"keyUp", "value":"b"}
+ ]
+ }]}),
+ )
+ } else {
+ (
+ format!("/session/{session_id}/element/{element_id}/value"),
+ json!({"text": format!("a{key}b")}),
+ )
+ };
+ let sent =
+ classic_request_json_with_body(app.clone(), Method::POST, &route, body).await;
+ assert_eq!(
+ sent,
+ json!({"value": null}),
+ "{key:?}, actions={use_actions}"
+ );
+ let observed = classic_request_json_with_body(
+ app.clone(),
+ Method::POST,
+ &format!("/session/{session_id}/execute/sync"),
+ json!({"script": "return [field.value,window.submits];", "args": []}),
+ )
+ .await;
+ assert_eq!(
+ observed["value"], expected,
+ "{target}, {key:?}, actions={use_actions}"
+ );
+ }
+ }
+ }
+ classic_request_json(app, Method::DELETE, &format!("/session/{session_id}")).await;
+}
+
#[tokio::test]
async fn webdriver_classic_element_send_keys_uses_shared_input() {
let app = build_router(test_state());
diff --git a/moli-protocol-server/src/protocol_server/webdriver_bidi.rs b/moli-protocol-server/src/protocol_server/webdriver_bidi.rs
index 913df2b3160..eb7179e52a2 100644
--- a/moli-protocol-server/src/protocol_server/webdriver_bidi.rs
+++ b/moli-protocol-server/src/protocol_server/webdriver_bidi.rs
@@ -2977,6 +2977,7 @@ fn bidi_response_from_classic_input_error(id: u64, error: ClassicError) -> serde
ClassicErrorCode::InvalidArgument
| ClassicErrorCode::MoveTargetOutOfBounds
| ClassicErrorCode::ElementNotInteractable
+ | ClassicErrorCode::ElementClickIntercepted
| ClassicErrorCode::InvalidElementState
| ClassicErrorCode::StaleElementReference
| ClassicErrorCode::NoSuchFrame
@@ -3236,6 +3237,29 @@ mod tests {
use super::*;
+ #[test]
+ fn webdriver_classic_click_errors_use_existing_bidi_input_error_mapping() {
+ for (classic, expected) in [
+ (
+ ClassicErrorCode::ElementClickIntercepted,
+ "invalid argument",
+ ),
+ (ClassicErrorCode::ElementNotInteractable, "invalid argument"),
+ (ClassicErrorCode::InvalidArgument, "invalid argument"),
+ (ClassicErrorCode::NoSuchElement, "no such node"),
+ (ClassicErrorCode::UnknownError, "unknown error"),
+ ] {
+ let response = bidi_response_from_classic_input_error(
+ 42,
+ ClassicError::new(classic, "input diagnostic"),
+ );
+ assert_eq!(response["type"], json!("error"));
+ assert_eq!(response["id"], json!(42));
+ assert_eq!(response["error"], json!(expected));
+ assert_eq!(response["message"], json!("input diagnostic"));
+ }
+ }
+
#[test]
fn protocol_output_hook_prefers_automation_sidecar_over_protocol_message_parse() {
let mut bidi = BidiConnectionState::new();
diff --git a/moli-protocol-server/src/protocol_server/webdriver_classic.rs b/moli-protocol-server/src/protocol_server/webdriver_classic.rs
index 5cd733d26a8..86b8753bd1b 100644
--- a/moli-protocol-server/src/protocol_server/webdriver_classic.rs
+++ b/moli-protocol-server/src/protocol_server/webdriver_classic.rs
@@ -16,17 +16,18 @@ use moli_html_input_type::InputType;
use moli_protocol::{
DevToolsPageResidenceIdentity,
devtools_runtime::{
- DevToolsCommand, DevToolsCommandContext, DevToolsCommandResult, DevToolsDomGeometryResult,
- DevToolsDomNodeReference, DevToolsDownloadBehaviorSetting, DevToolsError,
- DevToolsErrorKind, DevToolsFrameId, DevToolsGetFrameOwnerCommand,
- DevToolsGetFrameOwnerResult, DevToolsGetRealmsCommand, DevToolsGetRealmsResult,
+ DevToolsCommand, DevToolsCommandContext, DevToolsCommandResult, DevToolsDomGeometryCommand,
+ DevToolsDomGeometryOperation, DevToolsDomGeometryResult, DevToolsDomNodeReference,
+ DevToolsDownloadBehaviorSetting, DevToolsError, DevToolsErrorKind, DevToolsFrameId,
+ DevToolsGetFrameOwnerCommand, DevToolsGetFrameOwnerResult,
+ DevToolsGetNodeForLocationCommand, DevToolsGetRealmsCommand, DevToolsGetRealmsResult,
DevToolsGetServiceWorkerLogsCommand, DevToolsGetServiceWorkerLogsResult,
DevToolsGetTargetsCommand, DevToolsGetTargetsResult, DevToolsLayoutMetricsResult,
- DevToolsLocateNodesResult, DevToolsProtocol, DevToolsQuerySelectorResult,
- DevToolsRemoteHandleId, DevToolsRemoteValue, DevToolsScriptException, DevToolsScriptResult,
- DevToolsSessionId, DevToolsSetDownloadBehaviorCommand, DevToolsSetFileInputFilesCommand,
- DevToolsTargetId, DevToolsTargetInfo, DevToolsTargetKind, RuntimeConsoleEvent,
- RuntimeExecutionContextEvent,
+ DevToolsLocateNodesResult, DevToolsMouseEventType, DevToolsProtocol,
+ DevToolsQuerySelectorResult, DevToolsRemoteHandleId, DevToolsRemoteValue,
+ DevToolsScriptException, DevToolsScriptResult, DevToolsSessionId,
+ DevToolsSetDownloadBehaviorCommand, DevToolsSetFileInputFilesCommand, DevToolsTargetId,
+ DevToolsTargetInfo, DevToolsTargetKind, RuntimeConsoleEvent, RuntimeExecutionContextEvent,
},
version,
};
@@ -35,18 +36,19 @@ use moli_protocol_webdriver_classic::{
CLASSIC_WINDOW_REFERENCE_KEY, ClassicDevToolsCommandContext,
ClassicElementOriginViewportPoints, ClassicError, ClassicErrorCode, ClassicPageLoadStrategy,
ClassicPromptHandler, ClassicUnhandledPromptBehavior, ClassicViewportBounds,
- action_element_origin_ids, active_element_command, alert_handle_command, alert_text_command,
- classic_attribute_value, classic_error_from_devtools_error, classic_property_value,
- classic_rect_from_geometry, classic_shadow_root_reference, classic_text_value,
- clear_element_command, create_initial_target_command, current_url_command,
+ ClassicViewportPoint, action_element_origin_ids, active_element_command, alert_handle_command,
+ alert_text_command, classic_attribute_value, classic_error_from_devtools_error,
+ classic_property_value, classic_rect_from_geometry, classic_shadow_root_reference,
+ classic_text_value, clear_element_command, create_initial_target_command, current_url_command,
delete_session_response, describe_node_command, describe_node_reference_command,
element_center_from_geometry, element_click_command, element_click_input_commands,
- element_click_prepare_reference_commands, element_screenshot_command,
- element_send_keys_input_commands, element_send_keys_prepare_text_control_command,
- element_send_keys_text, execute_async_command, execute_sync_command, find_element_command,
- find_element_command_with_root, get_element_attributes_reference_command,
- get_element_computed_label_command, get_element_computed_role_command,
- get_element_css_value_command, get_element_displayed_command, get_element_enabled_command,
+ element_click_preflight_command, element_click_prepare_reference_commands,
+ element_screenshot_command, element_send_keys_input_commands,
+ element_send_keys_prepare_text_control_command, element_send_keys_text, execute_async_command,
+ execute_sync_command, find_element_command, find_element_command_with_root,
+ get_element_attributes_reference_command, get_element_computed_label_command,
+ get_element_computed_role_command, get_element_css_value_command,
+ get_element_displayed_command, get_element_enabled_command,
get_element_property_reference_command, get_element_rect_reference_command,
get_element_rendered_text_command, get_element_text_reference_command, history_traversal_entry,
layout_metrics_command, matched_capabilities_from_new_session_params, navigate_command,
@@ -4430,10 +4432,11 @@ pub(super) async fn webdriver_classic_send_keys_to_element(
ClassicSendKeysPreflight::NotTextControl
) {
let input_context = classic_top_level_context(&binding);
- let commands = match element_click_input_commands(&input_context, &geometry) {
- Ok(commands) => commands,
+ let point = match element_center_from_geometry(&geometry) {
+ Ok(point) => point,
Err(error) => return classic_error_into_response(error),
};
+ let commands = element_click_input_commands(&input_context, point);
if let Err(error) = webdriver_classic_execute_empty_commands(
&binding,
commands,
@@ -4515,6 +4518,158 @@ async fn webdriver_classic_prepare_text_control_for_send_keys(
}
}
+enum ClassicClickPreparation {
+ Dom,
+ Pointer {
+ point: ClassicViewportPoint,
+ viewport: ClassicViewportBounds,
+ },
+}
+
+impl ClassicClickPreparation {
+ fn from_value(value: Value) -> Result {
+ match value.get("status").and_then(Value::as_str) {
+ Some("option" | "dom") => Ok(Self::Dom),
+ Some("file") => Err(ClassicError::new(
+ ClassicErrorCode::InvalidArgument,
+ "file inputs cannot be clicked by WebDriver",
+ )),
+ Some("not interactable") => Err(ClassicError::new(
+ ClassicErrorCode::ElementNotInteractable,
+ "element has no in-view clickable rectangle",
+ )),
+ Some("intercepted") => Err(ClassicError::new(
+ ClassicErrorCode::ElementClickIntercepted,
+ "another element obscures the element's in-view center",
+ )),
+ Some("pointer") => {
+ let coordinate = |name: &str| {
+ value
+ .get(name)
+ .and_then(Value::as_f64)
+ .filter(|value| value.is_finite())
+ .ok_or_else(|| {
+ ClassicError::new(
+ ClassicErrorCode::UnknownError,
+ format!("click preflight has invalid {name}"),
+ )
+ })
+ };
+ let point = ClassicViewportPoint::new(coordinate("x")?, coordinate("y")?)?;
+ let viewport = ClassicViewportBounds {
+ width: coordinate("viewport_width")?,
+ height: coordinate("viewport_height")?,
+ };
+ if viewport.width <= 0.0 || viewport.height <= 0.0 {
+ return Err(ClassicError::new(
+ ClassicErrorCode::ElementNotInteractable,
+ "frame has an empty viewport",
+ ));
+ }
+ Ok(Self::Pointer { point, viewport })
+ }
+ _ => Err(ClassicError::new(
+ ClassicErrorCode::UnknownError,
+ "element click preflight returned an invalid status",
+ )),
+ }
+ }
+}
+
+async fn webdriver_classic_click_point(
+ binding: &ClassicSessionBinding,
+ point: ClassicViewportPoint,
+ viewport: ClassicViewportBounds,
+) -> Result {
+ let Some(frame_id) = binding.current_frame_id.as_deref() else {
+ return ClassicViewportPoint::new(point.x, point.y);
+ };
+ let owner = classic_frame_owner_dom_reference(binding, frame_id).await?;
+ let result = binding
+ .runtime
+ .execute_on_page(
+ DevToolsCommand::DomGeometry(DevToolsDomGeometryCommand {
+ context: classic_top_level_devtools_context(binding),
+ reference: owner.reference,
+ operation: DevToolsDomGeometryOperation::GetBoxModel,
+ }),
+ owner.page_residence.clone(),
+ )
+ .await
+ .map_err(classic_error_from_devtools_error)?;
+ let DevToolsCommandResult::DomGeometry(geometry) = result else {
+ return Err(ClassicError::new(
+ ClassicErrorCode::UnknownError,
+ "frame geometry returned an unexpected result",
+ ));
+ };
+ let model = geometry.box_model.ok_or_else(|| {
+ ClassicError::new(
+ ClassicErrorCode::ElementNotInteractable,
+ "frame has no content geometry",
+ )
+ })?;
+ let [x0, y0, x1, y1, _, _, x3, y3] = model.content.points.as_slice() else {
+ return Err(ClassicError::new(
+ ClassicErrorCode::UnknownError,
+ "frame has invalid content geometry",
+ ));
+ };
+ // The content quad already includes every ancestor-frame transform. Map
+ // the exact locally hit-tested point, retaining rotation/scale and borders.
+ let u = point.x / viewport.width;
+ let v = point.y / viewport.height;
+ let point = ClassicViewportPoint::new(
+ x0 + u * (x1 - x0) + v * (x3 - x0),
+ y0 + u * (y1 - y0) + v * (y3 - y0),
+ )?;
+ // Use the renderer's deep hit path, which enters a child document only
+ // when each ancestor frame owner is actually visible at this root point.
+ let hit = binding
+ .runtime
+ .execute_on_page(
+ DevToolsCommand::GetNodeForLocation(DevToolsGetNodeForLocationCommand {
+ context: classic_top_level_devtools_context(binding),
+ x: point.x,
+ y: point.y,
+ include_user_agent_shadow_dom: false,
+ ignore_pointer_events_none: false,
+ }),
+ owner.page_residence,
+ )
+ .await
+ .map_err(classic_error_from_devtools_error)?;
+ let DevToolsCommandResult::GetNodeForLocation(hit) = hit else {
+ return Err(ClassicError::new(
+ ClassicErrorCode::UnknownError,
+ "frame hit test returned an unexpected result",
+ ));
+ };
+ // Local preflight already requires the hit element to be in the target's
+ // subtree. A target iframe (or its container) can legitimately lead the
+ // deep hit into another document, so require the hit path to pass through
+ // the current frame, not to end there. Ancestor/sibling overlays fail this.
+ let mut hit_frame_id = Some(hit.frame_id.as_str().to_owned());
+ while let Some(hit_frame) = hit_frame_id {
+ if hit_frame == frame_id {
+ return Ok(point);
+ }
+ hit_frame_id = binding
+ .runtime
+ .parent_frame_id(
+ binding.session_id.clone(),
+ binding.target_id.clone(),
+ hit_frame,
+ )
+ .await
+ .map_err(classic_error_from_devtools_error)?;
+ }
+ Err(ClassicError::new(
+ ClassicErrorCode::ElementClickIntercepted,
+ "an ancestor document obscures the target frame's click point",
+ ))
+}
+
async fn webdriver_classic_activate_element_by_handle(
state: &AppState,
binding: &ClassicSessionBinding,
@@ -4532,18 +4687,98 @@ async fn webdriver_classic_activate_element_by_handle(
object_group,
)
.await?;
- let result = binding
+ let prepared = binding
.runtime
- .execute_with_pending_navigation_wait_on_page(
- element_click_command(context, object_id.object_id.clone()),
- None,
- binding.timeouts.page_load.map(Duration::from_millis),
+ .execute_on_page(
+ element_click_preflight_command(
+ context,
+ object_id.object_id.clone(),
+ state.layout_policy.uses_real_layout(),
+ ),
object_id.page_residence.clone(),
)
.await;
+ let preparation = match prepared {
+ Ok(DevToolsCommandResult::Script(result)) => match *result {
+ DevToolsScriptResult::Value(value) => ClassicClickPreparation::from_value(value.value),
+ DevToolsScriptResult::Exception(exception) => {
+ Err(classic_webdriver_command_exception_error(exception))
+ }
+ },
+ Ok(_) => Err(ClassicError::new(
+ ClassicErrorCode::UnknownError,
+ "element click preflight returned an unexpected result",
+ )),
+ Err(error) => Err(classic_error_from_devtools_error(error)),
+ };
+ let mut activation_dispatched = false;
+ let mut pointer_press_completed = false;
+ let operation = async {
+ let preparation = preparation?;
+ match preparation {
+ ClassicClickPreparation::Dom => {
+ activation_dispatched = true;
+ Ok(binding
+ .runtime
+ .execute_on_page(
+ element_click_command(context, object_id.object_id.clone()),
+ object_id.page_residence.clone(),
+ )
+ .await)
+ }
+ ClassicClickPreparation::Pointer { point, viewport } => {
+ let point = webdriver_classic_click_point(binding, point, viewport).await?;
+ // The preflight point has been mapped into the root viewport.
+ let pointer_context = classic_top_level_context(binding);
+ let commands = element_click_input_commands(&pointer_context, point);
+ let mut result = Ok(DevToolsCommandResult::Empty);
+ for command in commands {
+ let event_type = match &command {
+ DevToolsCommand::DispatchMouseEvent(event) => Some(event.event_type),
+ _ => None,
+ };
+ activation_dispatched = event_type == Some(DevToolsMouseEventType::Released);
+ // Do not retry an input command or wait for navigation
+ // between pointer phases. Page residence is not a promise
+ // that the active Document cannot change during input.
+ result = binding
+ .runtime
+ .execute_on_page(command, object_id.page_residence.clone())
+ .await;
+ if !matches!(result, Ok(DevToolsCommandResult::Empty)) {
+ break;
+ }
+ if event_type == Some(DevToolsMouseEventType::Pressed) {
+ pointer_press_completed = true;
+ }
+ }
+ Ok(result)
+ }
+ }
+ }
+ .await;
release_classic_page_bound_remote_object(binding, context, object_id, object_group).await;
+ let result = operation?;
let post_click_navigation = classic_wait_for_current_document(binding).await;
match result {
+ Ok(DevToolsCommandResult::Empty) => {
+ post_click_navigation?;
+ if binding.current_frame_id.is_some()
+ && let Err(error) = ensure_classic_current_browsing_context_exists(binding).await
+ {
+ if error.code != ClassicErrorCode::NoSuchWindow {
+ return Err(error);
+ }
+ state.classic_session_registry.lock().set_current_frame_id(
+ &binding.session_id,
+ Some(format!(
+ "__moli-detached-frame__:{}",
+ binding.browsing_context_target_id()
+ )),
+ );
+ }
+ Ok(())
+ }
Ok(DevToolsCommandResult::Script(result)) => {
post_click_navigation?;
match *result {
@@ -4588,7 +4823,20 @@ async fn webdriver_classic_activate_element_by_handle(
// Chromium references:
// chrome/test/chromedriver/element_commands.cc (ExecuteClickElement)
// chrome/test/chromedriver/window_commands.cc (ExecuteWindowCommand)
- Err(error) if error.message == "Renderer attachment changed" => post_click_navigation,
+ // A completed mousedown can replace the Page before the queued
+ // mouseup is admitted. The page-bound guard then rejects that phase
+ // before dispatch, rather than losing the renderer response. Both
+ // outcomes must leave the successor untouched. Require a completed
+ // pointer press so preflight/option stale handles remain errors.
+ Err(error)
+ if activation_dispatched
+ && (error.message == "Renderer attachment changed"
+ || (pointer_press_completed
+ && error.kind == DevToolsErrorKind::NoSuchNode
+ && error.message == "DOM reference belongs to a replaced Page")) =>
+ {
+ post_click_navigation
+ }
Err(error) => Err(classic_error_from_devtools_error(error)),
}
}
diff --git a/moli-protocol-server/src/protocol_server/webdriver_classic/helpers.rs b/moli-protocol-server/src/protocol_server/webdriver_classic/helpers.rs
index a76b16b488a..9fc371d1243 100644
--- a/moli-protocol-server/src/protocol_server/webdriver_classic/helpers.rs
+++ b/moli-protocol-server/src/protocol_server/webdriver_classic/helpers.rs
@@ -59,7 +59,8 @@ fn classic_error_status(code: ClassicErrorCode) -> StatusCode {
| ClassicErrorCode::InvalidCookieDomain
| ClassicErrorCode::InvalidElementState
| ClassicErrorCode::InvalidSelector
- | ClassicErrorCode::ElementNotInteractable => StatusCode::BAD_REQUEST,
+ | ClassicErrorCode::ElementNotInteractable
+ | ClassicErrorCode::ElementClickIntercepted => StatusCode::BAD_REQUEST,
ClassicErrorCode::InvalidSessionId
| ClassicErrorCode::NoSuchAlert
| ClassicErrorCode::NoSuchCookie
diff --git a/moli-protocol-server/src/protocol_server/webdriver_classic/state.rs b/moli-protocol-server/src/protocol_server/webdriver_classic/state.rs
index 192c02efd05..02b7b9bb0f8 100644
--- a/moli-protocol-server/src/protocol_server/webdriver_classic/state.rs
+++ b/moli-protocol-server/src/protocol_server/webdriver_classic/state.rs
@@ -519,24 +519,6 @@ impl ClassicSessionRuntimeHandle {
.await
}
- pub(super) async fn execute_with_pending_navigation_wait_on_page(
- &self,
- command: DevToolsCommand,
- timeout: Option,
- pending_navigation_timeout: Option,
- expected_page: DevToolsPageResidenceIdentity,
- ) -> Result {
- self.execute_request(
- command,
- timeout,
- pending_navigation_timeout,
- false,
- Some(expected_page),
- )
- .await
- .result
- }
-
pub(super) async fn wait_for_document_lifecycle(
&self,
context: DevToolsCommandContext,
diff --git a/moli-protocol-webdriver-classic/src/actions.rs b/moli-protocol-webdriver-classic/src/actions.rs
index 95f545b6343..955cfec1177 100644
--- a/moli-protocol-webdriver-classic/src/actions.rs
+++ b/moli-protocol-webdriver-classic/src/actions.rs
@@ -164,10 +164,18 @@ enum ClassicActionCancel {
pub fn element_click_input_commands(
context: &ClassicDevToolsCommandContext,
- geometry: &DevToolsDomGeometryResult,
-) -> Result, ClassicError> {
- let point = element_center_from_geometry(geometry)?;
- Ok(vec![
+ point: ClassicViewportPoint,
+) -> Vec {
+ vec![
+ dispatch_mouse_event_command(
+ context,
+ DevToolsMouseEventType::Moved,
+ DevToolsPointerType::Mouse,
+ point.x,
+ point.y,
+ 0,
+ Some(0),
+ ),
dispatch_mouse_event_command(
context,
DevToolsMouseEventType::Pressed,
@@ -186,7 +194,7 @@ pub fn element_click_input_commands(
0,
Some(0),
),
- ])
+ ]
}
pub fn element_send_keys_text(params: &Value) -> Result {
@@ -1633,7 +1641,9 @@ fn webdriver_key_for_action_character(character: char) -> ClassicWebDriverKey {
match character {
'\u{E003}' => ClassicWebDriverKey::named("Backspace", "Backspace"),
'\u{E004}' => ClassicWebDriverKey::named("Tab", "Tab"),
- '\u{E006}' | '\u{E007}' => ClassicWebDriverKey::named("Enter", "Enter"),
+ '\u{E006}' | '\u{E007}' => {
+ ClassicWebDriverKey::text("Enter".to_owned(), "Enter", "\r".to_owned())
+ }
'\u{E008}' => ClassicWebDriverKey::modifier("Shift", "ShiftLeft", CLASSIC_MODIFIER_SHIFT),
'\u{E009}' => {
ClassicWebDriverKey::modifier("Control", "ControlLeft", CLASSIC_MODIFIER_CONTROL)
diff --git a/moli-protocol-webdriver-classic/src/commands/elements.rs b/moli-protocol-webdriver-classic/src/commands/elements.rs
index 7e81929b864..fa9c0cb6c6a 100644
--- a/moli-protocol-webdriver-classic/src/commands/elements.rs
+++ b/moli-protocol-webdriver-classic/src/commands/elements.rs
@@ -737,11 +737,63 @@ pub fn clear_element_command(
})
}
+/// Validate activation without dispatching it. Real-layout sessions use pointer
+/// input; DOM-first sessions retain semantic activation without hit testing.
+pub fn element_click_preflight_command(
+ context: &ClassicDevToolsCommandContext,
+ object_id: impl Into,
+ use_real_layout: bool,
+) -> DevToolsCommand {
+ let mut command = element_click_call_function(context, object_id);
+ command.function_declaration = r#"function(useRealLayout) {
+ if (!this || this.nodeType !== Node.ELEMENT_NODE || !this.isConnected)
+ throw new Error('__moli_webdriver_classic_stale_element_reference__');
+ if (this.localName === 'input' && this.type === 'file')
+ return {status: 'file'};
+ // Options have a separate selection algorithm, not a pointer target
+ // in the page's rendered select popup.
+ if (this.localName === 'option') return {status: 'option'};
+ if (!useRealLayout) return {status: 'dom'};
+ this.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'});
+ const rect = this.getClientRects()[0];
+ if (!rect || rect.width <= 0 || rect.height <= 0)
+ return {status: 'not interactable'};
+ // WebDriver's in-view center is the clipped first client rectangle,
+ // not the bounding box of every fragment. Return the tested point so
+ // dispatch cannot recompute a different center.
+ const view = this.ownerDocument.defaultView;
+ const width = view.innerWidth, height = view.innerHeight;
+ const left = Math.max(0, rect.left), right = Math.min(width, rect.right);
+ const top = Math.max(0, rect.top), bottom = Math.min(height, rect.bottom);
+ if (right <= left || bottom <= top)
+ return {status: 'not interactable'};
+ const x = Math.floor((left + right) / 2), y = Math.floor((top + bottom) / 2);
+ const root = this.getRootNode();
+ const hit = (typeof root.elementFromPoint === 'function' ? root : this.ownerDocument)
+ .elementFromPoint(x, y);
+ if (!hit || !(hit === this || this.contains(hit)))
+ return {status: 'intercepted'};
+ return {status: 'pointer', x, y, viewport_width: width, viewport_height: height};
+ }"#
+ .to_owned();
+ command.arguments = vec![json!(use_real_layout)];
+ command.await_promise = false;
+ command.user_gesture = false;
+ DevToolsCommand::CallFunction(command)
+}
+
pub fn element_click_command(
context: &ClassicDevToolsCommandContext,
object_id: impl Into,
) -> DevToolsCommand {
- DevToolsCommand::CallFunction(DevToolsCallFunctionCommand {
+ DevToolsCommand::CallFunction(element_click_call_function(context, object_id))
+}
+
+fn element_click_call_function(
+ context: &ClassicDevToolsCommandContext,
+ object_id: impl Into,
+) -> DevToolsCallFunctionCommand {
+ DevToolsCallFunctionCommand {
context: context.command_context(),
realm_id: None,
world_name: None,
@@ -755,6 +807,19 @@ pub fn element_click_command(
throw new Error('__moli_webdriver_classic_element_not_interactable__');
}
const frameElementBeforeClick = window.frameElement || null;
+ // WebDriver's option branch focuses its select container. This
+ // is deliberately not part of ordinary HTMLElement.click().
+ if (this.localName === 'option') {
+ const select = this.closest('select');
+ if (select) {
+ select.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'});
+ HTMLElement.prototype.focus.call(select);
+ }
+ } else {
+ // DOM-first WebDriver activation still moves keyboard focus.
+ // Keep this at the automation boundary, not in script click().
+ HTMLElement.prototype.focus.call(this);
+ }
HTMLElement.prototype.click.call(this);
let detachedFrame = false;
try {
@@ -779,7 +844,7 @@ pub fn element_click_command(
preserve_remote_metadata: false,
materialize_bidi_script_result: false,
serialization_options: None,
- })
+ }
}
pub fn element_click_prepare_commands(
diff --git a/moli-protocol-webdriver-classic/src/commands/mod.rs b/moli-protocol-webdriver-classic/src/commands/mod.rs
index e59ac9c0ed9..f720f7fd126 100644
--- a/moli-protocol-webdriver-classic/src/commands/mod.rs
+++ b/moli-protocol-webdriver-classic/src/commands/mod.rs
@@ -21,7 +21,7 @@ pub use elements::{
classic_elements_from_query_result, classic_property_value, classic_rect_from_geometry,
classic_shadow_root_id, classic_shadow_root_reference, classic_text_value,
clear_element_command, describe_node_command, describe_node_reference_command,
- element_click_command, element_click_prepare_commands,
+ element_click_command, element_click_preflight_command, element_click_prepare_commands,
element_click_prepare_reference_commands, find_element_command, find_element_command_with_root,
frame_id_for_element_command, get_element_attributes_command,
get_element_attributes_reference_command, get_element_computed_label_command,
diff --git a/moli-protocol-webdriver-classic/src/lib.rs b/moli-protocol-webdriver-classic/src/lib.rs
index 90312ce0be1..89c2880c238 100644
--- a/moli-protocol-webdriver-classic/src/lib.rs
+++ b/moli-protocol-webdriver-classic/src/lib.rs
@@ -49,14 +49,14 @@ pub use commands::{
classic_text_value, classic_window_rect_for_state, classic_window_rect_from_metrics,
clear_element_command, close_window_command, create_initial_target_command,
current_url_command, delete_all_cookies_command, delete_cookie_command, describe_node_command,
- describe_node_reference_command, element_click_command, element_click_prepare_commands,
- element_click_prepare_reference_commands, element_screenshot_command, execute_async_command,
- execute_sync_command, find_element_command, find_element_command_with_root,
- frame_id_for_element_command, get_cookies_command, get_element_attributes_command,
- get_element_attributes_reference_command, get_element_computed_label_command,
- get_element_computed_role_command, get_element_css_value_command,
- get_element_displayed_command, get_element_enabled_command, get_element_property_command,
- get_element_property_reference_command, get_element_rect_command,
+ describe_node_reference_command, element_click_command, element_click_preflight_command,
+ element_click_prepare_commands, element_click_prepare_reference_commands,
+ element_screenshot_command, execute_async_command, execute_sync_command, find_element_command,
+ find_element_command_with_root, frame_id_for_element_command, get_cookies_command,
+ get_element_attributes_command, get_element_attributes_reference_command,
+ get_element_computed_label_command, get_element_computed_role_command,
+ get_element_css_value_command, get_element_displayed_command, get_element_enabled_command,
+ get_element_property_command, get_element_property_reference_command, get_element_rect_command,
get_element_rect_reference_command, get_element_rendered_text_command,
get_element_shadow_root_command, get_element_tag_name_command, get_element_text_command,
get_element_text_reference_command, history_traversal_entry, layout_metrics_command,
diff --git a/moli-protocol-webdriver-classic/src/tests.rs b/moli-protocol-webdriver-classic/src/tests.rs
index 3786bacb5d5..0e3427481ba 100644
--- a/moli-protocol-webdriver-classic/src/tests.rs
+++ b/moli-protocol-webdriver-classic/src/tests.rs
@@ -1519,11 +1519,11 @@ fn maps_element_click_to_shared_dom_geometry_and_input_commands() {
let input = element_click_input_commands(
&context,
- &box_model_geometry([10.0, 20.0, 30.0, 20.0, 30.0, 40.0, 10.0, 40.0], 20, 20),
- )
- .expect("input commands");
- assert_eq!(input.len(), 2);
+ ClassicViewportPoint::new(20.0, 30.0).expect("valid point"),
+ );
+ assert_eq!(input.len(), 3);
for (command, (event_type, buttons)) in input.into_iter().zip([
+ (DevToolsMouseEventType::Moved, Some(0)),
(DevToolsMouseEventType::Pressed, Some(1)),
(DevToolsMouseEventType::Released, Some(0)),
]) {
@@ -3223,6 +3223,10 @@ fn maps_alert_commands_to_shared_page_dialog_commands() {
#[test]
fn serializes_element_not_interactable_error_code() {
+ assert_eq!(
+ error_response(ClassicErrorCode::ElementClickIntercepted, "obscured")["value"]["error"],
+ json!("element click intercepted")
+ );
assert_eq!(
error_response(
ClassicErrorCode::ElementNotInteractable,
diff --git a/moli-protocol-webdriver-classic/src/types.rs b/moli-protocol-webdriver-classic/src/types.rs
index c7ac5533a1d..f0f2456cdfc 100644
--- a/moli-protocol-webdriver-classic/src/types.rs
+++ b/moli-protocol-webdriver-classic/src/types.rs
@@ -21,6 +21,7 @@ pub enum ClassicErrorCode {
InvalidElementState,
InvalidSelector,
ElementNotInteractable,
+ ElementClickIntercepted,
InvalidSessionId,
JavascriptError,
NoSuchAlert,
@@ -49,6 +50,7 @@ impl ClassicErrorCode {
Self::InvalidElementState => "invalid element state",
Self::InvalidSelector => "invalid selector",
Self::ElementNotInteractable => "element not interactable",
+ Self::ElementClickIntercepted => "element click intercepted",
Self::InvalidSessionId => "invalid session id",
Self::JavascriptError => "javascript error",
Self::NoSuchAlert => "no such alert",
diff --git a/moli-protocol/src/conn/devtools_command.rs b/moli-protocol/src/conn/devtools_command.rs
index 82fcce6659c..577183c22f5 100644
--- a/moli-protocol/src/conn/devtools_command.rs
+++ b/moli-protocol/src/conn/devtools_command.rs
@@ -324,6 +324,7 @@ impl CdpConnection {
| DevToolsCommand::GetOuterHtml(_)
| DevToolsCommand::DescribeNode(_)
| DevToolsCommand::GetFrameOwner(_)
+ | DevToolsCommand::GetNodeForLocation(_)
| DevToolsCommand::ResolveNode(_)
| DevToolsCommand::ScrollIntoViewIfNeeded(_)
| DevToolsCommand::DomObjectReference(_)
diff --git a/moli-protocol/src/domains/dom/resolve.rs b/moli-protocol/src/domains/dom/resolve.rs
index 1757a375d2a..b63d075f103 100644
--- a/moli-protocol/src/domains/dom/resolve.rs
+++ b/moli-protocol/src/domains/dom/resolve.rs
@@ -5842,6 +5842,7 @@ fn devtools_dom_command_context(
DevToolsCommand::GetOuterHtml(command) => &command.context,
DevToolsCommand::DescribeNode(command) => &command.context,
DevToolsCommand::GetFrameOwner(command) => &command.context,
+ DevToolsCommand::GetNodeForLocation(command) => &command.context,
DevToolsCommand::ResolveNode(command) => &command.context,
DevToolsCommand::ScrollIntoViewIfNeeded(command) => &command.context,
DevToolsCommand::DomObjectReference(command)
@@ -6000,15 +6001,12 @@ async fn execute_devtools_dom_command_for_owner(
await_pending_devtools_dom_command_result(conn, pending).await
}
- DevToolsCommand::DomGeometry(command) => {
- let pending = start_devtools_dom_command_for_owner(
- conn,
- None,
- owner,
- DevToolsCommand::DomGeometry(command),
- )
- .map_err(DevToolsError::from)?
- .ok_or_else(|| DevToolsError::new(DevToolsErrorKind::Internal, "MissingDomCommand"))?;
+ command @ (DevToolsCommand::DomGeometry(_) | DevToolsCommand::GetNodeForLocation(_)) => {
+ let pending = start_devtools_dom_command_for_owner(conn, None, owner, command)
+ .map_err(DevToolsError::from)?
+ .ok_or_else(|| {
+ DevToolsError::new(DevToolsErrorKind::Internal, "MissingDomCommand")
+ })?;
await_pending_devtools_dom_command_result(conn, pending).await
}
diff --git a/moli-protocol/src/domains/input.rs b/moli-protocol/src/domains/input.rs
index a6014b3920a..9b82692f2cb 100644
--- a/moli-protocol/src/domains/input.rs
+++ b/moli-protocol/src/domains/input.rs
@@ -7,8 +7,8 @@ use crate::devtools_runtime::{
DevToolsDispatchDragEventCommand, DevToolsDispatchKeyEventCommand,
DevToolsDispatchMouseEventCommand, DevToolsDispatchTouchEventCommand, DevToolsDragData,
DevToolsDragDataItem, DevToolsDragEventType, DevToolsError, DevToolsErrorKind,
- DevToolsMouseEventType, DevToolsPointerType, DevToolsSynthesizeTapGestureCommand,
- DevToolsTouchEventType, DevToolsTouchPoint,
+ DevToolsKeyEventType, DevToolsMouseEventType, DevToolsPointerType,
+ DevToolsSynthesizeTapGestureCommand, DevToolsTouchEventType, DevToolsTouchPoint,
};
use crate::domains::command_output::CommandOutputPlan;
use moli_core::page::{
@@ -571,7 +571,23 @@ fn start_pending_input_command(
action: InputAction,
) -> Result