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!( + "
{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})); + 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, PendingInputCommandStartError> { let owner = CommandOwnerScope::capture(conn, cmd.session_id); + let parsed_key_event = match action { + InputAction::DispatchKeyEvent => Some( + key::parse_dispatch_key_event(cmd) + .map_err(|_| PendingInputCommandStartError::invalid_params())?, + ), + _ => None, + }; + // A key press is a CDP sequence. Its keydown can start a replacement + // navigation before Playwright sends the matching keyup. Keep that + // terminal event on the still-attached outgoing Page; every other key + // event retains the normal cross-document navigation gate. + let targets_outgoing_document = parsed_key_event + .as_ref() + .is_some_and(|parsed| key_event_targets_outgoing_document(conn, &owner, parsed.event_type)); if action.requires_document_access() + && !targets_outgoing_document && let Err(message) = conn.ensure_document_accessible_for_owner(&owner) { return Err(PendingInputCommandStartError { @@ -582,8 +612,7 @@ fn start_pending_input_command( } match action { InputAction::DispatchKeyEvent => { - let parsed = key::parse_dispatch_key_event(cmd) - .map_err(|_| PendingInputCommandStartError::invalid_params())?; + let parsed = parsed_key_event.expect("dispatchKeyEvent parameters were parsed above"); if input_events_ignored_for_owner(conn, &owner) { return Ok(None); } @@ -915,6 +944,7 @@ fn devtools_input_command_route( command: &DevToolsCommand, ) -> Result<(Option, Option), DevToolsError> { let context = match command { + DevToolsCommand::ElementClick(command) => &command.context, DevToolsCommand::DispatchMouseEvent(command) => &command.context, DevToolsCommand::DispatchKeyEvent(command) => &command.context, DevToolsCommand::DispatchTouchEvent(command) => &command.context, @@ -977,11 +1007,14 @@ fn start_devtools_dispatch_key_event_command( owner: &CommandOwnerScope, command: DevToolsDispatchKeyEventCommand, ) -> Result, PendingInputCommandStartError> { - start_page_input_command( + let targets_outgoing_document = + key_event_targets_outgoing_document(conn, owner, command.event_type); + start_page_input_command_with_access( conn, command_id, owner, PendingInputCommandKind::DispatchKeyEvent, + targets_outgoing_document, |page| { page.start_dispatch_key_event_with_outcome( key::devtools_key_event_dom_event_name(command.event_type), @@ -1002,6 +1035,17 @@ fn start_page_input_command( command_owner: &CommandOwnerScope, kind: PendingInputCommandKind, start: impl FnOnce(&Page) -> anyhow::Result, +) -> Result, PendingInputCommandStartError> { + start_page_input_command_with_access(conn, command_id, command_owner, kind, false, start) +} + +fn start_page_input_command_with_access( + conn: &mut CdpConnection, + command_id: Option, + command_owner: &CommandOwnerScope, + kind: PendingInputCommandKind, + targets_outgoing_document: bool, + start: impl FnOnce(&Page) -> anyhow::Result, ) -> Result, PendingInputCommandStartError> { let page_owner = conn .target_page_residence_identity_for_owner(command_owner) @@ -1014,10 +1058,13 @@ fn start_page_input_command( } else { None }; - let page = conn - .loaded_page_mut_for_protocol_access_for_owner(command_owner) - .ok() - .ok_or_else(PendingInputCommandStartError::no_document_loaded)?; + let page = if targets_outgoing_document { + conn.loaded_page_mut_for_interruptible_protocol_access_for_owner(command_owner) + } else { + conn.loaded_page_mut_for_protocol_access_for_owner(command_owner) + } + .ok() + .ok_or_else(PendingInputCommandStartError::no_document_loaded)?; let pending = start(page).map_err(PendingInputCommandStartError::renderer_error)?; Ok(Some(PendingInputCommandDispatch { command_id, @@ -1237,7 +1284,11 @@ fn start_devtools_input_command_for_owner( command: DevToolsCommand, ) -> Result, PendingInputCommandStartError> { let coordinate_unsupported = match &command { - DevToolsCommand::DispatchMouseEvent(_) => Some(DISPATCH_MOUSE_EVENT_UNSUPPORTED_MESSAGE), + DevToolsCommand::ElementClick(crate::devtools_runtime::DevToolsElementClickCommand { + operation: DevToolsElementClickOperation::Dispatch(_), + .. + }) + | DevToolsCommand::DispatchMouseEvent(_) => Some(DISPATCH_MOUSE_EVENT_UNSUPPORTED_MESSAGE), DevToolsCommand::DispatchTouchEvent(_) => Some(DISPATCH_TOUCH_EVENT_UNSUPPORTED_MESSAGE), DevToolsCommand::DispatchDragEvent(_) => Some(DISPATCH_DRAG_EVENT_UNSUPPORTED_MESSAGE), DevToolsCommand::SynthesizeTapGesture(_) => { @@ -1254,6 +1305,27 @@ fn start_devtools_input_command_for_owner( } } match command { + DevToolsCommand::ElementClick(command) => match command.operation { + DevToolsElementClickOperation::Prepare { object_id } => start_page_input_command( + conn, + command_id, + owner, + PendingInputCommandKind::PrepareElementClick, + |page| { + page.start_prepare_element_click( + owner.session_id().map(str::to_owned), + object_id.to_string(), + ) + }, + ), + DevToolsElementClickOperation::Dispatch(click) => start_page_input_command( + conn, + command_id, + owner, + PendingInputCommandKind::DispatchElementClick, + |page| page.start_dispatch_prepared_element_click(click), + ), + }, DevToolsCommand::DispatchMouseEvent(command) => { start_devtools_dispatch_mouse_event_command(conn, command_id, owner, command) } @@ -1308,6 +1380,51 @@ async fn complete_pending_input_command( let mut side_effects = InputCommandSideEffects::default(); let result = match completed.kind { + kind @ (PendingInputCommandKind::PrepareElementClick + | PendingInputCommandKind::DispatchElementClick) => { + match completed_page_command_result(completed_operation) { + Err(error) => Err(error), + Ok(result) => { + let completion = settle_completed_input_page_command( + conn, + session_id, + &owner, + result, + command_context, + ); + if matches!(kind, PendingInputCommandKind::PrepareElementClick) { + decode_element_click_preparation_completion(completion) + .map(DevToolsCommandResult::ElementClickPreparation) + .map_err(|error| { + DevToolsError::new(DevToolsErrorKind::Internal, error.to_string()) + }) + } else { + match decode_element_click_dispatch_completion(completion) { + Err(error) => Err(DevToolsError::new( + DevToolsErrorKind::Internal, + error.to_string(), + )), + Ok(Err(error)) => { + Ok(DevToolsCommandResult::ElementClickDispatch(Err(error))) + } + Ok(Ok(outcome)) => handle_input_dispatch_outcome_async( + conn, + &mut side_effects, + session_id, + &owner, + outcome, + command_context, + ) + .await + .map(|()| DevToolsCommandResult::ElementClickDispatch(Ok(()))) + .map_err(|error| { + DevToolsError::new(DevToolsErrorKind::Internal, error) + }), + } + } + } + } + } kind @ (PendingInputCommandKind::DispatchMouseEvent | PendingInputCommandKind::DispatchTouchEvent | PendingInputCommandKind::DispatchDragEvent @@ -1334,7 +1451,10 @@ async fn complete_pending_input_command( PendingInputCommandKind::DispatchTouchEvent | PendingInputCommandKind::SynthesizeTapGesture => "touch event page command", PendingInputCommandKind::DispatchDragEvent => "drag event page command", - PendingInputCommandKind::DispatchKeyEvent | PendingInputCommandKind::InsertText => { + PendingInputCommandKind::DispatchKeyEvent + | PendingInputCommandKind::InsertText + | PendingInputCommandKind::PrepareElementClick + | PendingInputCommandKind::DispatchElementClick => { unreachable!() } }; diff --git a/moli-protocol/src/domains/input/tests.rs b/moli-protocol/src/domains/input/tests.rs index a4a4d4348b5..b3790654adc 100644 --- a/moli-protocol/src/domains/input/tests.rs +++ b/moli-protocol/src/domains/input/tests.rs @@ -3,6 +3,7 @@ use crate::conn::{BrowserContext, CdpCommandTaskStep, CommandDispatchContext}; use crate::testing::{TestContext, wait_until_frame_stopped_loading}; use moli_core::LayoutPolicy; +mod element_click; mod keyboard_events; const INPUT_HIT_X: u32 = 20; @@ -1577,6 +1578,151 @@ async fn dispatch_key_event_can_complete_through_pending_command_dispatch() { ); } +#[tokio::test(flavor = "multi_thread")] +async fn dispatch_key_press_acknowledges_real_link_navigation() { + tokio::task::LocalSet::new() + .run_until(async { + let mut ctx = TestContext::new(); + ctx.enable_background_navigation_scheduler_for_test(); + with_loaded_document( + &mut ctx, + r#" +
+ + + "#, + ) + .await; + ctx.enable_page_events_for_test(None); + + for (id, event_type, text) in [(4103, "keyDown", Some("\r")), (4104, "keyUp", None)] { + let mut command = json!({ + "id": id, + "method": "Input.dispatchKeyEvent", + "params": { + "type": event_type, + "key": "Enter", + "code": "Enter" + } + }); + if let Some(text) = text { + command["params"]["text"] = json!(text); + } + ctx.process_and_wait_for_response_async(command).await; + ctx.expect_result(id, json!({}), None); + } + + wait_until_frame_stopped_loading(&mut ctx, "TID-1").await; + ctx.expect_event("Page.frameNavigated", None); + assert_eq!( + evaluate_string(&mut ctx, "document.body.textContent").await, + "destination" + ); + }) + .await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn dispatch_keyup_reaches_outgoing_document_while_navigation_is_pending() { + let mut ctx = TestContext::new(); + with_loaded_document( + &mut ctx, + r#" + + + "#, + ) + .await; + ctx.conn + .browser_context + .as_mut() + .expect("browser context should exist") + .start_document_navigation_for_active_target("PENDING-KEYUP".to_owned()) + .expect("active navigation should start"); + + ctx.process_async(json!({ + "id": 4105, + "method": "Input.dispatchKeyEvent", + "params": { + "type": "keyUp", + "key": "Enter", + "code": "Enter" + } + })) + .await; + ctx.expect_result(4105, json!({}), None); + + let owner = CommandOwnerScope::capture(&ctx.conn, None); + ctx.conn + .clear_pending_document_navigation_for_owner_if_loader_matches(&owner, "PENDING-KEYUP"); + assert!( + !ctx.conn + .has_pending_document_navigation_for_session_owner(None) + ); + assert_eq!( + evaluate_string(&mut ctx, "String(window.__keyupCount)").await, + "1" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn dispatch_keydown_still_rejects_while_navigation_is_pending() { + let mut ctx = TestContext::new(); + with_loaded_document(&mut ctx, "").await; + ctx.conn + .browser_context + .as_mut() + .expect("browser context should exist") + .start_document_navigation_for_active_target("PENDING-KEYDOWN".to_owned()) + .expect("active navigation should start"); + + ctx.process_async(json!({ + "id": 4106, + "method": "Input.dispatchKeyEvent", + "params": { + "type": "rawKeyDown", + "key": "Enter", + "code": "Enter" + } + })) + .await; + ctx.expect_error(4106, -32000, "Navigation is changing the document"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn dispatch_keyup_without_document_reports_no_document_loaded() { + let mut ctx = TestContext::new(); + let mut browser_context = BrowserContext::new("BID-I".into()); + browser_context.set_active_target_id("TID-1"); + ctx.conn + .install_browser_context_fixture_for_test(browser_context); + + ctx.process_async(json!({ + "id": 4107, + "method": "Input.dispatchKeyEvent", + "params": { + "type": "keyUp", + "key": "Enter", + "code": "Enter" + } + })) + .await; + ctx.expect_error(4107, -32000, "NoDocumentLoaded"); +} + #[tokio::test(flavor = "multi_thread")] async fn insert_text_can_complete_through_pending_command_dispatch() { let mut ctx = TestContext::new(); @@ -2129,7 +2275,7 @@ async fn dispatch_key_event_enter_inserts_newline_in_textarea() { field.focus(); field.setSelectionRange(1, 1); window.__textareaEvents = []; - ['keydown', 'beforeinput', 'input'].forEach((type) => { + ['keydown', 'keypress', 'beforeinput', 'input'].forEach((type) => { field.addEventListener(type, () => window.__textareaEvents.push(type)); }); @@ -2149,6 +2295,29 @@ async fn dispatch_key_event_enter_inserts_newline_in_textarea() { .await; ctx.expect_result(16, json!({}), None); + // CDP keyDown without text has no character phase and must not edit. + assert_eq!( + evaluate_string(&mut ctx, "document.getElementById('field').value").await, + "ab" + ); + assert_eq!( + evaluate_string(&mut ctx, "JSON.stringify(window.__textareaEvents)").await, + r#"["keydown"]"# + ); + evaluate_string(&mut ctx, "window.__textareaEvents = []; ''").await; + ctx.process_async(json!({ + "id": 17, + "method": "Input.dispatchKeyEvent", + "params": { + "type": "keyDown", + "key": "Enter", + "code": "Enter", + "text": "\r" + } + })) + .await; + ctx.expect_result(17, json!({}), None); + assert_eq!( evaluate_string(&mut ctx, "document.getElementById('field').value").await, "a\nb" @@ -2162,7 +2331,7 @@ async fn dispatch_key_event_enter_inserts_newline_in_textarea() { ); assert_eq!( evaluate_string(&mut ctx, "JSON.stringify(window.__textareaEvents)").await, - r#"["keydown","beforeinput","input"]"# + r#"["keydown","keypress","beforeinput","input"]"# ); } diff --git a/moli-protocol/src/domains/input/tests/element_click.rs b/moli-protocol/src/domains/input/tests/element_click.rs new file mode 100644 index 00000000000..6b11e2a7a9c --- /dev/null +++ b/moli-protocol/src/domains/input/tests/element_click.rs @@ -0,0 +1,110 @@ +use super::*; +use crate::devtools_runtime::{ + DevToolsElementClickCommand, DevToolsElementClickOperation, DevToolsProtocol, +}; +use moli_core::page::{ + RendererElementClickError, RendererElementClickTarget, RendererPreparedPointerClick, +}; + +async fn command( + ctx: &mut TestContext, + operation: DevToolsElementClickOperation, +) -> DevToolsCommandResult { + ctx.conn + .execute_devtools_command(DevToolsCommand::ElementClick(DevToolsElementClickCommand { + context: DevToolsCommandContext { + protocol: DevToolsProtocol::WebDriverClassic, + session_id: None, + target_id: None, + browser_context_id: None, + }, + operation, + })) + .await + .into_parts() + .0 + .expect("native element click command") +} + +async fn prepare(ctx: &mut TestContext) -> RendererPreparedPointerClick { + let object_id = resolve_selector_object_id(ctx, "#target", 501).await; + match command( + ctx, + DevToolsElementClickOperation::Prepare { + object_id: object_id.into(), + }, + ) + .await + { + DevToolsCommandResult::ElementClickPreparation(Ok( + RendererElementClickTarget::Pointer(click), + )) => click, + result => panic!("expected prepared pointer click: {result:?}"), + } +} + +async fn fixture() -> TestContext { + let mut ctx = TestContext::new(); + with_loaded_document(&mut ctx, r#" + + "#).await; + ctx +} + +#[tokio::test(flavor = "multi_thread")] +async fn native_element_click_rejects_stale_preparation_before_observable_input() { + for mutation in [ + "target.remove()", + "document.open();document.write('');document.close()", + ] { + let mut ctx = fixture().await; + let click = prepare(&mut ctx).await; + assert!(evaluate_bool(&mut ctx, &format!("(()=>{{const target=document.getElementById('target');{mutation};return true}})()")).await); + let result = command(&mut ctx, DevToolsElementClickOperation::Dispatch(click)).await; + let DevToolsCommandResult::ElementClickDispatch(result) = result else { + panic!("unexpected dispatch result: {result:?}") + }; + assert_eq!( + result, + Err(RendererElementClickError::StaleNode), + "{mutation}" + ); + let observed = evaluate_string(&mut ctx, "JSON.stringify([clicks,events])").await; + assert_eq!( + serde_json::from_str::(&observed).unwrap(), + json!([0, []]), + "{mutation}" + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn native_element_click_stops_after_document_replacement_in_pointer_handler() { + for event in ["mousemove", "mousedown"] { + let mut ctx = fixture().await; + assert!(evaluate_bool(&mut ctx, &format!(r#"(() => {{ + document.getElementById('target').addEventListener('{event}', () => {{ + document.open();document.write('');document.close(); + window.successorEvents=[]; + for(const type of ['mousemove','mousedown','mouseup','click']) + document.getElementById('replacement').addEventListener(type,()=>successorEvents.push(type)); + }}); + return true; + }})()"#)).await); + let click = prepare(&mut ctx).await; + assert!(matches!( + command(&mut ctx, DevToolsElementClickOperation::Dispatch(click)).await, + DevToolsCommandResult::ElementClickDispatch(Ok(())) + )); + assert_eq!( + evaluate_string(&mut ctx, "JSON.stringify(successorEvents)").await, + "[]", + "{event}" + ); + } +} diff --git a/moli-protocol/src/domains/input/tests/keyboard_events.rs b/moli-protocol/src/domains/input/tests/keyboard_events.rs index 06033c61f71..55eccf384d5 100644 --- a/moli-protocol/src/domains/input/tests/keyboard_events.rs +++ b/moli-protocol/src/domains/input/tests/keyboard_events.rs @@ -11,6 +11,7 @@ async fn keyboard_fixture(control: &str) -> TestContext { let html = r#"CONTROL" + ); + let mut page = create_test_html_page_with_navigation_dispatch( + &runtime, + &loader, + url::Url::parse("https://example.test/source.html").unwrap(), + &html, + RendererTopLevelNavigationDispatch::DelegateToBrowser, + ) + .await; + let (snapshot, _) = page + .run_async_command(RendererPageCommand::PageDiagnosticsSnapshot) + .await + .expect("source snapshot"); + let RendererPageReply::PageDiagnosticsSnapshot(snapshot) = snapshot else { + panic!("expected diagnostics snapshot"); + }; + let source_document = snapshot + .document_lifecycle_identity() + .expect("source identity"); + let (preflight, _) = page.run_async_command(RendererPageCommand::EvaluateExpression { + expression: "JSON.stringify([document.elementFromPoint(30,30).id,window.handlerInstalled,window.handlerRuns])".to_owned(), + await_promise: false, + }).await.expect("input fixture preflight"); + assert_eq!( + mouse_input_json_witness(&preflight), + serde_json::json!(["target", true, 0]) + ); + output_rx.drain(); + let (reply, _) = page + .run_async_command(RendererPageCommand::DispatchMouseEventAtPoint { + x: 30.0, + y: 30.0, + event_name: event_name.to_owned(), + button: if event_name == "mousedown" { 0 } else { -1 }, + buttons: Some(if event_name == "mousedown" { 1 } else { 0 }), + click_count: 1, + delta_x: 0.0, + delta_y: 0.0, + pointer: RendererPointerEventProperties::default(), + modifiers: 0, + }) + .await + .expect("input command"); + let RendererPageReply::InputDispatchOutcome(outcome) = reply else { + panic!("expected input outcome"); + }; + assert_eq!( + outcome.triggered_top_level_navigation, navigates, + "{listener}, microtask={microtask}" + ); + let navigations = output_rx + .drain() + .into_iter() + .flat_map(RendererOutputPublication::into_records) + .filter_map(|record| match record.into_parts().1 { + RendererOutputItem::OwnerAction( + RendererOwnerAction::TopLevelLocationNavigation(navigation), + ) => Some(navigation), + _ => None, + }) + .collect::>(); + assert_eq!( + navigations.len(), + usize::from(navigates), + "{listener}, microtask={microtask}" + ); + if let Some(navigation) = navigations.first() { + assert_eq!(navigation.source_document(), source_document); + assert_eq!(navigation.url(), "https://example.test/next.html"); + } + assert_eq!( + has_pending_location_navigation_for_test(&page).await, + Some(false), + "completed input must not leave navigation for a later Runtime command" + ); + // Read only after the command's navigation-output assertions: a + // Runtime command must not make a broken input publication look green. + let (handler_runs, _) = page + .run_async_command(RendererPageCommand::EvaluateExpression { + expression: "window.handlerRuns".to_owned(), + await_promise: false, + }) + .await + .expect("handler execution witness"); + assert_eq!( + renderer_json_value(handler_runs), + Some(serde_json::json!(1)) + ); + assert!( + output_rx + .drain() + .into_iter() + .flat_map(RendererOutputPublication::into_records) + .all(|record| !matches!( + record.item(), + RendererOutputItem::OwnerAction( + RendererOwnerAction::TopLevelLocationNavigation(_) + ) + )), + "the next command must not republish the input navigation" + ); + page.close_async().await.expect("close input fixture"); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn mouse_input_completion_preserves_standalone_and_child_navigation_scope() { + let runtime = initialize_layout_test_runtime(); + let (output_tx, mut output_rx) = renderer_external_activity_test_channel(); + runtime.set_renderer_output_transport_sender(output_tx); + let loader = + ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("default loader"); + for (case, mode) in [ + ( + "standalone", + RendererTopLevelNavigationDispatch::FollowInStandaloneAdapter, + ), + ( + "child self", + RendererTopLevelNavigationDispatch::DelegateToBrowser, + ), + ( + "child top", + RendererTopLevelNavigationDispatch::DelegateToBrowser, + ), + ] { + let html = if case == "standalone" { + "".to_owned() + } else { + let navigation = if case == "child top" { + "top.location.href='https://example.test/next.html'" + } else { + "location.href='data:text/html,child-next'" + }; + let child = format!( + "" + ); + let child_literal = serde_json::to_string(&child).expect("child HTML literal"); + format!( + "" + ) + }; + let mut page = create_test_html_page_with_navigation_dispatch( + &runtime, + &loader, + url::Url::parse("https://example.test/source.html").unwrap(), + &html, + mode, + ) + .await; + let hit_expression = if case == "standalone" { + "document.elementFromPoint(30,30).id" + } else { + "document.getElementById('frame').contentDocument.elementFromPoint(30,30).id" + }; + let (hit, _) = page + .run_async_command(RendererPageCommand::EvaluateExpression { + expression: hit_expression.to_owned(), + await_promise: false, + }) + .await + .expect("scope fixture hit preflight"); + assert_eq!( + renderer_json_value(hit), + Some(serde_json::json!("target")), + "{case}" + ); + output_rx.drain(); + let (reply, _) = page + .run_async_command(RendererPageCommand::DispatchMouseEventAtPoint { + x: 30.0, + y: 30.0, + event_name: "mousedown".to_owned(), + button: 0, + buttons: Some(1), + click_count: 1, + delta_x: 0.0, + delta_y: 0.0, + pointer: RendererPointerEventProperties::default(), + modifiers: 0, + }) + .await + .expect("scope input command"); + let RendererPageReply::InputDispatchOutcome(outcome) = reply else { + panic!("expected input outcome"); + }; + assert_eq!( + outcome.triggered_top_level_navigation, + case != "child self", + "{case}" + ); + let navigations = output_rx + .drain() + .into_iter() + .flat_map(RendererOutputPublication::into_records) + .filter_map(|record| match record.into_parts().1 { + RendererOutputItem::OwnerAction( + RendererOwnerAction::TopLevelLocationNavigation(navigation), + ) => Some(navigation), + _ => None, + }) + .collect::>(); + assert_eq!( + navigations.len(), + usize::from(case == "child top"), + "{case}" + ); + if let Some(navigation) = navigations.first() { + assert_eq!(navigation.url(), "https://example.test/next.html"); + } + if case != "standalone" { + let (witness, _) = page + .run_async_command(RendererPageCommand::EvaluateExpression { + expression: + "JSON.stringify([window.handlerRuns,!!document.getElementById('frame')])" + .to_owned(), + await_promise: false, + }) + .await + .expect("child input witness after publication assertions"); + assert_eq!( + mouse_input_json_witness(&witness), + serde_json::json!([1, true]), + "{case}" + ); + } + page.close_async().await.expect("close scope fixture"); + } +} + +fn mouse_input_json_witness(reply: &RendererPageReply) -> serde_json::Value { + let RendererPageReply::RuntimeEvaluationResult(result) = reply else { + panic!( + "expected JSON-string input witness; unexpected reply variant: {:?}", + std::mem::discriminant(reply) + ); + }; + let payload = result.as_protocol_payload(); + let serialized = payload + .get("value") + .and_then(serde_json::Value::as_str) + .unwrap_or_else(|| panic!("expected JSON-string input witness; raw payload: {payload:?}")); + serde_json::from_str(serialized).unwrap_or_else(|error| { + panic!("invalid input witness JSON: {error}; raw payload: {payload:?}") + }) +} + async fn dispatch_wheel_for_action_window_test( page: &RendererPageHandle, delta_y: f64, diff --git a/moli-renderer-v8/src/script_vm.rs b/moli-renderer-v8/src/script_vm.rs index e0830df6422..1215bb12cc0 100644 --- a/moli-renderer-v8/src/script_vm.rs +++ b/moli-renderer-v8/src/script_vm.rs @@ -704,6 +704,7 @@ mod dom_debugger; mod dom_inspector; pub(crate) use dom_inspector::{DomInspectorEdit, DomInspectorEditOutcome}; mod drop_cleanup; +mod element_click; mod element_toggle_event; mod eval_exec; mod file_entry_file_callback; @@ -956,6 +957,7 @@ pub(super) struct ScriptVm { active_touch_event_handle: Option, active_touch_point: Option, active_touch_points: BTreeMap, + suppress_next_keypress_after_canceled_raw_keydown: bool, suppress_compat_mouse_events: bool, active_scrollbar_drag: Option, active_drag_session: Option, @@ -2359,6 +2361,7 @@ impl ScriptVmDefaultWorldBootstrap { active_touch_event_handle: None, active_touch_point: None, active_touch_points: BTreeMap::new(), + suppress_next_keypress_after_canceled_raw_keydown: false, suppress_compat_mouse_events: false, active_scrollbar_drag: None, active_drag_session: None, diff --git a/moli-renderer-v8/src/script_vm/element_click.rs b/moli-renderer-v8/src/script_vm/element_click.rs new file mode 100644 index 00000000000..ba64c9ad73a --- /dev/null +++ b/moli-renderer-v8/src/script_vm/element_click.rs @@ -0,0 +1,210 @@ +use super::ScriptVm; +use super::input_helpers::clear_input_dispatch_state; +use crate::document_runtime::DomHandle; +use crate::dom::{forms::InputType, native::Node}; +use crate::native_bridge::element::{observable_client_rects, scroll_node_into_view_at_center}; +use crate::runtime::{ + RendererElementClickError as ClickError, RendererElementClickTarget as ClickTarget, + RendererInputDispatchOutcome, RendererPointerEventProperties, RendererPreparedPointerClick, +}; +use moli_layout::{LayoutFlushReason, LayoutPoint, LayoutQuad}; + +fn layout_error(error: impl std::fmt::Display) -> ClickError { + ClickError::LayoutUnavailable(error.to_string()) +} + +impl ScriptVm { + pub(crate) fn prepare_element_click( + &mut self, + handle: DomHandle, + ) -> Result { + { + let host = self._context_host.borrow(); + let element = host + .dom_host() + .node(handle) + .and_then(Node::as_element) + .filter(|_| host.dom_host().is_connected(handle)) + .ok_or(ClickError::StaleNode)?; + if element.is_html_input() && element.input_type() == InputType::File { + return Ok(ClickTarget::FileInput); + } + if element.local_name() == "option" { + return Ok(ClickTarget::Option); + } + if !host.layout_policy().uses_real_layout() { + return Ok(ClickTarget::DomActivation); + } + } + self.with_default_context_scope(|scope, runtime_ptr| { + Ok(scroll_node_into_view_at_center(scope, runtime_ptr, handle)?) + }) + .map_err(layout_error)? + .ok_or(ClickError::NoClickableRect)?; + self.prepare_pointer_click_geometry(handle) + .map(ClickTarget::Pointer) + } + + fn prepare_pointer_click_geometry( + &mut self, + handle: DomHandle, + ) -> Result { + self.reconcile_document_web_fonts_for_layout(); + let (document, point, root_document) = { + let host = self._context_host.borrow(); + if !host.dom_host().is_connected(handle) { + return Err(ClickError::StaleNode); + } + let document = host + .dom_host() + .owner_document_handle(handle) + .ok_or(ClickError::StaleNode)?; + let rect = + observable_client_rects(&host, handle, LayoutFlushReason::SynchronousGeometry) + .map_err(layout_error)? + .into_iter() + .next() + .ok_or(ClickError::NoClickableRect)?; + let viewport = host.layout_viewport_for_document(document); + let left = rect.left.max(0.0); + let top = rect.top.max(0.0); + let right = rect.right.min(f64::from(viewport.css_width)); + let bottom = rect.bottom.min(f64::from(viewport.css_height)); + if right <= left || bottom <= top { + return Err(ClickError::NoClickableRect); + } + ( + document, + LayoutPoint::new( + ((left + right) / 2.0).floor() as f32, + ((top + bottom) / 2.0).floor() as f32, + ), + host.root_document_lifecycle_identity(), + ) + }; + // Use the same frame geometry composition as DOM geometry. The server + // must never reconstruct this transform from serialized box quads. + let mut quad = [LayoutQuad { points: [point; 4] }]; + self.compose_layout_quads_to_top(document, &mut quad) + .map_err(layout_error)?; + let point = quad[0].points[0]; + let hit = self + .observable_deep_hit_test_for_current_document(point, false) + .map_err(layout_error)? + .ok_or(ClickError::Obscured)?; + let mut documents = Vec::new(); + let mut document_cursor = self + ._context_host + .borrow() + .dom_host() + .owner_document_handle(hit) + .ok_or(ClickError::MissingBrowsingContext)?; + loop { + let id = self + .document_id_for_live_node_handle(document_cursor) + .ok_or(ClickError::MissingBrowsingContext)?; + documents.push((document_cursor, id)); + let host = self._context_host.borrow(); + if document_cursor == host.document_handle() { + break; + } + let frame = host + .child_browsing_context_host_for_document_handle(document_cursor) + .ok_or(ClickError::MissingBrowsingContext)?; + document_cursor = host + .dom_host() + .owner_document_handle(frame) + .ok_or(ClickError::MissingBrowsingContext)?; + } + // The deep hit already passed every ancestor's clipping and paint + // order. Walk the live composed ancestry, including frame owners, to + // accept descendants without accidentally accepting sibling overlays. + let host = self._context_host.borrow(); + let mut cursor = Some(hit); + while let Some(candidate) = cursor { + if candidate == handle { + return Ok(RendererPreparedPointerClick { + target: handle, + root_x: f64::from(point.x), + root_y: f64::from(point.y), + root_document, + documents, + }); + } + cursor = host + .dom_host() + .parent_node(candidate) + .or_else(|| host.dom_host().shadow_root_host(candidate)) + .or_else(|| host.child_browsing_context_host_for_document_handle(candidate)); + } + Err(ClickError::Obscured) + } + + fn click_documents_are_current(&self, click: &RendererPreparedPointerClick) -> bool { + self._context_host + .borrow() + .root_document_lifecycle_identity() + == click.root_document + && click + .documents + .iter() + .all(|(handle, id)| self.document_id_for_live_node_handle(*handle) == Some(*id)) + } + + pub(crate) fn dispatch_prepared_element_click( + &mut self, + click: RendererPreparedPointerClick, + ) -> Result { + if !self.click_documents_are_current(&click) { + return Err(ClickError::StaleNode); + } + if !self + ._context_host + .borrow() + .dom_host() + .is_connected(click.target) + { + return Err(ClickError::StaleNode); + } + // Preserve the point sampled during preparation. Each pointer phase + // uses the existing hit-test/snapshot policy; DOM or style mutations + // must not introduce an extra layout refresh or replay the sequence. + let mut outcome = RendererInputDispatchOutcome::default(); + for (event, buttons) in [("mousemove", 0), ("mousedown", 1), ("mouseup", 0)] { + if !self.click_documents_are_current(&click) { + clear_input_dispatch_state(self); + break; + } + let dispatched = self.dispatch_mouse_event_at_point_with_pointer_and_modifiers( + click.root_x, + click.root_y, + event, + 0, + Some(buttons), + i32::from(event != "mousemove"), + 0.0, + 0.0, + RendererPointerEventProperties::default(), + 0, + ); + let next = match dispatched { + Ok(next) => next, + Err(error) => { + clear_input_dispatch_state(self); + return Err(layout_error(error)); + } + }; + outcome.handled |= next.handled; + outcome.triggered_top_level_navigation |= + next.triggered_top_level_navigation || self.has_pending_location_navigation(); + outcome.pending_download = next.pending_download.or(outcome.pending_download); + outcome.pending_file_chooser = + next.pending_file_chooser.or(outcome.pending_file_chooser); + if outcome.triggered_top_level_navigation { + clear_input_dispatch_state(self); + break; + } + } + Ok(outcome) + } +} diff --git a/moli-renderer-v8/src/script_vm/input_dispatch.rs b/moli-renderer-v8/src/script_vm/input_dispatch.rs index 77e6e7b202a..17ad5addb4e 100644 --- a/moli-renderer-v8/src/script_vm/input_dispatch.rs +++ b/moli-renderer-v8/src/script_vm/input_dispatch.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use std::cell::Cell; use std::rc::Rc; use super::input_dispatch_outcome; @@ -6,8 +7,7 @@ use super::input_helpers::{ MouseReleaseFollowUp, PendingMousePress, mouse_button_mask, single_changed_mouse_button, }; use super::inspector::{ - current_selection_range, current_selection_state, is_space_key, key_target_info, - option_is_disabled, radio_group_members, + current_selection_state, is_space_key, key_target_info, option_is_disabled, radio_group_members, }; use super::{ActiveDragSession, ActiveScrollbarDrag, ActiveTouchPoint, ScriptVm}; use crate::document_runtime::DomHandle; @@ -16,9 +16,9 @@ use crate::dom::{ native::{Node, SelectedFile}, }; use crate::native_bridge::element::{ - TouchEventPoint, activate_handle_via_click, - activate_handle_via_click_with_detail_and_modifiers, cache_input_files_from_selected_files, - construct_drag_event, construct_keyboard_event, + TextEditInputType, TouchEventPoint, activate_handle_after_pointer_release, + activate_handle_via_click, activate_handle_via_click_with_detail_and_modifiers, + cache_input_files_from_selected_files, construct_drag_event, construct_keyboard_event, construct_mouse_event_with_detail_and_modifiers, construct_mouse_event_with_modifiers, construct_mouse_event_with_related_target_and_modifiers, construct_pointer_event, construct_pointer_event_with_modifiers, construct_pointer_event_with_related_target, @@ -27,10 +27,10 @@ use crate::native_bridge::element::{ contenteditable_editing_host, dispatch_public_event, is_text_control, observable_input_hit_test, observable_input_surface_hit_test, perform_auxiliary_link_default_action, perform_drop_default_action, - perform_mouse_focus_default_action, perform_scrollbar_scroll_default_action, - perform_wheel_scroll_default_action, replace_contenteditable_selection, - replace_text_control_selection, select_contenteditable_contents, - text_control_set_selection_range_internal, + perform_implicit_submission_from_control, perform_mouse_focus_default_action, + perform_scrollbar_scroll_default_action, perform_wheel_scroll_default_action, + replace_contenteditable_selection, replace_text_control_selection, + select_contenteditable_contents, text_control_set_selection_range_internal, text_control_set_selection_range_with_direction_internal, text_control_value, update_focus, }; use crate::native_bridge::{ @@ -1004,7 +1004,7 @@ impl ScriptVm { if event_name == "mouseup" { suppress_compat_mouse_events = false; } - let outcome = activate_handle_via_click_with_detail_and_modifiers( + let outcome = activate_handle_after_pointer_release( scope, runtime_ptr, handle, @@ -1775,7 +1775,13 @@ impl ScriptVm { let result = self.with_default_context_scope(|scope, runtime_ptr| { if is_text_control(unsafe { &*runtime_ptr }, handle) { - let _ = replace_text_control_selection(scope, runtime_ptr, handle, text); + let _ = replace_text_control_selection( + scope, + runtime_ptr, + handle, + text, + TextEditInputType::InsertText, + ); return Ok(true); } let runtime = unsafe { &*runtime_ptr }; @@ -1787,6 +1793,7 @@ impl ScriptVm { runtime_ptr, editing_host, text, + TextEditInputType::InsertText, )) }); self.finish_input_command_checkpoint(result) @@ -1802,6 +1809,14 @@ impl ScriptVm { auto_repeat: bool, should_insert_text: bool, ) -> Result { + if event_name == "keypress" && self.suppress_next_keypress_after_canceled_raw_keydown { + self.suppress_next_keypress_after_canceled_raw_keydown = false; + return Ok(input_dispatch_outcome(false)); + } + if matches!(event_name, "keydown" | "keyup") { + self.suppress_next_keypress_after_canceled_raw_keydown = false; + } + let _current_input_event = CurrentInputEventScope::enter( Rc::clone(&self._context_host), CurrentInputEvent::keyboard(key, modifiers), @@ -1819,6 +1834,7 @@ impl ScriptVm { let ctrl = modifiers & 2 == 2; let meta = modifiers & 4 == 4; let shift = modifiers & 8 == 8; + let canceled_raw_keydown = Cell::new(false); let result = self.with_default_context_scope(|scope, runtime_ptr| { let Some(event) = construct_keyboard_event( @@ -1836,6 +1852,9 @@ impl ScriptVm { }; let dispatched = dispatch_public_event(scope, runtime_ptr, handle, event); if !dispatched.allows_default() { + if event_name == "keydown" && !should_insert_text { + canceled_raw_keydown.set(true); + } return Ok(input_dispatch_outcome(false)); } @@ -1876,25 +1895,55 @@ impl ScriptVm { let runtime = unsafe { &mut *runtime_ptr }; let target = key_target_info(runtime, handle); + let is_enter_key = key_lower == "enter" || (should_insert_text && text == "\r"); + if is_enter_key { + let is_character_phase = + should_insert_text && matches!(event_name, "keydown" | "keypress"); + if target.is_textarea && is_character_phase { + return Ok(input_dispatch_outcome(replace_text_control_selection( + scope, + runtime_ptr, + handle, + "\n", + TextEditInputType::InsertLineBreak, + ))); + } + if is_character_phase + && let Some(outcome) = perform_implicit_submission_from_control( + scope, + runtime_ptr, + handle, + modifiers, + ) + { + return Ok(outcome); + } + if event_name == "keydown" && (target.is_button_like || target.is_anchor_like) { + return Ok(activate_handle_via_click_with_detail_and_modifiers( + scope, + runtime_ptr, + handle, + 0.0, + 0.0, + 0, + 0, + 1, + modifiers, + )); + } + } + if should_insert_text { if text.is_empty() { return Ok(input_dispatch_outcome(true)); } if target.is_text_control { - if target.is_textarea && text.contains('\r') { - let normalized_text = text.replace("\r\n", "\n").replace('\r', "\n"); - return Ok(input_dispatch_outcome(replace_text_control_selection( - scope, - runtime_ptr, - handle, - &normalized_text, - ))); - } return Ok(input_dispatch_outcome(replace_text_control_selection( scope, runtime_ptr, handle, text, + TextEditInputType::InsertText, ))); } if let Some(editing_host) = contenteditable_editing_host(runtime, handle) { @@ -1903,6 +1952,7 @@ impl ScriptVm { runtime_ptr, editing_host, text, + TextEditInputType::InsertText, ))); } return Ok(input_dispatch_outcome(true)); @@ -1929,30 +1979,6 @@ impl ScriptVm { return Ok(input_dispatch_outcome(true)); } - if key_lower == "enter" { - if target.is_textarea { - return Ok(input_dispatch_outcome(replace_text_control_selection( - scope, - runtime_ptr, - handle, - "\n", - ))); - } - if target.is_button_like || target.is_anchor_like { - return Ok(activate_handle_via_click_with_detail_and_modifiers( - scope, - runtime_ptr, - handle, - 0.0, - 0.0, - 0, - 0, - 1, - modifiers, - )); - } - } - if target.is_radio && matches!( key_lower.as_str(), @@ -2059,33 +2085,16 @@ impl ScriptVm { } if target.is_text_control && matches!(key_lower.as_str(), "backspace" | "delete") { - let value_units = utf16_units(&text_control_value(runtime, handle)); - let (start, end) = current_selection_range(runtime, handle); - let start = utf16_scalar_boundary_at_or_after(&value_units, start as usize) as u32; - let end = utf16_scalar_boundary_at_or_after(&value_units, end as usize) as u32; - let (from, to) = if start != end { - (start, end) - } else if key_lower == "backspace" { - ( - utf16_previous_scalar_boundary(&value_units, start as usize) as u32, - start, - ) - } else { - ( - start, - utf16_next_scalar_boundary(&value_units, start as usize) as u32, - ) - }; - if from == to { - return Ok(input_dispatch_outcome(true)); - } - let _ = - text_control_set_selection_range_internal(scope, runtime_ptr, handle, from, to); return Ok(input_dispatch_outcome(replace_text_control_selection( scope, runtime_ptr, handle, "", + if key_lower == "backspace" { + TextEditInputType::DeleteContentBackward + } else { + TextEditInputType::DeleteContentForward + }, ))); } @@ -2119,6 +2128,11 @@ impl ScriptVm { runtime_ptr, editing_host, "", + if key_lower == "backspace" { + TextEditInputType::DeleteContentBackward + } else { + TextEditInputType::DeleteContentForward + }, ))); } @@ -2215,6 +2229,9 @@ impl ScriptVm { Ok(input_dispatch_outcome(true)) }); + if canceled_raw_keydown.get() { + self.suppress_next_keypress_after_canceled_raw_keydown = true; + } self.finish_input_event_dispatch_turn(result) } } diff --git a/moli-renderer-v8/src/script_vm/inspector/input_state.rs b/moli-renderer-v8/src/script_vm/inspector/input_state.rs index 9f16232bd63..2cd6665afa8 100644 --- a/moli-renderer-v8/src/script_vm/inspector/input_state.rs +++ b/moli-renderer-v8/src/script_vm/inspector/input_state.rs @@ -109,26 +109,6 @@ pub(in crate::script_vm) fn radio_group_members( .collect() } -pub(in crate::script_vm) fn current_selection_range( - runtime: &JsContextHost, - handle: DomHandle, -) -> (u32, u32) { - runtime - .dom_host() - .node(handle) - .and_then(Node::as_element) - .map(|element| { - let start = element.selection_start(); - let end = element.selection_end(); - if start <= end { - (start, end) - } else { - (end, end) - } - }) - .unwrap_or((0, 0)) -} - pub(in crate::script_vm) fn current_selection_state( runtime: &JsContextHost, handle: DomHandle, diff --git a/moli-renderer-v8/src/script_vm/inspector/mod.rs b/moli-renderer-v8/src/script_vm/inspector/mod.rs index 3300662469f..1b7d9819e93 100644 --- a/moli-renderer-v8/src/script_vm/inspector/mod.rs +++ b/moli-renderer-v8/src/script_vm/inspector/mod.rs @@ -20,8 +20,7 @@ use self::context_registry::DocumentInspectorContextGroupId; pub(in crate::script_vm) use self::context_registry::DocumentInspectorContextRegistrationId; use self::document_backend::DocumentInspectorBackendState; pub(super) use self::input_state::{ - current_selection_range, current_selection_state, is_space_key, key_target_info, - option_is_disabled, radio_group_members, + current_selection_state, is_space_key, key_target_info, option_is_disabled, radio_group_members, }; pub(in crate::script_vm) use self::outbound::InspectorOutbound; pub(super) use self::outbound::ScriptVmInspectorCommandTurnOutputScope; diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs index a828a6da6b8..a77b9c3c3b3 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs @@ -4754,6 +4754,648 @@ fn submit_button_click_queues_pending_top_level_location_navigation() { "default form submission should not synthesize a history seed" ); } + +#[test] +fn enter_implicitly_submits_single_search_input_without_a_submit_button() { + let mut vm = new_parsed_test_vm( + "https://implicit-search-submit.test/", + r#" +
+ + +
+ "#, + ); + vm.eval( + r#" +window.__submitterWasNull = false; +document.querySelector('form').addEventListener('submit', event => { + window.__submitterWasNull = event.submitter === null; +}); +document.getElementById('query').focus(); +"#, + ) + .expect("implicit submission fixture should initialize"); + + let outcome = vm + .dispatch_key_event("keydown", "Enter", "Enter", "\r", 0, false, true) + .expect("Enter keydown should dispatch"); + + assert!(outcome.handled); + assert!(outcome.triggered_top_level_navigation); + assert_eq!( + vm.eval("String(window.__submitterWasNull)") + .expect("submitter state should evaluate"), + "true" + ); + let pending = vm + .take_pending_location_navigation_with_seed() + .expect("implicit submission should queue a location navigation"); + assert_eq!( + pending.url.as_str(), + "https://implicit-search-submit.test/search?q=Nvidia+RTX+4090" + ); +} + +#[test] +fn enter_implicitly_clicks_the_default_submit_button() { + let mut vm = new_parsed_test_vm( + "https://implicit-default-button.test/", + r#" +
+ + +
+ "#, + ); + vm.eval( + r#" +window.__events = []; +const submitter = document.getElementById('submitter'); +submitter.addEventListener('click', event => { + __events.push(`click:${event.detail}`); +}); +document.querySelector('form').addEventListener('submit', event => { + __events.push(`submit:${event.submitter === submitter}`); +}); +document.getElementById('query').focus(); +"#, + ) + .expect("default submit button fixture should initialize"); + + let outcome = vm + .dispatch_key_event("keydown", "Enter", "Enter", "\r", 0, false, true) + .expect("Enter keydown should dispatch"); + + assert!(outcome.handled); + assert!(outcome.triggered_top_level_navigation); + assert_eq!( + vm.eval("`${__events.join('|')}|focus:${document.activeElement.id}`") + .expect("implicit activation events should evaluate"), + "click:0|submit:true|focus:query" + ); + let pending = vm + .take_pending_location_navigation_with_seed() + .expect("default submit button should queue a location navigation"); + assert_eq!( + pending.url.as_str(), + "https://implicit-default-button.test/search?q=moli&source=header" + ); +} + +#[test] +fn enter_does_not_implicitly_submit_multiple_blocking_inputs_without_a_submit_button() { + let mut vm = new_parsed_test_vm( + "https://implicit-multiple-inputs.test/", + r#" +
+ + +
+ "#, + ); + vm.eval( + r#" +window.__submitCount = 0; +document.querySelector('form').addEventListener('submit', () => __submitCount++); +document.getElementById('query').focus(); +"#, + ) + .expect("multiple blocker fixture should initialize"); + + let outcome = vm + .dispatch_key_event("keydown", "Enter", "Enter", "\r", 0, false, true) + .expect("Enter keydown should dispatch"); + + assert!(outcome.handled); + assert!(!outcome.triggered_top_level_navigation); + assert_eq!( + vm.eval("String(window.__submitCount)") + .expect("submit count should evaluate"), + "0" + ); + assert!(vm.take_pending_location_navigation_with_seed().is_none()); +} + +#[test] +fn canceling_an_implicit_submit_event_prevents_navigation() { + let mut vm = new_parsed_test_vm( + "https://implicit-canceled-submit.test/", + r#" +
+ +
+ "#, + ); + vm.eval( + r#" +window.__submitCount = 0; +document.querySelector('form').addEventListener('submit', event => { + __submitCount++; + event.preventDefault(); +}); +document.getElementById('query').focus(); +"#, + ) + .expect("canceled implicit submission fixture should initialize"); + + let outcome = vm + .dispatch_key_event("keydown", "Enter", "Enter", "\r", 0, false, true) + .expect("Enter keydown should dispatch"); + + assert!(outcome.handled); + assert!(!outcome.triggered_top_level_navigation); + assert_eq!( + vm.eval("String(window.__submitCount)") + .expect("submit count should evaluate"), + "1" + ); + assert!(vm.take_pending_location_navigation_with_seed().is_none()); +} + +#[test] +fn disabled_default_submit_button_suppresses_implicit_submission() { + let mut vm = new_parsed_test_vm( + "https://implicit-disabled-default.test/", + r#"
+ + + +
"#, + ); + vm.eval( + r#" +window.__events = []; +for (const id of ['disabled', 'second']) { + document.getElementById(id).addEventListener('click', () => __events.push(`click:${id}`)); +} +document.querySelector('form').addEventListener('submit', () => __events.push('submit')); +document.getElementById('query').focus(); +"#, + ) + .expect("disabled default button fixture should initialize"); + + vm.dispatch_key_event("keydown", "Enter", "Enter", "\r", 0, false, true) + .expect("Enter keydown should dispatch"); + + assert_eq!( + vm.eval("`${__events.join('|')}|focus:${document.activeElement.id}`") + .expect("disabled default state should evaluate"), + "|focus:query" + ); + assert!(vm.take_pending_location_navigation_with_seed().is_none()); +} + +#[test] +fn external_form_owned_submit_button_participates_in_document_tree_order() { + let mut vm = new_parsed_test_vm( + "https://implicit-external-default.test/", + r#" + + + "#, + ); + vm.eval( + r#" +window.__events = []; +for (const id of ['external', 'inside']) { + document.getElementById(id).addEventListener('click', event => { + __events.push(`click:${id}:${event.detail}:${event.isTrusted}`); + }); +} +document.getElementById('search').addEventListener('submit', event => { + __events.push(`submit:${event.submitter.id}`); + event.preventDefault(); +}); +document.getElementById('query').focus(); +"#, + ) + .expect("external default button fixture should initialize"); + + vm.dispatch_key_event("keydown", "Enter", "Enter", "\r", 0, false, true) + .expect("Enter keydown should dispatch"); + + assert_eq!( + vm.eval("`${__events.join('|')}|focus:${document.activeElement.id}`") + .expect("external default state should evaluate"), + "click:external:0:true|submit:external|focus:query" + ); + assert!(vm.take_pending_location_navigation_with_seed().is_none()); +} + +#[test] +fn image_input_is_the_default_submit_button_and_serializes_click_coordinates() { + let mut vm = new_parsed_test_vm( + "https://implicit-image-default.test/", + r#"
+ + + +
"#, + ); + vm.eval( + r#" +window.__events = []; +document.getElementById('image').addEventListener('click', event => { + __events.push(`click:image:${event.detail}:${event.isTrusted}`); +}); +document.querySelector('form').addEventListener('submit', event => { + __events.push(`submit:${event.submitter.id}`); +}); +document.getElementById('query').focus(); +"#, + ) + .expect("image default button fixture should initialize"); + + let outcome = vm + .dispatch_key_event("keydown", "Enter", "Enter", "\r", 0, false, true) + .expect("Enter keydown should dispatch"); + + assert!(outcome.triggered_top_level_navigation); + assert_eq!( + vm.eval("`${__events.join('|')}|focus:${document.activeElement.id}`") + .expect("image default state should evaluate"), + "click:image:0:true|submit:image|focus:query" + ); + let pending = vm + .take_pending_location_navigation_with_seed() + .expect("image default button should queue a location navigation"); + assert_eq!( + pending.url.as_str(), + "https://implicit-image-default.test/search?q=moli&photo.x=0&photo.y=0" + ); +} + +#[test] +fn raw_keydown_waits_for_the_character_phase_before_implicit_submission() { + let mut vm = new_parsed_test_vm( + "https://implicit-character-phase.test/", + r#"
"#, + ); + vm.eval( + r#" +window.__events = []; +document.querySelector('form').addEventListener('submit', event => { + __events.push('submit'); + event.preventDefault(); +}); +document.getElementById('query').focus(); +"#, + ) + .expect("character phase fixture should initialize"); + + vm.dispatch_key_event("keydown", "Enter", "Enter", "", 0, false, false) + .expect("raw Enter keydown should dispatch"); + assert_eq!( + vm.eval("__events.join('|')") + .expect("raw keydown event state should evaluate"), + "" + ); + + vm.dispatch_key_event("keypress", "Enter", "Enter", "\r", 0, false, true) + .expect("Enter character phase should dispatch"); + assert_eq!( + vm.eval("__events.join('|')") + .expect("character phase event state should evaluate"), + "submit" + ); + assert!(vm.take_pending_location_navigation_with_seed().is_none()); +} + +#[test] +fn canceling_keydown_or_keypress_prevents_implicit_submission() { + for canceled_event in ["keydown", "keypress"] { + let mut vm = new_parsed_test_vm( + "https://implicit-canceled-key-event.test/", + r#"
"#, + ); + vm.eval(&format!( + r#" +window.__submitCount = 0; +const query = document.getElementById('query'); +query.addEventListener('{canceled_event}', event => event.preventDefault()); +document.querySelector('form').addEventListener('submit', () => __submitCount++); +query.focus(); +"# + )) + .expect("canceled key event fixture should initialize"); + + vm.dispatch_key_event("keydown", "Enter", "Enter", "\r", 0, false, true) + .expect("Enter keydown should dispatch"); + + assert_eq!( + vm.eval("String(window.__submitCount)") + .expect("submit count should evaluate"), + "0", + "canceling {canceled_event} must suppress implicit submission" + ); + assert!(vm.take_pending_location_navigation_with_seed().is_none()); + } +} + +#[test] +fn enter_beforeinput_precedes_implicit_submission_and_can_cancel_it() { + for with_button in [false, true] { + for (event_name, key, code) in [ + ("keydown", "Enter", "Enter"), + ("keypress", "Enter", "Enter"), + ("keypress", "", ""), + ] { + for cancel_beforeinput in [false, true] { + let mut vm = new_parsed_test_vm( + "https://implicit-beforeinput.test/", + r#"
+ + +
"#, + ); + vm.eval(&format!( + r#" +window.__events = []; +window.__beforeInput = null; +const query = document.getElementById('query'); +const submitter = document.getElementById('submitter'); +if (!{with_button}) submitter.remove(); +for (const type of ['keydown', 'keypress', 'keyup', 'input']) {{ + query.addEventListener(type, () => __events.push(type)); +}} +query.addEventListener('beforeinput', event => {{ + __events.push('beforeinput'); + __beforeInput = [event instanceof InputEvent, event.target === query, + event.inputType, event.data, event.bubbles, event.cancelable, + event.composed, event.isTrusted]; + if ({cancel_beforeinput}) event.preventDefault(); +}}); +submitter.addEventListener('click', () => __events.push('click')); +document.querySelector('form').addEventListener('submit', event => {{ + __events.push('submit'); + event.preventDefault(); +}}); +query.focus(); +"# + )) + .expect("implicit beforeinput fixture should initialize"); + + let outcome = vm + .dispatch_key_event(event_name, key, code, "\r", 0, false, true) + .expect("Enter character phase should dispatch"); + vm.dispatch_key_event("keyup", "Enter", "Enter", "", 0, false, false) + .expect("Enter keyup should dispatch"); + + let mut expected = Vec::new(); + if event_name == "keydown" { + expected.push("keydown"); + } + expected.extend(["keypress", "beforeinput"]); + if !cancel_beforeinput { + if with_button { + expected.push("click"); + } + expected.push("submit"); + } + expected.push("keyup"); + assert_eq!( + vm.eval("__events.join('|')") + .expect("implicit beforeinput event order should evaluate"), + expected.join("|"), + "with_button={with_button}, event_name={event_name}, key={key:?}, cancel_beforeinput={cancel_beforeinput}" + ); + assert_eq!( + vm.eval("JSON.stringify(__beforeInput)") + .expect("Enter beforeinput properties should evaluate"), + r#"[true,true,"insertLineBreak",null,true,true,true,true]"# + ); + assert_eq!( + vm.eval("query.value") + .expect("input value should remain unchanged"), + "moli" + ); + assert!(!outcome.triggered_top_level_navigation); + assert!(vm.take_pending_location_navigation_with_seed().is_none()); + } + } + } +} + +#[test] +fn canceled_raw_keydown_suppresses_only_its_following_implicit_submit_char_phase() { + let mut vm = new_parsed_test_vm( + "https://implicit-canceled-raw-keydown.test/", + r#"
"#, + ); + vm.eval( + r#" +window.__cancelKeydown = true; +window.__submitCount = 0; +const query = document.getElementById('query'); +query.addEventListener('keydown', event => { + if (__cancelKeydown) event.preventDefault(); +}); +document.querySelector('form').addEventListener('submit', event => { + __submitCount++; + event.preventDefault(); +}); +query.focus(); +"#, + ) + .expect("canceled raw keydown fixture should initialize"); + + vm.dispatch_key_event("keydown", "Enter", "Enter", "", 0, false, false) + .expect("canceled raw Enter keydown should dispatch"); + vm.dispatch_key_event("keypress", "", "", "\r", 0, false, true) + .expect("suppressed Enter character phase should settle"); + assert_eq!( + vm.eval("String(window.__submitCount)") + .expect("suppressed submit count should evaluate"), + "0" + ); + + vm.eval("window.__cancelKeydown = false") + .expect("keydown cancellation should disable"); + vm.dispatch_key_event("keydown", "Enter", "Enter", "", 0, false, false) + .expect("allowed raw Enter keydown should dispatch"); + vm.dispatch_key_event("keypress", "", "", "\r", 0, false, true) + .expect("allowed Enter character phase should dispatch"); + assert_eq!( + vm.eval("String(window.__submitCount)") + .expect("allowed submit count should evaluate"), + "1" + ); + assert!(vm.take_pending_location_navigation_with_seed().is_none()); +} + +#[test] +fn implicit_submission_runs_constraint_validation() { + let mut vm = new_parsed_test_vm( + "https://implicit-constraint-validation.test/", + r#"
+ + +
"#, + ); + vm.eval( + r#" +window.__events = []; +document.getElementById('submitter').addEventListener('click', () => __events.push('click')); +document.getElementById('query').addEventListener('invalid', () => __events.push('invalid')); +document.querySelector('form').addEventListener('submit', () => __events.push('submit')); +document.getElementById('query').focus(); +"#, + ) + .expect("constraint validation fixture should initialize"); + + vm.dispatch_key_event("keydown", "Enter", "Enter", "\r", 0, false, true) + .expect("Enter keydown should dispatch"); + + assert_eq!( + vm.eval("__events.join('|')") + .expect("constraint validation events should evaluate"), + "click|invalid" + ); + assert!(vm.take_pending_location_navigation_with_seed().is_none()); +} + +#[test] +fn textarea_enter_inserts_one_newline_during_the_character_phase_without_submitting() { + let mut vm = new_parsed_test_vm( + "https://textarea-enter-character-phase.test/", + r#"
"#, + ); + vm.eval( + r#" +window.__submitCount = 0; +document.querySelector('form').addEventListener('submit', () => __submitCount++); +const editor = document.getElementById('editor'); +editor.focus(); +editor.setSelectionRange(1, 1); +"#, + ) + .expect("textarea Enter fixture should initialize"); + + vm.dispatch_key_event("keydown", "Enter", "Enter", "", 0, false, false) + .expect("raw Enter keydown should dispatch"); + assert_eq!( + vm.eval("document.getElementById('editor').value") + .expect("textarea value after raw keydown should evaluate"), + "x" + ); + + vm.dispatch_key_event("keypress", "Enter", "Enter", "\r", 0, false, true) + .expect("Enter character phase should dispatch"); + assert_eq!( + vm.eval("`${document.getElementById('editor').value}|${__submitCount}`") + .expect("textarea character phase state should evaluate"), + "x\n|0" + ); + assert!(vm.take_pending_location_navigation_with_seed().is_none()); +} + +#[test] +fn carriage_return_in_character_text_preserves_text_without_implicit_submission() { + for (control, expected_value) in [ + (r#""#, "a b"), + (r#""#, "a\nb"), + ] { + for (event_name, key, code) in [ + ("keydown", "a", "KeyA"), + ("keydown", "", ""), + ("keypress", "a", ""), + ("keypress", "", ""), + ] { + for text in ["a\rb", "a\r\nb"] { + let mut vm = new_parsed_test_vm( + "https://carriage-return-character-text.test/", + &format!( + r#"
{control} + +
"# + ), + ); + vm.eval( + r#" +window.__events = []; +const editor = document.getElementById('editor'); +for (const type of ['beforeinput', 'input']) { + editor.addEventListener(type, () => __events.push(type)); +} +document.getElementById('submitter').addEventListener('click', () => __events.push('click')); +document.querySelector('form').addEventListener('submit', event => { + __events.push('submit'); + event.preventDefault(); +}); +editor.focus(); +"#, + ) + .expect("carriage return text fixture should initialize"); + + let outcome = vm + .dispatch_key_event(event_name, key, code, text, 0, false, true) + .expect("character text should dispatch"); + + assert_eq!( + vm.eval("editor.value") + .expect("control value should evaluate"), + expected_value, + "control={control}, event_name={event_name}, key={key:?}, text={text:?}" + ); + assert_eq!( + vm.eval("__events.join('|')") + .expect("text insertion events should evaluate"), + "beforeinput|input" + ); + assert!(!outcome.triggered_top_level_navigation); + assert!(vm.take_pending_location_navigation_with_seed().is_none()); + } + } + } +} + +#[test] +fn standalone_carriage_return_character_preserves_enter_default_actions() { + for (control, expected_value, expected_submit_count) in [ + (r#""#, "", 1), + (r#""#, "\n", 0), + ] { + let mut vm = new_parsed_test_vm( + "https://standalone-carriage-return.test/", + &format!(r#"
{control}
"#), + ); + vm.eval( + r#" +window.__submitCount = 0; +document.querySelector('form').addEventListener('submit', event => { + __submitCount++; + event.preventDefault(); +}); +document.getElementById('editor').focus(); +"#, + ) + .expect("standalone carriage return fixture should initialize"); + + let outcome = vm + .dispatch_key_event("keypress", "", "", "\r", 0, false, true) + .expect("keyless Enter character phase should dispatch"); + + assert_eq!( + vm.eval("document.getElementById('editor').value") + .expect("control value should evaluate"), + expected_value, + "control={control}" + ); + assert_eq!( + vm.eval("String(__submitCount)") + .expect("submit count should evaluate"), + expected_submit_count.to_string(), + "control={control}" + ); + assert!(!outcome.triggered_top_level_navigation); + assert!(vm.take_pending_location_navigation_with_seed().is_none()); + } +} + #[test] fn empty_get_form_submit_replaces_existing_action_query() { let mut vm = new_storage_test_vm("https://form-empty-get.test/path/index.html"); diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms/event_activation.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms/event_activation.rs index d5c20a785db..43e5668419b 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms/event_activation.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms/event_activation.rs @@ -1,5 +1,176 @@ use super::*; +#[test] +fn script_control_click_preserves_focus_and_pending_text_commit() { + for dispatch in [false, true] { + for target in [ + "button", "submit", "text", "check", "radio", "select", "textarea", + ] { + let mut vm = new_parsed_test_vm( + "https://script-click-focus.test/", + r#"
+ + + +
"#, + ); + vm.eval( + r#"window.events = []; window.submits = []; window.clicks = []; +const origin = document.getElementById('origin'); +for (const type of ['change', 'blur']) origin.addEventListener(type, () => events.push(type)); +document.addEventListener('click', e => clicks.push([e.target.id, e.isTrusted, e.detail])); +document.querySelector('form').addEventListener('submit', e => {e.preventDefault(); submits.push(e.submitter.id)}); +origin.focus();"#, + ) + .expect("script activation fixture should initialize"); + vm.dispatch_key_event("keydown", "e", "KeyE", "edited", 0, false, true) + .expect("actual text input should leave a pending change commit"); + let result = vm + .eval(&format!( + r#"(() => {{ +const target = document.getElementById('{target}'); +if ({dispatch}) target.dispatchEvent(new MouseEvent('click', {{bubbles:true, cancelable:true}})); +else target.click(); +return JSON.stringify({{active:document.activeElement.id, events, clicks, submits, +checked:document.getElementById('check').checked, radio:document.getElementById('radio').checked}}); +}})()"# + )) + .expect("script activation should evaluate"); + let actual: serde_json::Value = serde_json::from_str(&result).unwrap(); + let submits = if target == "submit" { + vec!["submit"] + } else { + vec![] + }; + assert_eq!( + actual, + serde_json::json!({"active":"origin", "events":[], + "clicks":[[target,false,0]], "submits":submits, + "checked":target == "check", "radio":target == "radio"}), + "target={target}, dispatch={dispatch}" + ); + assert_eq!( + vm.eval("origin.blur(); events.join('|')").unwrap(), + "change|blur", + "explicit blur must still commit the edited origin" + ); + } + } +} + +#[test] +fn canceled_script_click_keeps_focus_and_rolls_back_activation() { + for dispatch in [false, true] { + for target in ["check", "radio", "submit"] { + let mut vm = new_parsed_test_vm( + "https://script-click-cancel-focus.test/", + r#"
+ +
"#, + ); + let result = vm.eval(&format!(r#"(() => {{ +const origin = document.getElementById('origin'); const target = document.getElementById('{target}'); +const seen = []; const submissions = []; +document.querySelector('form').addEventListener('submit', e => {{e.preventDefault(); submissions.push(e.submitter.id)}}); +target.addEventListener('click', e => {{seen.push([target.checked, e.isTrusted]); e.preventDefault()}}); +origin.focus(); +if ({dispatch}) target.dispatchEvent(new MouseEvent('click', {{bubbles:true,cancelable:true}})); else target.click(); +return JSON.stringify({{active:document.activeElement.id, checked:target.checked, seen, submissions}}); +}})()"#)).unwrap(); + let actual: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!( + actual, + serde_json::json!({"active":"origin", "checked":false, + "seen":[[target != "submit",false]], "submissions":[]}), + "target={target}, dispatch={dispatch}" + ); + } + } +} + +#[test] +fn script_label_activation_keeps_its_independent_control_focus_rule() { + for dispatch in [false, true] { + for target in ["label", "nested"] { + let mut vm = new_parsed_test_vm( + "https://script-label-focus.test/", + r#""#, + ); + let result = vm.eval(&format!(r#"(() => {{ +const origin = document.getElementById('origin'); const check = document.getElementById('check'); +const target = document.getElementById('{target}'); const seen = []; +check.addEventListener('click', () => seen.push(document.activeElement === check)); +origin.focus(); +if ({dispatch}) target.dispatchEvent(new MouseEvent('click', {{bubbles:true,cancelable:true}})); else target.click(); +return JSON.stringify({{active:document.activeElement.id, checked:check.checked, seen}}); +}})()"#)).unwrap(); + assert_eq!( + result, r#"{"active":"check","checked":true,"seen":[true]}"#, + "target={target}, dispatch={dispatch}" + ); + } + } +} + +#[test] +fn pointer_release_does_not_reapply_canceled_mouse_focus() { + for canceled_event in ["none", "mousedown", "pointerdown"] { + for label in [false, true] { + let target = if label { "label" } else { "button" }; + let mut vm = new_parsed_test_vm( + "https://pointer-focus-ownership.test/", + r#" + + +"#, + ); + vm.eval(&format!( + r#"window.events = []; window.clicks = []; +const origin = document.getElementById('origin'); +for (const type of ['change','blur']) origin.addEventListener(type, () => events.push(type)); +document.addEventListener('click', e => clicks.push(e.target.id)); +document.getElementById('{target}').addEventListener('{canceled_event}', e => e.preventDefault()); +origin.focus();"# + )) + .unwrap(); + vm.dispatch_key_event("keydown", "e", "KeyE", "edited", 0, false, true) + .expect("text edit should create a pending commit"); + let y = if label { 85.0 } else { 25.0 }; + vm.dispatch_mouse_event_at_point(25.0, y, "mousedown", 0, None, 0.0, 0.0) + .expect("pointer down should dispatch"); + vm.dispatch_mouse_event_at_point(25.0, y, "mouseup", 0, None, 0.0, 0.0) + .expect("pointer up and click should dispatch"); + let result = vm.eval("JSON.stringify({active:document.activeElement.id, events, clicks, checked:document.getElementById('check').checked})").unwrap(); + let actual: serde_json::Value = serde_json::from_str(&result).unwrap(); + let moves_focus = label || canceled_event == "none"; + let events = if moves_focus { + vec!["change", "blur"] + } else { + vec![] + }; + let clicks = if label { + vec!["label", "check"] + } else { + vec!["button"] + }; + let active = if label { + "check" + } else if moves_focus { + "button" + } else { + "origin" + }; + assert_eq!( + actual, + serde_json::json!({"active":active, "events":events, + "clicks":clicks, "checked":label}), + "target={target}, canceled={canceled_event}" + ); + } + } +} + #[test] fn dispatched_bubbling_child_click_uses_ancestor_button_activation_behavior() { let mut vm = new_storage_test_vm("https://button-child-dispatched-click.test/"); diff --git a/moli-renderer-v8/src/script_vm/tests/element_click.rs b/moli-renderer-v8/src/script_vm/tests/element_click.rs new file mode 100644 index 00000000000..ad0e60d3d87 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/element_click.rs @@ -0,0 +1,132 @@ +use super::*; +use crate::runtime::{RendererElementClickTarget, RendererPointerEventProperties}; + +#[test] +fn native_element_click_cold_preparation_keeps_existing_layout_cost() { + let markup = r#" + + "#; + let mut reference = new_parsed_test_vm("https://click-js-geometry.test/", markup); + let before = reference.layout_pass_observability_for_test().1; + // The previous preflight's public geometry operations define the budget. + reference + .eval( + r#"(() => { + const target=document.getElementById('target'); + target.scrollIntoView({block:'center',inline:'center',behavior:'instant'}); + const rect=target.getClientRects()[0]; + return document.elementFromPoint((rect.left+rect.right)/2,(rect.top+rect.bottom)/2).id; + })()"#, + ) + .expect("existing geometry preparation"); + let existing_passes = reference.layout_pass_observability_for_test().1 - before; + + let mut vm = new_parsed_test_vm("https://click-native-geometry.test/", markup); + let target = vm.document_runtime.get_element_by_id("target").unwrap(); + let before = vm.layout_pass_observability_for_test().1; + assert!(matches!( + vm.prepare_element_click(target).unwrap(), + RendererElementClickTarget::Pointer(_) + )); + assert!( + vm.layout_pass_observability_for_test().1 - before <= existing_passes, + "native preparation must not increase the existing cold layout cost" + ); +} + +#[test] +fn native_element_click_reuses_frozen_layout_after_dom_and_style_changes() { + for mutation in [ + "target.style.left='300px'", + "const veil=document.createElement('div');veil.style.cssText='position:fixed;inset:0;z-index:999';document.body.appendChild(veil)", + "const style=document.createElement('style');document.head.appendChild(style);style.sheet.insertRule('button {pointer-events:none}')", + ] { + let mut reference = None; + for native_sequence in [false, true] { + let mut vm = new_parsed_test_vm( + "https://click-snapshot.test/", + r#" + + "#, + ); + vm.eval( + r#"window.clicks=0;window.events=[]; + document.getElementById('target').onclick=()=>clicks++; + for(const type of ['mousemove','mousedown','mouseup','click']) + document.addEventListener(type,e=>events.push([type,e.target.id]));"#, + ) + .expect("install click listener"); + let target = vm.document_runtime.get_element_by_id("target").unwrap(); + let before = vm.layout_pass_observability_for_test().1; + vm.eval("document.elementFromPoint(100,75)") + .expect("populate the existing hit-test snapshot"); + assert_eq!(vm.layout_pass_observability_for_test().1, before + 1); + let RendererElementClickTarget::Pointer(first) = + vm.prepare_element_click(target).unwrap() + else { + panic!("real layout must prepare pointer input"); + }; + assert_eq!(vm.layout_pass_observability_for_test().1, before + 1); + + vm.eval(&format!( + "(()=>{{const target=document.getElementById('target');{mutation}}})()" + )) + .expect("mutate live DOM/style without requesting a fresh snapshot"); + let RendererElementClickTarget::Pointer(click) = + vm.prepare_element_click(target).unwrap() + else { + panic!("real layout must prepare pointer input"); + }; + assert_eq!((click.root_x, click.root_y), (first.root_x, first.root_y)); + assert_eq!( + vm.layout_pass_observability_for_test().1, + before + 1, + "warm preparation must reuse the frozen tree: {mutation}" + ); + if native_sequence { + vm.dispatch_prepared_element_click(click).unwrap(); + } else { + // Compare to the existing independent pointer phases. Their hover + // transitions can invalidate layout; preparation must not add more. + for (event, buttons) in [("mousemove", 0), ("mousedown", 1), ("mouseup", 0)] { + vm.dispatch_mouse_event_at_point_with_pointer( + click.root_x, + click.root_y, + event, + 0, + Some(buttons), + i32::from(event != "mousemove"), + 0.0, + 0.0, + RendererPointerEventProperties::default(), + ) + .unwrap(); + } + } + let passes = vm.layout_pass_observability_for_test().1 - before; + let events = vm.eval("JSON.stringify([clicks,events])").unwrap(); + if let Some((existing_passes, existing_events)) = &reference { + assert!(passes <= *existing_passes, "extra layout for {mutation}"); + assert_eq!(&events, existing_events, "{mutation}"); + } else { + reference = Some((passes, events)); + } + } + } +} + +#[test] +fn native_element_click_mock_preparation_does_not_build_layout() { + let mut vm = new_parsed_test_vm( + "https://click-mock.test/", + "", + ); + vm.set_layout_policy(moli_page_types::LayoutPolicy::Mock); + let target = vm.document_runtime.get_element_by_id("target").unwrap(); + let before = vm.layout_pass_observability_for_test().1; + assert_eq!( + vm.prepare_element_click(target).unwrap(), + RendererElementClickTarget::DomActivation + ); + assert_eq!(vm.layout_pass_observability_for_test().1, before); +} diff --git a/moli-renderer-v8/src/script_vm/tests/mod.rs b/moli-renderer-v8/src/script_vm/tests/mod.rs index 72dedf02600..e7173179e2d 100644 --- a/moli-renderer-v8/src/script_vm/tests/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/mod.rs @@ -15120,6 +15120,7 @@ mod canvas_webgl; mod child_dynamic_inline_scripts; mod dom_elements; mod dom_xhr; +mod element_click; mod http_fixture; mod indexed_db; mod inspector_unwrap;