diff --git a/moli-protocol-server/src/protocol_server/tests/websocket.rs b/moli-protocol-server/src/protocol_server/tests/websocket.rs
index 9d06f8aa7..0b6ce3b63 100644
--- a/moli-protocol-server/src/protocol_server/tests/websocket.rs
+++ b/moli-protocol-server/src/protocol_server/tests/websocket.rs
@@ -2044,6 +2044,292 @@ async fn websocket_cdp_raw_client_runtime_evaluate_immediately_after_page_naviga
fixture_server.abort();
}
+#[tokio::test]
+async fn websocket_cdp_offline_emulation_loopback_navigation_proceeds_and_evaluates() {
+ async fn page() -> impl IntoResponse {
+ (
+ [(axum::http::header::CONTENT_TYPE.as_str(), "text/html")],
+ "
online
",
+ )
+ }
+ async fn probe() -> &'static str {
+ "pong"
+ }
+ let fixture_app = Router::new()
+ .route("/", get(page))
+ .route("/probe", get(probe));
+ let fixture_listener = TcpListener::bind("127.0.0.1:0")
+ .await
+ .expect("bind fixture listener");
+ let fixture_addr = fixture_listener.local_addr().expect("fixture addr");
+ let fixture_server =
+ tokio::spawn(async move { axum::serve(fixture_listener, fixture_app).await });
+ let fixture_url = format!("http://{fixture_addr}/");
+
+ let (cdp_addr, protocol_server) = spawn_test_protocol_server().await;
+ let (mut socket, _) = connect_async(format!(
+ "ws://{cdp_addr}/devtools/browser/{DEFAULT_BROWSER_ID}"
+ ))
+ .await
+ .expect("connect to cdp websocket");
+
+ socket
+ .send(WsMessage::Text(
+ json!({ "id": 1_u64, "method": "Target.createBrowserContext" })
+ .to_string()
+ .into(),
+ ))
+ .await
+ .expect("send createBrowserContext");
+ let create_browser_context = recv_until_id(&mut socket, 1).await;
+ let browser_context_id = create_browser_context
+ .iter()
+ .find(|message| message["id"] == json!(1_u64))
+ .and_then(|message| message["result"]["browserContextId"].as_str())
+ .expect("browserContextId")
+ .to_owned();
+
+ socket
+ .send(WsMessage::Text(
+ json!({
+ "id": 2_u64,
+ "method": "Target.createTarget",
+ "params": { "browserContextId": browser_context_id, "url": "about:blank" }
+ })
+ .to_string()
+ .into(),
+ ))
+ .await
+ .expect("send createTarget");
+ let create_target = recv_until_id(&mut socket, 2).await;
+ let target_id = create_target
+ .iter()
+ .find(|message| message["id"] == json!(2_u64))
+ .and_then(|message| message["result"]["targetId"].as_str())
+ .expect("targetId")
+ .to_owned();
+
+ socket
+ .send(WsMessage::Text(
+ json!({
+ "id": 3_u64,
+ "method": "Target.attachToTarget",
+ "params": { "targetId": target_id, "flatten": true }
+ })
+ .to_string()
+ .into(),
+ ))
+ .await
+ .expect("send attachToTarget");
+ let attach = recv_until_id(&mut socket, 3).await;
+ let session_id = attach
+ .iter()
+ .find(|message| message["id"] == json!(3_u64))
+ .and_then(|message| message["result"]["sessionId"].as_str())
+ .expect("sessionId")
+ .to_owned();
+
+ // Reproduce the offline scenario from the bug report: enable offline
+ // emulation, then navigate to a loopback fixture. Chromium does not
+ // hard-block loopback navigations under offline emulation, so the
+ // navigation must proceed and the committed page must stay usable.
+ socket
+ .send(WsMessage::Text(
+ json!({
+ "id": 4_u64,
+ "method": "Network.emulateNetworkConditions",
+ "sessionId": session_id,
+ "params": {
+ "offline": true,
+ "latency": 0,
+ "downloadThroughput": -1,
+ "uploadThroughput": -1
+ }
+ })
+ .to_string()
+ .into(),
+ ))
+ .await
+ .expect("send emulateNetworkConditions");
+ let emulate = recv_until_id(&mut socket, 4).await;
+ let emulate_response = emulate
+ .iter()
+ .find(|message| message["id"] == json!(4_u64))
+ .expect("emulateNetworkConditions response");
+ assert!(
+ emulate_response.get("error").is_none(),
+ "offline emulation should be accepted; got {emulate_response}"
+ );
+
+ socket
+ .send(WsMessage::Text(
+ json!({
+ "id": 5_u64,
+ "method": "Page.navigate",
+ "sessionId": session_id,
+ "params": { "url": fixture_url }
+ })
+ .to_string()
+ .into(),
+ ))
+ .await
+ .expect("send Page.navigate");
+ let navigate = recv_until_id(&mut socket, 5).await;
+ let navigate_response = navigate
+ .iter()
+ .find(|message| message["id"] == json!(5_u64))
+ .expect("Page.navigate response");
+ assert!(
+ navigate_response.get("error").is_none(),
+ "loopback navigation must proceed under offline emulation; got {navigate_response}"
+ );
+ assert!(
+ navigate_response["result"]["frameId"].is_string(),
+ "Page.navigate must resolve with a frameId; got {navigate_response}"
+ );
+
+ socket
+ .send(WsMessage::Text(
+ json!({
+ "id": 6_u64,
+ "method": "Runtime.evaluate",
+ "sessionId": session_id,
+ "params": { "expression": "1 + 1", "returnByValue": true }
+ })
+ .to_string()
+ .into(),
+ ))
+ .await
+ .expect("send Runtime.evaluate");
+ let evaluate = recv_until_id(&mut socket, 6).await;
+ let evaluate_response = evaluate
+ .iter()
+ .find(|message| message["id"] == json!(6_u64))
+ .expect("Runtime.evaluate response");
+ assert!(
+ evaluate_response.get("error").is_none(),
+ "Runtime.evaluate must not error after loopback navigation; got {evaluate_response}"
+ );
+ assert_eq!(
+ evaluate_response["result"]["result"]["value"],
+ json!(2),
+ "Runtime.evaluate must observe the committed loopback document"
+ );
+
+ // The loopback document keeps navigator.onLine true, matching the known
+ // Chromium quirk where offline emulation leaves loopback pages online.
+ socket
+ .send(WsMessage::Text(
+ json!({
+ "id": 7_u64,
+ "method": "Runtime.evaluate",
+ "sessionId": session_id,
+ "params": { "expression": "navigator.onLine", "returnByValue": true }
+ })
+ .to_string()
+ .into(),
+ ))
+ .await
+ .expect("send navigator.onLine evaluate");
+ let on_line = recv_until_id(&mut socket, 7).await;
+ let on_line_response = on_line
+ .iter()
+ .find(|message| message["id"] == json!(7_u64))
+ .expect("navigator.onLine response");
+ assert_eq!(
+ on_line_response["result"]["result"]["value"],
+ json!(true),
+ "navigator.onLine should stay true on a loopback page; got {on_line_response}"
+ );
+
+ // Subresource fetches to the loopback fixture also succeed while offline
+ // emulation stays active, because the committed document is online.
+ socket
+ .send(WsMessage::Text(
+ json!({
+ "id": 8_u64,
+ "method": "Runtime.evaluate",
+ "sessionId": session_id,
+ "params": {
+ "expression": "fetch('/probe').then((r) => r.text()).catch(() => 'offline')",
+ "awaitPromise": true,
+ "returnByValue": true
+ }
+ })
+ .to_string()
+ .into(),
+ ))
+ .await
+ .expect("send loopback fetch evaluate");
+ let fetched = recv_until_id(&mut socket, 8).await;
+ let fetch_response = fetched
+ .iter()
+ .find(|message| message["id"] == json!(8_u64))
+ .expect("loopback fetch response");
+ assert_eq!(
+ fetch_response["result"]["result"]["value"],
+ json!("pong"),
+ "loopback subresource fetch must succeed while offline; got {fetch_response}"
+ );
+
+ // Restoring offline:false is accepted and the target stays responsive.
+ socket
+ .send(WsMessage::Text(
+ json!({
+ "id": 9_u64,
+ "method": "Network.emulateNetworkConditions",
+ "sessionId": session_id,
+ "params": {
+ "offline": false,
+ "latency": 0,
+ "downloadThroughput": -1,
+ "uploadThroughput": -1
+ }
+ })
+ .to_string()
+ .into(),
+ ))
+ .await
+ .expect("send offline restore");
+ let restore = recv_until_id(&mut socket, 9).await;
+ let restore_response = restore
+ .iter()
+ .find(|message| message["id"] == json!(9_u64))
+ .expect("offline restore response");
+ assert!(
+ restore_response.get("error").is_none(),
+ "offline:false must be accepted; got {restore_response}"
+ );
+
+ socket
+ .send(WsMessage::Text(
+ json!({
+ "id": 10_u64,
+ "method": "Runtime.evaluate",
+ "sessionId": session_id,
+ "params": { "expression": "1 + 1", "returnByValue": true }
+ })
+ .to_string()
+ .into(),
+ ))
+ .await
+ .expect("send post-restore evaluate");
+ let restored = recv_until_id(&mut socket, 10).await;
+ let restored_response = restored
+ .iter()
+ .find(|message| message["id"] == json!(10_u64))
+ .expect("post-restore evaluate response");
+ assert_eq!(
+ restored_response["result"]["result"]["value"],
+ json!(2),
+ "target must remain responsive after restoring offline; got {restored_response}"
+ );
+
+ let _ = socket.close(None).await;
+ abort_test_cdp_server(protocol_server).await;
+ fixture_server.abort();
+}
+
#[tokio::test]
async fn websocket_cdp_runtime_control_command_waits_for_navigation_attachment_cutover() {
let release_tail = Arc::new(tokio::sync::Notify::new());
diff --git a/moli-protocol/src/conn/runtime_load.rs b/moli-protocol/src/conn/runtime_load.rs
index d3692de7b..fe02e2c1a 100644
--- a/moli-protocol/src/conn/runtime_load.rs
+++ b/moli-protocol/src/conn/runtime_load.rs
@@ -858,9 +858,12 @@ impl BackgroundNavigationLoadJob {
}
ensure_url_not_blocked_for_load_inputs(&self.load_inputs, &self.raw_url)?;
- if self.load_inputs.network_offline {
+ if network_offline_blocks_url(self.load_inputs.network_offline, &self.raw_url) {
return Err("Network emulation offline".to_owned());
}
+ if self.load_inputs.network_offline {
+ self.load_inputs.network_offline = false;
+ }
let requested_url = Url::parse(&self.raw_url).map_err(|error| {
format!("failed to parse request url `{}`: {error}", self.raw_url)
@@ -1562,7 +1565,7 @@ impl CdpConnection {
document_activity: load_inputs.document_activity,
browser_resource_runtime,
navigator_identity,
- network_offline: load_inputs.network_offline,
+ network_offline: load_inputs.network_offline && !url_is_loopback(final_url),
bypass_service_worker: load_inputs.bypass_service_worker,
cache_disabled: load_inputs.cache_disabled,
blocked_url_patterns: load_inputs.blocked_url_patterns,
@@ -2280,7 +2283,7 @@ impl CdpConnection {
async fn load_navigation_request_via_runtime_with_network_events_and_load_inputs_async(
&mut self,
owner: &CommandOwnerScope,
- load_inputs: TargetNavigationLoadInputs,
+ mut load_inputs: TargetNavigationLoadInputs,
method: &str,
raw_url: &str,
body: Option>,
@@ -2351,9 +2354,12 @@ impl CdpConnection {
}
ensure_url_not_blocked_for_load_inputs(&load_inputs, raw_url)?;
- if load_inputs.network_offline {
+ if network_offline_blocks_url(load_inputs.network_offline, raw_url) {
return Err("Network emulation offline".to_owned());
}
+ if load_inputs.network_offline {
+ load_inputs.network_offline = false;
+ }
let requested_url = Url::parse(raw_url)
.map_err(|error| format!("failed to parse request url `{raw_url}`: {error}"))?;
@@ -2965,7 +2971,7 @@ impl CdpConnection {
) -> Result, String> {
let load_inputs = self.navigation_load_inputs_for_session_owner(None);
ensure_url_not_blocked_for_load_inputs(&load_inputs, raw_url)?;
- if load_inputs.network_offline {
+ if network_offline_blocks_url(load_inputs.network_offline, raw_url) {
return Err("Network emulation offline".to_owned());
}
let resource_storage = load_inputs.resource_storage_handles();
@@ -3005,7 +3011,7 @@ impl CdpConnection {
request_load_policy,
);
ensure_url_not_blocked_for_load_inputs(&load_inputs, raw_url)?;
- if load_inputs.network_offline {
+ if network_offline_blocks_url(load_inputs.network_offline, raw_url) {
return Err("Network emulation offline".to_owned());
}
@@ -3097,7 +3103,7 @@ impl CdpConnection {
auth: Option,
) -> Result, String> {
ensure_url_not_blocked_for_load_inputs(load_inputs, raw_url)?;
- if load_inputs.network_offline {
+ if network_offline_blocks_url(load_inputs.network_offline, raw_url) {
return Err("Network emulation offline".to_owned());
}
@@ -3897,6 +3903,33 @@ fn ensure_url_not_blocked_for_load_inputs(
}
}
+/// Chromium does not hard-block loopback navigations under emulated offline
+/// conditions (`Network.emulateNetworkConditions {offline:true}`); requests
+/// to `localhost`/`*.localhost` and loopback IPs keep working while the page
+/// is otherwise offline. Match that quirk so loopback navigations proceed.
+fn url_is_loopback(url: &Url) -> bool {
+ match url.host() {
+ Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
+ Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
+ Some(url::Host::Domain(domain)) => {
+ let domain = domain.trim_end_matches('.');
+ let domain = domain.to_ascii_lowercase();
+ domain == "localhost" || domain.ends_with(".localhost")
+ }
+ None => false,
+ }
+}
+
+/// Resolves whether the emulated network-offline policy should block a
+/// navigation to `raw_url`. Loopback destinations are exempt so that a
+/// navigation to a local fixture proceeds like Chromium.
+fn network_offline_blocks_url(network_offline: bool, raw_url: &str) -> bool {
+ network_offline
+ && Url::parse(raw_url)
+ .map(|url| !url_is_loopback(&url))
+ .unwrap_or(true)
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NavigationLoadInputOverrideMode {
FreshlyBuiltPage,
@@ -3940,9 +3973,10 @@ mod tests {
use super::{
BackgroundNavigationEarlyResult, decode_data_url_body, decode_data_url_response,
decode_text_html_data_url, decoded_data_url_navigation_response,
- inline_html_navigation_source,
+ inline_html_navigation_source, network_offline_blocks_url, url_is_loopback,
};
use serde_json::json;
+ use url::Url;
#[test]
fn decode_text_html_data_url_uses_data_url_processor() {
@@ -4065,4 +4099,70 @@ mod tests {
})
);
}
+
+ #[test]
+ fn url_is_loopback_accepts_local_hosts_and_loopback_addresses() {
+ for url in [
+ "http://localhost/",
+ "http://localhost:8765/page.html",
+ "http://LOCALHOST/",
+ "http://localhost./",
+ "http://app.localhost/page",
+ "http://127.0.0.1/",
+ "http://127.0.0.1:8765/page.html",
+ "http://127.0.0.2/",
+ "http://[::1]/",
+ "http://[::1]:8765/page.html",
+ ] {
+ let parsed = Url::parse(url).expect(url);
+ assert!(
+ url_is_loopback(&parsed),
+ "expected `{url}` to be treated as loopback"
+ );
+ }
+ }
+
+ #[test]
+ fn url_is_loopback_rejects_remote_hosts_and_schemeless_urls() {
+ for url in [
+ "http://example.test/",
+ "http://example.test:8765/offline",
+ "https://example.com/",
+ "http://8.8.8.8/",
+ "http://192.168.1.10/",
+ "http://localhost.evil.example/",
+ "about:blank",
+ "data:text/html,hello",
+ ] {
+ let parsed = Url::parse(url).expect(url);
+ assert!(
+ !url_is_loopback(&parsed),
+ "expected `{url}` not to be treated as loopback"
+ );
+ }
+ }
+
+ #[test]
+ fn network_offline_blocks_url_exempts_loopback_destinations_only() {
+ assert!(network_offline_blocks_url(true, "http://example.test/"));
+ assert!(network_offline_blocks_url(
+ true,
+ "http://example.test/offline"
+ ));
+ assert!(network_offline_blocks_url(true, "http://8.8.8.8/"));
+ assert!(!network_offline_blocks_url(
+ true,
+ "http://127.0.0.1:8765/page.html"
+ ));
+ assert!(!network_offline_blocks_url(
+ true,
+ "http://localhost/page.html"
+ ));
+ assert!(!network_offline_blocks_url(true, "http://[::1]/"));
+ assert!(!network_offline_blocks_url(false, "http://example.test/"));
+ assert!(!network_offline_blocks_url(
+ false,
+ "http://127.0.0.1:8765/page.html"
+ ));
+ }
}