diff --git a/moli-core/src/page/settings_support.rs b/moli-core/src/page/settings_support.rs index 6a2845f4fd..4634c881ef 100644 --- a/moli-core/src/page/settings_support.rs +++ b/moli-core/src/page/settings_support.rs @@ -228,12 +228,18 @@ impl Page { ) -> Result { let pending = self.start_page_command(RendererPageCommand::SetIdleOverride(idle_override))?; + self.record_admitted_idle_override(idle_override); + Ok(pending) + } + + /// Records frame-host state after either a typed or frontend command has + /// been admitted. It belongs to this Page, not the target's replay policy. + pub fn record_admitted_idle_override(&mut self, idle_override: Option) { // SetIdleOverride is synchronous browser-side state in Chromium. Make // it visible at command admission so a navigation from another CDP // session cannot observe an older protocol snapshot after the renderer // has already accepted the command. self.idle_override = idle_override; - Ok(pending) } pub fn finish_set_idle_override(&mut self, completion: CompletedPageCommand) -> Result<()> { diff --git a/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs b/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs index a34011a396..f9983011ca 100644 --- a/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs +++ b/moli-protocol-server/src/protocol_server/tests/cdp_dynamic_page.rs @@ -395,6 +395,282 @@ async fn native_snapshot_reply_precedes_later_inspector_context_replay() { abort_test_cdp_server(server).await; } +#[tokio::test(flavor = "current_thread")] +async fn native_node_queries_publish_before_later_inspector_replay() { + let (addr, server) = spawn_test_protocol_server().await; + let (mut browser, _) = + connect_async(format!("ws://{addr}/devtools/browser/{DEFAULT_BROWSER_ID}")) + .await + .unwrap(); + let context = cdp_create_browser_context(&mut browser, 1).await; + let target = cdp_create_attached_target(&mut browser, 2, &context).await; + let session = Some(target.session_id.as_str()); + send_cdp_command(&mut browser, 4, "Runtime.evaluate", session, json!({ + "expression": "document.head.innerHTML = ''; document.body.innerHTML = ''" + })).await; + let document = send_cdp_command(&mut browser, 5, "DOM.getDocument", session, json!({})).await; + let root = response_by_id(&document, 5)["result"]["root"]["nodeId"] + .as_u64() + .unwrap(); + let node = send_cdp_command( + &mut browser, + 6, + "DOM.querySelector", + session, + json!({"nodeId": root, "selector": "#target"}), + ) + .await; + let node_id = response_by_id(&node, 6)["result"]["nodeId"] + .as_u64() + .unwrap(); + let described = send_cdp_command( + &mut browser, + 7, + "DOM.describeNode", + session, + json!({"nodeId": node_id}), + ) + .await; + let backend_id = response_by_id(&described, 7)["result"]["node"]["backendNodeId"] + .as_u64() + .unwrap(); + let queries = [ + ("CSS.getComputedStyleForNode", json!({"nodeId": node_id})), + ("Accessibility.getPartialAXTree", json!({"nodeId": node_id})), + ("CSS.getComputedStyleForNode", json!({"nodeId": 2147483647})), + ("CSS.getInlineStylesForNode", json!({"nodeId": node_id})), + ("Accessibility.getRootAXNode", json!({})), + ("CSS.enable", json!({})), + ("DOM.getAttributes", json!({"nodeId": node_id})), + ("DOM.describeNode", json!({"nodeId": node_id})), + ( + "DOM.querySelector", + json!({"nodeId": root, "selector": "#target"}), + ), + ("DOM.getOuterHTML", json!({"nodeId": node_id})), + ( + "DOM.setAttributeValue", + json!({"nodeId": node_id, "name": "data-order", "value": "ready"}), + ), + ("DOM.focus", json!({"nodeId": node_id})), + ("DOM.scrollIntoViewIfNeeded", json!({"nodeId": node_id})), + ("DOM.setNodeStackTracesEnabled", json!({"enable": true})), + ("DOM.getNodeStackTraces", json!({"nodeId": node_id})), + ( + "DOM.removeAttribute", + json!({"nodeId": node_id, "name": "data-order"}), + ), + ("DOM.disable", json!({})), + ("Page.getLayoutMetrics", json!({})), + ("Page.captureSnapshot", json!({})), + ("Page.setBypassCSP", json!({"enabled": true})), + ("Page.resetNavigationHistory", json!({})), + ( + "DOM.resolveNode", + json!({"backendNodeId": backend_id, "objectGroup": "native-order"}), + ), + ( + "DOMDebugger.setXHRBreakpoint", + json!({"url": "native-order"}), + ), + ( + "DOMDebugger.removeXHRBreakpoint", + json!({"url": "native-order"}), + ), + ( + "Autofill.trigger", + json!({"fieldId": 0, "card": {"number": "4111111111111111", "name": "Test", "expiryMonth": "12", "expiryYear": "2030", "cvc": "123"}}), + ), + ( + "Page.createIsolatedWorld", + json!({"frameId": target.target_id, "worldName": "native-order"}), + ), + ( + "Page.addScriptToEvaluateOnNewDocument", + json!({"source": "window.nativePreloadRan = true", "runImmediately": true}), + ), + ("Page.getResourceTree", json!({})), + ( + "Emulation.setHardwareConcurrencyOverride", + json!({"hardwareConcurrency": 4}), + ), + ( + "Emulation.setDataSaverOverride", + json!({"dataSaverEnabled": true}), + ), + ("Emulation.setAutomationOverride", json!({"enabled": true})), + ( + "Emulation.setTouchEmulationEnabled", + json!({"enabled": true, "maxTouchPoints": 2}), + ), + ("Emulation.setEmulatedMedia", json!({"media": "print"})), + ( + "Emulation.setFocusEmulationEnabled", + json!({"enabled": true}), + ), + ( + "Emulation.setGeolocationOverride", + json!({"latitude": 40.0, "longitude": 116.0, "accuracy": 1.0}), + ), + ("Emulation.clearGeolocationOverride", json!({})), + ( + "Emulation.setIdleOverride", + json!({"isUserActive": false, "isScreenUnlocked": false}), + ), + ("Emulation.clearIdleOverride", json!({})), + ( + "Emulation.setDeviceMetricsOverride", + json!({"width": 900, "height": 650, "deviceScaleFactor": 1, "mobile": false}), + ), + ("Emulation.clearDeviceMetricsOverride", json!({})), + ]; + let response_end = 10 + queries.len() as u64; + for (offset, (method, params)) in queries.into_iter().enumerate() { + send_cdp_command_without_wait(&mut browser, 10 + offset as u64, method, session, params) + .await; + } + send_cdp_command_without_wait(&mut browser, 140, "Runtime.enable", session, json!({})).await; + let mut replies = Vec::new(); + let mut preload_identifier = None; + let mut resolved_object = None; + let mut saw_stylesheet = false; + let mut saw_context = false; + recv_until_match(&mut browser, |message| { + if message["sessionId"] != target.session_id { + return false; + } + if message["method"] == "CSS.styleSheetAdded" { + assert!( + !replies.contains(&15), + "CSS notification must precede its ready response" + ); + saw_stylesheet = true; + } + if let Some(id) = message["id"].as_u64() + && (10..response_end).contains(&id) + { + assert!(!replies.contains(&id), "duplicate response: {message}"); + if id == 12 { + assert_eq!(message["error"]["code"], -32000); + } else if id == 34 { + assert_eq!(message["error"]["code"], -32600); + } else { + assert!(message["error"].is_null(), "{message}"); + } + match id { + 10 => assert!(message["result"]["computedStyle"].as_array().is_some_and( + |styles| { + styles.iter().any(|style| { + style["name"] == "color" && style["value"] == "rgb(255, 0, 0)" + }) + } + )), + 11 => assert!(message["result"]["nodes"].as_array().is_some_and(|nodes| { + nodes.iter().any(|node| { + node["role"]["value"] == "button" && node["name"]["value"] == "native query" + }) + })), + 13 => assert!(message["result"]["inlineStyle"]["cssProperties"].is_array()), + 14 => assert!(message["result"]["node"].is_object()), + 15 => assert!(saw_stylesheet), + 16 => assert!( + message["result"]["attributes"] + .as_array() + .is_some_and(|attributes| attributes.contains(&json!("target"))) + ), + 17 => assert_eq!(message["result"]["node"]["nodeName"], "BUTTON"), + 18 => assert_eq!(message["result"]["nodeId"], node_id), + 19 => assert!( + message["result"]["outerHTML"] + .as_str() + .is_some_and(|html| html.contains("native query")) + ), + 27 => assert!(message["result"]["cssLayoutViewport"].is_object()), + 28 => assert!( + message["result"]["data"] + .as_str() + .is_some_and(|snapshot| snapshot.contains("MIME-Version: 1.0")) + ), + 35 => assert!(message["result"]["executionContextId"].as_i64().is_some()), + 36 => { + preload_identifier = + message["result"]["identifier"].as_str().map(str::to_owned); + } + 37 => assert!(message["result"]["frameTree"]["resources"].is_array()), + 31 => { + resolved_object = message["result"]["object"]["objectId"] + .as_str() + .map(str::to_owned); + } + _ => {} + } + replies.push(id); + } + if message["method"] == "Runtime.executionContextCreated" + && message["params"]["context"]["auxData"]["isDefault"] == true + { + assert_eq!( + replies, + (10..response_end).collect::>(), + "native lookup/response was overtaken by Inspector" + ); + saw_context = true; + } + if message["id"] == 140 { + assert!(saw_context); + return true; + } + false + }) + .await; + let state = send_cdp_command(&mut browser, 141, "Runtime.evaluate", session, json!({ + "expression": "({preload: window.nativePreloadRan, focused: document.activeElement.id, attribute: document.querySelector('#target').getAttribute('data-order')})", "returnByValue": true + })).await; + assert_eq!( + response_by_id(&state, 141)["result"]["result"]["value"], + json!({"preload": true, "focused": "target", "attribute": null}) + ); + let object_id = resolved_object.expect("native resolveNode object"); + let listeners = send_cdp_command( + &mut browser, + 142, + "DOMDebugger.getEventListeners", + session, + json!({"objectId": object_id}), + ) + .await; + assert!(response_by_id(&listeners, 142)["result"]["listeners"].is_array()); + let released = send_cdp_command( + &mut browser, + 143, + "Runtime.releaseObjectGroup", + session, + json!({"objectGroup": "native-order"}), + ) + .await; + assert!(response_by_id(&released, 143)["error"].is_null()); + let stale = send_cdp_command( + &mut browser, + 144, + "Runtime.getProperties", + session, + json!({"objectId": object_id}), + ) + .await; + assert_eq!(response_by_id(&stale, 144)["error"]["code"], -32000); + let removed = send_cdp_command( + &mut browser, + 145, + "Page.removeScriptToEvaluateOnNewDocument", + session, + json!({"identifier": preload_identifier.unwrap()}), + ) + .await; + assert!(response_by_id(&removed, 145)["error"].is_null()); + browser.close(None).await.unwrap(); + abort_test_cdp_server(server).await; +} + async fn assert_pause_command_ordering(instrumentation: bool) { let (fixture_addr, _fixture) = spawn_dedicated_fixture_server( Router::new().route( @@ -3658,3 +3934,9 @@ async fn cdp_ordering_autofill_waits_for_isolate_owner() { async fn cdp_ordering_stylesheet_edit_waits_for_isolate_owner() { assert_native_isolate_handler_waits_for_resume("CSS.setStyleSheetText").await; } + +#[tokio::test(flavor = "current_thread")] +async fn cdp_ordering_navigator_configuration_waits_for_isolate_owner() { + assert_native_isolate_handler_waits_for_resume("Emulation.setHardwareConcurrencyOverride") + .await; +} diff --git a/moli-protocol/src/conn/dispatch.rs b/moli-protocol/src/conn/dispatch.rs index 944fb3f387..5f1ab57b5d 100644 --- a/moli-protocol/src/conn/dispatch.rs +++ b/moli-protocol/src/conn/dispatch.rs @@ -869,6 +869,9 @@ impl CdpConnection { self, &cmd, ) .map(|step| match step { + crate::domains::emulation::EmulationCommandTaskStep::Native(pending) => { + self.pending_step(PendingCdpCommandDispatchKind::Native(*pending)) + } crate::domains::emulation::EmulationCommandTaskStep::Pending(pending) => { self.pending_step(PendingCdpCommandDispatchKind::Emulation(pending)) } diff --git a/moli-protocol/src/conn/dispatch_tests/target.rs b/moli-protocol/src/conn/dispatch_tests/target.rs index 697382a3cc..375b257506 100644 --- a/moli-protocol/src/conn/dispatch_tests/target.rs +++ b/moli-protocol/src/conn/dispatch_tests/target.rs @@ -813,22 +813,9 @@ async fn devtools_command_executes_default_preload_add_and_remove_without_loaded #[tokio::test(flavor = "multi_thread")] async fn pending_emulation_viewport_keeps_original_page_when_active_target_changes() { - let mut conn = CdpConnection::new(); - let original_page = conn - .load_page_via_runtime_async("data:text/html,original viewport owner") - .await - .expect("original page should load"); - let replacement_page = conn - .load_page_via_runtime_async("data:text/html,replacement active page") - .await - .expect("replacement page should load"); - + let mut ctx = crate::testing::TestContext::new(); let mut browser_context = BrowserContext::new("BID-emulation-viewport-owner".to_owned()); browser_context.set_active_target_id("TID-emulation-viewport-original".to_owned()); - browser_context - .active_page_target_mut() - .runtime_slot - .set_loaded_page_for_test(original_page); browser_context.stage_background_target( "TID-emulation-viewport-replacement".to_owned(), None, @@ -836,11 +823,27 @@ async fn pending_emulation_viewport_keeps_original_page_when_active_target_chang None, None, ); - browser_context - .background_target_mut("TID-emulation-viewport-replacement") - .expect("replacement target") - .replace_loaded_page(Some(replacement_page)); - conn.install_browser_context_fixture_for_test(browser_context); + ctx.conn + .install_browser_context_fixture_for_test(browser_context); + ctx.install_navigation_fixture_for_session_owner( + "data:text/html,original viewport owner", + None, + ) + .await; + ctx.conn + .select_page_target_for_connection_async("TID-emulation-viewport-replacement") + .await + .unwrap(); + ctx.install_navigation_fixture_for_session_owner( + "data:text/html,replacement active page", + None, + ) + .await; + ctx.conn + .select_page_target_for_connection_async("TID-emulation-viewport-original") + .await + .unwrap(); + ctx.take_all(); let raw = serde_json::to_string(&json!({ "id": 690, @@ -853,7 +856,7 @@ async fn pending_emulation_viewport_keeps_original_page_when_active_target_chang } })) .unwrap(); - let pending = match conn.start_command_dispatch(&raw) { + let pending = match ctx.conn.start_command_dispatch(&raw) { CdpCommandTaskStep::Pending(pending) => pending, CdpCommandTaskStep::Complete(outcome) => { panic!( @@ -864,15 +867,18 @@ async fn pending_emulation_viewport_keeps_original_page_when_active_target_chang }; assert!( - conn.select_page_target_for_connection_async("TID-emulation-viewport-replacement",) + ctx.conn + .select_page_target_for_connection_async("TID-emulation-viewport-replacement",) .await .expect("target activation should succeed") .is_some() ); - let messages = complete_command_task_for_test(&mut conn, *pending).await; + let (messages, _) = ctx + .complete_command_task_step_for_test(CdpCommandTaskStep::Pending(pending)) + .await; assert_eq!(messages, vec![json!({ "id": 690, "result": {} })]); - let browser_context = conn.browser_context.as_ref().expect("browser context"); + let browser_context = ctx.conn.browser_context.as_ref().expect("browser context"); assert_eq!( browser_context.active_target_id(), Some("TID-emulation-viewport-replacement") diff --git a/moli-protocol/src/domains/emulation.rs b/moli-protocol/src/domains/emulation.rs index 8f80897bfb..6f16170295 100644 --- a/moli-protocol/src/domains/emulation.rs +++ b/moli-protocol/src/domains/emulation.rs @@ -23,6 +23,7 @@ use serde_json::json; mod device; mod media; +mod native_configuration; mod page_session; mod params; #[cfg(test)] @@ -84,6 +85,7 @@ enum PendingEmulationPageTarget { } pub(crate) enum EmulationCommandTaskStep { + Native(Box), Pending(PendingEmulationCommandDispatch), Complete(CommandOutputPlan), } @@ -273,6 +275,9 @@ fn start_focus_emulation_enabled_command( }; return EmulationCommandTaskStep::Complete(CommandOutputPlan::error(code, message)); } + if let Some(step) = native_configuration::try_start_surface(conn, cmd) { + return step; + } let pending = match start_surface_override_page_commands(conn, cmd) { Ok(pending) => pending, Err(error) => { @@ -418,6 +423,16 @@ fn start_navigator_override_page_command( let overrides = conn .navigation_load_inputs_for_owner(&owner_scope) .navigator_overrides; + if let Some(step) = native_configuration::try_start( + conn, + cmd, + native_configuration::unit(moli_core::RendererPageCommand::SetNavigatorOverrides( + overrides.clone(), + )), + PendingEmulationPageOperation::SetNavigatorOverrides, + ) { + return step; + } let Some(page) = loaded_page_mut_for_target_configuration(conn, cmd.session_id) else { return EmulationCommandTaskStep::Complete(CommandOutputPlan::result(json!({}))); }; @@ -649,6 +664,24 @@ fn start_update_idle_override_command( return EmulationCommandTaskStep::Complete(CommandOutputPlan::result(json!({}))); } let owner_scope = CommandOwnerScope::capture(conn, cmd.session_id); + if let Some(step) = native_configuration::try_start( + conn, + cmd, + native_configuration::unit(moli_core::RendererPageCommand::SetIdleOverride( + idle_override, + )), + PendingEmulationPageOperation::SetIdleOverride, + ) { + if matches!(step, EmulationCommandTaskStep::Native(_)) { + // Preserve the Browser frame-host admission state while the + // Renderer publishes the terminal independently of its waiter. + conn.loaded_page_mut_for_protocol_access_for_owner(&owner_scope) + .expect("admitted idle command retains its Page until dispatch returns") + .record_admitted_idle_override(idle_override); + } + return step; + } + let Some(page) = loaded_page_mut_for_target_configuration(conn, cmd.session_id) else { return EmulationCommandTaskStep::Complete(CommandOutputPlan::result(json!({}))); }; @@ -758,6 +791,9 @@ fn start_update_geolocation_override_command( "BrowserContextNotLoaded", )); } + if let Some(step) = native_configuration::try_start_surface(conn, cmd) { + return step; + } let pending = match start_surface_override_page_commands(conn, cmd) { Ok(pending) => pending, Err(error) => { @@ -833,6 +869,19 @@ fn start_emulated_media_command( )); } let page_overrides: moli_core::page::EmulatedMediaOverrides = (&overrides).into(); + if !emulation_command_is_context_wide(conn, cmd.session_id) + && let Some(step) = native_configuration::try_start( + conn, + cmd, + native_configuration::unit(moli_core::RendererPageCommand::SetEmulatedMedia( + page_overrides.clone(), + )), + PendingEmulationPageOperation::SetEmulatedMedia, + ) + { + return step; + } + let pending = if emulation_command_is_context_wide(conn, cmd.session_id) { match start_context_emulated_media_page_commands(conn, &page_overrides) { Ok(pending) => pending, @@ -954,6 +1003,22 @@ fn start_device_metrics_override_command( { return EmulationCommandTaskStep::Complete(CommandOutputPlan::result(json!({}))); } + if crate::domains::native::frontend_attachment(conn, cmd).is_some() { + if let Err(error) = record_device_metrics(conn, &owner, &metrics) { + return EmulationCommandTaskStep::Complete(CommandOutputPlan::from_devtools_error( + error, + )); + } + return native_configuration::try_start( + conn, + cmd, + native_configuration::unit(moli_core::RendererPageCommand::SetViewportSurface(Some( + metrics.viewport_surface(), + ))), + PendingEmulationPageOperation::SetViewportSurface, + ) + .expect("native configuration admission"); + } match start_apply_device_metrics(conn, cmd.id, metrics, owner) { Ok(Some(pending)) => EmulationCommandTaskStep::Pending(pending), Ok(None) => EmulationCommandTaskStep::Complete(CommandOutputPlan::success()), @@ -988,6 +1053,17 @@ fn start_clear_device_metrics_override_command( let viewport_surface = conn .navigation_load_inputs_for_owner(&owner_scope) .viewport_surface; + if let Some(step) = native_configuration::try_start( + conn, + cmd, + native_configuration::unit(moli_core::RendererPageCommand::SetViewportSurface( + viewport_surface, + )), + PendingEmulationPageOperation::SetViewportSurface, + ) { + return step; + } + let Some(page) = loaded_page_mut_for_target_configuration(conn, cmd.session_id) else { return EmulationCommandTaskStep::Complete(CommandOutputPlan::result(json!({}))); }; @@ -1024,13 +1100,12 @@ fn start_devtools_set_viewport_command( start_apply_device_metrics(conn, command_id, metrics, owner_scope) } -fn start_apply_device_metrics( +fn record_device_metrics( conn: &mut CdpConnection, - command_id: Option, - metrics: EmulatedDeviceMetrics, - owner_scope: CommandOwnerScope, -) -> Result, DevToolsError> { - if !conn.update_emulation_state_for_owner(&owner_scope, |state| { + owner_scope: &CommandOwnerScope, + metrics: &EmulatedDeviceMetrics, +) -> Result<(), DevToolsError> { + if !conn.update_emulation_state_for_owner(owner_scope, |state| { if let Some(mut state) = state { state.set_emulated_device_metrics(Some(metrics.clone())); } @@ -1040,6 +1115,16 @@ fn start_apply_device_metrics( "BrowserContextNotLoaded", )); } + Ok(()) +} + +fn start_apply_device_metrics( + conn: &mut CdpConnection, + command_id: Option, + metrics: EmulatedDeviceMetrics, + owner_scope: CommandOwnerScope, +) -> Result, DevToolsError> { + record_device_metrics(conn, &owner_scope, &metrics)?; let Some(page) = conn .loaded_page_mut_for_target_configuration_for_owner(&owner_scope) .ok() diff --git a/moli-protocol/src/domains/emulation/native_configuration.rs b/moli-protocol/src/domains/emulation/native_configuration.rs new file mode 100644 index 0000000000..bcd59ab651 --- /dev/null +++ b/moli-protocol/src/domains/emulation/native_configuration.rs @@ -0,0 +1,71 @@ +//! Target-local configuration has a Renderer terminal and Browser replay +//! policy. Only a genuinely unpublished, retired attachment can fall back to +//! the Browser acknowledgement of that policy; a published reply always wins. +use super::*; +use crate::domains::native::{self, NativeCommandStep}; +use moli_core::{ + RendererNativeOperation as Operation, RendererNativeOperationStep as Step, + RendererNativeProtocolResponse as Response, RendererPageCommand as Command, + RendererPageReply as Reply, +}; + +pub(super) fn try_start( + conn: &mut CdpConnection, + cmd: &Cmd<'_>, + operation: Operation, + policy: PendingEmulationPageOperation, +) -> Option { + let attachment = native::frontend_attachment(conn, cmd)?; + let owner = CommandOwnerScope::capture(conn, cmd.session_id); + Some(match native::start_operation(conn, cmd, operation) { + NativeCommandStep::Complete(plan) => EmulationCommandTaskStep::Complete(plan), + NativeCommandStep::Pending(pending) => EmulationCommandTaskStep::Native(Box::new( + pending.on_unpublished_failure(move |conn, error| { + let target = PendingEmulationPageTarget::SessionOwner { owner_scope: owner }; + if pending_emulation_page_configuration_will_be_replayed( + conn, + &target, + &policy, + Some(attachment), + ) { + CommandOutputPlan::success() + } else { + CommandOutputPlan::error(-32000, error) + } + }), + )), + }) +} + +pub(super) fn try_start_surface( + conn: &mut CdpConnection, + cmd: &Cmd<'_>, +) -> Option { + native::frontend_attachment(conn, cmd)?; + let inputs = + conn.navigation_load_inputs_for_owner(&CommandOwnerScope::capture(conn, cmd.session_id)); + let operation = Operation::then( + Command::SetNavigatorOverrides(inputs.navigator_overrides), + move |reply| match reply { + Ok(Reply::Unit) => { + Step::Continue(unit(Command::SetDocumentActivity(inputs.document_activity))) + } + Err(error) => Step::Complete(Response::error(-32000, error.to_string())), + _ => unreachable!("navigator configuration acknowledgement"), + }, + ); + try_start( + conn, + cmd, + operation, + PendingEmulationPageOperation::SetDocumentActivity, + ) +} + +pub(super) fn unit(command: Command) -> Operation { + Operation::new(command, |reply| match reply { + Ok(Reply::Unit) => Response::success(json!({})), + Err(error) => Response::error(-32000, error.to_string()), + _ => unreachable!("configuration acknowledgement"), + }) +} diff --git a/moli-protocol/src/domains/emulation/tests.rs b/moli-protocol/src/domains/emulation/tests.rs index 2b677b6fe7..6b7db8409e 100644 --- a/moli-protocol/src/domains/emulation/tests.rs +++ b/moli-protocol/src/domains/emulation/tests.rs @@ -545,7 +545,16 @@ async fn pending_idle_override_response_does_not_replay_into_replacement_page() else { panic!("the retired idle override should settle in one protocol phase"); }; - assert!(outcome.into_parts().0.iter().any(|message| { + let (mut messages, _) = ctx.route_completed_command_outcome_for_test(outcome).await; + messages.extend(ctx.take_all()); + assert_eq!( + messages + .iter() + .filter(|message| message["id"] == json!(9_105)) + .count(), + 1 + ); + assert!(messages.iter().any(|message| { message["id"] == json!(9_105) && message["sessionId"] == json!("SID-1") && message["result"] == json!({}) @@ -614,7 +623,16 @@ async fn admitted_idle_override_is_visible_to_concurrent_same_site_navigation() else { panic!("the retired idle override should settle in one protocol phase"); }; - assert!(outcome.into_parts().0.iter().any(|message| { + let (mut messages, _) = ctx.route_completed_command_outcome_for_test(outcome).await; + messages.extend(ctx.take_all()); + assert_eq!( + messages + .iter() + .filter(|message| message["id"] == json!(9_107)) + .count(), + 1 + ); + assert!(messages.iter().any(|message| { message["id"] == json!(9_107) && message["sessionId"] == json!("SID-1") && message["result"] == json!({}) @@ -983,13 +1001,7 @@ async fn live_geolocation_override_uses_pending_command_dispatch() { let CdpCommandTaskStep::Pending(pending) = ctx.conn.start_command_dispatch(&raw) else { panic!("loaded Emulation.setGeolocationOverride should update the live page"); }; - let completed = pending.wait().await; - let CdpCommandTaskStep::Complete(outcome) = - ctx.conn.complete_pending_command_dispatch(completed).await - else { - panic!("geolocation override should complete in one renderer phase"); - }; - let messages = outcome.into_parts().0; + let messages = complete_pending_command_task_for_test(&mut ctx, *pending).await; assert!(messages.iter().any(|message| { message["id"] == json!(9127) && message["sessionId"] == json!("SID-1") @@ -1039,7 +1051,15 @@ async fn device_metrics_completion_survives_initial_page_replacement() { else { panic!("device metrics completion should settle after Page replacement"); }; - let messages = outcome.into_parts().0; + let (mut messages, _) = ctx.route_completed_command_outcome_for_test(outcome).await; + messages.extend(ctx.take_all()); + assert_eq!( + messages + .iter() + .filter(|message| message["id"] == json!(9128)) + .count(), + 1 + ); assert!(messages.iter().any(|message| { message["id"] == json!(9128) && message["sessionId"] == json!("SID-1") diff --git a/moli-protocol/src/domains/emulation/tests/native_navigator.rs b/moli-protocol/src/domains/emulation/tests/native_navigator.rs index 21dc4f849b..6a594d55a5 100644 --- a/moli-protocol/src/domains/emulation/tests/native_navigator.rs +++ b/moli-protocol/src/domains/emulation/tests/native_navigator.rs @@ -287,6 +287,10 @@ async fn focus_override_dispatches_native_visibility_and_focus_events() { surfaceEvents.push(['focus', event.isTrusted, event.target === window])); window.addEventListener('blur', event => surfaceEvents.push(['blur', event.isTrusted, event.target === window])); + globalThis.focusArrived = new Promise(resolve => + window.addEventListener('focus', () => resolve(), {once: true})); + globalThis.blurArrived = new Promise(resolve => + window.addEventListener('blur', () => resolve(), {once: true})); [Object.hasOwn(document, 'hidden'), Object.hasOwn(document, 'visibilityState'), Object.hasOwn(document, 'hasFocus')]"#, ) @@ -301,8 +305,10 @@ async fn focus_override_dispatches_native_visibility_and_focus_events() { json!({"enabled": true}), ) .await; + // The configuration acknowledgement does not promise a rendering update. + // Observe the queued native events through listeners installed beforehand. assert_eq!( - evaluate(&mut ctx, "surfaceEvents").await, + evaluate(&mut ctx, "focusArrived.then(() => surfaceEvents)").await, json!([["visibilitychange", true, true], ["focus", true, true]]) ); expect_session_command_result( @@ -314,7 +320,7 @@ async fn focus_override_dispatches_native_visibility_and_focus_events() { ) .await; assert_eq!( - evaluate(&mut ctx, "surfaceEvents.slice(2)").await, + evaluate(&mut ctx, "blurArrived.then(() => surfaceEvents.slice(2))").await, json!([["visibilitychange", true, true], ["blur", true, true]]) ); } @@ -885,3 +891,42 @@ async fn automation_overrides_combine_sessions_without_replacing_native_getters( json!([false, 2]) ); } + +#[tokio::test(flavor = "multi_thread")] +async fn published_configuration_survives_replacement_and_preserves_replay_policy() { + let mut ctx = setup().await; + ctx.take_all(); + let raw = json!({"id": 88200, "sessionId": "SID-1", + "method": "Emulation.setHardwareConcurrencyOverride", + "params": {"hardwareConcurrency": 4}}) + .to_string(); + let pending = ctx.conn.try_start_pending_command_dispatch(&raw).unwrap(); + assert_eq!(pending.kind_name(), "Native"); + // Hold the adapter after the producer committed its terminal, then replace + // the document. Neither replay policy nor completion ownership may depend + // on polling that adapter before the navigation. + let completed = pending.wait().await; + ctx.process_async(json!({"id": 88201, "sessionId": "SID-1", "method": "Page.navigate", + "params": {"url": "data:text/html,"}})).await; + let messages = ctx.take_all(); + let replies = messages + .iter() + .filter(|message| message["id"] == 88200) + .collect::>(); + assert_eq!(replies.len(), 1, "{messages:?}"); + assert_eq!(replies[0]["result"], json!({})); + let CdpCommandTaskStep::Complete(outcome) = + ctx.conn.complete_pending_command_dispatch(completed).await + else { + panic!("published configuration must not restart"); + }; + let (late, _) = ctx.route_completed_command_outcome_for_test(outcome).await; + assert!( + late.iter().all(|message| message["id"] != 88200), + "{late:?}" + ); + assert_eq!( + evaluate(&mut ctx, "[initialHardware,navigator.hardwareConcurrency]").await, + json!([4, 4]) + ); +} diff --git a/moli-protocol/src/domains/native.rs b/moli-protocol/src/domains/native.rs index e1d1b8be7b..a3a5a66d43 100644 --- a/moli-protocol/src/domains/native.rs +++ b/moli-protocol/src/domains/native.rs @@ -21,6 +21,9 @@ pub(crate) enum NativeCommandStep { Complete(CommandOutputPlan), } +type UnpublishedFailureProjection = + Box CommandOutputPlan + Send>; + pub(crate) struct PendingNativeCommand { // Retain wire routing only; Page settlement uses the exact residence. session_id: Option, @@ -28,6 +31,7 @@ pub(crate) struct PendingNativeCommand { command_id: u64, pending: PendingPageCommand, guard: RendererNativeCommandResponseGuard, + unpublished_failure: Option, } pub(crate) struct CompletedNativeCommand { @@ -36,9 +40,20 @@ pub(crate) struct CompletedNativeCommand { pub(crate) command_id: u64, result: Result, guard: RendererNativeCommandResponseGuard, + unpublished_failure: Option, } impl PendingNativeCommand { + /// Browser policy may outlive a rejected old attachment. This projection + /// only runs after publication lost; it cannot replace a committed reply. + pub(crate) fn on_unpublished_failure( + mut self, + project: impl FnOnce(&CdpConnection, String) -> CommandOutputPlan + Send + 'static, + ) -> Self { + self.unpublished_failure = Some(Box::new(project)); + self + } + pub(crate) fn command_id(&self) -> u64 { self.command_id } @@ -54,6 +69,7 @@ impl PendingNativeCommand { command_id: self.command_id, result: self.pending.wait().await.map_err(|error| error.to_string()), guard: self.guard, + unpublished_failure: self.unpublished_failure, } } } @@ -80,7 +96,10 @@ impl CompletedNativeCommand { context.set_renderer_output_predecessor(fence); CommandOutputPlan::default() } - None => CommandOutputPlan::error(-32000, error), + None => match self.unpublished_failure { + Some(project) => project(conn, error), + None => CommandOutputPlan::error(-32000, error), + }, }, } } @@ -122,6 +141,7 @@ pub(crate) fn start_operation( command_id, pending, guard, + unpublished_failure: None, }), Err(error) => { NativeCommandStep::Complete(CommandOutputPlan::error(-32000, error.to_string()))