diff --git a/moli-cdp-smoke/moli_cdp_smoke/groups/dom_input.py b/moli-cdp-smoke/moli_cdp_smoke/groups/dom_input.py index 44dac500255..cc8d140091e 100644 --- a/moli-cdp-smoke/moli_cdp_smoke/groups/dom_input.py +++ b/moli-cdp-smoke/moli_cdp_smoke/groups/dom_input.py @@ -9,6 +9,7 @@ from . import SmokeState from ..assertions import SmokeError, assert_equal, wait_until +from ..progress import await_with_progress async def run_dom_input_group(state: SmokeState) -> None: @@ -192,7 +193,11 @@ async def run_dom_input_group(state: SmokeState) -> None: await run_playwright_expect_matcher_workflows(state) await run_locator_composition_workflows(state) await run_keyboard_editing_workflows(state) - await run_keypress_dispatch_workflow(state) + await await_with_progress( + "scenario/playwright-and-cdp-keypress", + run_keypress_dispatch_workflow(state), + timeout_seconds=30, + ) await run_cdp_control_key_name_workflow(state) await run_cdp_input_navigation_replacement_workflows(state) await run_mouse_event_workflows(state) @@ -976,10 +981,10 @@ async def run_keyboard_editing_workflows(state: SmokeState) -> None: async def run_keypress_dispatch_workflow(state: SmokeState) -> None: - page = await state.context.new_page() + page = await await_with_progress("keypress/new-page", state.context.new_page()) session = None try: - await page.set_content(""" + await await_with_progress("keypress/set-content", page.set_content(""" - """) - session = await state.context.new_cdp_session(page) + """)) + session = await await_with_progress( + "keypress/new-cdp-session", state.context.new_cdp_session(page), + ) full_sequence = ["keydown", "keypress", "beforeinput", "input", "keyup"] async def reset(cancel_at: bool = False) -> None: @@ -1045,8 +1052,8 @@ async def check(value: str, event_types: list[str], label: str) -> None: state.record("playwright_and_cdp_keypress_dispatch") finally: if session is not None: - await session.detach() - await page.close() + await await_with_progress("keypress/detach", session.detach(), timeout_seconds=5) + await await_with_progress("keypress/close-page", page.close(), timeout_seconds=5) async def run_cdp_control_key_name_workflow(state: SmokeState) -> None: diff --git a/moli-protocol-server/src/protocol_server/tests/classic.rs b/moli-protocol-server/src/protocol_server/tests/classic.rs index 84a3ea0be7d..69beb69ef98 100644 --- a/moli-protocol-server/src/protocol_server/tests/classic.rs +++ b/moli-protocol-server/src/protocol_server/tests/classic.rs @@ -11129,6 +11129,84 @@ async fn webdriver_classic_execute_async_honors_script_timeout() { .await; } +#[tokio::test] +async fn webdriver_classic_click_respects_dom_first_and_real_layout_policies() { + for policy in [LayoutPolicy::Mock, LayoutPolicy::OnDemand] { + let state = AppState::new_with_storage_partition_and_runtime_config( + "127.0.0.1:9222".parse().unwrap(), + Arc::new(StoragePartitionState::open(None).unwrap()), + NavigationRuntimeConfig::new( + protocol_server_test_fetch_config(FetchConfig::default()), + OptionalResourceFetchMask::NONE, + true, + policy, + ), + crate::config::DEFAULT_SCREENCAST_INTERVAL_MS, + ) + .unwrap(); + let app = build_router(state); + let session = classic_request_json(app.clone(), Method::POST, "/session").await; + let session_id = session["value"]["sessionId"].as_str().unwrap(); + let navigated = classic_request_json_with_body( + app.clone(), Method::POST, &format!("/session/{session_id}/url"), + json!({"url": "data:text/html,"}), + ).await; + assert_eq!(navigated, json!({"value": null})); + let element_id = classic_find_css_element_id(app.clone(), session_id, "#target").await; + let clicked = classic_request_json( + app.clone(), + Method::POST, + &format!("/session/{session_id}/element/{element_id}/click"), + ) + .await; + assert_eq!(clicked, json!({"value": null}), "{policy:?}"); + let observed = classic_request_json_with_body( + app.clone(), + Method::POST, + &format!("/session/{session_id}/execute/sync"), + json!({"script": "return window.events;", "args": []}), + ) + .await; + let expected = match policy { + LayoutPolicy::Mock => json!(["click"]), + LayoutPolicy::OnDemand => json!(["pointerdown", "mousedown", "mouseup", "click"]), + }; + assert_eq!(observed["value"], expected, "{policy:?}"); + classic_request_json_with_body( + app.clone(), Method::POST, &format!("/session/{session_id}/url"), + json!({"url": "data:text/html,"}), + ).await; + let target = classic_find_css_element_id(app.clone(), session_id, "#target").await; + assert_eq!( + classic_request_json( + app.clone(), + Method::POST, + &format!("/session/{session_id}/element/{target}/click"), + ) + .await, + json!({"value":null}) + ); + assert_eq!( + classic_request_json_with_body( + app.clone(), + Method::POST, + &format!("/session/{session_id}/actions"), + json!({"actions":[{"type":"key","id":"keyboard","actions":[ + {"type":"keyDown","value":"x"},{"type":"keyUp","value":"x"} + ]}]}), + ) + .await, + json!({"value":null}) + ); + let focused = classic_request_json_with_body( + app.clone(), Method::POST, &format!("/session/{session_id}/execute/sync"), + json!({"script":"return [document.activeElement.id,document.getElementById('origin').value,document.getElementById('target').value];","args":[]}), + ).await; + assert_eq!(focused["value"], json!(["target", "", "x"]), "{policy:?}"); + classic_request_json(app, Method::DELETE, &format!("/session/{session_id}")).await; + } +} + #[tokio::test] async fn webdriver_classic_element_click_uses_shared_dom_geometry_and_input() { let app = build_router(test_state()); @@ -11137,7 +11215,7 @@ async fn webdriver_classic_element_click_uses_shared_dom_geometry_and_input() { let session_id = session["value"]["sessionId"] .as_str() .expect("classic session id"); - let url = "data:text/html,"; + let url = "data:text/html,"; let navigated = classic_request_json_with_body( app.clone(), @@ -11175,12 +11253,18 @@ async fn webdriver_classic_element_click_uses_shared_dom_geometry_and_input() { Method::POST, &format!("/session/{session_id}/execute/sync"), json!({ - "script": "return Boolean(window.__clicked);", + "script": "return [Boolean(window.__clicked),document.activeElement.id,window.events];", "args": [] }), ) .await; - assert_eq!(clicked_state, json!({ "value": true })); + assert_eq!( + clicked_state, + json!({ "value": [true, "target", [ + ["pointerdown", true], ["mousedown", true], ["focus", true], + ["mouseup", true], ["click", true] + ]] }) + ); let _ = classic_request_json( app.clone(), @@ -11190,6 +11274,414 @@ async fn webdriver_classic_element_click_uses_shared_dom_geometry_and_input() { .await; } +#[tokio::test] +async fn webdriver_classic_click_pointer_focus_and_interactability_controls() { + let app = build_router(test_state()); + let session = classic_request_json(app.clone(), Method::POST, "/session").await; + let session_id = session["value"]["sessionId"].as_str().expect("session id"); + for (name, target, setup, expected_focus, expected_clicks, expected_error) in [ + ("ordinary", "", "", "target", 1, None), + ( + "clipped fixed button", + "", + "", + "target", + 1, + None, + ), + ( + "entirely outside viewport", + "", + "", + "origin", + 0, + Some("element not interactable"), + ), + ( + "cancel mousedown", + "", + "target.onmousedown=e=>e.preventDefault();", + "origin", + 1, + None, + ), + ( + "cancel pointerdown", + "", + "target.onpointerdown=e=>e.preventDefault();", + "origin", + 1, + None, + ), + ( + "label", + "", + "", + "check", + 1, + None, + ), + ( + "disabled", + "", + "", + "origin", + 0, + None, + ), + ( + "scroll", + "", + "", + "target", + 1, + None, + ), + ( + "file", + "", + "", + "origin", + 0, + Some("invalid argument"), + ), + ( + "hidden", + "", + "", + "origin", + 0, + Some("element not interactable"), + ), + ( + "obscured", + "
", + "", + "origin", + 0, + Some("element click intercepted"), + ), + ] { + let html = format!( + "{target}" + ); + let navigated = classic_request_json_with_body( + app.clone(), + Method::POST, + &format!("/session/{session_id}/url"), + json!({"url":format!("data:text/html,{html}")}), + ) + .await; + assert_eq!(navigated, json!({"value":null}), "{name}"); + let element_id = classic_find_css_element_id(app.clone(), session_id, "#target").await; + let (status, clicked) = classic_request_status_and_json( + app.clone(), + Method::POST, + &format!("/session/{session_id}/element/{element_id}/click"), + ) + .await; + if let Some(error) = expected_error { + assert_eq!(status, StatusCode::BAD_REQUEST, "{name}: {clicked}"); + assert_eq!(clicked["value"]["error"], error, "{name}"); + } else { + assert_eq!(clicked, json!({"value":null}), "{name}"); + } + let observed = classic_request_json_with_body( + app.clone(), + Method::POST, + &format!("/session/{session_id}/execute/sync"), + json!({ + "script":"return [document.activeElement.id,window.clicks];", "args":[] + }), + ) + .await; + assert_eq!(observed["value"][1], expected_clicks, "{name}"); + // Disabled controls must not be rejected or activated. Their focus + // result is not the behavior this adapter correction changes. + if name != "disabled" { + assert_eq!(observed["value"][0], expected_focus, "{name}"); + } + } + // execute/sync remains a JavaScript operation, even with user_gesture set. + classic_request_json_with_body(app.clone(), Method::POST, + &format!("/session/{session_id}/url"), json!({ + "url":"data:text/html," + })).await; + let synthetic = classic_request_json_with_body(app.clone(), Method::POST, + &format!("/session/{session_id}/execute/sync"), json!({ + "script":"let events=[];for(const type of ['pointerdown','mousedown','focus','mouseup','click'])target.addEventListener(type,e=>events.push([e.type,e.isTrusted]));target.click();return [document.activeElement.id,events];", "args":[] + })).await; + assert_eq!(synthetic, json!({"value":["origin",[["click",false]]]})); + classic_request_json(app, Method::DELETE, &format!("/session/{session_id}")).await; +} + +#[tokio::test] +async fn webdriver_classic_click_uses_top_level_pointer_coordinates_inside_offset_frame() { + for transform in ["none", "scale(0.75)", "rotate(12deg)"] { + let app = build_router(test_state()); + let session = classic_request_json(app.clone(), Method::POST, "/session").await; + let session_id = session["value"]["sessionId"].as_str().expect("session id"); + let html = format!( + "" + ); + classic_request_json_with_body( + app.clone(), + Method::POST, + &format!("/session/{session_id}/url"), + json!({"url":format!("data:text/html,{html}")}), + ) + .await; + assert_eq!( + classic_request_json_with_body( + app.clone(), + Method::POST, + &format!("/session/{session_id}/frame"), + json!({"id":0}) + ) + .await, + json!({"value":null}) + ); + let element_id = classic_find_css_element_id(app.clone(), session_id, "#target").await; + assert_eq!( + classic_request_json( + app.clone(), + Method::POST, + &format!("/session/{session_id}/element/{element_id}/click") + ) + .await, + json!({"value":null}) + ); + let observed = classic_request_json_with_body( + app.clone(), + Method::POST, + &format!("/session/{session_id}/execute/sync"), + json!({ + "script":"return [document.activeElement.id,window.clicks,window.trusted];", "args":[] + }), + ) + .await; + assert_eq!(observed, json!({"value":["target",1,true]}), "{transform}"); + classic_request_json(app, Method::DELETE, &format!("/session/{session_id}")).await; + } +} + +#[tokio::test] +async fn webdriver_classic_frame_target_click_is_invariant_under_neutral_frame_wrapping() { + for depth in 0..=2 { + for selector in ["#target", "#container"] { + let app = build_router(test_state()); + let session = classic_request_json(app.clone(), Method::POST, "/session").await; + let session_id = session["value"]["sessionId"].as_str().unwrap(); + let mut html = "
".to_owned(); + for _ in 0..depth { + let child = html.replace('&', "&").replace('"', """); + html = format!( + "" + ); + } + classic_request_json_with_body( + app.clone(), + Method::POST, + &format!("/session/{session_id}/url"), + json!({"url":classic_data_url(&html)}), + ) + .await; + for _ in 0..depth { + classic_request_json_with_body( + app.clone(), + Method::POST, + &format!("/session/{session_id}/frame"), + json!({"id":0}), + ) + .await; + } + let target = classic_find_css_element_id(app.clone(), session_id, selector).await; + let (status, clicked) = classic_request_status_and_json( + app.clone(), + Method::POST, + &format!("/session/{session_id}/element/{target}/click"), + ) + .await; + assert_eq!( + status, + StatusCode::OK, + "depth={depth} target={selector}: {clicked}" + ); + assert_eq!(clicked, json!({"value":null})); + let observed = classic_request_json_with_body( + app.clone(), Method::POST, &format!("/session/{session_id}/execute/sync"), + json!({"script":"return document.getElementById('target').contentWindow.clicks;","args":[]}), + ).await; + assert_eq!(observed["value"], 1, "depth={depth} target={selector}"); + // Another child of the same document is not a descendant of the + // requested target. It must not become an accepted deep hit. + classic_request_json_with_body( + app.clone(), Method::POST, &format!("/session/{session_id}/execute/sync"), + json!({"script":r#" + window.overlayClicks=0; + const veil=document.createElement('iframe'); + veil.style.cssText='position:fixed;inset:0;width:100%;height:100%;border:0;z-index:999'; + veil.srcdoc='overlay'; + document.body.appendChild(veil); + "#,"args":[]}), + ).await; + let (status, blocked) = classic_request_status_and_json( + app.clone(), + Method::POST, + &format!("/session/{session_id}/element/{target}/click"), + ) + .await; + assert_eq!( + status, + StatusCode::BAD_REQUEST, + "depth={depth} target={selector}: {blocked}" + ); + assert_eq!(blocked["value"]["error"], "element click intercepted"); + let observed = classic_request_json_with_body( + app.clone(), Method::POST, &format!("/session/{session_id}/execute/sync"), + json!({"script":"return [document.getElementById('target').contentWindow.clicks,window.overlayClicks];","args":[]}), + ).await; + assert_eq!( + observed["value"], + json!([1, 0]), + "rejected clicks must have no effect" + ); + classic_request_json(app, Method::DELETE, &format!("/session/{session_id}")).await; + } + } +} + +#[tokio::test] +async fn webdriver_classic_frame_click_rejects_ancestor_overlays_without_dispatch() { + for (depth, blocked_level) in [(1, 0), (2, 0), (2, 1)] { + let app = build_router(test_state()); + let session = classic_request_json(app.clone(), Method::POST, "/session").await; + let session_id = session["value"]["sessionId"].as_str().unwrap(); + let mut html = "".to_owned(); + for _ in 0..depth { + let child = html.replace('&', "&").replace('"', """); + html = format!( + "" + ); + } + classic_request_json_with_body( + app.clone(), + Method::POST, + &format!("/session/{session_id}/url"), + json!({"url":classic_data_url(&html)}), + ) + .await; + for _ in 0..depth { + assert_eq!( + classic_request_json_with_body( + app.clone(), + Method::POST, + &format!("/session/{session_id}/frame"), + json!({"id":0}), + ) + .await, + json!({"value":null}) + ); + } + let target = classic_find_css_element_id(app.clone(), session_id, "#target").await; + let overlay_script = format!( + "let w=window;for(let i=0;i<{};i++)w=w.parent;w.document.getElementById('veil').style.display=arguments[0];", + depth - blocked_level + ); + let read_script = "let w=window,total=0;while(w!==w.parent){w=w.parent;total+=w.overlayClicks;}return [window.clicks,total];"; + for blocked in [true, false] { + classic_request_json_with_body( + app.clone(), + Method::POST, + &format!("/session/{session_id}/execute/sync"), + json!({"script":overlay_script,"args":[if blocked {"block"} else {"none"}]}), + ) + .await; + let (status, clicked) = classic_request_status_and_json( + app.clone(), + Method::POST, + &format!("/session/{session_id}/element/{target}/click"), + ) + .await; + if blocked { + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + clicked["value"]["error"], "element click intercepted", + "depth={depth} level={blocked_level}: {clicked}" + ); + } else { + assert_eq!(status, StatusCode::OK); + assert_eq!(clicked, json!({"value":null})); + } + let observed = classic_request_json_with_body( + app.clone(), + Method::POST, + &format!("/session/{session_id}/execute/sync"), + json!({"script":read_script,"args":[]}), + ) + .await; + assert_eq!(observed["value"], json!([if blocked { 0 } else { 1 }, 0])); + } + classic_request_json(app, Method::DELETE, &format!("/session/{session_id}")).await; + } +} + +#[tokio::test] +async fn webdriver_classic_click_mousedown_navigation_does_not_activate_successor() { + let app = build_router(test_state()); + let session = classic_request_json(app.clone(), Method::POST, "/session").await; + let session_id = session["value"]["sessionId"].as_str().expect("session id"); + let successor = classic_data_url( + "", + ); + let source = classic_data_url(&format!( + "" + )); + classic_request_json_with_body( + app.clone(), + Method::POST, + &format!("/session/{session_id}/url"), + json!({"url":source}), + ) + .await; + let element_id = classic_find_css_element_id(app.clone(), session_id, "#target").await; + let clicked = classic_request_json( + app.clone(), + Method::POST, + &format!("/session/{session_id}/element/{element_id}/click"), + ) + .await; + // A complete physical sequence may navigate during mousedown; no input + // phase is retried against the successor document. + assert_eq!(clicked, json!({"value":null})); + let observed = classic_request_json_with_body( + app.clone(), + Method::POST, + &format!("/session/{session_id}/execute/sync"), + json!({ + "script":"return window.clicks;", "args":[] + }), + ) + .await; + assert_eq!(observed, json!({"value":0})); + let successor_id = classic_find_css_element_id(app.clone(), session_id, "#target").await; + assert_eq!( + classic_request_json( + app.clone(), + Method::POST, + &format!("/session/{session_id}/element/{successor_id}/click") + ) + .await, + json!({"value":null}) + ); + let observed = classic_request_json_with_body(app.clone(), Method::POST, + &format!("/session/{session_id}/execute/sync"), json!({ + "script":"return [window.clicks,document.activeElement.id,window.buttons.every(b=>b===0)];", "args":[] + })).await; + assert_eq!(observed, json!({"value":[1,"target",true]})); + classic_request_json(app, Method::DELETE, &format!("/session/{session_id}")).await; +} + #[tokio::test] async fn webdriver_classic_anchor_target_blank_click_opens_window_handle() { // Mirrors ChromeDriver's link-click new window smoke: @@ -11532,6 +12024,14 @@ async fn webdriver_classic_option_click_updates_select_state_ported_from_seleniu ) .await; assert_eq!(clicked_brie, json!({ "value": null })); + let focused_select = classic_request_json_with_body( + app.clone(), + Method::POST, + &format!("/session/{session_id}/execute/sync"), + json!({"script":"return document.activeElement.id;", "args":[]}), + ) + .await; + assert_eq!(focused_select, json!({"value":"single"})); let clicked_ham = classic_request_json( app.clone(), Method::POST, @@ -12012,6 +12512,71 @@ async fn webdriver_classic_option_click_edges_ported_from_chromium_wpt() { .await; } +#[tokio::test] +async fn webdriver_classic_enter_inserts_newline_and_submits_through_both_key_routes() { + let app = build_router(test_state()); + let session = classic_request_json(app.clone(), Method::POST, "/session").await; + let session_id = session["value"]["sessionId"].as_str().unwrap(); + for key in ["\u{E006}", "\u{E007}"] { + for use_actions in [false, true] { + for (target, expected) in [ + ("", json!(["a\nb", 0])), + ("", json!(["ab", 1])), + ] { + let html = format!( + "
{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..86b8753bd1b 100644 --- a/moli-protocol-server/src/protocol_server/webdriver_classic.rs +++ b/moli-protocol-server/src/protocol_server/webdriver_classic.rs @@ -16,17 +16,18 @@ use moli_html_input_type::InputType; use moli_protocol::{ DevToolsPageResidenceIdentity, devtools_runtime::{ - DevToolsCommand, DevToolsCommandContext, DevToolsCommandResult, DevToolsDomGeometryResult, - DevToolsDomNodeReference, DevToolsDownloadBehaviorSetting, DevToolsError, - DevToolsErrorKind, DevToolsFrameId, DevToolsGetFrameOwnerCommand, - DevToolsGetFrameOwnerResult, DevToolsGetRealmsCommand, DevToolsGetRealmsResult, + DevToolsCommand, DevToolsCommandContext, DevToolsCommandResult, DevToolsDomGeometryCommand, + DevToolsDomGeometryOperation, DevToolsDomGeometryResult, DevToolsDomNodeReference, + DevToolsDownloadBehaviorSetting, DevToolsError, DevToolsErrorKind, DevToolsFrameId, + DevToolsGetFrameOwnerCommand, DevToolsGetFrameOwnerResult, + DevToolsGetNodeForLocationCommand, DevToolsGetRealmsCommand, DevToolsGetRealmsResult, DevToolsGetServiceWorkerLogsCommand, DevToolsGetServiceWorkerLogsResult, DevToolsGetTargetsCommand, DevToolsGetTargetsResult, DevToolsLayoutMetricsResult, - DevToolsLocateNodesResult, DevToolsProtocol, DevToolsQuerySelectorResult, - DevToolsRemoteHandleId, DevToolsRemoteValue, DevToolsScriptException, DevToolsScriptResult, - DevToolsSessionId, DevToolsSetDownloadBehaviorCommand, DevToolsSetFileInputFilesCommand, - DevToolsTargetId, DevToolsTargetInfo, DevToolsTargetKind, RuntimeConsoleEvent, - RuntimeExecutionContextEvent, + DevToolsLocateNodesResult, DevToolsMouseEventType, DevToolsProtocol, + DevToolsQuerySelectorResult, DevToolsRemoteHandleId, DevToolsRemoteValue, + DevToolsScriptException, DevToolsScriptResult, DevToolsSessionId, + DevToolsSetDownloadBehaviorCommand, DevToolsSetFileInputFilesCommand, DevToolsTargetId, + DevToolsTargetInfo, DevToolsTargetKind, RuntimeConsoleEvent, RuntimeExecutionContextEvent, }, version, }; @@ -35,18 +36,19 @@ use moli_protocol_webdriver_classic::{ CLASSIC_WINDOW_REFERENCE_KEY, ClassicDevToolsCommandContext, ClassicElementOriginViewportPoints, ClassicError, ClassicErrorCode, ClassicPageLoadStrategy, ClassicPromptHandler, ClassicUnhandledPromptBehavior, ClassicViewportBounds, - action_element_origin_ids, active_element_command, alert_handle_command, alert_text_command, - classic_attribute_value, classic_error_from_devtools_error, classic_property_value, - classic_rect_from_geometry, classic_shadow_root_reference, classic_text_value, - clear_element_command, create_initial_target_command, current_url_command, + ClassicViewportPoint, action_element_origin_ids, active_element_command, alert_handle_command, + alert_text_command, classic_attribute_value, classic_error_from_devtools_error, + classic_property_value, classic_rect_from_geometry, classic_shadow_root_reference, + classic_text_value, clear_element_command, create_initial_target_command, current_url_command, delete_session_response, describe_node_command, describe_node_reference_command, element_center_from_geometry, element_click_command, element_click_input_commands, - element_click_prepare_reference_commands, element_screenshot_command, - element_send_keys_input_commands, element_send_keys_prepare_text_control_command, - element_send_keys_text, execute_async_command, execute_sync_command, find_element_command, - find_element_command_with_root, get_element_attributes_reference_command, - get_element_computed_label_command, get_element_computed_role_command, - get_element_css_value_command, get_element_displayed_command, get_element_enabled_command, + element_click_preflight_command, element_click_prepare_reference_commands, + element_screenshot_command, element_send_keys_input_commands, + element_send_keys_prepare_text_control_command, element_send_keys_text, execute_async_command, + execute_sync_command, find_element_command, find_element_command_with_root, + get_element_attributes_reference_command, get_element_computed_label_command, + get_element_computed_role_command, get_element_css_value_command, + get_element_displayed_command, get_element_enabled_command, get_element_property_reference_command, get_element_rect_reference_command, get_element_rendered_text_command, get_element_text_reference_command, history_traversal_entry, layout_metrics_command, matched_capabilities_from_new_session_params, navigate_command, @@ -4430,10 +4432,11 @@ pub(super) async fn webdriver_classic_send_keys_to_element( ClassicSendKeysPreflight::NotTextControl ) { let input_context = classic_top_level_context(&binding); - let commands = match element_click_input_commands(&input_context, &geometry) { - Ok(commands) => commands, + let point = match element_center_from_geometry(&geometry) { + Ok(point) => point, Err(error) => return classic_error_into_response(error), }; + let commands = element_click_input_commands(&input_context, point); if let Err(error) = webdriver_classic_execute_empty_commands( &binding, commands, @@ -4515,6 +4518,158 @@ async fn webdriver_classic_prepare_text_control_for_send_keys( } } +enum ClassicClickPreparation { + Dom, + Pointer { + point: ClassicViewportPoint, + viewport: ClassicViewportBounds, + }, +} + +impl ClassicClickPreparation { + fn from_value(value: Value) -> Result { + match value.get("status").and_then(Value::as_str) { + Some("option" | "dom") => Ok(Self::Dom), + Some("file") => Err(ClassicError::new( + ClassicErrorCode::InvalidArgument, + "file inputs cannot be clicked by WebDriver", + )), + Some("not interactable") => Err(ClassicError::new( + ClassicErrorCode::ElementNotInteractable, + "element has no in-view clickable rectangle", + )), + Some("intercepted") => Err(ClassicError::new( + ClassicErrorCode::ElementClickIntercepted, + "another element obscures the element's in-view center", + )), + Some("pointer") => { + let coordinate = |name: &str| { + value + .get(name) + .and_then(Value::as_f64) + .filter(|value| value.is_finite()) + .ok_or_else(|| { + ClassicError::new( + ClassicErrorCode::UnknownError, + format!("click preflight has invalid {name}"), + ) + }) + }; + let point = ClassicViewportPoint::new(coordinate("x")?, coordinate("y")?)?; + let viewport = ClassicViewportBounds { + width: coordinate("viewport_width")?, + height: coordinate("viewport_height")?, + }; + if viewport.width <= 0.0 || viewport.height <= 0.0 { + return Err(ClassicError::new( + ClassicErrorCode::ElementNotInteractable, + "frame has an empty viewport", + )); + } + Ok(Self::Pointer { point, viewport }) + } + _ => Err(ClassicError::new( + ClassicErrorCode::UnknownError, + "element click preflight returned an invalid status", + )), + } + } +} + +async fn webdriver_classic_click_point( + binding: &ClassicSessionBinding, + point: ClassicViewportPoint, + viewport: ClassicViewportBounds, +) -> Result { + let Some(frame_id) = binding.current_frame_id.as_deref() else { + return ClassicViewportPoint::new(point.x, point.y); + }; + let owner = classic_frame_owner_dom_reference(binding, frame_id).await?; + let result = binding + .runtime + .execute_on_page( + DevToolsCommand::DomGeometry(DevToolsDomGeometryCommand { + context: classic_top_level_devtools_context(binding), + reference: owner.reference, + operation: DevToolsDomGeometryOperation::GetBoxModel, + }), + owner.page_residence.clone(), + ) + .await + .map_err(classic_error_from_devtools_error)?; + let DevToolsCommandResult::DomGeometry(geometry) = result else { + return Err(ClassicError::new( + ClassicErrorCode::UnknownError, + "frame geometry returned an unexpected result", + )); + }; + let model = geometry.box_model.ok_or_else(|| { + ClassicError::new( + ClassicErrorCode::ElementNotInteractable, + "frame has no content geometry", + ) + })?; + let [x0, y0, x1, y1, _, _, x3, y3] = model.content.points.as_slice() else { + return Err(ClassicError::new( + ClassicErrorCode::UnknownError, + "frame has invalid content geometry", + )); + }; + // The content quad already includes every ancestor-frame transform. Map + // the exact locally hit-tested point, retaining rotation/scale and borders. + let u = point.x / viewport.width; + let v = point.y / viewport.height; + let point = ClassicViewportPoint::new( + x0 + u * (x1 - x0) + v * (x3 - x0), + y0 + u * (y1 - y0) + v * (y3 - y0), + )?; + // Use the renderer's deep hit path, which enters a child document only + // when each ancestor frame owner is actually visible at this root point. + let hit = binding + .runtime + .execute_on_page( + DevToolsCommand::GetNodeForLocation(DevToolsGetNodeForLocationCommand { + context: classic_top_level_devtools_context(binding), + x: point.x, + y: point.y, + include_user_agent_shadow_dom: false, + ignore_pointer_events_none: false, + }), + owner.page_residence, + ) + .await + .map_err(classic_error_from_devtools_error)?; + let DevToolsCommandResult::GetNodeForLocation(hit) = hit else { + return Err(ClassicError::new( + ClassicErrorCode::UnknownError, + "frame hit test returned an unexpected result", + )); + }; + // Local preflight already requires the hit element to be in the target's + // subtree. A target iframe (or its container) can legitimately lead the + // deep hit into another document, so require the hit path to pass through + // the current frame, not to end there. Ancestor/sibling overlays fail this. + let mut hit_frame_id = Some(hit.frame_id.as_str().to_owned()); + while let Some(hit_frame) = hit_frame_id { + if hit_frame == frame_id { + return Ok(point); + } + hit_frame_id = binding + .runtime + .parent_frame_id( + binding.session_id.clone(), + binding.target_id.clone(), + hit_frame, + ) + .await + .map_err(classic_error_from_devtools_error)?; + } + Err(ClassicError::new( + ClassicErrorCode::ElementClickIntercepted, + "an ancestor document obscures the target frame's click point", + )) +} + async fn webdriver_classic_activate_element_by_handle( state: &AppState, binding: &ClassicSessionBinding, @@ -4532,18 +4687,98 @@ async fn webdriver_classic_activate_element_by_handle( object_group, ) .await?; - let result = binding + let prepared = binding .runtime - .execute_with_pending_navigation_wait_on_page( - element_click_command(context, object_id.object_id.clone()), - None, - binding.timeouts.page_load.map(Duration::from_millis), + .execute_on_page( + element_click_preflight_command( + context, + object_id.object_id.clone(), + state.layout_policy.uses_real_layout(), + ), object_id.page_residence.clone(), ) .await; + let preparation = match prepared { + Ok(DevToolsCommandResult::Script(result)) => match *result { + DevToolsScriptResult::Value(value) => ClassicClickPreparation::from_value(value.value), + DevToolsScriptResult::Exception(exception) => { + Err(classic_webdriver_command_exception_error(exception)) + } + }, + Ok(_) => Err(ClassicError::new( + ClassicErrorCode::UnknownError, + "element click preflight returned an unexpected result", + )), + Err(error) => Err(classic_error_from_devtools_error(error)), + }; + let mut activation_dispatched = false; + let mut pointer_press_completed = false; + let operation = async { + let preparation = preparation?; + match preparation { + ClassicClickPreparation::Dom => { + activation_dispatched = true; + Ok(binding + .runtime + .execute_on_page( + element_click_command(context, object_id.object_id.clone()), + object_id.page_residence.clone(), + ) + .await) + } + ClassicClickPreparation::Pointer { point, viewport } => { + let point = webdriver_classic_click_point(binding, point, viewport).await?; + // The preflight point has been mapped into the root viewport. + let pointer_context = classic_top_level_context(binding); + let commands = element_click_input_commands(&pointer_context, point); + let mut result = Ok(DevToolsCommandResult::Empty); + for command in commands { + let event_type = match &command { + DevToolsCommand::DispatchMouseEvent(event) => Some(event.event_type), + _ => None, + }; + activation_dispatched = event_type == Some(DevToolsMouseEventType::Released); + // Do not retry an input command or wait for navigation + // between pointer phases. Page residence is not a promise + // that the active Document cannot change during input. + result = binding + .runtime + .execute_on_page(command, object_id.page_residence.clone()) + .await; + if !matches!(result, Ok(DevToolsCommandResult::Empty)) { + break; + } + if event_type == Some(DevToolsMouseEventType::Pressed) { + pointer_press_completed = true; + } + } + Ok(result) + } + } + } + .await; release_classic_page_bound_remote_object(binding, context, object_id, object_group).await; + let result = operation?; let post_click_navigation = classic_wait_for_current_document(binding).await; match result { + Ok(DevToolsCommandResult::Empty) => { + post_click_navigation?; + if binding.current_frame_id.is_some() + && let Err(error) = ensure_classic_current_browsing_context_exists(binding).await + { + if error.code != ClassicErrorCode::NoSuchWindow { + return Err(error); + } + state.classic_session_registry.lock().set_current_frame_id( + &binding.session_id, + Some(format!( + "__moli-detached-frame__:{}", + binding.browsing_context_target_id() + )), + ); + } + Ok(()) + } Ok(DevToolsCommandResult::Script(result)) => { post_click_navigation?; match *result { @@ -4588,7 +4823,20 @@ async fn webdriver_classic_activate_element_by_handle( // Chromium references: // chrome/test/chromedriver/element_commands.cc (ExecuteClickElement) // chrome/test/chromedriver/window_commands.cc (ExecuteWindowCommand) - Err(error) if error.message == "Renderer attachment changed" => post_click_navigation, + // A completed mousedown can replace the Page before the queued + // mouseup is admitted. The page-bound guard then rejects that phase + // before dispatch, rather than losing the renderer response. Both + // outcomes must leave the successor untouched. Require a completed + // pointer press so preflight/option stale handles remain errors. + Err(error) + if activation_dispatched + && (error.message == "Renderer attachment changed" + || (pointer_press_completed + && error.kind == DevToolsErrorKind::NoSuchNode + && error.message == "DOM reference belongs to a replaced Page")) => + { + post_click_navigation + } Err(error) => Err(classic_error_from_devtools_error(error)), } } diff --git a/moli-protocol-server/src/protocol_server/webdriver_classic/helpers.rs b/moli-protocol-server/src/protocol_server/webdriver_classic/helpers.rs index a76b16b488a..9fc371d1243 100644 --- a/moli-protocol-server/src/protocol_server/webdriver_classic/helpers.rs +++ b/moli-protocol-server/src/protocol_server/webdriver_classic/helpers.rs @@ -59,7 +59,8 @@ fn classic_error_status(code: ClassicErrorCode) -> StatusCode { | ClassicErrorCode::InvalidCookieDomain | ClassicErrorCode::InvalidElementState | ClassicErrorCode::InvalidSelector - | ClassicErrorCode::ElementNotInteractable => StatusCode::BAD_REQUEST, + | ClassicErrorCode::ElementNotInteractable + | ClassicErrorCode::ElementClickIntercepted => StatusCode::BAD_REQUEST, ClassicErrorCode::InvalidSessionId | ClassicErrorCode::NoSuchAlert | ClassicErrorCode::NoSuchCookie diff --git a/moli-protocol-server/src/protocol_server/webdriver_classic/state.rs b/moli-protocol-server/src/protocol_server/webdriver_classic/state.rs index 192c02efd05..02b7b9bb0f8 100644 --- a/moli-protocol-server/src/protocol_server/webdriver_classic/state.rs +++ b/moli-protocol-server/src/protocol_server/webdriver_classic/state.rs @@ -519,24 +519,6 @@ impl ClassicSessionRuntimeHandle { .await } - pub(super) async fn execute_with_pending_navigation_wait_on_page( - &self, - command: DevToolsCommand, - timeout: Option, - pending_navigation_timeout: Option, - expected_page: DevToolsPageResidenceIdentity, - ) -> Result { - self.execute_request( - command, - timeout, - pending_navigation_timeout, - false, - Some(expected_page), - ) - .await - .result - } - pub(super) async fn wait_for_document_lifecycle( &self, context: DevToolsCommandContext, diff --git a/moli-protocol-webdriver-classic/src/actions.rs b/moli-protocol-webdriver-classic/src/actions.rs index 95f545b6343..955cfec1177 100644 --- a/moli-protocol-webdriver-classic/src/actions.rs +++ b/moli-protocol-webdriver-classic/src/actions.rs @@ -164,10 +164,18 @@ enum ClassicActionCancel { pub fn element_click_input_commands( context: &ClassicDevToolsCommandContext, - geometry: &DevToolsDomGeometryResult, -) -> Result, ClassicError> { - let point = element_center_from_geometry(geometry)?; - Ok(vec![ + point: ClassicViewportPoint, +) -> Vec { + vec![ + dispatch_mouse_event_command( + context, + DevToolsMouseEventType::Moved, + DevToolsPointerType::Mouse, + point.x, + point.y, + 0, + Some(0), + ), dispatch_mouse_event_command( context, DevToolsMouseEventType::Pressed, @@ -186,7 +194,7 @@ pub fn element_click_input_commands( 0, Some(0), ), - ]) + ] } pub fn element_send_keys_text(params: &Value) -> Result { @@ -1633,7 +1641,9 @@ fn webdriver_key_for_action_character(character: char) -> ClassicWebDriverKey { match character { '\u{E003}' => ClassicWebDriverKey::named("Backspace", "Backspace"), '\u{E004}' => ClassicWebDriverKey::named("Tab", "Tab"), - '\u{E006}' | '\u{E007}' => ClassicWebDriverKey::named("Enter", "Enter"), + '\u{E006}' | '\u{E007}' => { + ClassicWebDriverKey::text("Enter".to_owned(), "Enter", "\r".to_owned()) + } '\u{E008}' => ClassicWebDriverKey::modifier("Shift", "ShiftLeft", CLASSIC_MODIFIER_SHIFT), '\u{E009}' => { ClassicWebDriverKey::modifier("Control", "ControlLeft", CLASSIC_MODIFIER_CONTROL) diff --git a/moli-protocol-webdriver-classic/src/commands/elements.rs b/moli-protocol-webdriver-classic/src/commands/elements.rs index 7e81929b864..fa9c0cb6c6a 100644 --- a/moli-protocol-webdriver-classic/src/commands/elements.rs +++ b/moli-protocol-webdriver-classic/src/commands/elements.rs @@ -737,11 +737,63 @@ pub fn clear_element_command( }) } +/// Validate activation without dispatching it. Real-layout sessions use pointer +/// input; DOM-first sessions retain semantic activation without hit testing. +pub fn element_click_preflight_command( + context: &ClassicDevToolsCommandContext, + object_id: impl Into, + use_real_layout: bool, +) -> DevToolsCommand { + let mut command = element_click_call_function(context, object_id); + command.function_declaration = r#"function(useRealLayout) { + if (!this || this.nodeType !== Node.ELEMENT_NODE || !this.isConnected) + throw new Error('__moli_webdriver_classic_stale_element_reference__'); + if (this.localName === 'input' && this.type === 'file') + return {status: 'file'}; + // Options have a separate selection algorithm, not a pointer target + // in the page's rendered select popup. + if (this.localName === 'option') return {status: 'option'}; + if (!useRealLayout) return {status: 'dom'}; + this.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'}); + const rect = this.getClientRects()[0]; + if (!rect || rect.width <= 0 || rect.height <= 0) + return {status: 'not interactable'}; + // WebDriver's in-view center is the clipped first client rectangle, + // not the bounding box of every fragment. Return the tested point so + // dispatch cannot recompute a different center. + const view = this.ownerDocument.defaultView; + const width = view.innerWidth, height = view.innerHeight; + const left = Math.max(0, rect.left), right = Math.min(width, rect.right); + const top = Math.max(0, rect.top), bottom = Math.min(height, rect.bottom); + if (right <= left || bottom <= top) + return {status: 'not interactable'}; + const x = Math.floor((left + right) / 2), y = Math.floor((top + bottom) / 2); + const root = this.getRootNode(); + const hit = (typeof root.elementFromPoint === 'function' ? root : this.ownerDocument) + .elementFromPoint(x, y); + if (!hit || !(hit === this || this.contains(hit))) + return {status: 'intercepted'}; + return {status: 'pointer', x, y, viewport_width: width, viewport_height: height}; + }"# + .to_owned(); + command.arguments = vec![json!(use_real_layout)]; + command.await_promise = false; + command.user_gesture = false; + DevToolsCommand::CallFunction(command) +} + pub fn element_click_command( context: &ClassicDevToolsCommandContext, object_id: impl Into, ) -> DevToolsCommand { - DevToolsCommand::CallFunction(DevToolsCallFunctionCommand { + DevToolsCommand::CallFunction(element_click_call_function(context, object_id)) +} + +fn element_click_call_function( + context: &ClassicDevToolsCommandContext, + object_id: impl Into, +) -> DevToolsCallFunctionCommand { + DevToolsCallFunctionCommand { context: context.command_context(), realm_id: None, world_name: None, @@ -755,6 +807,19 @@ pub fn element_click_command( throw new Error('__moli_webdriver_classic_element_not_interactable__'); } const frameElementBeforeClick = window.frameElement || null; + // WebDriver's option branch focuses its select container. This + // is deliberately not part of ordinary HTMLElement.click(). + if (this.localName === 'option') { + const select = this.closest('select'); + if (select) { + select.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'}); + HTMLElement.prototype.focus.call(select); + } + } else { + // DOM-first WebDriver activation still moves keyboard focus. + // Keep this at the automation boundary, not in script click(). + HTMLElement.prototype.focus.call(this); + } HTMLElement.prototype.click.call(this); let detachedFrame = false; try { @@ -779,7 +844,7 @@ pub fn element_click_command( preserve_remote_metadata: false, materialize_bidi_script_result: false, serialization_options: None, - }) + } } pub fn element_click_prepare_commands( diff --git a/moli-protocol-webdriver-classic/src/commands/mod.rs b/moli-protocol-webdriver-classic/src/commands/mod.rs index e59ac9c0ed9..f720f7fd126 100644 --- a/moli-protocol-webdriver-classic/src/commands/mod.rs +++ b/moli-protocol-webdriver-classic/src/commands/mod.rs @@ -21,7 +21,7 @@ pub use elements::{ classic_elements_from_query_result, classic_property_value, classic_rect_from_geometry, classic_shadow_root_id, classic_shadow_root_reference, classic_text_value, clear_element_command, describe_node_command, describe_node_reference_command, - element_click_command, element_click_prepare_commands, + element_click_command, element_click_preflight_command, element_click_prepare_commands, element_click_prepare_reference_commands, find_element_command, find_element_command_with_root, frame_id_for_element_command, get_element_attributes_command, get_element_attributes_reference_command, get_element_computed_label_command, diff --git a/moli-protocol-webdriver-classic/src/lib.rs b/moli-protocol-webdriver-classic/src/lib.rs index 90312ce0be1..89c2880c238 100644 --- a/moli-protocol-webdriver-classic/src/lib.rs +++ b/moli-protocol-webdriver-classic/src/lib.rs @@ -49,14 +49,14 @@ pub use commands::{ classic_text_value, classic_window_rect_for_state, classic_window_rect_from_metrics, clear_element_command, close_window_command, create_initial_target_command, current_url_command, delete_all_cookies_command, delete_cookie_command, describe_node_command, - describe_node_reference_command, element_click_command, element_click_prepare_commands, - element_click_prepare_reference_commands, element_screenshot_command, execute_async_command, - execute_sync_command, find_element_command, find_element_command_with_root, - frame_id_for_element_command, get_cookies_command, get_element_attributes_command, - get_element_attributes_reference_command, get_element_computed_label_command, - get_element_computed_role_command, get_element_css_value_command, - get_element_displayed_command, get_element_enabled_command, get_element_property_command, - get_element_property_reference_command, get_element_rect_command, + describe_node_reference_command, element_click_command, element_click_preflight_command, + element_click_prepare_commands, element_click_prepare_reference_commands, + element_screenshot_command, execute_async_command, execute_sync_command, find_element_command, + find_element_command_with_root, frame_id_for_element_command, get_cookies_command, + get_element_attributes_command, get_element_attributes_reference_command, + get_element_computed_label_command, get_element_computed_role_command, + get_element_css_value_command, get_element_displayed_command, get_element_enabled_command, + get_element_property_command, get_element_property_reference_command, get_element_rect_command, get_element_rect_reference_command, get_element_rendered_text_command, get_element_shadow_root_command, get_element_tag_name_command, get_element_text_command, get_element_text_reference_command, history_traversal_entry, layout_metrics_command, diff --git a/moli-protocol-webdriver-classic/src/tests.rs b/moli-protocol-webdriver-classic/src/tests.rs index 3786bacb5d5..0e3427481ba 100644 --- a/moli-protocol-webdriver-classic/src/tests.rs +++ b/moli-protocol-webdriver-classic/src/tests.rs @@ -1519,11 +1519,11 @@ fn maps_element_click_to_shared_dom_geometry_and_input_commands() { let input = element_click_input_commands( &context, - &box_model_geometry([10.0, 20.0, 30.0, 20.0, 30.0, 40.0, 10.0, 40.0], 20, 20), - ) - .expect("input commands"); - assert_eq!(input.len(), 2); + ClassicViewportPoint::new(20.0, 30.0).expect("valid point"), + ); + assert_eq!(input.len(), 3); for (command, (event_type, buttons)) in input.into_iter().zip([ + (DevToolsMouseEventType::Moved, Some(0)), (DevToolsMouseEventType::Pressed, Some(1)), (DevToolsMouseEventType::Released, Some(0)), ]) { @@ -3223,6 +3223,10 @@ fn maps_alert_commands_to_shared_page_dialog_commands() { #[test] fn serializes_element_not_interactable_error_code() { + assert_eq!( + error_response(ClassicErrorCode::ElementClickIntercepted, "obscured")["value"]["error"], + json!("element click intercepted") + ); assert_eq!( error_response( ClassicErrorCode::ElementNotInteractable, diff --git a/moli-protocol-webdriver-classic/src/types.rs b/moli-protocol-webdriver-classic/src/types.rs index c7ac5533a1d..f0f2456cdfc 100644 --- a/moli-protocol-webdriver-classic/src/types.rs +++ b/moli-protocol-webdriver-classic/src/types.rs @@ -21,6 +21,7 @@ pub enum ClassicErrorCode { InvalidElementState, InvalidSelector, ElementNotInteractable, + ElementClickIntercepted, InvalidSessionId, JavascriptError, NoSuchAlert, @@ -49,6 +50,7 @@ impl ClassicErrorCode { Self::InvalidElementState => "invalid element state", Self::InvalidSelector => "invalid selector", Self::ElementNotInteractable => "element not interactable", + Self::ElementClickIntercepted => "element click intercepted", Self::InvalidSessionId => "invalid session id", Self::JavascriptError => "javascript error", Self::NoSuchAlert => "no such alert", diff --git a/moli-protocol/src/conn/devtools_command.rs b/moli-protocol/src/conn/devtools_command.rs index 82fcce6659c..577183c22f5 100644 --- a/moli-protocol/src/conn/devtools_command.rs +++ b/moli-protocol/src/conn/devtools_command.rs @@ -324,6 +324,7 @@ impl CdpConnection { | DevToolsCommand::GetOuterHtml(_) | DevToolsCommand::DescribeNode(_) | DevToolsCommand::GetFrameOwner(_) + | DevToolsCommand::GetNodeForLocation(_) | DevToolsCommand::ResolveNode(_) | DevToolsCommand::ScrollIntoViewIfNeeded(_) | DevToolsCommand::DomObjectReference(_) diff --git a/moli-protocol/src/domains/dom/resolve.rs b/moli-protocol/src/domains/dom/resolve.rs index 1757a375d2a..b63d075f103 100644 --- a/moli-protocol/src/domains/dom/resolve.rs +++ b/moli-protocol/src/domains/dom/resolve.rs @@ -5842,6 +5842,7 @@ fn devtools_dom_command_context( DevToolsCommand::GetOuterHtml(command) => &command.context, DevToolsCommand::DescribeNode(command) => &command.context, DevToolsCommand::GetFrameOwner(command) => &command.context, + DevToolsCommand::GetNodeForLocation(command) => &command.context, DevToolsCommand::ResolveNode(command) => &command.context, DevToolsCommand::ScrollIntoViewIfNeeded(command) => &command.context, DevToolsCommand::DomObjectReference(command) @@ -6000,15 +6001,12 @@ async fn execute_devtools_dom_command_for_owner( await_pending_devtools_dom_command_result(conn, pending).await } - DevToolsCommand::DomGeometry(command) => { - let pending = start_devtools_dom_command_for_owner( - conn, - None, - owner, - DevToolsCommand::DomGeometry(command), - ) - .map_err(DevToolsError::from)? - .ok_or_else(|| DevToolsError::new(DevToolsErrorKind::Internal, "MissingDomCommand"))?; + command @ (DevToolsCommand::DomGeometry(_) | DevToolsCommand::GetNodeForLocation(_)) => { + let pending = start_devtools_dom_command_for_owner(conn, None, owner, command) + .map_err(DevToolsError::from)? + .ok_or_else(|| { + DevToolsError::new(DevToolsErrorKind::Internal, "MissingDomCommand") + })?; await_pending_devtools_dom_command_result(conn, pending).await } diff --git a/moli-protocol/src/domains/input.rs b/moli-protocol/src/domains/input.rs index a6014b3920a..9b82692f2cb 100644 --- a/moli-protocol/src/domains/input.rs +++ b/moli-protocol/src/domains/input.rs @@ -7,8 +7,8 @@ use crate::devtools_runtime::{ DevToolsDispatchDragEventCommand, DevToolsDispatchKeyEventCommand, DevToolsDispatchMouseEventCommand, DevToolsDispatchTouchEventCommand, DevToolsDragData, DevToolsDragDataItem, DevToolsDragEventType, DevToolsError, DevToolsErrorKind, - DevToolsMouseEventType, DevToolsPointerType, DevToolsSynthesizeTapGestureCommand, - DevToolsTouchEventType, DevToolsTouchPoint, + DevToolsKeyEventType, DevToolsMouseEventType, DevToolsPointerType, + DevToolsSynthesizeTapGestureCommand, DevToolsTouchEventType, DevToolsTouchPoint, }; use crate::domains::command_output::CommandOutputPlan; use moli_core::page::{ @@ -571,7 +571,23 @@ fn start_pending_input_command( action: InputAction, ) -> Result, 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 = conn.has_pending_document_navigation_for_owner(&owner) + && parsed_key_event + .as_ref() + .is_some_and(|parsed| parsed.event_type == DevToolsKeyEventType::KeyUp); if action.requires_document_access() + && !targets_outgoing_document && let Err(message) = conn.ensure_document_accessible_for_owner(&owner) { return Err(PendingInputCommandStartError { @@ -582,8 +598,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); } @@ -977,11 +992,14 @@ fn start_devtools_dispatch_key_event_command( owner: &CommandOwnerScope, command: DevToolsDispatchKeyEventCommand, ) -> Result, PendingInputCommandStartError> { - start_page_input_command( + let targets_outgoing_document = command.event_type == DevToolsKeyEventType::KeyUp + && conn.has_pending_document_navigation_for_owner(owner); + 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 +1020,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 +1043,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, diff --git a/moli-protocol/src/domains/input/tests.rs b/moli-protocol/src/domains/input/tests.rs index a4a4d4348b5..86084b17a4a 100644 --- a/moli-protocol/src/domains/input/tests.rs +++ b/moli-protocol/src/domains/input/tests.rs @@ -1577,6 +1577,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 +2274,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 +2294,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 +2330,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/keyboard_events.rs b/moli-protocol/src/domains/input/tests/keyboard_events.rs index 06033c61f71..482274bc032 100644 --- a/moli-protocol/src/domains/input/tests/keyboard_events.rs +++ b/moli-protocol/src/domains/input/tests/keyboard_events.rs @@ -133,6 +133,54 @@ async fn cdp_combined_keydown_honors_each_cancellation_boundary() { } } +#[tokio::test(flavor = "multi_thread")] +async fn cdp_canceled_raw_keydown_suppresses_only_its_following_char_phase() { + let mut ctx = keyboard_fixture(CONTROLS[0]).await; + assert!(evaluate_bool(&mut ctx, "(__cancel = 'keydown') === 'keydown'").await); + + dispatch(&mut ctx, "rawKeyDown", "a", "KeyA", "").await; + dispatch(&mut ctx, "char", "a", "KeyA", "a").await; + assert_eq!(field_value(&mut ctx).await, ""); + assert_eq!(events(&mut ctx).await, json!(["keydown:field"])); + + assert!(evaluate_bool(&mut ctx, "(__cancel = '') === ''").await); + dispatch(&mut ctx, "rawKeyDown", "b", "KeyB", "").await; + dispatch(&mut ctx, "char", "b", "KeyB", "b").await; + assert_eq!(field_value(&mut ctx).await, "b"); + assert_eq!( + events(&mut ctx).await, + json!([ + "keydown:field", + "keydown:field", + "keypress:field", + "beforeinput:field", + "input:field" + ]) + ); + + assert!(evaluate_bool(&mut ctx, "(__cancel = 'keydown') === 'keydown'").await); + dispatch(&mut ctx, "rawKeyDown", "c", "KeyC", "").await; + dispatch(&mut ctx, "keyUp", "c", "KeyC", "").await; + assert!(evaluate_bool(&mut ctx, "(__cancel = '') === ''").await); + dispatch(&mut ctx, "char", "c", "KeyC", "c").await; + assert_eq!(field_value(&mut ctx).await, "bc"); + assert_eq!( + events(&mut ctx).await, + json!([ + "keydown:field", + "keydown:field", + "keypress:field", + "beforeinput:field", + "input:field", + "keydown:field", + "keyup:field", + "keypress:field", + "beforeinput:field", + "input:field" + ]) + ); +} + #[tokio::test(flavor = "multi_thread")] async fn cdp_combined_keydown_delivers_trusted_keypresses_for_typed_characters() { let mut ctx = keyboard_fixture(CONTROLS[0]).await; diff --git a/moli-renderer-v8/src/native_bridge/element.rs b/moli-renderer-v8/src/native_bridge/element.rs index 1fd0d41a1d3..c0e6fef5d2e 100644 --- a/moli-renderer-v8/src/native_bridge/element.rs +++ b/moli-renderer-v8/src/native_bridge/element.rs @@ -62,6 +62,7 @@ use trusted_types::{ pub(crate) use forms::{ autocomplete_field_name, autofill_related_form_control_elements, form_associated_form_owner, form_control_elements, form_data_control_elements, is_valid_submit_button, + submit_form_with_submit_event, }; #[cfg(test)] pub(crate) use styles::iframe_width_attribute_viewport_width; @@ -124,6 +125,7 @@ pub(crate) use activation::{ navigate_named_iframe_target, }; pub(crate) use activation::{ + activate_default_submit_button_via_keyboard, activate_handle_after_pointer_release, activate_handle_via_click, activate_handle_via_click_with_detail_and_modifiers, activate_handle_via_synthetic_click, dispatched_click_activation_target, finish_legacy_activation_for_dispatched_click, perform_auxiliary_link_default_action, @@ -306,7 +308,7 @@ pub(super) use forms::{ select_selected_index_getter_function, select_selected_index_setter_function, select_selected_options_getter_function, select_size_getter_function, select_size_setter_function, select_value_getter_function, select_value_setter_function, - set_select_indexed_option, submit_form_with_submit_event, text_control_select_callback, + set_select_indexed_option, text_control_select_callback, text_control_selection_direction_getter_function, text_control_selection_direction_setter_function, text_control_selection_end_getter_function, text_control_selection_end_setter_function, text_control_selection_start_getter_function, diff --git a/moli-renderer-v8/src/native_bridge/element/activation/default_action.rs b/moli-renderer-v8/src/native_bridge/element/activation/default_action.rs index 6f5cf6d9b4a..afef5375fa0 100644 --- a/moli-renderer-v8/src/native_bridge/element/activation/default_action.rs +++ b/moli-renderer-v8/src/native_bridge/element/activation/default_action.rs @@ -762,6 +762,7 @@ fn click_handle_internal( click_detail: i32, modifiers: u8, user_initiated: bool, + focus_before_dispatch: bool, ) -> RendererInputDispatchOutcome { let runtime = unsafe { &*runtime_ptr }; if is_disabled_form_control(runtime, handle) { @@ -772,7 +773,8 @@ fn click_handle_internal( pending_file_chooser: None, }; } - if user_initiated || synthetic_click_focuses_element(runtime, handle) { + if focus_before_dispatch && (user_initiated || synthetic_click_focuses_element(runtime, handle)) + { let focus_handle = if is_focusable(runtime, handle) { Some(handle) } else { @@ -962,6 +964,9 @@ fn click_handle_internal( 0, modifiers, user_initiated, + // Label activation focuses its associated control, + // even when the initiating script click does not. + true, ); pending_file_chooser = control_outcome.pending_file_chooser; control_outcome.pending_download @@ -1126,6 +1131,7 @@ pub(crate) fn activate_handle_via_click( 0, 0, true, + true, ) } @@ -1151,6 +1157,59 @@ pub(crate) fn activate_handle_via_click_with_detail_and_modifiers( click_detail, modifiers, true, + true, + ) +} + +pub(crate) fn activate_default_submit_button_via_keyboard( + scope: &mut v8::PinScope<'_, '_>, + runtime_ptr: *mut JsContextHost, + handle: DomHandle, + modifiers: u8, +) -> RendererInputDispatchOutcome { + // Implicit submission runs trusted synthetic click activation steps. The + // click has detail 0 and does not move focus away from the text control. + click_handle_internal( + scope, + runtime_ptr, + handle, + 0.0, + 0.0, + 0, + 0, + 0, + modifiers, + true, + false, + ) +} + +pub(crate) fn activate_handle_after_pointer_release( + scope: &mut v8::PinScope<'_, '_>, + runtime_ptr: *mut JsContextHost, + handle: DomHandle, + x: f64, + y: f64, + button: i32, + buttons: i32, + click_detail: i32, + modifiers: u8, +) -> RendererInputDispatchOutcome { + // Ordinary pointer focus belongs to uncanceled mouse-down default action. + // Reapplying it here would undo a canceled pointerdown/mousedown. Label + // activation still applies its separate associated-control focus rule. + click_handle_internal( + scope, + runtime_ptr, + handle, + x, + y, + button, + buttons, + click_detail, + modifiers, + true, + false, ) } @@ -1174,6 +1233,7 @@ pub(crate) fn activate_handle_via_synthetic_click( 0, 0, false, + false, ) } @@ -1400,6 +1460,7 @@ fn perform_click_default_action( 0, modifiers, user_initiated, + true, ); return None; } diff --git a/moli-renderer-v8/src/native_bridge/element/activation/mod.rs b/moli-renderer-v8/src/native_bridge/element/activation/mod.rs index 1d8682ddc3d..0219bdd595f 100644 --- a/moli-renderer-v8/src/native_bridge/element/activation/mod.rs +++ b/moli-renderer-v8/src/native_bridge/element/activation/mod.rs @@ -5,6 +5,7 @@ mod targets; pub(in crate::native_bridge) use click::{input_show_picker_callback, node_click_callback}; pub(in crate::native_bridge) use default_action::navigate_form_target_browsing_context; pub(crate) use default_action::{ + activate_default_submit_button_via_keyboard, activate_handle_after_pointer_release, activate_handle_via_click, activate_handle_via_click_with_detail_and_modifiers, activate_handle_via_synthetic_click, dispatched_click_activation_target, finish_legacy_activation_for_dispatched_click, perform_auxiliary_link_default_action, diff --git a/moli-renderer-v8/src/native_bridge/element/forms.rs b/moli-renderer-v8/src/native_bridge/element/forms.rs index 28a20b093c5..717f98bf0e6 100644 --- a/moli-renderer-v8/src/native_bridge/element/forms.rs +++ b/moli-renderer-v8/src/native_bridge/element/forms.rs @@ -224,9 +224,10 @@ pub(in crate::native_bridge) use self::simple_controls::{ progress_max_setter_function, progress_position_getter_function, progress_value_getter_function, progress_value_setter_function, }; +pub(crate) use self::submission::submit_form_with_submit_event; pub(in crate::native_bridge) use self::submission::{ FormAssociatedResetCallbackTiming, form_request_submit_callback, form_reset_callback, - form_submit_callback, reset_form_default_action, submit_form_with_submit_event, + form_submit_callback, reset_form_default_action, }; pub(crate) use self::submission::{ align_event_constructor_function_realm_with_constructor, diff --git a/moli-renderer-v8/src/native_bridge/element/forms/submission.rs b/moli-renderer-v8/src/native_bridge/element/forms/submission.rs index f2c18e35930..e2069fa4551 100644 --- a/moli-renderer-v8/src/native_bridge/element/forms/submission.rs +++ b/moli-renderer-v8/src/native_bridge/element/forms/submission.rs @@ -78,7 +78,7 @@ fn wrap_handle_object<'s>( .wrap_handle(scope, runtime_ptr, handle) } -pub(in crate::native_bridge) fn submit_form_with_submit_event( +pub(crate) fn submit_form_with_submit_event( scope: &mut v8::PinScope<'_, '_>, runtime_ptr: *mut JsContextHost, form_handle: DomHandle, diff --git a/moli-renderer-v8/src/runtime/owner_local_store/mod.rs b/moli-renderer-v8/src/runtime/owner_local_store/mod.rs index b3a1dd66348..0f4dc4af96d 100644 --- a/moli-renderer-v8/src/runtime/owner_local_store/mod.rs +++ b/moli-renderer-v8/src/runtime/owner_local_store/mod.rs @@ -2569,7 +2569,7 @@ impl RendererOwnerLocalStore { let command_epoch = Self::advance_command_epoch(entry); let slot = entry.slot.clone(); let _nested_main_page = super::nested_main::bind_active_nested_main_page(entry); - let reply = slot + let mut reply = slot .dispatch_async_owned(command_epoch, entry.page_vm_mut(), command) .await; let replacement_lifecycle = { @@ -2595,6 +2595,15 @@ impl RendererOwnerLocalStore { } else { Ok(()) }; + // Input handlers and their completed microtask checkpoint may request + // navigation without running a click default action (pointerdown, + // mousemove, focus, etc.). Publish that real pending navigation at this + // input task's completion, not as a side effect of a later Runtime + // command. Preserve the action-specific download/file-chooser result. + if let Ok(RendererPageReply::InputDispatchOutcome(outcome)) = &mut reply { + outcome.triggered_top_level_navigation |= + entry.page_vm().vm().has_pending_location_navigation(); + } let input_triggered_top_level_navigation = reply.as_ref().is_ok_and(|reply| { matches!( reply, diff --git a/moli-renderer-v8/src/runtime/tests.rs b/moli-renderer-v8/src/runtime/tests.rs index 7f08b8783fe..18bab24d5ed 100644 --- a/moli-renderer-v8/src/runtime/tests.rs +++ b/moli-renderer-v8/src/runtime/tests.rs @@ -1127,6 +1127,284 @@ async fn capture_screenshot_for_renderer_page( } } +#[tokio::test(flavor = "current_thread")] +async fn mouse_input_completion_publishes_handler_navigation_once() { + 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 (listener, event_name, microtask, navigates, cancel) in [ + ("mousedown", "mousedown", false, true, false), + ("pointerdown", "mousedown", false, true, false), + ("mousemove", "mousemove", false, true, false), + ("pointermove", "mousemove", false, true, false), + ("mousedown", "mousedown", true, true, false), + ("pointermove", "mousemove", true, true, false), + ("mousedown", "mousedown", false, true, true), + ("pointerdown", "mousedown", true, true, true), + ("mousedown", "mousedown", false, false, false), + ("mousemove", "mousemove", true, false, true), + ] { + let body = if navigates { + "location.href = 'https://example.test/next.html';" + } else { + "window.handlerRan = true;" + }; + let handler = if microtask { + format!("queueMicrotask(() => {{ {body} }});") + } else { + body.to_owned() + }; + let html = format!( + "" + ); + 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..3e742bfb7d3 100644 --- a/moli-renderer-v8/src/script_vm.rs +++ b/moli-renderer-v8/src/script_vm.rs @@ -956,6 +956,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 +2360,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/input_dispatch.rs b/moli-renderer-v8/src/script_vm/input_dispatch.rs index 77e6e7b202a..863e85ab6c2 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; @@ -16,7 +17,8 @@ use crate::dom::{ native::{Node, SelectedFile}, }; use crate::native_bridge::element::{ - TouchEventPoint, activate_handle_via_click, + TouchEventPoint, activate_default_submit_button_via_keyboard, + 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, @@ -24,12 +26,13 @@ use crate::native_bridge::element::{ construct_pointer_event_with_modifiers, construct_pointer_event_with_related_target, construct_pointer_event_with_related_target_and_modifiers, construct_simple_event, construct_touch_event, construct_touch_event_with_points, construct_wheel_event, - 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, + contenteditable_editing_host, dispatch_public_event, form_associated_form_owner, + form_data_control_elements, is_text_control, is_valid_submit_button, 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, submit_form_with_submit_event, text_control_set_selection_range_internal, text_control_set_selection_range_with_direction_internal, text_control_value, update_focus, }; @@ -82,6 +85,80 @@ fn can_suppress_compat_mouse_event(event_name: &str) -> bool { const MOUSE_POINTER_ID: i32 = 1; const TOUCH_POINTER_ID: i32 = 2; +fn input_blocks_implicit_submission( + runtime: &crate::native_bridge::JsContextHost, + handle: DomHandle, +) -> bool { + runtime + .dom_host() + .node(handle) + .and_then(Node::as_element) + .is_some_and(|element| { + element.is_html_input() + && matches!( + element.input_type(), + InputType::Text + | InputType::Search + | InputType::Tel + | InputType::Url + | InputType::Email + | InputType::Password + | InputType::Date + | InputType::Month + | InputType::Week + | InputType::Time + | InputType::DatetimeLocal + | InputType::Number + ) + }) +} + +fn perform_implicit_form_submission( + scope: &mut v8::PinScope<'_, '_>, + runtime_ptr: *mut crate::native_bridge::JsContextHost, + handle: DomHandle, + modifiers: u8, +) -> RendererInputDispatchOutcome { + let runtime = unsafe { &*runtime_ptr }; + let Some(form_handle) = form_associated_form_owner(runtime, handle) else { + return input_dispatch_outcome(true); + }; + // The form.elements collection excludes image inputs, but an image input + // is still a submit button for the implicit-submission default-button + // search. Use the complete form-data control traversal here. + let controls = form_data_control_elements(runtime, form_handle); + if let Some(default_button) = controls + .iter() + .copied() + .find(|candidate| is_valid_submit_button(runtime, *candidate)) + { + return activate_default_submit_button_via_keyboard( + scope, + runtime_ptr, + default_button, + modifiers, + ); + } + if controls + .iter() + .filter(|candidate| input_blocks_implicit_submission(runtime, **candidate)) + .count() + > 1 + { + return input_dispatch_outcome(true); + } + + let had_pending_top_level_navigation = runtime.has_pending_location_navigation(); + let _ = submit_form_with_submit_event(scope, runtime_ptr, form_handle, None, true); + RendererInputDispatchOutcome { + handled: true, + triggered_top_level_navigation: !had_pending_top_level_navigation + && unsafe { &*runtime_ptr }.has_pending_location_navigation(), + pending_download: None, + pending_file_chooser: None, + } +} + struct PreparedMouseInputDispatch { button: i32, buttons: i32, @@ -1004,7 +1081,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, @@ -1802,6 +1879,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 +1904,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 +1922,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,6 +1965,41 @@ 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.contains('\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", + ))); + } + if input_blocks_implicit_submission(runtime, handle) && is_character_phase { + return Ok(perform_implicit_form_submission( + scope, + runtime_ptr, + handle, + modifiers, + )); + } + 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)); @@ -1929,30 +2053,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(), @@ -2215,6 +2315,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/tests/dom_xhr/forms.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs index 3ec889f4cc9..2dd8a635b99 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,461 @@ 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 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 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/");