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-core/src/page/command_dispatch.rs b/moli-core/src/page/command_dispatch.rs
index f29102a9864..a10a8362923 100644
--- a/moli-core/src/page/command_dispatch.rs
+++ b/moli-core/src/page/command_dispatch.rs
@@ -273,6 +273,8 @@ impl Page {
pub(super) fn page_reply_kind(reply: &RendererPageReply) -> &'static str {
match reply {
RendererPageReply::RuntimeEvaluationResult(_) => "a runtime evaluation result reply",
+ RendererPageReply::ElementClickPreparation(_) => "an element click preparation reply",
+ RendererPageReply::ElementClickDispatch(_) => "an element click dispatch reply",
RendererPageReply::Bool(_) => "a bool reply",
RendererPageReply::OptionalBool(_) => "an optional bool reply",
RendererPageReply::InputDispatchOutcome(_) => "an input dispatch outcome reply",
diff --git a/moli-core/src/page/input_support.rs b/moli-core/src/page/input_support.rs
index 93dd020717f..0415a6b385f 100644
--- a/moli-core/src/page/input_support.rs
+++ b/moli-core/src/page/input_support.rs
@@ -519,3 +519,39 @@ impl PageInputExt for Page {
.await
}
}
+
+impl Page {
+ pub fn start_prepare_element_click(
+ &self,
+ inspector_session_id: Option,
+ object_id: String,
+ ) -> Result {
+ self.start_page_command(RendererPageCommand::prepare_element_click(
+ inspector_session_id,
+ object_id,
+ ))
+ }
+
+ pub fn start_dispatch_prepared_element_click(
+ &self,
+ click: super::RendererPreparedPointerClick,
+ ) -> Result {
+ self.start_page_command(RendererPageCommand::DispatchPreparedElementClick(click))
+ }
+}
+
+#[doc(hidden)]
+pub fn decode_element_click_preparation_completion(
+ completion: RendererCommandTurnCompletion,
+) -> Result> {
+ expect_page_reply!(settled_input_reply(completion)?, "prepare element click", "element click preparation",
+ RendererPageReply::ElementClickPreparation(preparation) => Ok(preparation))
+}
+
+#[doc(hidden)]
+pub fn decode_element_click_dispatch_completion(
+ completion: RendererCommandTurnCompletion,
+) -> Result> {
+ expect_page_reply!(settled_input_reply(completion)?, "dispatch element click", "element click dispatch",
+ RendererPageReply::ElementClickDispatch(outcome) => Ok(outcome))
+}
diff --git a/moli-core/src/page/mod.rs b/moli-core/src/page/mod.rs
index 64e794556b2..3b3738e5743 100644
--- a/moli-core/src/page/mod.rs
+++ b/moli-core/src/page/mod.rs
@@ -64,7 +64,9 @@ pub use dom_protocol_support::{
RENDERER_BACKEND_NODE_ID_START, is_renderer_backend_node_id,
};
pub use input_support::{
- PageInputExt, decode_input_dispatch_outcome_completion, decode_insert_text_completion,
+ PageInputExt, decode_element_click_dispatch_completion,
+ decode_element_click_preparation_completion, decode_input_dispatch_outcome_completion,
+ decode_insert_text_completion,
};
pub use moli_page_types::DomScrollIntoViewRect;
pub use moli_page_types::{
@@ -140,6 +142,9 @@ pub use moli_renderer_v8::{
RendererAppManifestQueryResult, RendererAppManifestRelatedApplication,
RendererAppManifestShortcut, RendererPreparedAppManifestLoad,
};
+pub use moli_renderer_v8::{
+ RendererElementClickError, RendererElementClickTarget, RendererPreparedPointerClick,
+};
pub use navigation_diagnostics::{NavigationRedirect, NavigationResponse};
pub use protocol_support::{
BidiPreloadChannelHandoff, ChildFrameAttachmentSnapshot, ChildFrameDetachmentSnapshot,
diff --git a/moli-protocol-server/src/protocol_server/tests/classic.rs b/moli-protocol-server/src/protocol_server/tests/classic.rs
index 84a3ea0be7d..98517588a77 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,416 @@ 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]
+#[ignore = "Pending iframe overlay click fix; expectations verified against Chromium"]
+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]
+#[ignore = "Pending ancestor overlay click fix; expectations verified against Chromium"]
+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 +12026,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 +12514,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..7a6af3962f7 100644
--- a/moli-protocol-server/src/protocol_server/webdriver_classic.rs
+++ b/moli-protocol-server/src/protocol_server/webdriver_classic.rs
@@ -11,22 +11,24 @@ use axum::{
http::StatusCode,
response::Response,
};
-use moli_core::page::RendererDocumentLifecycleMilestone;
+use moli_core::page::{
+ RendererDocumentLifecycleMilestone, RendererElementClickError, RendererElementClickTarget,
+};
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,
- DevToolsGetServiceWorkerLogsCommand, DevToolsGetServiceWorkerLogsResult,
- DevToolsGetTargetsCommand, DevToolsGetTargetsResult, DevToolsLayoutMetricsResult,
- DevToolsLocateNodesResult, DevToolsProtocol, DevToolsQuerySelectorResult,
- DevToolsRemoteHandleId, DevToolsRemoteValue, DevToolsScriptException, DevToolsScriptResult,
- DevToolsSessionId, DevToolsSetDownloadBehaviorCommand, DevToolsSetFileInputFilesCommand,
- DevToolsTargetId, DevToolsTargetInfo, DevToolsTargetKind, RuntimeConsoleEvent,
- RuntimeExecutionContextEvent,
+ DevToolsDomNodeReference, DevToolsDownloadBehaviorSetting, DevToolsElementClickCommand,
+ DevToolsElementClickOperation, DevToolsError, DevToolsErrorKind, DevToolsFrameId,
+ DevToolsGetFrameOwnerCommand, DevToolsGetFrameOwnerResult, DevToolsGetRealmsCommand,
+ DevToolsGetRealmsResult, DevToolsGetServiceWorkerLogsCommand,
+ DevToolsGetServiceWorkerLogsResult, DevToolsGetTargetsCommand, DevToolsGetTargetsResult,
+ DevToolsLayoutMetricsResult, DevToolsLocateNodesResult, DevToolsProtocol,
+ DevToolsQuerySelectorResult, DevToolsRemoteHandleId, DevToolsRemoteValue,
+ DevToolsScriptException, DevToolsScriptResult, DevToolsSessionId,
+ DevToolsSetDownloadBehaviorCommand, DevToolsSetFileInputFilesCommand, DevToolsTargetId,
+ DevToolsTargetInfo, DevToolsTargetKind, RuntimeConsoleEvent, RuntimeExecutionContextEvent,
},
version,
};
@@ -41,7 +43,7 @@ use moli_protocol_webdriver_classic::{
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_geometry_prepare_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,
@@ -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,31 @@ async fn webdriver_classic_prepare_text_control_for_send_keys(
}
}
+fn classic_error_from_click_preparation(error: RendererElementClickError) -> ClassicError {
+ let (code, message) = match error {
+ RendererElementClickError::StaleNode => (
+ ClassicErrorCode::StaleElementReference,
+ "element no longer belongs to the current document".to_owned(),
+ ),
+ RendererElementClickError::MissingBrowsingContext => (
+ ClassicErrorCode::NoSuchWindow,
+ "element browsing context is no longer available".to_owned(),
+ ),
+ RendererElementClickError::NoClickableRect => (
+ ClassicErrorCode::ElementNotInteractable,
+ "element has no in-view clickable rectangle".to_owned(),
+ ),
+ RendererElementClickError::Obscured => (
+ ClassicErrorCode::ElementClickIntercepted,
+ "another element obscures the element's in-view center".to_owned(),
+ ),
+ RendererElementClickError::LayoutUnavailable(message) => {
+ (ClassicErrorCode::UnknownError, message)
+ }
+ };
+ ClassicError::new(code, message)
+}
+
async fn webdriver_classic_activate_element_by_handle(
state: &AppState,
binding: &ClassicSessionBinding,
@@ -4532,18 +4560,91 @@ 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(
+ DevToolsCommand::ElementClick(DevToolsElementClickCommand {
+ context: classic_top_level_devtools_context(binding),
+ operation: DevToolsElementClickOperation::Prepare {
+ object_id: object_id.object_id.clone().into(),
+ },
+ }),
object_id.page_residence.clone(),
)
.await;
+ let preparation = match prepared {
+ Ok(DevToolsCommandResult::ElementClickPreparation(result)) => {
+ result.map_err(classic_error_from_click_preparation)
+ }
+ Ok(_) => Err(ClassicError::new(
+ ClassicErrorCode::UnknownError,
+ "element click preparation returned an unexpected result",
+ )),
+ Err(error) => Err(classic_error_from_devtools_error(error)),
+ };
+ let mut activation_dispatched = false;
+ let operation = async {
+ match preparation? {
+ RendererElementClickTarget::FileInput => Err(ClassicError::new(
+ ClassicErrorCode::InvalidArgument,
+ "file inputs cannot be clicked by WebDriver",
+ )),
+ RendererElementClickTarget::DomActivation | RendererElementClickTarget::Option => {
+ activation_dispatched = true;
+ Ok(binding
+ .runtime
+ .execute_on_page(
+ element_click_command(context, object_id.object_id.clone()),
+ object_id.page_residence.clone(),
+ )
+ .await)
+ }
+ RendererElementClickTarget::Pointer(click) => {
+ activation_dispatched = true;
+ // The renderer validates document ownership before move and
+ // between phases. No phase can be retried on a successor.
+ let result = binding
+ .runtime
+ .execute_on_page(
+ DevToolsCommand::ElementClick(DevToolsElementClickCommand {
+ context: classic_top_level_devtools_context(binding),
+ operation: DevToolsElementClickOperation::Dispatch(click),
+ }),
+ object_id.page_residence.clone(),
+ )
+ .await;
+ match result {
+ Ok(DevToolsCommandResult::ElementClickDispatch(Err(error))) => {
+ Err(classic_error_from_click_preparation(error))
+ }
+ result => 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 | DevToolsCommandResult::ElementClickDispatch(Ok(()))) => {
+ 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 +4689,9 @@ 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,
+ Err(error) if activation_dispatched && error.message == "Renderer attachment changed" => {
+ post_click_navigation
+ }
Err(error) => Err(classic_error_from_devtools_error(error)),
}
}
@@ -4719,7 +4822,7 @@ async fn webdriver_classic_element_geometry(
) -> Result {
let reference = resolve_classic_element_dom_reference(state, binding, element_id)?;
let page_residence = reference.page_residence;
- let commands = element_click_prepare_reference_commands(context, reference.reference);
+ let commands = element_geometry_prepare_commands(context, reference.reference);
let mut geometry = None;
for command in commands {
match binding
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-bidi/src/responses.rs b/moli-protocol-webdriver-bidi/src/responses.rs
index f14e3c53c2c..f1a516ab8bf 100644
--- a/moli-protocol-webdriver-bidi/src/responses.rs
+++ b/moli-protocol-webdriver-bidi/src/responses.rs
@@ -39,6 +39,17 @@ pub fn error_response(id: Option, code: BidiErrorCode, message: &str) -> Va
}
pub fn bidi_response_from_devtools_result(id: u64, result: DevToolsCommandResult) -> Value {
+ if matches!(
+ result,
+ DevToolsCommandResult::ElementClickPreparation(_)
+ | DevToolsCommandResult::ElementClickDispatch(_)
+ ) {
+ return error_response(
+ Some(id),
+ BidiErrorCode::UnsupportedOperation,
+ "element click results are internal to WebDriver Classic",
+ );
+ }
if matches!(result, DevToolsCommandResult::GetNodeForLocation(_)) {
return error_response(
Some(id),
@@ -75,6 +86,10 @@ pub fn bidi_response_from_devtools_error(id: u64, error: DevToolsError) -> Value
fn bidi_result_payload_from_devtools_result(result: DevToolsCommandResult) -> Value {
match result {
+ DevToolsCommandResult::ElementClickPreparation(_)
+ | DevToolsCommandResult::ElementClickDispatch(_) => {
+ unreachable!("element click results have no WebDriver BiDi success projection")
+ }
DevToolsCommandResult::Empty | DevToolsCommandResult::TraverseHistory(_) => json!({}),
DevToolsCommandResult::Navigate(result) => json!({
"navigation": result
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..59bba959249 100644
--- a/moli-protocol-webdriver-classic/src/commands/elements.rs
+++ b/moli-protocol-webdriver-classic/src/commands/elements.rs
@@ -741,7 +741,14 @@ 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 +762,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,18 +799,10 @@ pub fn element_click_command(
preserve_remote_metadata: false,
materialize_bidi_script_result: false,
serialization_options: None,
- })
-}
-
-pub fn element_click_prepare_commands(
- context: &ClassicDevToolsCommandContext,
- element_id: &str,
-) -> Result, ClassicError> {
- let reference = dom_node_reference_from_classic_element_id(element_id)?;
- Ok(element_click_prepare_reference_commands(context, reference))
+ }
}
-pub fn element_click_prepare_reference_commands(
+pub fn element_geometry_prepare_commands(
context: &ClassicDevToolsCommandContext,
reference: DevToolsDomNodeReference,
) -> Vec {
diff --git a/moli-protocol-webdriver-classic/src/commands/mod.rs b/moli-protocol-webdriver-classic/src/commands/mod.rs
index e59ac9c0ed9..694305e6610 100644
--- a/moli-protocol-webdriver-classic/src/commands/mod.rs
+++ b/moli-protocol-webdriver-classic/src/commands/mod.rs
@@ -21,9 +21,8 @@ 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_prepare_reference_commands, find_element_command, find_element_command_with_root,
- frame_id_for_element_command, get_element_attributes_command,
+ element_click_command, element_geometry_prepare_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,
get_element_computed_role_command, get_element_css_value_command,
get_element_displayed_command, get_element_enabled_command, get_element_property_command,
diff --git a/moli-protocol-webdriver-classic/src/lib.rs b/moli-protocol-webdriver-classic/src/lib.rs
index 90312ce0be1..2918cfba605 100644
--- a/moli-protocol-webdriver-classic/src/lib.rs
+++ b/moli-protocol-webdriver-classic/src/lib.rs
@@ -49,14 +49,13 @@ 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_geometry_prepare_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..8ddd703a303 100644
--- a/moli-protocol-webdriver-classic/src/tests.rs
+++ b/moli-protocol-webdriver-classic/src/tests.rs
@@ -1485,10 +1485,11 @@ fn maps_element_clear_to_shared_dom_resolve_and_runtime_command() {
}
#[test]
-fn maps_element_click_to_shared_dom_geometry_and_input_commands() {
+fn maps_element_geometry_and_coordinate_input_commands() {
let context = ClassicDevToolsCommandContext::with_target_id("classic-session-1", "TID-1");
- let commands = element_click_prepare_commands(&context, "moli-node-23").expect("commands");
+ let commands =
+ element_geometry_prepare_commands(&context, DevToolsDomNodeReference::FrontendNodeId(23));
assert_eq!(commands.len(), 2);
let DevToolsCommand::ScrollIntoViewIfNeeded(scroll) = &commands[0] else {
panic!("expected scroll command");
@@ -1519,11 +1520,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)),
]) {
@@ -1536,10 +1537,6 @@ fn maps_element_click_to_shared_dom_geometry_and_input_commands() {
assert_eq!(command.button, 0);
assert_eq!(command.buttons, buttons);
}
-
- let invalid = element_click_prepare_commands(&context, "not-a-moli-node")
- .expect_err("invalid element id should fail");
- assert_eq!(invalid.code, ClassicErrorCode::NoSuchElement);
}
#[test]
@@ -3223,6 +3220,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..d28b926ed87 100644
--- a/moli-protocol/src/conn/devtools_command.rs
+++ b/moli-protocol/src/conn/devtools_command.rs
@@ -305,7 +305,8 @@ impl CdpConnection {
crate::domains::network::execute_devtools_network_command(self, command),
Vec::new(),
),
- command @ (DevToolsCommand::DispatchMouseEvent(_)
+ command @ (DevToolsCommand::ElementClick(_)
+ | DevToolsCommand::DispatchMouseEvent(_)
| DevToolsCommand::DispatchKeyEvent(_)
| DevToolsCommand::DispatchTouchEvent(_)
| DevToolsCommand::DispatchDragEvent(_)
@@ -324,6 +325,7 @@ impl CdpConnection {
| DevToolsCommand::GetOuterHtml(_)
| DevToolsCommand::DescribeNode(_)
| DevToolsCommand::GetFrameOwner(_)
+ | DevToolsCommand::GetNodeForLocation(_)
| DevToolsCommand::ResolveNode(_)
| DevToolsCommand::ScrollIntoViewIfNeeded(_)
| DevToolsCommand::DomObjectReference(_)
diff --git a/moli-protocol/src/devtools_runtime.rs b/moli-protocol/src/devtools_runtime.rs
index b94feadbdc1..ae87ce3a2c5 100644
--- a/moli-protocol/src/devtools_runtime.rs
+++ b/moli-protocol/src/devtools_runtime.rs
@@ -269,6 +269,7 @@ pub enum DevToolsCommand {
RemoveNode(DevToolsRemoveNodeCommand),
AddPreloadScript(DevToolsAddPreloadScriptCommand),
RemovePreloadScript(DevToolsRemovePreloadScriptCommand),
+ ElementClick(DevToolsElementClickCommand),
DispatchMouseEvent(DevToolsDispatchMouseEventCommand),
DispatchKeyEvent(DevToolsDispatchKeyEventCommand),
DispatchTouchEvent(DevToolsDispatchTouchEventCommand),
@@ -355,6 +356,7 @@ impl DevToolsCommand {
DevToolsCommand::RemoveNode(command) => &command.context,
DevToolsCommand::AddPreloadScript(command) => &command.context,
DevToolsCommand::RemovePreloadScript(command) => &command.context,
+ DevToolsCommand::ElementClick(command) => &command.context,
DevToolsCommand::DispatchMouseEvent(command) => &command.context,
DevToolsCommand::DispatchKeyEvent(command) => &command.context,
DevToolsCommand::DispatchTouchEvent(command) => &command.context,
@@ -1029,6 +1031,18 @@ pub enum DevToolsDomObjectReferenceOperation {
DescribeNode { depth: i32, pierce: bool },
}
+#[derive(Debug, Clone, PartialEq)]
+pub struct DevToolsElementClickCommand {
+ pub context: DevToolsCommandContext,
+ pub operation: DevToolsElementClickOperation,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum DevToolsElementClickOperation {
+ Prepare { object_id: DevToolsRemoteHandleId },
+ Dispatch(moli_core::page::RendererPreparedPointerClick),
+}
+
#[derive(Debug, Clone, PartialEq)]
pub struct DevToolsDomObjectReferenceCommand {
pub context: DevToolsCommandContext,
@@ -1367,6 +1381,13 @@ pub struct DevToolsFulfillInterceptedRequestCommand {
#[derive(Debug, Clone, PartialEq)]
pub enum DevToolsCommandResult {
+ ElementClickPreparation(
+ Result<
+ moli_core::page::RendererElementClickTarget,
+ moli_core::page::RendererElementClickError,
+ >,
+ ),
+ ElementClickDispatch(Result<(), moli_core::page::RendererElementClickError>),
Empty,
Navigate(DevToolsNavigateResult),
TraverseHistory(DevToolsTraverseHistoryResult),
diff --git a/moli-protocol/src/domains/command_output.rs b/moli-protocol/src/domains/command_output.rs
index dec5db6b251..0ad537d3b22 100644
--- a/moli-protocol/src/domains/command_output.rs
+++ b/moli-protocol/src/domains/command_output.rs
@@ -1607,6 +1607,10 @@ fn blocked_intercepts_from_cdp_params(params: &Value) -> Vec Value {
match result {
+ DevToolsCommandResult::ElementClickPreparation(_)
+ | DevToolsCommandResult::ElementClickDispatch(_) => {
+ unreachable!("element click is an internal automation operation with no CDP method")
+ }
DevToolsCommandResult::Empty | DevToolsCommandResult::TraverseHistory(_) => json!({}),
DevToolsCommandResult::Navigate(result) => {
let mut payload = json!({});
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..fd3ba4df06e 100644
--- a/moli-protocol/src/domains/input.rs
+++ b/moli-protocol/src/domains/input.rs
@@ -6,16 +6,17 @@ use crate::devtools_runtime::{
DevToolsCommand, DevToolsCommandContext, DevToolsCommandResult,
DevToolsDispatchDragEventCommand, DevToolsDispatchKeyEventCommand,
DevToolsDispatchMouseEventCommand, DevToolsDispatchTouchEventCommand, DevToolsDragData,
- DevToolsDragDataItem, DevToolsDragEventType, DevToolsError, DevToolsErrorKind,
- DevToolsMouseEventType, DevToolsPointerType, DevToolsSynthesizeTapGestureCommand,
- DevToolsTouchEventType, DevToolsTouchPoint,
+ DevToolsDragDataItem, DevToolsDragEventType, DevToolsElementClickOperation, DevToolsError,
+ DevToolsErrorKind, DevToolsKeyEventType, DevToolsMouseEventType, DevToolsPointerType,
+ DevToolsSynthesizeTapGestureCommand, DevToolsTouchEventType, DevToolsTouchPoint,
};
use crate::domains::command_output::CommandOutputPlan;
use moli_core::page::{
CompletedPageCommand, Page, PageInputExt, PendingPageCommand, RendererCommandTurnCompletion,
RendererDragData, RendererDragDataItem, RendererDraggedFile, RendererInputDispatchOutcome,
RendererPendingDownloadActivation, RendererPendingFileChooserActivation,
- RendererPointerEventProperties, RendererTouchPoint, decode_input_dispatch_outcome_completion,
+ RendererPointerEventProperties, RendererTouchPoint, decode_element_click_dispatch_completion,
+ decode_element_click_preparation_completion, decode_input_dispatch_outcome_completion,
decode_insert_text_completion,
};
use serde::Deserialize;
@@ -122,6 +123,8 @@ struct PendingInputCommandStartError {
#[derive(Clone)]
enum PendingInputCommandKind {
+ PrepareElementClick,
+ DispatchElementClick,
DispatchMouseEvent,
DispatchKeyEvent,
DispatchTouchEvent,
@@ -242,7 +245,10 @@ impl PendingInputCommandKind {
// Chromium's InputInjector owns replacement cleanup for the mouse and
// key callback queues. Touch, drag, and IME commands have separate
// completion protocols and must keep waiting for those terminals.
- matches!(self, Self::DispatchMouseEvent | Self::DispatchKeyEvent)
+ matches!(
+ self,
+ Self::DispatchMouseEvent | Self::DispatchKeyEvent | Self::DispatchElementClick
+ )
}
}
@@ -565,13 +571,37 @@ fn start_input_command_dispatch_after_noop_checks(
}
}
+fn key_event_targets_outgoing_document(
+ conn: &CdpConnection,
+ owner: &CommandOwnerScope,
+ event_type: DevToolsKeyEventType,
+) -> bool {
+ event_type == DevToolsKeyEventType::KeyUp
+ && conn.has_pending_document_navigation_for_owner(owner)
+}
+
fn start_pending_input_command(
conn: &mut CdpConnection,
cmd: &Cmd<'_>,
action: InputAction,
) -> Result