mirror of
https://github.com/lexmount/moli.git
synced 2026-10-06 08:00:59 +00:00
refactor(cdp): publish Emulation replies with exact replay ownership
Use the shared frontend admission and native terminal path for renderer configuration. Keep Browser replay policy on its captured owner, record admitted idle state, and project a fallback only if publication lost. Verify mixed native replies precede later Inspector replay and that isolate-entering configuration waits for the suspended owner to resume.
This commit is contained in:
@@ -228,12 +228,18 @@ impl Page {
|
||||
) -> Result<PendingPageCommand> {
|
||||
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<EmulatedIdleOverride>) {
|
||||
// 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<()> {
|
||||
|
||||
@@ -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 = '<style>button {display: block}</style>'; document.body.innerHTML = '<button id=target style=\"color: red\">native query</button>'"
|
||||
})).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::<Vec<_>>(),
|
||||
"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;
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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,<title>original viewport owner</title>")
|
||||
.await
|
||||
.expect("original page should load");
|
||||
let replacement_page = conn
|
||||
.load_page_via_runtime_async("data:text/html,<title>replacement active page</title>")
|
||||
.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,<title>original viewport owner</title>",
|
||||
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,<title>replacement active page</title>",
|
||||
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")
|
||||
|
||||
@@ -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<crate::domains::native::PendingNativeCommand>),
|
||||
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<u64>,
|
||||
metrics: EmulatedDeviceMetrics,
|
||||
owner_scope: CommandOwnerScope,
|
||||
) -> Result<Option<PendingEmulationCommandDispatch>, 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<u64>,
|
||||
metrics: EmulatedDeviceMetrics,
|
||||
owner_scope: CommandOwnerScope,
|
||||
) -> Result<Option<PendingEmulationCommandDispatch>, DevToolsError> {
|
||||
record_device_metrics(conn, &owner_scope, &metrics)?;
|
||||
let Some(page) = conn
|
||||
.loaded_page_mut_for_target_configuration_for_owner(&owner_scope)
|
||||
.ok()
|
||||
|
||||
@@ -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<EmulationCommandTaskStep> {
|
||||
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<EmulationCommandTaskStep> {
|
||||
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"),
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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,<script>window.initialHardware=navigator.hardwareConcurrency</script>"}})).await;
|
||||
let messages = ctx.take_all();
|
||||
let replies = messages
|
||||
.iter()
|
||||
.filter(|message| message["id"] == 88200)
|
||||
.collect::<Vec<_>>();
|
||||
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])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ pub(crate) enum NativeCommandStep {
|
||||
Complete(CommandOutputPlan),
|
||||
}
|
||||
|
||||
type UnpublishedFailureProjection =
|
||||
Box<dyn FnOnce(&CdpConnection, String) -> CommandOutputPlan + Send>;
|
||||
|
||||
pub(crate) struct PendingNativeCommand {
|
||||
// Retain wire routing only; Page settlement uses the exact residence.
|
||||
session_id: Option<String>,
|
||||
@@ -28,6 +31,7 @@ pub(crate) struct PendingNativeCommand {
|
||||
command_id: u64,
|
||||
pending: PendingPageCommand,
|
||||
guard: RendererNativeCommandResponseGuard,
|
||||
unpublished_failure: Option<UnpublishedFailureProjection>,
|
||||
}
|
||||
|
||||
pub(crate) struct CompletedNativeCommand {
|
||||
@@ -36,9 +40,20 @@ pub(crate) struct CompletedNativeCommand {
|
||||
pub(crate) command_id: u64,
|
||||
result: Result<CompletedPageCommand, String>,
|
||||
guard: RendererNativeCommandResponseGuard,
|
||||
unpublished_failure: Option<UnpublishedFailureProjection>,
|
||||
}
|
||||
|
||||
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()))
|
||||
|
||||
Reference in New Issue
Block a user