From 7f7a503b90e57edbc68b16d6015ceda034f9beba Mon Sep 17 00:00:00 2001 From: ldm0 Date: Thu, 27 Aug 2026 18:56:47 +0800 Subject: [PATCH] fix(renderer): derive popup policy from current input --- moli-cdp-smoke/README.md | 14 +- .../moli_cdp_smoke/groups/target_semantics.py | 211 +++++++++++++ .../window_runtime/dialogs.rs | 23 +- .../src/native_bridge/context_host/core.rs | 2 +- .../context_host/host_environment.rs | 14 +- .../src/native_bridge/context_host/mod.rs | 2 +- .../src/native_bridge/current_input.rs | 278 ++++++++++++++++++ moli-renderer-v8/src/native_bridge/element.rs | 2 +- .../element/activation/default_action.rs | 168 +++++------ .../native_bridge/element/activation/mod.rs | 2 +- moli-renderer-v8/src/native_bridge/mod.rs | 4 + .../src/script_vm/input_dispatch.rs | 130 ++++++-- .../src/script_vm/tests/dom_xhr/forms.rs | 261 +++++++++++++++- 13 files changed, 977 insertions(+), 134 deletions(-) create mode 100644 moli-renderer-v8/src/native_bridge/current_input.rs diff --git a/moli-cdp-smoke/README.md b/moli-cdp-smoke/README.md index 8568762021..05870d9330 100644 --- a/moli-cdp-smoke/README.md +++ b/moli-cdp-smoke/README.md @@ -107,11 +107,15 @@ auto-attach reconciliation without duplicate sessions, stable Tab-to-Page ownership across foreground changes, `Target.createTarget` foreground and background behavior, Page visibility changes, screencast visibility `true -> false -> true` across demotion and close-driven promotion, and a real -default discovery target that is not recreated after close. The complete -12-contract group passed three consecutive fresh Chromium processes and five -consecutive fresh Moli processes. Chromium source revision `a03603fe9af6` was -also inspected to explain the access-mode and auto-attach machinery; the -executable probe remains the behavioral authority. +default discovery target that is not recreated after close. It also drives a +real middle-button press/release through `Input.dispatchMouseEvent` and checks +that the trusted `auxclick` anchor default action leaves the source visible and +creates a hidden background Page. The original 12-contract matrix passed three +consecutive fresh Chromium processes and five consecutive fresh Moli processes; +the middle-click contract was then run unchanged against both engines. +Chromium source revision `a03603fe9af6` was also inspected to explain the +access-mode and auto-attach machinery; the executable probe remains the +behavioral authority. ## Current Coverage diff --git a/moli-cdp-smoke/moli_cdp_smoke/groups/target_semantics.py b/moli-cdp-smoke/moli_cdp_smoke/groups/target_semantics.py index b843eaa293..13d20a493c 100644 --- a/moli-cdp-smoke/moli_cdp_smoke/groups/target_semantics.py +++ b/moli-cdp-smoke/moli_cdp_smoke/groups/target_semantics.py @@ -106,6 +106,21 @@ def _raw_semantic_contracts() -> tuple[RawSemanticContract, ...]: ], _noopener_popup_devtools_attribution, ), + RawSemanticContract( + "raw_cdp_contract_middle_click_background_popup", + "A real middle-button press/release dispatches an uncanceled auxclick whose anchor default action opens a background Page target while the source target stays foreground.", + "Chromium CurrentInputEvent, NavigationPolicyFromEvent, and HTMLAnchorElement auxclick default action", + [ + "Target.createBrowserContext", + "Target.setDiscoverTargets", + "Target.createTarget", + "Target.attachToTarget x2", + "Runtime.evaluate", + "Input.dispatchMouseEvent x2", + "Target.getTargetInfo", + ], + _middle_click_background_popup, + ), RawSemanticContract( "raw_cdp_contract_target_multi_attach_independence", "Each flattened attachment has a distinct session and detaching one session leaves the other attachment usable.", @@ -1375,6 +1390,202 @@ async def _noopener_popup_devtools_attribution( await client.websocket.close() +async def _middle_click_background_popup( + endpoint: str, + fixture: str, +) -> dict[str, Any]: + client = await connect_raw_cdp(endpoint) + browser_context_id: str | None = None + source_target_id: str | None = None + popup_target_id: str | None = None + stage = "create browser context" + try: + context_result, _ = await _raw_command(client, "Target.createBrowserContext") + browser_context_id = context_result["browserContextId"] + await _raw_command(client, "Target.setDiscoverTargets", {"discover": True}) + + source_url = f"{fixture}/plain?middle-click-source=1" + popup_url = f"{fixture}/plain?middle-click-popup=1" + stage = "create middle-click source target" + source_result, _ = await _raw_command( + client, + "Target.createTarget", + {"url": source_url, "browserContextId": browser_context_id}, + ) + source_target_id = source_result["targetId"] + attached, _ = await _raw_command( + client, + "Target.attachToTarget", + {"targetId": source_target_id, "flatten": True}, + ) + source_session_id = attached["sessionId"] + + source_document: dict[str, Any] | None = None + + async def source_document_loaded() -> bool: + nonlocal source_document + evaluation, _ = await _raw_command( + client, + "Runtime.evaluate", + { + "expression": "({url: location.href, readyState: document.readyState})", + "returnByValue": True, + }, + session_id=source_session_id, + ) + candidate = evaluation["result"].get("value") + source_document = candidate if isinstance(candidate, dict) else None + return source_document == {"url": source_url, "readyState": "complete"} + + stage = "wait for middle-click source document" + await wait_until(source_document_loaded, "middle-click source document complete") + + stage = "install middle-click anchor" + fixture_result, _ = await _raw_command( + client, + "Runtime.evaluate", + { + "expression": ( + "(() => {" + "document.body.replaceChildren();" + "document.body.style.margin = '0';" + "const anchor = document.createElement('a');" + f"anchor.href = {json.dumps(popup_url)};" + "anchor.textContent = 'open';" + "anchor.style.cssText = 'position:fixed;left:0;top:0;width:120px;height:80px;display:block';" + "window.__middleAuxclickEvents = [];" + "anchor.addEventListener('auxclick', event => {" + "window.__middleAuxclickEvents.push(`${event.type}:${event.button}:${event.isTrusted}`);" + "});" + "document.body.append(anchor);" + "return anchor.getBoundingClientRect().toJSON();" + "})()" + ), + "returnByValue": True, + }, + session_id=source_session_id, + ) + fixture_rect = fixture_result["result"].get("value") + _require(isinstance(fixture_rect, dict), "middle-click anchor has no layout rect") + + stage = "dispatch real middle-button input" + await _raw_command( + client, + "Input.dispatchMouseEvent", + { + "type": "mousePressed", + "x": 20, + "y": 20, + "button": "middle", + "buttons": 4, + "clickCount": 1, + }, + session_id=source_session_id, + ) + _, release_seen = await _raw_command( + client, + "Input.dispatchMouseEvent", + { + "type": "mouseReleased", + "x": 20, + "y": 20, + "button": "middle", + "buttons": 0, + "clickCount": 1, + }, + session_id=source_session_id, + ) + + auxclick_result, auxclick_seen = await _raw_command( + client, + "Runtime.evaluate", + { + "expression": "window.__middleAuxclickEvents.join('|')", + "returnByValue": True, + }, + session_id=source_session_id, + ) + assert_equal( + auxclick_result["result"].get("value"), + "auxclick:1:true", + "real middle-button auxclick", + ) + + stage = "wait for middle-click popup target" + popup_event = await _raw_wait_for_event( + client, + "Target.targetCreated", + lambda params: params.get("targetInfo", {}).get("browserContextId") + == browser_context_id + and params.get("targetInfo", {}).get("targetId") != source_target_id, + [*release_seen, *auxclick_seen], + ) + popup_target_id = popup_event["params"]["targetInfo"]["targetId"] + + popup_info: dict[str, Any] | None = None + + async def popup_navigation_committed() -> bool: + nonlocal popup_info + result, _ = await _raw_command( + client, + "Target.getTargetInfo", + {"targetId": popup_target_id}, + ) + candidate = result.get("targetInfo") + popup_info = candidate if isinstance(candidate, dict) else None + return popup_info is not None and popup_info.get("url") == popup_url + + stage = "wait for middle-click popup navigation" + await wait_until(popup_navigation_committed, "middle-click popup navigation") + + popup_attach, _ = await _raw_command( + client, + "Target.attachToTarget", + {"targetId": popup_target_id, "flatten": True}, + ) + + async def visibility(session_id: str) -> str | None: + result, _ = await _raw_command( + client, + "Runtime.evaluate", + { + "expression": "document.visibilityState", + "returnByValue": True, + }, + session_id=session_id, + ) + return result.get("result", {}).get("value") + + source_visibility = await visibility(source_session_id) + popup_visibility = await visibility(popup_attach["sessionId"]) + assert_equal(source_visibility, "visible", "middle-click source visibility") + assert_equal(popup_visibility, "hidden", "middle-click popup visibility") + + return { + "auxclick": auxclick_result["result"].get("value"), + "popupUrl": popup_info.get("url") if popup_info is not None else None, + "sourceVisibility": source_visibility, + "popupVisibility": popup_visibility, + } + except Exception as error: + raise SmokeError(f"{stage}: {type(error).__name__}: {error}") from error + finally: + for target_id in (popup_target_id, source_target_id): + if target_id is not None: + await _ignore_raw_error( + client, + "Target.closeTarget", + {"targetId": target_id}, + ) + if browser_context_id is not None: + await _ignore_raw_error( + client, + "Target.disposeBrowserContext", + {"browserContextId": browser_context_id}, + ) + await client.websocket.close() + + async def _target_close_lifecycle( endpoint: str, _fixture: str, diff --git a/moli-renderer-v8/src/context_bootstrap/window_runtime/dialogs.rs b/moli-renderer-v8/src/context_bootstrap/window_runtime/dialogs.rs index 95054a8f48..507300f34b 100644 --- a/moli-renderer-v8/src/context_bootstrap/window_runtime/dialogs.rs +++ b/moli-renderer-v8/src/context_bootstrap/window_runtime/dialogs.rs @@ -6,7 +6,7 @@ use crate::{ context_bootstrap::CHILD_BROWSING_CONTEXT_HANDLE_SLOT, document_runtime::{DocumentPolicyContainer, DomHandle}, native_bridge::{ - child_window_handle_from_marker_data, + InputNavigationPolicy, child_window_handle_from_marker_data, element::{ SpecialBrowsingContextTarget, navigate_existing_browsing_context_target, navigate_named_iframe_target, @@ -221,6 +221,21 @@ pub(crate) fn window_open_callback<'s>( let opener = (!suppress_opener).then_some(entered_window); let opener_child_handle = opener.and_then(|opener| window_open_receiver_child_handle(scope, opener)); + let popup_disposition = match host + .current_input_event() + .map(crate::native_bridge::CurrentInputEvent::navigation_policy) + { + Some(InputNavigationPolicy::NewBackgroundSurface) => { + crate::RendererPopupDisposition::Background + } + Some( + InputNavigationPolicy::Current + | InputNavigationPolicy::Download + | InputNavigationPolicy::NewWindow + | InputNavigationPolicy::NewForegroundSurface, + ) + | None => crate::RendererPopupDisposition::Foreground, + }; if popup_target_can_use_lightweight_window(&parsed.target_name, &url) && let Some(opened_popup) = host.open_lightweight_popup_window( scope, @@ -240,9 +255,6 @@ pub(crate) fn window_open_callback<'s>( let window_open_event = opened_popup .created_new_browsing_context .then_some(window_open_event); - let popup_disposition = host - .current_input_popup_disposition() - .unwrap_or(crate::RendererPopupDisposition::Foreground); host.record_pending_popup_activation( RendererPendingPopupActivation::window( root_document, @@ -266,9 +278,6 @@ pub(crate) fn window_open_callback<'s>( } return; } - let popup_disposition = host - .current_input_popup_disposition() - .unwrap_or(crate::RendererPopupDisposition::Foreground); host.record_pending_popup_activation( RendererPendingPopupActivation::window( root_document, diff --git a/moli-renderer-v8/src/native_bridge/context_host/core.rs b/moli-renderer-v8/src/native_bridge/context_host/core.rs index 9e0cdaa691..7da72795db 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/core.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/core.rs @@ -267,7 +267,7 @@ impl JsContextHost { timezone_override: None, idle_override: None, protocol_user_gesture_activation_depth: 0, - current_input_popup_disposition: None, + current_input_event: None, webdriver_bidi_file_prompt_handler_stack: Vec::new(), emulated_media: crate::protocol_types::EmulatedMediaOverrides::default(), viewport_surface: None, diff --git a/moli-renderer-v8/src/native_bridge/context_host/host_environment.rs b/moli-renderer-v8/src/native_bridge/context_host/host_environment.rs index cdee65c7f8..d4a98e35c0 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/host_environment.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/host_environment.rs @@ -747,17 +747,15 @@ impl JsContextHost { self.protocol_user_gesture_activation_depth > 0 } - pub(crate) fn replace_current_input_popup_disposition( + pub(crate) fn replace_current_input_event( &mut self, - disposition: Option, - ) -> Option { - std::mem::replace(&mut self.current_input_popup_disposition, disposition) + event: Option, + ) -> Option { + std::mem::replace(&mut self.current_input_event, event) } - pub(crate) fn current_input_popup_disposition( - &self, - ) -> Option { - self.current_input_popup_disposition + pub(crate) fn current_input_event(&self) -> Option { + self.current_input_event } pub(crate) fn begin_webdriver_bidi_file_prompt_handler(&mut self, handler: &str) { diff --git a/moli-renderer-v8/src/native_bridge/context_host/mod.rs b/moli-renderer-v8/src/native_bridge/context_host/mod.rs index 6072434dcb..e4b99d7f7f 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/mod.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/mod.rs @@ -869,7 +869,7 @@ pub(crate) struct JsContextHost { timezone_override: Option, idle_override: Option, protocol_user_gesture_activation_depth: usize, - current_input_popup_disposition: Option, + current_input_event: Option, webdriver_bidi_file_prompt_handler_stack: Vec, emulated_media: crate::protocol_types::EmulatedMediaOverrides, viewport_surface: Option, diff --git a/moli-renderer-v8/src/native_bridge/current_input.rs b/moli-renderer-v8/src/native_bridge/current_input.rs new file mode 100644 index 0000000000..d850435b44 --- /dev/null +++ b/moli-renderer-v8/src/native_bridge/current_input.rs @@ -0,0 +1,278 @@ +use std::{cell::RefCell, rc::Rc}; + +use super::JsContextHost; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct InputModifiers(u8); + +impl InputModifiers { + const ALT: u8 = 1; + const CONTROL: u8 = 2; + const META: u8 = 4; + const SHIFT: u8 = 8; + + const fn from_bits(bits: u8) -> Self { + Self(bits) + } + + const fn alt(self) -> bool { + self.0 & Self::ALT != 0 + } + + const fn control(self) -> bool { + self.0 & Self::CONTROL != 0 + } + + const fn meta(self) -> bool { + self.0 & Self::META != 0 + } + + const fn shift(self) -> bool { + self.0 & Self::SHIFT != 0 + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum CurrentInputEventKind { + MouseUp { button: i32 }, + EnterKey, + Other, +} + +/// The navigation-relevant subset of the real platform input currently being +/// handled. DOM events are deliberately not stored here: synthetic events may +/// describe modifiers, but cannot manufacture the corresponding user intent. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct CurrentInputEvent { + kind: CurrentInputEventKind, + modifiers: InputModifiers, +} + +impl CurrentInputEvent { + pub(crate) fn mouse(event_name: &str, button: i32, modifiers: u8) -> Self { + Self { + kind: if event_name == "mouseup" { + CurrentInputEventKind::MouseUp { button } + } else { + CurrentInputEventKind::Other + }, + modifiers: InputModifiers::from_bits(modifiers), + } + } + + pub(crate) fn keyboard(key: &str, modifiers: u8) -> Self { + Self { + kind: if key.eq_ignore_ascii_case("enter") { + CurrentInputEventKind::EnterKey + } else { + CurrentInputEventKind::Other + }, + modifiers: InputModifiers::from_bits(modifiers), + } + } + + pub(crate) fn navigation_policy(self) -> InputNavigationPolicy { + let button = match self.kind { + CurrentInputEventKind::MouseUp { button } => button, + CurrentInputEventKind::EnterKey => 0, + CurrentInputEventKind::Other => return InputNavigationPolicy::Current, + }; + navigation_policy_from_modifiers(button, self.modifiers) + } +} + +/// Chromium-shaped navigation intent before Moli folds window and tab chrome +/// into its foreground/background surface model. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum InputNavigationPolicy { + Current, + Download, + NewWindow, + NewForegroundSurface, + NewBackgroundSurface, +} + +/// Computes a DOM event's requested policy, then applies Blink's synthetic +/// download and tab-under protections against the ambient real input. +pub(crate) fn navigation_policy_from_event( + button: i32, + modifiers: u8, + current_input: Option, +) -> InputNavigationPolicy { + let event_policy = + navigation_policy_from_modifiers(button, InputModifiers::from_bits(modifiers)); + let input_policy = current_input + .map(CurrentInputEvent::navigation_policy) + .unwrap_or(InputNavigationPolicy::Current); + + match event_policy { + InputNavigationPolicy::Download if input_policy != InputNavigationPolicy::Download => { + InputNavigationPolicy::Current + } + InputNavigationPolicy::NewBackgroundSurface + if input_policy != InputNavigationPolicy::NewBackgroundSurface => + { + InputNavigationPolicy::NewForegroundSurface + } + _ => event_policy, + } +} + +fn navigation_policy_from_modifiers( + button: i32, + modifiers: InputModifiers, +) -> InputNavigationPolicy { + let platform_new_tab_modifier = if cfg!(target_os = "macos") { + modifiers.meta() + } else { + modifiers.control() + }; + + let requests_new_tab = button == 1 || platform_new_tab_modifier; + if !requests_new_tab && !modifiers.shift() && !modifiers.alt() { + InputNavigationPolicy::Current + } else if requests_new_tab { + if modifiers.shift() { + InputNavigationPolicy::NewForegroundSurface + } else { + InputNavigationPolicy::NewBackgroundSurface + } + } else if modifiers.shift() { + InputNavigationPolicy::NewWindow + } else { + InputNavigationPolicy::Download + } +} + +/// Restores the ambient real input automatically across early returns, +/// nested input dispatch, and unwinding. +#[must_use = "dropping the scope immediately would discard the current input event"] +pub(crate) struct CurrentInputEventScope { + context_host: Rc>, + previous: Option, + installed: CurrentInputEvent, +} + +impl CurrentInputEventScope { + pub(crate) fn enter( + context_host: Rc>, + event: CurrentInputEvent, + ) -> Self { + let previous = context_host + .borrow_mut() + .replace_current_input_event(Some(event)); + Self { + context_host, + previous, + installed: event, + } + } +} + +impl Drop for CurrentInputEventScope { + fn drop(&mut self) { + let replaced = self + .context_host + .borrow_mut() + .replace_current_input_event(self.previous); + assert_eq!( + replaced, + Some(self.installed), + "the current input event must remain scoped to its real input dispatch" + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ALT: u8 = 1; + const CONTROL: u8 = 2; + const META: u8 = 4; + const SHIFT: u8 = 8; + + #[cfg(target_os = "macos")] + const PLATFORM_NEW_TAB_MODIFIER: u8 = META; + #[cfg(not(target_os = "macos"))] + const PLATFORM_NEW_TAB_MODIFIER: u8 = CONTROL; + + #[cfg(target_os = "macos")] + const NON_PLATFORM_NEW_TAB_MODIFIER: u8 = CONTROL; + #[cfg(not(target_os = "macos"))] + const NON_PLATFORM_NEW_TAB_MODIFIER: u8 = META; + + #[test] + fn modifier_policy_matches_chromium_surface_selection() { + assert_eq!( + navigation_policy_from_modifiers(0, InputModifiers::from_bits(0)), + InputNavigationPolicy::Current + ); + assert_eq!( + navigation_policy_from_modifiers( + 0, + InputModifiers::from_bits(NON_PLATFORM_NEW_TAB_MODIFIER), + ), + InputNavigationPolicy::Current + ); + assert_eq!( + navigation_policy_from_modifiers(1, InputModifiers::from_bits(0)), + InputNavigationPolicy::NewBackgroundSurface + ); + assert_eq!( + navigation_policy_from_modifiers( + 0, + InputModifiers::from_bits(PLATFORM_NEW_TAB_MODIFIER), + ), + InputNavigationPolicy::NewBackgroundSurface + ); + assert_eq!( + navigation_policy_from_modifiers(0, InputModifiers::from_bits(SHIFT)), + InputNavigationPolicy::NewWindow + ); + assert_eq!( + navigation_policy_from_modifiers(1, InputModifiers::from_bits(SHIFT)), + InputNavigationPolicy::NewForegroundSurface + ); + assert_eq!( + navigation_policy_from_modifiers(0, InputModifiers::from_bits(ALT)), + InputNavigationPolicy::Download + ); + } + + #[test] + fn synthesized_events_cannot_request_tab_unders_or_downloads() { + assert_eq!( + navigation_policy_from_event(1, 0, None), + InputNavigationPolicy::NewForegroundSurface + ); + assert_eq!( + navigation_policy_from_event(0, PLATFORM_NEW_TAB_MODIFIER, None), + InputNavigationPolicy::NewForegroundSurface + ); + assert_eq!( + navigation_policy_from_event(0, ALT, None), + InputNavigationPolicy::Current + ); + } + + #[test] + fn only_navigation_capable_real_input_supplies_an_input_policy() { + assert_eq!( + CurrentInputEvent::mouse("mousedown", 0, PLATFORM_NEW_TAB_MODIFIER).navigation_policy(), + InputNavigationPolicy::Current + ); + assert_eq!( + CurrentInputEvent::mouse("mouseup", 0, PLATFORM_NEW_TAB_MODIFIER).navigation_policy(), + InputNavigationPolicy::NewBackgroundSurface + ); + assert_eq!( + CurrentInputEvent::keyboard("Enter", PLATFORM_NEW_TAB_MODIFIER).navigation_policy(), + InputNavigationPolicy::NewBackgroundSurface + ); + assert_eq!( + CurrentInputEvent::keyboard(" ", PLATFORM_NEW_TAB_MODIFIER).navigation_policy(), + InputNavigationPolicy::Current + ); + } +} diff --git a/moli-renderer-v8/src/native_bridge/element.rs b/moli-renderer-v8/src/native_bridge/element.rs index 35be7b11a8..046e06f361 100644 --- a/moli-renderer-v8/src/native_bridge/element.rs +++ b/moli-renderer-v8/src/native_bridge/element.rs @@ -125,7 +125,7 @@ pub(crate) use activation::{ pub(crate) use activation::{ 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, + finish_legacy_activation_for_dispatched_click, perform_auxiliary_link_default_action, perform_click_default_action_for_dispatched_event, perform_drop_default_action, prepare_legacy_activation_for_dispatched_click, replace_contenteditable_selection, scroll_to_url_fragment_or_top, select_contenteditable_contents, 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 5e9b845b6a..04bbf650cc 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 @@ -13,6 +13,7 @@ use crate::{ document_runtime::{DomHandle, EventTargetHandle}, frame_owner_model::DocumentId, native_bridge::context_host::ChildBrowsingContextBootstrap, + native_bridge::{CurrentInputEvent, InputNavigationPolicy, navigation_policy_from_event}, runtime::RendererDocumentLifecycleIdentity, }; @@ -730,45 +731,8 @@ fn dispatch_click_event( Some(dispatch_public_event(scope, runtime_ptr, handle, event)) } -fn click_handle_internal( - scope: &mut v8::PinScope<'_, '_>, - runtime_ptr: *mut JsContextHost, - handle: DomHandle, - x: f64, - y: f64, - button: i32, - buttons: i32, - click_detail: i32, - modifiers: u8, - user_initiated: bool, -) -> RendererInputDispatchOutcome { - let popup_disposition = current_click_popup_disposition(button, modifiers, user_initiated); - let previous_popup_disposition = unsafe { &mut *runtime_ptr } - .replace_current_input_popup_disposition(Some(popup_disposition)); - let outcome = click_handle_internal_with_current_input( - scope, - runtime_ptr, - handle, - x, - y, - button, - buttons, - click_detail, - modifiers, - user_initiated, - ); - let replaced_popup_disposition = unsafe { &mut *runtime_ptr } - .replace_current_input_popup_disposition(previous_popup_disposition); - assert_eq!( - replaced_popup_disposition, - Some(popup_disposition), - "the current click popup disposition must remain scoped to its activation" - ); - outcome -} - #[allow(clippy::too_many_arguments)] -fn click_handle_internal_with_current_input( +fn click_handle_internal( scope: &mut v8::PinScope<'_, '_>, runtime_ptr: *mut JsContextHost, handle: DomHandle, @@ -1045,6 +1009,23 @@ fn click_activation_default_action_handle( None } +fn hyperlink_activation_default_action_handle( + runtime: &JsContextHost, + target: DomHandle, +) -> Option { + let mut current = Some(target); + while let Some(handle) = current { + if is_disabled_form_control(runtime, handle) { + return None; + } + if element_is_anchor_with_href(runtime, handle) { + return Some(handle); + } + current = runtime.dom_host().parent_node(handle); + } + None +} + enum ClickActivationDefaultAction { Element(DomHandle), LabelControl(DomHandle), @@ -1203,6 +1184,26 @@ pub(crate) fn perform_click_default_action_for_dispatched_event( ); } +pub(crate) fn perform_auxiliary_link_default_action( + scope: &mut v8::PinScope<'_, '_>, + runtime_ptr: *mut JsContextHost, + target: DomHandle, + button: i32, + modifiers: u8, + pending_child_navigations_before_default: &[(DomHandle, ChildBrowsingContextBootstrap)], +) -> Option { + let handle = hyperlink_activation_default_action_handle(unsafe { &*runtime_ptr }, target)?; + anchor_click_default_action( + scope, + runtime_ptr, + handle, + button, + modifiers, + true, + pending_child_navigations_before_default, + ) +} + pub(crate) enum DispatchedClickLegacyActivation { Checkbox { handle: DomHandle, @@ -1855,7 +1856,8 @@ fn anchor_click_default_action( suggested_filename, ); } - let navigation_policy = hyperlink_navigation_policy(button, modifiers, user_initiated); + let navigation_policy = + hyperlink_navigation_policy(button, modifiers, runtime.current_input_event()); if navigation_policy == HyperlinkNavigationPolicy::Download { return anchor_download_activation( scope, @@ -2015,47 +2017,19 @@ enum HyperlinkNavigationPolicy { fn hyperlink_navigation_policy( button: i32, modifiers: u8, - user_initiated: bool, + current_input: Option, ) -> HyperlinkNavigationPolicy { - const ALT: u8 = 1; - const SHIFT: u8 = 8; - - #[cfg(target_os = "macos")] - let platform_new_tab_modifier = 4; - #[cfg(not(target_os = "macos"))] - let platform_new_tab_modifier = 2; - - let requests_new_tab = button == 1 || modifiers & platform_new_tab_modifier != 0; - let shift = modifiers & SHIFT != 0; - let alt = modifiers & ALT != 0; - if requests_new_tab { - let disposition = if shift || !user_initiated { - RendererPopupDisposition::Foreground - } else { - RendererPopupDisposition::Background - }; - HyperlinkNavigationPolicy::Auxiliary(disposition) - } else if shift { - // The renderer target model has no separate window chrome. Preserve - // Chromium's selected-surface behavior by folding a new window into a - // foreground auxiliary target. - HyperlinkNavigationPolicy::Auxiliary(RendererPopupDisposition::Foreground) - } else if alt && user_initiated { - HyperlinkNavigationPolicy::Download - } else { - HyperlinkNavigationPolicy::Current - } -} - -fn current_click_popup_disposition( - button: i32, - modifiers: u8, - user_initiated: bool, -) -> RendererPopupDisposition { - match hyperlink_navigation_policy(button, modifiers, user_initiated) { - HyperlinkNavigationPolicy::Auxiliary(disposition) => disposition, - HyperlinkNavigationPolicy::Current | HyperlinkNavigationPolicy::Download => { - RendererPopupDisposition::Foreground + match navigation_policy_from_event(button, modifiers, current_input) { + InputNavigationPolicy::Current => HyperlinkNavigationPolicy::Current, + InputNavigationPolicy::Download => HyperlinkNavigationPolicy::Download, + InputNavigationPolicy::NewBackgroundSurface => { + HyperlinkNavigationPolicy::Auxiliary(RendererPopupDisposition::Background) + } + InputNavigationPolicy::NewWindow | InputNavigationPolicy::NewForegroundSurface => { + // The renderer target model has no separate window chrome. Preserve + // Chromium's selected-surface behavior by folding a new window into a + // foreground auxiliary target. + HyperlinkNavigationPolicy::Auxiliary(RendererPopupDisposition::Foreground) } } } @@ -2394,11 +2368,11 @@ mod hyperlink_popup_disposition_tests { #[test] fn ordinary_click_uses_the_declared_target() { assert_eq!( - hyperlink_navigation_policy(0, 0, true), + hyperlink_navigation_policy(0, 0, None), HyperlinkNavigationPolicy::Current ); assert_eq!( - hyperlink_navigation_policy(0, NON_PLATFORM_NEW_TAB_MODIFIER, true), + hyperlink_navigation_policy(0, NON_PLATFORM_NEW_TAB_MODIFIER, None), HyperlinkNavigationPolicy::Current ); } @@ -2406,19 +2380,35 @@ mod hyperlink_popup_disposition_tests { #[test] fn trusted_new_context_input_matches_chromium_surface_selection() { assert_eq!( - hyperlink_navigation_policy(1, 0, true), + hyperlink_navigation_policy(1, 0, Some(CurrentInputEvent::mouse("mouseup", 1, 0)),), HyperlinkNavigationPolicy::Auxiliary(RendererPopupDisposition::Background) ); assert_eq!( - hyperlink_navigation_policy(0, PLATFORM_NEW_TAB_MODIFIER, true), + hyperlink_navigation_policy( + 0, + PLATFORM_NEW_TAB_MODIFIER, + Some(CurrentInputEvent::mouse( + "mouseup", + 0, + PLATFORM_NEW_TAB_MODIFIER, + )), + ), HyperlinkNavigationPolicy::Auxiliary(RendererPopupDisposition::Background) ); assert_eq!( - hyperlink_navigation_policy(0, SHIFT, true), + hyperlink_navigation_policy( + 0, + SHIFT, + Some(CurrentInputEvent::mouse("mouseup", 0, SHIFT)), + ), HyperlinkNavigationPolicy::Auxiliary(RendererPopupDisposition::Foreground) ); assert_eq!( - hyperlink_navigation_policy(1, SHIFT, true), + hyperlink_navigation_policy( + 1, + SHIFT, + Some(CurrentInputEvent::mouse("mouseup", 1, SHIFT)), + ), HyperlinkNavigationPolicy::Auxiliary(RendererPopupDisposition::Foreground) ); } @@ -2426,11 +2416,11 @@ mod hyperlink_popup_disposition_tests { #[test] fn synthetic_new_tab_input_cannot_create_a_background_tab_under() { assert_eq!( - hyperlink_navigation_policy(1, 0, false), + hyperlink_navigation_policy(1, 0, None), HyperlinkNavigationPolicy::Auxiliary(RendererPopupDisposition::Foreground) ); assert_eq!( - hyperlink_navigation_policy(0, PLATFORM_NEW_TAB_MODIFIER, false), + hyperlink_navigation_policy(0, PLATFORM_NEW_TAB_MODIFIER, None), HyperlinkNavigationPolicy::Auxiliary(RendererPopupDisposition::Foreground) ); } @@ -2438,11 +2428,11 @@ mod hyperlink_popup_disposition_tests { #[test] fn only_trusted_alt_click_requests_a_download() { assert_eq!( - hyperlink_navigation_policy(0, ALT, true), + hyperlink_navigation_policy(0, ALT, Some(CurrentInputEvent::mouse("mouseup", 0, ALT)),), HyperlinkNavigationPolicy::Download ); assert_eq!( - hyperlink_navigation_policy(0, ALT, false), + hyperlink_navigation_policy(0, ALT, None), HyperlinkNavigationPolicy::Current ); } 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 094e725d54..1d8682ddc3 100644 --- a/moli-renderer-v8/src/native_bridge/element/activation/mod.rs +++ b/moli-renderer-v8/src/native_bridge/element/activation/mod.rs @@ -7,7 +7,7 @@ pub(in crate::native_bridge) use default_action::navigate_form_target_browsing_c pub(crate) use default_action::{ 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, + finish_legacy_activation_for_dispatched_click, perform_auxiliary_link_default_action, perform_click_default_action_for_dispatched_event, perform_drop_default_action, prepare_legacy_activation_for_dispatched_click, replace_contenteditable_selection, scroll_to_url_fragment_or_top, select_contenteditable_contents, diff --git a/moli-renderer-v8/src/native_bridge/mod.rs b/moli-renderer-v8/src/native_bridge/mod.rs index 880cb3f4ae..8812f993c1 100644 --- a/moli-renderer-v8/src/native_bridge/mod.rs +++ b/moli-renderer-v8/src/native_bridge/mod.rs @@ -5,10 +5,14 @@ mod bridge; mod child_window_surface; mod collections; mod context_host; +mod current_input; pub(crate) use context_host::{ JsContextHost, JsContextHostPageTaskCapabilities, PendingScrollObservableEffects, PostParseAutofocusAdmission, ServiceWorkerWindowOwner, }; +pub(crate) use current_input::{ + CurrentInputEvent, CurrentInputEventScope, InputNavigationPolicy, navigation_policy_from_event, +}; pub(crate) mod document; pub(crate) mod element; mod helpers; diff --git a/moli-renderer-v8/src/script_vm/input_dispatch.rs b/moli-renderer-v8/src/script_vm/input_dispatch.rs index f1865eeff1..848c708a3c 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::rc::Rc; use super::input_dispatch_outcome; use super::input_helpers::{ @@ -11,7 +12,6 @@ use super::inspector::{ use super::{ActiveDragSession, ActiveScrollbarDrag, ActiveTouchPoint, ScriptVm}; use crate::document_runtime::DomHandle; use crate::dom::native::{Node, SelectedFile}; -use crate::native_bridge::PointerCaptureDispatchEvent; use crate::native_bridge::element::{ TouchEventPoint, activate_handle_via_click, activate_handle_via_click_with_detail_and_modifiers, cache_input_files_from_selected_files, @@ -22,13 +22,16 @@ use crate::native_bridge::element::{ 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, observable_input_hit_test, - observable_input_surface_hit_test, perform_drop_default_action, - perform_mouse_focus_default_action, perform_scrollbar_scroll_default_action, - perform_wheel_scroll_default_action, replace_contenteditable_selection, - replace_text_control_selection, select_contenteditable_contents, - text_control_set_selection_range_internal, + observable_input_surface_hit_test, perform_auxiliary_link_default_action, + perform_drop_default_action, perform_mouse_focus_default_action, + perform_scrollbar_scroll_default_action, perform_wheel_scroll_default_action, + replace_contenteditable_selection, replace_text_control_selection, + select_contenteditable_contents, text_control_set_selection_range_internal, text_control_set_selection_range_with_direction_internal, text_control_value, update_focus, }; +use crate::native_bridge::{ + CurrentInputEvent, CurrentInputEventScope, PointerCaptureDispatchEvent, +}; use crate::runtime::{ RendererDragData, RendererInputDispatchOutcome, RendererPointerEventProperties, RendererTouchPoint, @@ -72,6 +75,12 @@ fn can_suppress_compat_mouse_event(event_name: &str) -> bool { const MOUSE_POINTER_ID: i32 = 1; const TOUCH_POINTER_ID: i32 = 2; +struct PreparedMouseInputDispatch { + button: i32, + buttons: i32, + released_press: Option, +} + fn touch_pointer_id(touch_id: i32) -> i32 { TOUCH_POINTER_ID.saturating_add(touch_id.max(0)) } @@ -362,19 +371,22 @@ impl ScriptVm { pointer: RendererPointerEventProperties, modifiers: u8, ) -> Result { - let result = self - .dispatch_mouse_event_at_point_with_pointer_and_modifiers_without_checkpoint( - x, - y, - event_name, - button, - buttons, - click_count, - delta_x, - delta_y, - pointer, - modifiers, - ); + let prepared = self.prepare_mouse_input_dispatch(event_name, button, buttons); + let _current_input_event = CurrentInputEventScope::enter( + Rc::clone(&self._context_host), + CurrentInputEvent::mouse(event_name, prepared.button, modifiers), + ); + let result = self.dispatch_mouse_event_at_point_with_pointer_and_modifiers_body( + x, + y, + event_name, + prepared, + click_count, + delta_x, + delta_y, + pointer, + modifiers, + ); self.finish_input_event_dispatch_turn(result) } @@ -428,6 +440,30 @@ impl ScriptVm { pointer: RendererPointerEventProperties, modifiers: u8, ) -> Result { + let prepared = self.prepare_mouse_input_dispatch(event_name, button, buttons); + let _current_input_event = CurrentInputEventScope::enter( + Rc::clone(&self._context_host), + CurrentInputEvent::mouse(event_name, prepared.button, modifiers), + ); + self.dispatch_mouse_event_at_point_with_pointer_and_modifiers_body( + x, + y, + event_name, + prepared, + click_count, + delta_x, + delta_y, + pointer, + modifiers, + ) + } + + fn prepare_mouse_input_dispatch( + &mut self, + event_name: &str, + button: i32, + buttons: Option, + ) -> PreparedMouseInputDispatch { let previous_pressed_buttons = self.pressed_mouse_buttons; let released_press = if event_name == "mouseup" { self.pending_mouse_press.take() @@ -474,6 +510,31 @@ impl ScriptVm { } } let buttons = buttons.unwrap_or(self.pressed_mouse_buttons); + PreparedMouseInputDispatch { + button, + buttons, + released_press, + } + } + + #[allow(clippy::too_many_arguments)] + fn dispatch_mouse_event_at_point_with_pointer_and_modifiers_body( + &mut self, + x: f64, + y: f64, + event_name: &str, + prepared: PreparedMouseInputDispatch, + click_count: i32, + delta_x: f64, + delta_y: f64, + pointer: RendererPointerEventProperties, + modifiers: u8, + ) -> Result { + let PreparedMouseInputDispatch { + button, + buttons, + released_press, + } = prepared; if let Some(outcome) = self.dispatch_active_scrollbar_mouse_event(x, y, event_name, button, buttons)? @@ -967,6 +1028,11 @@ impl ScriptVm { if event_name == "mouseup" { suppress_compat_mouse_events = false; } + let had_pending_top_level_navigation_before_event = + unsafe { &*runtime_ptr }.has_pending_location_navigation(); + let pending_child_navigations_before_event = unsafe { &*runtime_ptr } + .pending_live_child_browsing_context_navigation_snapshot(); + let mut pending_download = None; if let Some(event) = construct_mouse_event_with_modifiers( scope, follow_up_event_name, @@ -976,7 +1042,27 @@ impl ScriptVm { buttons, modifiers, ) { - let _ = dispatch_public_event(scope, runtime_ptr, handle, event); + let dispatched = dispatch_public_event(scope, runtime_ptr, handle, event); + if follow_up_event_name == "auxclick" && dispatched.allows_default() { + pending_download = perform_auxiliary_link_default_action( + scope, + runtime_ptr, + handle, + button, + modifiers, + &pending_child_navigations_before_event, + ); + } + } + if follow_up_event_name == "auxclick" { + return Ok(RendererInputDispatchOutcome { + handled: true, + triggered_top_level_navigation: + !had_pending_top_level_navigation_before_event + && unsafe { &*runtime_ptr }.has_pending_location_navigation(), + pending_download, + pending_file_chooser: None, + }); } } None => {} @@ -1708,6 +1794,10 @@ impl ScriptVm { auto_repeat: bool, should_insert_text: bool, ) -> Result { + let _current_input_event = CurrentInputEventScope::enter( + Rc::clone(&self._context_host), + CurrentInputEvent::keyboard(key, modifiers), + ); let handle = self .document_runtime .active_element_handle() 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 d30f8ce96a..96cbd3ea20 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 @@ -3727,7 +3727,61 @@ fn trusted_alt_click_downloads_an_anchor_without_a_download_attribute() { } #[test] -fn window_open_in_click_listener_uses_current_input_popup_disposition() { +fn alt_input_does_not_turn_window_open_into_a_download_or_background_popup() { + let mut vm = new_storage_test_vm("https://alt-window-open.test/path/index.html"); + vm.eval( + r#"const html = document.createElement('html'); + const body = document.createElement('body'); + body.style.margin = '0'; + const button = document.createElement('button'); + button.style.cssText = 'display:block;width:100px;height:100px'; + button.addEventListener('click', () => window.open('/target', '_blank')); + body.appendChild(button); + html.appendChild(body); + document.appendChild(html); + 'installed'"#, + ) + .expect("Alt window.open fixture should initialize"); + + vm.dispatch_mouse_event_at_point_with_pointer_and_modifiers( + 20.0, + 20.0, + "mousedown", + 0, + None, + 1, + 0.0, + 0.0, + crate::runtime::RendererPointerEventProperties::default(), + 1, + ) + .expect("Alt mousedown should dispatch"); + let outcome = vm + .dispatch_mouse_event_at_point_with_pointer_and_modifiers( + 20.0, + 20.0, + "mouseup", + 0, + None, + 1, + 0.0, + 0.0, + crate::runtime::RendererPointerEventProperties::default(), + 1, + ) + .expect("Alt mouseup should dispatch"); + + assert!(outcome.pending_download.is_none()); + let popups = vm.take_pending_popup_activations(); + assert_eq!(popups.len(), 1); + assert_eq!( + popups[0].disposition(), + crate::RendererPopupDisposition::Foreground + ); +} + +#[test] +fn window_open_in_click_listener_uses_current_input_event() { #[cfg(target_os = "macos")] let (platform_modifier, synthetic_modifier) = (4, "metaKey: true"); #[cfg(not(target_os = "macos"))] @@ -3821,6 +3875,211 @@ fn window_open_in_click_listener_uses_current_input_popup_disposition() { ); } +#[test] +fn current_mouseup_input_covers_pointer_mouse_click_and_microtask_callbacks() { + #[cfg(target_os = "macos")] + let platform_modifier = 4; + #[cfg(not(target_os = "macos"))] + let platform_modifier = 2; + + let mut vm = new_storage_test_vm("https://window-open-input-scope.test/path/index.html"); + vm.eval( + r#" +(() => { + const html = document.createElement('html'); + const body = document.createElement('body'); + body.style.margin = '0'; + const button = document.createElement('button'); + button.style.cssText = 'display:block;width:100px;height:100px'; + const nested = document.createElement('button'); + nested.addEventListener('click', () => window.open('/nested-click', '_blank')); + button.addEventListener('pointerup', () => window.open('/pointerup', '_blank')); + button.addEventListener('mouseup', () => window.open('/mouseup', '_blank')); + button.addEventListener('click', () => { + window.open('/click', '_blank'); + nested.click(); + Promise.resolve().then(() => window.open('/microtask', '_blank')); + }); + body.append(button, nested); + html.appendChild(body); + document.appendChild(html); +})() +"#, + ) + .expect("current input scope fixture should initialize"); + + vm.dispatch_mouse_event_at_point_with_pointer_and_modifiers( + 20.0, + 20.0, + "mousedown", + 0, + None, + 1, + 0.0, + 0.0, + crate::runtime::RendererPointerEventProperties::default(), + platform_modifier, + ) + .expect("new-tab-modified mousedown should dispatch"); + vm.dispatch_mouse_event_at_point_with_pointer_and_modifiers( + 20.0, + 20.0, + "mouseup", + 0, + None, + 1, + 0.0, + 0.0, + crate::runtime::RendererPointerEventProperties::default(), + platform_modifier, + ) + .expect("new-tab-modified mouseup should dispatch"); + + let popups = vm.take_pending_popup_activations(); + assert_eq!( + popups.iter().map(|popup| popup.url()).collect::>(), + vec![ + "https://window-open-input-scope.test/pointerup", + "https://window-open-input-scope.test/mouseup", + "https://window-open-input-scope.test/click", + "https://window-open-input-scope.test/nested-click", + "https://window-open-input-scope.test/microtask", + ] + ); + assert!( + popups + .iter() + .all(|popup| { popup.disposition() == crate::RendererPopupDisposition::Background }) + ); + + vm.eval("window.open('/after-input', '_blank'); 'done'") + .expect("post-input window.open should evaluate"); + let after_input = vm.take_pending_popup_activations(); + assert_eq!(after_input.len(), 1); + assert_eq!( + after_input[0].disposition(), + crate::RendererPopupDisposition::Foreground, + "the real input policy must be restored after its dispatch turn" + ); +} + +#[test] +fn middle_auxclick_runs_the_anchor_default_action_in_the_background() { + let mut vm = new_storage_test_vm("https://middle-auxclick.test/path/index.html"); + vm.eval( + r#"const html = document.createElement('html'); + const body = document.createElement('body'); + body.style.margin = '0'; + const link = document.createElement('a'); + link.href = '/anchor-target'; + link.style.cssText = 'display:block;width:100px;height:100px'; + body.appendChild(link); + html.appendChild(body); + document.appendChild(html); + window.__auxclickEvents = []; + link.addEventListener('auxclick', event => { + __auxclickEvents.push(`${event.type}:${event.button}:${event.isTrusted}`); + if (event.button === 1) window.open('/listener-target', '_blank'); + }); 'installed'"#, + ) + .expect("auxclick listener should install"); + + vm.dispatch_mouse_event_at_point(20.0, 20.0, "mousedown", 1, None, 0.0, 0.0) + .expect("middle mousedown should dispatch"); + let outcome = vm + .dispatch_mouse_event_at_point(20.0, 20.0, "mouseup", 1, None, 0.0, 0.0) + .expect("middle mouseup should dispatch"); + assert!(outcome.handled); + assert!(!outcome.triggered_top_level_navigation); + assert_eq!( + vm.eval("__auxclickEvents.join('|')") + .expect("auxclick event log should evaluate"), + "auxclick:1:true" + ); + + let popups = vm.take_pending_popup_activations(); + assert_eq!( + popups.iter().map(|popup| popup.url()).collect::>(), + vec![ + "https://middle-auxclick.test/listener-target", + "https://middle-auxclick.test/anchor-target", + ] + ); + assert!( + popups + .iter() + .all(|popup| { popup.disposition() == crate::RendererPopupDisposition::Background }) + ); + assert!(vm.take_pending_location_navigation_with_seed().is_none()); +} + +#[test] +fn canceling_middle_auxclick_suppresses_the_anchor_default_action() { + let mut vm = new_storage_test_vm("https://middle-auxclick-canceled.test/path/index.html"); + vm.eval( + r#"const html = document.createElement('html'); + const body = document.createElement('body'); + body.style.margin = '0'; + const link = document.createElement('a'); + link.href = '/anchor-target'; + link.style.cssText = 'display:block;width:100px;height:100px'; + link.addEventListener('auxclick', event => event.preventDefault()); + body.appendChild(link); + html.appendChild(body); + document.appendChild(html); + 'installed'"#, + ) + .expect("canceling auxclick listener should install"); + + vm.dispatch_mouse_event_at_point(20.0, 20.0, "mousedown", 1, None, 0.0, 0.0) + .expect("middle mousedown should dispatch"); + vm.dispatch_mouse_event_at_point(20.0, 20.0, "mouseup", 1, None, 0.0, 0.0) + .expect("middle mouseup should dispatch"); + + assert!(vm.take_pending_popup_activations().is_empty()); + assert!(vm.take_pending_location_navigation_with_seed().is_none()); +} + +#[test] +fn enter_key_window_open_inherits_the_real_input_modifiers() { + #[cfg(target_os = "macos")] + let platform_modifier = 4; + #[cfg(not(target_os = "macos"))] + let platform_modifier = 2; + + let mut vm = new_storage_test_vm("https://enter-window-open.test/path/index.html"); + vm.eval( + r#"const html = document.createElement('html'); + const body = document.createElement('body'); + const button = document.createElement('button'); + body.appendChild(button); + html.appendChild(body); + document.appendChild(html); + button.addEventListener('click', () => window.open('/target', '_blank')); + button.focus(); + 'installed'"#, + ) + .expect("Enter activation fixture should initialize"); + + vm.dispatch_key_event( + "keydown", + "Enter", + "Enter", + "", + platform_modifier, + false, + false, + ) + .expect("new-tab-modified Enter should dispatch"); + + let popups = vm.take_pending_popup_activations(); + assert_eq!(popups.len(), 1); + assert_eq!( + popups[0].disposition(), + crate::RendererPopupDisposition::Background + ); +} + #[test] fn keyboard_activation_click_preserves_modifiers() { let mut vm = new_parsed_test_vm(