From 1d76f6e6cc45eca73597e3c9b52505dbac0b7813 Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 17 Sep 2026 16:16:08 +0800 Subject: [PATCH 01/23] fix(browser): address benchmark-discovered protocol and rendering failures --- README.md | 4 +- docs/README.de.md | 2 +- docs/README.es.md | 2 +- docs/README.fr.md | 2 +- docs/README.ja.md | 2 +- docs/README.zh-CN.md | 2 +- moli-bounded-buffer/src/buffer.rs | 30 + moli-bounded-buffer/src/tests.rs | 23 + moli-dom/src/native/host/document.rs | 26 + moli-dom/src/native/host/query_index.rs | 23 + moli-encoding/src/document.rs | 25 + moli-encoding/src/tests.rs | 96 +++ moli-fetch/src/network_fetch_result.rs | 34 +- moli-layout/src/builder.rs | 19 + moli-layout/src/inline.rs | 10 + moli-layout/src/pass.rs | 8 +- moli-layout/src/projection.rs | 235 +++++- moli-layout/src/taffy_tree.rs | 69 +- moli-layout/src/world.rs | 38 + moli-layout/tests/phase4_layout_contract.rs | 43 +- moli-layout/tests/phase5_output_contract.rs | 767 +++++++++++++++++- moli-page-types/src/lib.rs | 8 + moli-parser/src/html.rs | 46 ++ moli-parser/src/session.rs | 15 + moli-parser/src/stream.rs | 4 + .../src/actions.rs | 9 + .../src/conn/browser_context/network_owner.rs | 8 + .../src/conn/page_state/fetch_state.rs | 2 +- moli-protocol/src/conn/runtime_load.rs | 41 + moli-protocol/src/conn/state/page_slot.rs | 33 + moli-protocol/src/conn/state/runtime_slot.rs | 17 + .../src/domains/dom/tests/geometry.rs | 4 +- moli-protocol/src/domains/emulation/tests.rs | 18 +- moli-protocol/src/domains/network.rs | 12 + moli-protocol/src/domains/network/agent.rs | 245 +++++- moli-protocol/src/domains/network/backlog.rs | 7 + moli-protocol/src/domains/network/events.rs | 4 +- .../network/main_document_progress/mod.rs | 295 +++++-- .../network/main_document_progress/tests.rs | 341 +++++++- .../src/domains/network/output_queue.rs | 71 +- moli-protocol/src/domains/network/settings.rs | 29 + .../domains/network/tests/response_body.rs | 162 ++++ moli-protocol/src/domains/page.rs | 110 +++ moli-protocol/src/domains/page/termination.rs | 12 +- .../src/domains/page/tests/lifecycle.rs | 16 +- .../src/domains/page/tests/navigation.rs | 367 +++++++++ .../src/domains/page/tests/runtime.rs | 10 +- .../target/tests/tests_background_staging.rs | 8 +- .../src/domains/target/worker_target.rs | 2 + .../src/document_runtime/mutation_commands.rs | 2 +- moli-renderer-v8/src/frame_owner_model.rs | 3 +- .../frame_owner_model/lifecycle_blockers.rs | 5 + .../src/frame_owner_model/lifecycle_tasks.rs | 7 + .../src/frame_owner_model/load_event_gate.rs | 91 ++- .../src/frame_owner_model/records.rs | 2 +- .../src/frame_owner_model/store.rs | 33 +- .../src/frame_owner_model/store_tests.rs | 66 +- moli-renderer-v8/src/live_document_parser.rs | 9 + .../context_host/child_documents/loads.rs | 45 +- .../context_host/host_environment.rs | 122 ++- .../context_host/image_resources/mod.rs | 4 + .../src/native_bridge/context_host/layout.rs | 8 + .../context_host/layout_state.rs | 25 +- .../context_host/main_document_lifecycle.rs | 3 +- moli-renderer-v8/src/native_bridge/element.rs | 26 + .../src/native_bridge/element/forms.rs | 1 + .../element/forms/form_element.rs | 39 +- .../src/network/navigation/loader.rs | 37 + .../src/page_task_queue/stylesheet_task.rs | 3 + .../src/runtime/owner_local_store/entry.rs | 19 + .../src/runtime/owner_local_store/mod.rs | 3 + .../tests/child_document_completion.rs | 2 + .../page_vm/tests/computed_size/sampling.rs | 38 +- .../tests/grid_resolved_track_values.rs | 68 +- .../runtime/page_vm/tests/rendering_update.rs | 115 ++- .../runtime/page_vm/tests/stylesheet_task.rs | 100 +++ .../src/runtime/phase_one/bootstrap.rs | 2 + .../runtime/phase_one/pending_residence.rs | 8 + .../src/runtime/phase_one/state.rs | 9 + .../src/runtime/phase_one/streaming.rs | 188 ++++- .../src/runtime/phase_one/streaming_input.rs | 56 +- .../runtime/phase_one/streaming_residence.rs | 6 + .../protocol_output/transport_memory.rs | 133 +++ .../src/runtime/tests/open_streaming.rs | 83 ++ .../src/script_vm/stylesheet_page_tasks.rs | 7 +- .../tests/dom_elements/dom_surface.rs | 291 +++++++ .../script_vm/tests/dom_xhr/computed_style.rs | 185 +++++ .../src/script_vm/tests/dom_xhr/dom.rs | 31 +- .../src/script_vm/tests/dom_xhr/forms.rs | 1 + .../tests/dom_xhr/forms/named_lookup.rs | 255 ++++++ .../src/script_vm/tests/dom_xhr/misc.rs | 4 +- moli-renderer-v8/src/style_engine/computed.rs | 39 +- moli-renderer-v8/src/style_engine/retained.rs | 32 + moli-renderer-v8/src/style_engine/state.rs | 6 + .../src/stylesheet_runtime/connected.rs | 4 +- moli/src/fetch_dump/tests.rs | 4 +- moli/src/telemetry.rs | 44 + moli/tests/fetch_cli.rs | 67 ++ 98 files changed, 5344 insertions(+), 343 deletions(-) create mode 100644 moli-renderer-v8/src/script_vm/tests/dom_xhr/forms/named_lookup.rs diff --git a/README.md b/README.md index 74bebb3e43..688757201e 100644 --- a/README.md +++ b/README.md @@ -249,7 +249,9 @@ Layout is an on-demand snapshot rather than continuously maintained state. The first geometry request (a cold start) builds a working layout tree from the current DOM/style, freezes its canonical geometry into an immutable, DOM-independent `FrozenLayoutTree`, and retains only that latest tree. Ordinary -geometry reads may reuse it even if the page has changed. Screenshots always +geometry reads reuse it while layout inputs are unchanged. Input changes mark +the retained tree dirty without running layout; the next geometry request +rebuilds it once from the current inputs. Screenshots always rebuild and replace the frozen tree. Each screencast subscription remembers only an opaque visual-state token: an unchanged token suppresses the poll, while a changed token triggers one fresh frame. Paint results are never reused. diff --git a/docs/README.de.md b/docs/README.de.md index fc2a5635b9..03257df5f3 100644 --- a/docs/README.de.md +++ b/docs/README.de.md @@ -195,7 +195,7 @@ Rechenintensive Browser-Operationen sind in Moli standardmäßig deaktiviert und | `--image`, `--font`, `--audio`, `--video`, `--media`, `--text-track` | Eine bestimmte optionale Ressourcengruppe einzeln aktivieren | | `--profile-dir`, `--http-cache-dir`, `--cookie-file` | Persistenz gezielt aktivieren, je nach Bedarf des Workloads | -Das Layoutergebnis ist ein bei Bedarf erzeugter Snapshot, kein dauerhaft gepflegter Zustand: Die erste Geometrieanfrage (Kaltstart) baut aus dem aktuellen DOM/Stil einen temporären Arbeitsbaum auf und friert dessen kanonische Geometrie in einen unveränderlichen, vom DOM unabhängigen `FrozenLayoutTree` ein — nur dieser jeweils neueste Baum wird vorgehalten. Normale Geometrieabfragen können ihn auch nach Seitenänderungen weiterverwenden; Screenshots und Screencasts dagegen bauen jedes Mal neu auf, ersetzen den eingefrorenen Baum und greifen nie auf alte Zeichenergebnisse zurück. +Das Layoutergebnis ist ein bei Bedarf erzeugter Snapshot, kein dauerhaft gepflegter Zustand: Die erste Geometrieanfrage (Kaltstart) baut aus dem aktuellen DOM/Stil einen temporären Arbeitsbaum auf und friert dessen kanonische Geometrie in einen unveränderlichen, vom DOM unabhängigen `FrozenLayoutTree` ein — nur dieser jeweils neueste Baum wird vorgehalten. Normale Geometrieabfragen verwenden ihn wieder, solange die Layouteingaben unverändert sind. Änderungen markieren den Baum als veraltet, ohne sofort ein Layout auszuführen; erst die nächste Geometrieanfrage baut ihn neu auf. Screenshots und Screencasts dagegen bauen jedes Mal neu auf, ersetzen den eingefrorenen Baum und greifen nie auf alte Zeichenergebnisse zurück. ## Architektur diff --git a/docs/README.es.md b/docs/README.es.md index 99eb389c60..89d758b7d7 100644 --- a/docs/README.es.md +++ b/docs/README.es.md @@ -196,7 +196,7 @@ En Moli, las operaciones caras del navegador hay que activarlas explícitamente: | `--image`, `--font`, `--audio`, `--video`, `--media`, `--text-track` | Activa por separado una familia concreta de recursos opcionales | | `--profile-dir`, `--http-cache-dir`, `--cookie-file` | Activa de forma selectiva la persistencia que necesite tu carga de trabajo | -El resultado del layout es una instantánea tomada bajo demanda, no un estado que se mantiene todo el tiempo: la primera petición de geometría (cold start) construye un árbol de trabajo temporal a partir del DOM y los estilos actuales, congela su geometría canónica en un `FrozenLayoutTree` inmutable e independiente del DOM, y se queda solo con ese árbol, el más reciente. Las lecturas de geometría normales pueden reutilizarlo aunque la página haya cambiado entretanto. Las capturas de pantalla siempre reconstruyen y sustituyen el árbol congelado. Cada suscripción de screencast solo conserva un token opaco del estado visual: un token sin cambios evita el trabajo, mientras que un cambio genera un frame nuevo. Los resultados de pintado nunca se reutilizan. +El resultado del layout es una instantánea tomada bajo demanda, no un estado que se mantiene todo el tiempo: la primera petición de geometría (cold start) construye un árbol de trabajo temporal a partir del DOM y los estilos actuales, congela su geometría canónica en un `FrozenLayoutTree` inmutable e independiente del DOM, y se queda solo con ese árbol, el más reciente. Las lecturas de geometría normales lo reutilizan mientras no cambien las entradas del layout. Los cambios marcan el árbol como desactualizado sin calcular el layout de inmediato; la siguiente petición de geometría lo reconstruye una sola vez. Las capturas de pantalla siempre reconstruyen y sustituyen el árbol congelado. Cada suscripción de screencast solo conserva un token opaco del estado visual: un token sin cambios evita el trabajo, mientras que un cambio genera un frame nuevo. Los resultados de pintado nunca se reutilizan. ## Arquitectura diff --git a/docs/README.fr.md b/docs/README.fr.md index e0174bb4f9..7d0978d962 100644 --- a/docs/README.fr.md +++ b/docs/README.fr.md @@ -196,7 +196,7 @@ Dans Moli, les opérations coûteuses du navigateur ne sont jamais activées par | `--image`, `--font`, `--audio`, `--video`, `--media`, `--text-track` | Active une famille spécifique de ressources optionnelles | | `--profile-dir`, `--http-cache-dir`, `--cookie-file` | Active, au cas par cas, la persistance dont la charge de travail a besoin | -Le résultat de la mise en page est un instantané produit à la demande, pas un état maintenu en continu : la première requête de géométrie (« à froid ») construit un arbre de travail temporaire à partir du DOM et des styles courants, fige sa géométrie canonique dans un `FrozenLayoutTree` immuable et indépendant du DOM, puis ne conserve que ce dernier. Les lectures de géométrie ultérieures peuvent réutiliser cet arbre figé, même si la page a changé entre-temps. Les captures d'écran reconstruisent toujours et remplacent l'arbre figé. Chaque abonnement de screencast ne mémorise qu'un jeton opaque d'état visuel : un jeton inchangé supprime le travail, tandis qu'un changement produit une image fraîche. Les résultats de rendu ne sont jamais réutilisés. +Le résultat de la mise en page est un instantané produit à la demande, pas un état maintenu en continu : la première requête de géométrie (« à froid ») construit un arbre de travail temporaire à partir du DOM et des styles courants, fige sa géométrie canonique dans un `FrozenLayoutTree` immuable et indépendant du DOM, puis ne conserve que ce dernier. Les lectures de géométrie réutilisent cet arbre tant que les données de mise en page restent inchangées. Une modification marque l’arbre comme périmé sans recalcul immédiat ; la prochaine requête de géométrie le reconstruit une seule fois. Les captures d'écran reconstruisent toujours et remplacent l'arbre figé. Chaque abonnement de screencast ne mémorise qu'un jeton opaque d'état visuel : un jeton inchangé supprime le travail, tandis qu'un changement produit une image fraîche. Les résultats de rendu ne sont jamais réutilisés. ## Architecture diff --git a/docs/README.ja.md b/docs/README.ja.md index a245c0b1a9..e38cef060e 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -194,7 +194,7 @@ Moli では、処理負荷の高い機能は明示的に有効にした場合だ | `--image`、`--font`、`--audio`、`--video`、`--media`、`--text-track` | 指定した種類のデータを取得します。 | | `--profile-dir`、`--http-cache-dir`、`--cookie-file` | 必要な保存機能だけを選んで有効にします。 | -レイアウト結果は常に更新される状態ではなく、要求を受けた時点の状態です。最初に位置・寸法を要求されたときは、現在の DOM とスタイルから一時的な作業用レイアウト木を構築し、その確定済みジオメトリを DOM から独立した不変の `FrozenLayoutTree` に凍結して、最新の木だけを保持します。その後はページが変化していても通常の位置・寸法取得で古い木を再利用する場合があります。一方、画面画像と連続画面配信は毎回再構築して凍結木を置き換え、古い描画結果を再利用しません。 +レイアウト結果は常に更新される状態ではなく、要求を受けた時点の状態です。最初に位置・寸法を要求されたときは、現在の DOM とスタイルから一時的な作業用レイアウト木を構築し、その確定済みジオメトリを DOM から独立した不変の `FrozenLayoutTree` に凍結して、最新の木だけを保持します。レイアウトの入力が変わらない間は、通常の位置・寸法取得でこの木を再利用します。入力が変わってもすぐにはレイアウトを計算せず、木を古い状態としてマークし、次の位置・寸法要求で一度だけ再構築します。一方、画面画像と連続画面配信は毎回再構築して凍結木を置き換え、古い描画結果を再利用しません。 ## 仕組み diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 5cfa8a7bcb..dc4889cf01 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -194,7 +194,7 @@ Moli 是 Lexmount 旗下的开源无头浏览器;Lexmount Browser 则是围绕 | `--image`、`--font`、`--audio`、`--video`、`--media`、`--text-track` | 单独启用某一类可选资源 | | `--profile-dir`、`--http-cache-dir`、`--cookie-file` | 按工作负载需要,选择性开启持久化能力 | -布局结果是按需采样的一份快照,而不是持续维护的状态:第一次几何请求(冷启动)会根据当前 DOM/样式构建临时工作布局树,再把工作树丢弃后无法重算的几何冻结成一棵与 DOM 无关、不可变的 `FrozenLayoutTree`,长期只保留最新的这一棵树。在此之后,即便页面发生了变化,普通几何读取也可能复用旧树。截图始终重新构建并替换冻结树。每个屏幕串流订阅只记录一个不透明的视觉状态 token:状态未变时不产帧,状态变化时才新鲜渲染一帧;绘制结果不会被复用。 +布局结果是按需采样的一份快照,而不是持续维护的状态:第一次几何请求(冷启动)会根据当前 DOM/样式构建临时工作布局树,再把工作树丢弃后无法重算的几何冻结成一棵与 DOM 无关、不可变的 `FrozenLayoutTree`,长期只保留最新的这一棵树。布局输入未变时,普通几何读取复用这棵树;输入变化只标记旧树失效,不立即执行布局,下一次几何请求才按当前输入重新构建一次。截图始终重新构建并替换冻结树。每个屏幕串流订阅只记录一个不透明的视觉状态 token:状态未变时不产帧,状态变化时才新鲜渲染一帧;绘制结果不会被复用。 ## 架构 diff --git a/moli-bounded-buffer/src/buffer.rs b/moli-bounded-buffer/src/buffer.rs index 9a8d14fb55..d5a415e6e7 100644 --- a/moli-bounded-buffer/src/buffer.rs +++ b/moli-bounded-buffer/src/buffer.rs @@ -66,6 +66,36 @@ where self.limits } + /// Changes budgets, returning oversized and then oldest entries that no + /// longer fit. Retained entries keep their original insertion order. + pub fn set_limits(&mut self, limits: ByteLimits) -> Vec<(K, V)> { + self.limits = limits; + let oversized = self + .insertion_order + .iter() + .filter(|key| { + self.entries + .get(*key) + .is_some_and(|entry| entry.byte_len > limits.max_entry_bytes) + }) + .cloned() + .collect::>(); + let mut evicted = Vec::new(); + for key in oversized { + if let Some(value) = self.remove(&key) { + evicted.push((key, value)); + } + } + while self.used_bytes > limits.max_total_bytes { + if let Some(entry) = self.pop_oldest() { + evicted.push(entry); + } else { + break; + } + } + evicted + } + /// Returns the sum of the logical byte charges for retained entries. pub fn used_bytes(&self) -> usize { self.used_bytes diff --git a/moli-bounded-buffer/src/tests.rs b/moli-bounded-buffer/src/tests.rs index dc013dd920..d4558052ce 100644 --- a/moli-bounded-buffer/src/tests.rs +++ b/moli-bounded-buffer/src/tests.rs @@ -1,5 +1,28 @@ use super::{BoundedByteBuffer, ByteLimits, InsertOutcome}; +#[test] +fn changing_limits_evicts_oversized_then_oldest_without_reordering_survivors() { + let mut buffer = BoundedByteBuffer::new(ByteLimits::new(20, 10)); + buffer.insert("a", 1, 3); + buffer.insert("b", 2, 6); + buffer.insert("c", 3, 3); + assert_eq!( + buffer.set_limits(ByteLimits::new(4, 4)), + vec![("b", 2), ("a", 1)] + ); + assert_eq!(buffer.used_bytes(), 3); + assert_eq!(buffer.get(&"c"), Some(&3)); + assert!(buffer.set_limits(ByteLimits::new(10, 10)).is_empty()); + assert_eq!( + buffer.insert("d", 4, 8), + InsertOutcome::Stored { + evicted: vec![("c", 3)] + } + ); + assert_eq!(buffer.set_limits(ByteLimits::new(0, 0)), vec![("d", 4)]); + assert!(buffer.is_empty()); +} + #[test] fn accepts_entries_at_exact_limits() { let mut buffer = BoundedByteBuffer::new(ByteLimits::new(4, 4)); diff --git a/moli-dom/src/native/host/document.rs b/moli-dom/src/native/host/document.rs index 61bafdaba3..230de2e627 100644 --- a/moli-dom/src/native/host/document.rs +++ b/moli-dom/src/native/host/document.rs @@ -810,6 +810,32 @@ impl DomHost { }) } + /// Whether any current element can match this legacy named-property key. + /// + /// This is deliberately independent of connectivity and document/tree scope: + /// callers may use a miss to avoid a traversal, but must still validate the + /// owner and scope on a hit. Reuse the incrementally maintained named indexes + /// rather than caching misses (custom-element upgrades can change eligibility + /// without changing an element's id or name). + pub fn has_element_with_named_item_key(&self, key: &str) -> bool { + if key.is_empty() { + return false; + } + self.ensure_id_index(); + self.ensure_name_index(); + let matches = |index: &NamedElementIndex| { + index.handles_by_value.get(key).is_some_and(|handles| { + handles.iter().any(|handle| { + self.node(*handle) + .and_then(Node::as_element) + .is_some_and(|element| element.matches_named_item_key(key)) + }) + }) + }; + self.id_index.borrow().as_ref().is_some_and(matches) + || self.name_index.borrow().as_ref().is_some_and(matches) + } + pub fn element_handles_by_id_or_name_matching( &self, key: &str, diff --git a/moli-dom/src/native/host/query_index.rs b/moli-dom/src/native/host/query_index.rs index ee18e39205..a3ba184c42 100644 --- a/moli-dom/src/native/host/query_index.rs +++ b/moli-dom/src/native/host/query_index.rs @@ -518,6 +518,29 @@ mod tests { )) } + #[test] + fn global_named_item_candidate_check_tracks_live_attributes_in_detached_trees() { + let mut host = test_host(); + assert!(!host.has_element_with_named_item_key("late")); + assert!(!host.has_element_with_named_item_key("")); + let input = host.create_element("input"); + assert!(host.set_attribute(input, "name", "late")); + assert!(host.has_element_with_named_item_key("late")); + assert!(host.set_attribute(input, "name", "renamed")); + assert!(!host.has_element_with_named_item_key("late")); + assert!(host.has_element_with_named_item_key("renamed")); + assert!(host.set_attribute(input, "id", "renamed")); + assert!(host.remove_attribute(input, "name")); + assert!(host.has_element_with_named_item_key("renamed")); + let root = host.create_element("div"); + assert!(host.append_child(root, input)); + assert!(host.has_element_with_named_item_key("renamed")); + assert!(host.remove_attribute(input, "id")); + assert!(!host.has_element_with_named_item_key("renamed")); + assert!(host.set_attribute(input, "name", "")); + assert!(!host.has_element_with_named_item_key("")); + } + #[test] fn cached_tag_name_collection_reuses_query_until_mutation() { let mut host = test_host(); diff --git a/moli-encoding/src/document.rs b/moli-encoding/src/document.rs index 1ceb79b9f9..8b3a70398b 100644 --- a/moli-encoding/src/document.rs +++ b/moli-encoding/src/document.rs @@ -47,9 +47,26 @@ pub struct HtmlDocumentStreamingDecoder { url_hint: Option, decoder: Option, selected_encoding: Option<&'static Encoding>, + sniff_html_declarations: bool, } impl HtmlDocumentStreamingDecoder { + /// Decode a text document without interpreting literal HTML/XML declarations. + /// JSON defaults to UTF-8; other text retains the configured legacy detector. + pub fn new_text_document( + headers: &[(String, Vec)], + url_hint: &str, + detector: LegacyEncodingDetector, + json: bool, + ) -> Self { + let mut decoder = Self::new_with_options(headers, None, Some(url_hint), Some(detector)); + decoder.sniff_html_declarations = false; + if json && decoder.transport_encoding.is_none() { + decoder.transport_encoding = Some(encoding_rs::UTF_8); + } + decoder + } + pub fn new(headers: &[(String, Vec)]) -> Self { Self::new_with_options(headers, None, None, None) } @@ -88,6 +105,7 @@ impl HtmlDocumentStreamingDecoder { url_hint: url_hint.map(str::to_owned), decoder: None, selected_encoding: None, + sniff_html_declarations: true, } } @@ -97,6 +115,7 @@ impl HtmlDocumentStreamingDecoder { pub fn document_encoding_name(&self) -> &'static str { self.selected_encoding_name() + .or_else(|| self.transport_encoding.map(Encoding::name)) .unwrap_or(self.fallback_encoding.name()) } @@ -149,6 +168,12 @@ impl HtmlDocumentStreamingDecoder { if let Some(encoding) = self.transport_encoding { return Some(encoding); } + if !self.sniff_html_declarations { + return (finishing || self.sniff_buffer.len() >= 1024).then(|| { + self.detected_legacy_content_encoding() + .unwrap_or(self.fallback_encoding) + }); + } if let Some(encoding) = encoding_for_document_utf16_xml_prefix(&self.sniff_buffer) { return Some(encoding); } diff --git a/moli-encoding/src/tests.rs b/moli-encoding/src/tests.rs index 6693d5d545..2217e0521a 100644 --- a/moli-encoding/src/tests.rs +++ b/moli-encoding/src/tests.rs @@ -24,6 +24,102 @@ fn unexpected_legacy_encoding_detector( panic!("declared encoding must take precedence over heuristic detection") } +#[test] +fn json_text_document_defaults_to_utf8_across_every_chunk_split() { + let input = "{\"name\":\"Gülçek\"}"; + for split in 0..=input.len() { + let mut decoder = HtmlDocumentStreamingDecoder::new_text_document( + &[], + "https://example.test/", + unexpected_legacy_encoding_detector, + true, + ); + let mut output = decoder.push(&input.as_bytes()[..split]).join(""); + output.push_str(&decoder.push(&input.as_bytes()[split..]).join("")); + output.push_str(&decoder.finish().unwrap_or_default()); + assert_eq!(output, input, "split {split}"); + assert_eq!(decoder.selected_encoding_name(), Some("UTF-8")); + } +} + +#[test] +fn plain_text_document_honors_transport_charset() { + let headers = [( + "Content-Type".to_owned(), + b"text/plain; charset=gbk".to_vec(), + )]; + let mut decoder = HtmlDocumentStreamingDecoder::new_text_document( + &headers, + "https://example.test/", + unexpected_legacy_encoding_detector, + false, + ); + let mut output = decoder.push(&gbk_bytes("家居")).join(""); + output.push_str(&decoder.finish().unwrap_or_default()); + assert_eq!(output, "家居"); + assert_eq!(decoder.selected_encoding_name(), Some("GBK")); +} + +#[test] +fn text_document_bom_precedes_transport_charset() { + let headers = [( + "Content-Type".to_owned(), + b"text/plain; charset=gbk".to_vec(), + )]; + let mut decoder = HtmlDocumentStreamingDecoder::new_text_document( + &headers, + "https://example.test/", + unexpected_legacy_encoding_detector, + false, + ); + assert_eq!(decoder.document_encoding_name(), "GBK"); + assert_eq!(decoder.selected_encoding_name(), None); + assert!(decoder.push(&[0xef]).is_empty()); + assert!(decoder.push(&[0xbb]).is_empty()); + let mut tail = vec![0xbf]; + tail.extend_from_slice("Gülçek".as_bytes()); + let mut output = decoder.push(&tail).join(""); + output.push_str(&decoder.finish().unwrap_or_default()); + assert_eq!(output, "Gülçek"); + assert_eq!(decoder.selected_encoding_name(), Some("UTF-8")); + assert_eq!(decoder.document_encoding_name(), "UTF-8"); +} + +#[test] +fn plain_text_document_retains_legacy_detection_without_a_charset() { + let mut decoder = HtmlDocumentStreamingDecoder::new_text_document( + &[], + "https://legacy.example/", + test_legacy_encoding_detector, + false, + ); + let mut output = decoder.push(&[0x80]).join(""); + output.push_str(&decoder.finish().unwrap_or_default()); + assert_eq!(output, "А"); + assert_eq!(decoder.selected_encoding_name(), Some("IBM866")); +} + +#[test] +fn plain_text_document_ignores_literal_encoding_declarations() { + for prefix in [ + "", + "", + ] { + let mut input = prefix.as_bytes().to_vec(); + input.push(0xe9); + let mut decoder = HtmlDocumentStreamingDecoder::new_text_document( + &[], + "https://example.test/", + test_legacy_encoding_detector, + false, + ); + let mut output = decoder.push(&input).join(""); + output.push_str(&decoder.finish().unwrap_or_default()); + assert_eq!(output, format!("{prefix}é")); + assert_eq!(decoder.selected_encoding_name(), Some("windows-1252")); + } +} + #[test] fn content_type_charset_is_selected() { let headers: Vec<(String, Vec)> = vec![( diff --git a/moli-fetch/src/network_fetch_result.rs b/moli-fetch/src/network_fetch_result.rs index ded5de0963..23ab1647e7 100644 --- a/moli-fetch/src/network_fetch_result.rs +++ b/moli-fetch/src/network_fetch_result.rs @@ -16,6 +16,7 @@ const MAX_OBSERVED_HEADER_BLOCK_BYTES: usize = 256 * 1024; /// Request headers observed after the transport has finalized an HTTP exchange. #[derive(Clone, Debug, Eq, PartialEq)] pub struct NetworkRequestObservation { + method: Option, headers: Vec<(String, String)>, cookie_report: Option, truncated: bool, @@ -24,6 +25,16 @@ pub struct NetworkRequestObservation { impl NetworkRequestObservation { pub fn new(headers: Vec<(String, String)>) -> Self { Self { + method: None, + headers, + cookie_report: None, + truncated: false, + } + } + + pub fn new_with_method(method: impl Into, headers: Vec<(String, String)>) -> Self { + Self { + method: Some(method.into()), headers, cookie_report: None, truncated: false, @@ -33,12 +44,17 @@ impl NetworkRequestObservation { fn from_header_block(data: &[u8], cookie_report: Option) -> Self { let (data, truncated) = bounded_header_data(data); Self { + method: parse_request_method(data), headers: parse_request_header_block(data), cookie_report, truncated, } } + pub fn method(&self) -> Option<&str> { + self.method.as_deref() + } + pub fn headers(&self) -> &[(String, String)] { &self.headers } @@ -340,6 +356,14 @@ fn parse_request_header_block(data: &[u8]) -> Vec<(String, String)> { .collect() } +fn parse_request_method(data: &[u8]) -> Option { + let line = String::from_utf8_lossy(data); + let line = line.lines().next()?.trim_end_matches('\r'); + is_http_request_line(line) + .then(|| line.split_whitespace().next().map(str::to_owned)) + .flatten() +} + fn is_http_request_line(line: &str) -> bool { let mut fields = line.split_whitespace(); let (Some(method), Some(target), Some(version), None) = @@ -701,7 +725,7 @@ mod tests { fn recorder_preserves_redirect_exchange_order_and_raw_response_status() { let recorder = NetworkObservationRecorder::default(); recorder.record_request_header_block( - b"GET /start HTTP/1.1\r\nHost: example.test\r\nAccept-Encoding: gzip\r\n\r\n", + b"POST /start HTTP/1.1\r\nHost: example.test\r\nAccept-Encoding: gzip\r\n\r\n", ); recorder.record_response_header_line(b"HTTP/1.1 302 Found\r\n"); recorder.record_response_header_line(b"Location: /final\r\n"); @@ -715,6 +739,7 @@ mod tests { let journal = recorder.snapshot(); assert_eq!(journal.exchanges().len(), 2); + assert_eq!(journal.exchanges()[0].request().method(), Some("POST")); assert_eq!( journal.exchanges()[0].request().headers(), [ @@ -729,6 +754,13 @@ mod tests { .status(), 302 ); + assert_eq!( + journal + .final_request_observation() + .expect("final request") + .method(), + Some("GET") + ); assert_eq!( journal .final_request_observation() diff --git a/moli-layout/src/builder.rs b/moli-layout/src/builder.rs index 2145844ef2..ce2091d378 100644 --- a/moli-layout/src/builder.rs +++ b/moli-layout/src/builder.rs @@ -622,6 +622,9 @@ where let mut output = Vec::new(); let mut current = Some(principal); let mut run = Vec::new(); + let mut blocks: Option = None; + let mut preceding_inline = principal; + world.inline_split_sources.insert(owner); for child in children { if self.is_block_in_flow(world, child) { @@ -632,11 +635,24 @@ where std::mem::take(&mut run), )?; output.push(fragment); + preceding_inline = fragment; + } + if let Some(run) = blocks.as_mut() { + run.last = child; + } else { + blocks = Some(crate::world::InlineSplitBlockRun { + preceding_inline, + first: child, + last: child, + }); } output.push(child); continue; } + if let Some(run) = blocks.take() { + world.inline_split_block_runs.push(run); + } if current.is_none() { current = Some(self.allocate_inline_continuation(world, owner, style, semantics.clone())); @@ -644,6 +660,9 @@ where run.push(child); } + if let Some(run) = blocks { + world.inline_split_block_runs.push(run); + } if let Some(fragment) = current { self.replace_children_and_mark_context(world, fragment, run)?; output.push(fragment); diff --git a/moli-layout/src/inline.rs b/moli-layout/src/inline.rs index 8110acc167..bb9a03586a 100644 --- a/moli-layout/src/inline.rs +++ b/moli-layout/src/inline.rs @@ -35,6 +35,16 @@ pub(crate) fn relative_atomic_inset_offset( style: &taffy::Style, containing_block_size: Size, container_direction: InlineDirection, +) -> Point { + relative_inset_offset(style, containing_block_size.map(Some), container_direction) +} + +/// The block-axis percentage basis may be indefinite even when the final +/// content height is nonzero (auto height or min-height-only sizing). +pub(crate) fn relative_inset_offset( + style: &taffy::Style, + containing_block_size: Size>, + container_direction: InlineDirection, ) -> Point { let inset = taffy::Rect { left: style.inset.left.maybe_resolve( diff --git a/moli-layout/src/pass.rs b/moli-layout/src/pass.rs index e519c2eacf..4a069efdf4 100644 --- a/moli-layout/src/pass.rs +++ b/moli-layout/src/pass.rs @@ -306,11 +306,13 @@ where /// /// Every iteration enters Taffy through the synthetic viewport root, but that /// is a scheduling entry point rather than an unconditional full-tree layout. -/// `invalidate_scrollbar_feedback` clears only changed boxes and their numeric -/// ancestors. All other subtrees keep valid Taffy cache entries and return +/// `invalidate_scrollbar_feedback` clears a changed local scroll container's +/// subtree and numeric ancestor path. A viewport gutter invalidates the whole +/// tree because it changes the initial containing block. Unaffected branches +/// of a local scrollbar change keep valid Taffy cache entries and return /// immediately. This is the same important boundary as Blink's corrective /// scrollbar relayout: pay a follow-up pass only after state changes, and only -/// recompute the paths whose available space can have changed. +/// recompute boxes whose available space can have changed. #[derive(Default)] struct NumericLayoutMetrics { pass_count: usize, diff --git a/moli-layout/src/projection.rs b/moli-layout/src/projection.rs index bd6275c6e6..c4e79df6f2 100644 --- a/moli-layout/src/projection.rs +++ b/moli-layout/src/projection.rs @@ -1,6 +1,6 @@ use std::{collections::HashMap, fmt::Debug, hash::Hash, time::Instant}; -use taffy::ResolveOrZero; +use taffy::{MaybeMath, MaybeResolve, ResolveOrZero}; use crate::layout_tree::{CssSizing, CssSizingBox, LayoutCoordinateSpace}; use crate::overflow::{OverflowProjection, inset_rect, offset_rect, outset_rect}; @@ -52,6 +52,7 @@ where projection.build_scroll_extents(&overflow); projection.build_coordinate_spaces()?; projection.build_fragments(); + projection.build_split_block_fragments(); projection.assign_clip_and_paint_order(); let content_size = projection.document_content_size(); @@ -744,6 +745,140 @@ where } } + fn build_split_block_fragments(&mut self) { + // Construction records only contiguous, promoted in-flow block runs. + // Their middle continuation uses the existing flow bounds and parent + // content width, not transformed descendant ink or a new numeric box. + // In particular this must not introduce an auto-height containing block. + for run in &self.world.inline_split_block_runs { + let first = &self.world.boxes[run.first.index()]; + let last = &self.world.boxes[run.last.index()]; + let Some(parent) = first.parent else { + continue; + }; + let content = self.boxes[parent.index()].content_box; + let basis = self.split_block_relative_inset_basis(parent); + let direction = self.world.boxes[parent.index()].style.direction(); + let first_offset = + crate::inline::relative_inset_offset(&first.style.taffy, basis, direction); + let last_offset = + crate::inline::relative_inset_offset(&last.style.taffy, basis, direction); + let top = first.final_layout.location.y - first_offset.y; + let bottom = + last.final_layout.location.y - last_offset.y + last.final_layout.size.height; + let rect = LayoutRect::new(content.x, top, content.width, (bottom - top).max(0.0)); + // A zero-area leading continuation follows escaped leading + // margins into the first block's flow position. It carries no + // line strut of its own and must not remain at the pre-collapse y. + for id in &self.boxes[run.preceding_inline.index()].fragments { + let existing = &mut self.fragments[id.index()]; + if matches!(existing.kind, LayoutFragmentKind::InlineBox { .. }) + && existing.rect.width == 0.0 + && existing.rect.height == 0.0 + { + let original = self.coordinate_spaces[existing.coordinate_space.index()] + .layout + .local_to_document_ignoring_css_transforms + .map_point(LayoutPoint::new(existing.rect.x, existing.rect.y)); + let x = self.coordinate_spaces[parent.index() + 1] + .layout + .local_to_document_ignoring_css_transforms + .inverse() + .map(|inverse| inverse.map_point(original).x) + .unwrap_or(existing.rect.x); + let boundary = LayoutRect::new(x, top, 0.0, 0.0); + existing.rect = boundary; + existing.coordinate_space = + LayoutCoordinateSpaceId::from_index(parent.index() + 1); + existing.box_model = Some(LayoutFragmentBoxModel { + content: boundary, + padding: boundary, + border: boundary, + margin: boundary, + }); + } + } + let fragment = self.push_fragment(LayoutFragment { + id: LayoutFragmentId::from_index(0), + kind: LayoutFragmentKind::Box { + box_id: LayoutOutputBoxId::from_index(run.preceding_inline.index()), + }, + rect, + box_model: Some(LayoutFragmentBoxModel { + content: rect, + padding: rect, + border: rect, + margin: rect, + }), + coordinate_space: LayoutCoordinateSpaceId::from_index(parent.index() + 1), + clip_chain: None, + paint_order: None, + }); + self.register_box_fragment(run.preceding_inline.index(), fragment); + } + } + + fn split_block_relative_inset_basis(&self, parent: LayoutBoxId) -> taffy::Size> { + let content = self.boxes[parent.index()].content_box; + let Some(inputs) = self + .world + .inline_split_parent_inputs + .get(&parent) + .copied() + .flatten() + else { + return taffy::Size { + width: Some(content.width), + height: None, + }; + }; + let layout_box = &self.world.boxes[parent.index()]; + let style = &layout_box.style.taffy; + let percentage_basis = inputs + .constraint_space(layout_box.style.writing_mode()) + .margin_padding_percentage_basis(); + let padding = style + .padding + .resolve_or_zero(percentage_basis, crate::style::resolve_stylo_calc_value); + let border = style + .border + .resolve_or_zero(percentage_basis, crate::style::resolve_stylo_calc_value); + let padding_border = (padding + border).sum_axes(); + let adjustment = if style.box_sizing == taffy::BoxSizing::ContentBox { + padding_border + } else { + taffy::Size::ZERO + }; + // Match the block backend's relative-inset definiteness, not its + // broader percentage-size/min-height basis. The actual incoming + // definite dimensions win; auto/min-height alone remain indefinite. + let preferred = if inputs.sizing_mode == taffy::SizingMode::InherentSize { + let size = style + .size + .maybe_resolve(inputs.parent_size, crate::style::resolve_stylo_calc_value) + .maybe_add(adjustment); + block_relative_inset_preferred_size( + size, + style.size.map(|dimension| dimension.is_auto()), + layout_box.style.writing_mode(), + inputs.block_auto_behavior, + layout_box.resolved_aspect_ratio(), + padding_border, + ) + } else { + taffy::Size::NONE + }; + let vertical_inset = layout_box.final_layout.size.height - content.height; + taffy::Size { + width: Some(content.width), + height: inputs + .definite_dimensions + .height + .or(preferred.height) + .map(|height| (height - vertical_inset).max(0.0)), + } + } + fn register_box_fragment(&mut self, box_index: usize, fragment: LayoutFragmentId) { self.boxes[box_index].fragments.push(fragment); } @@ -1312,12 +1447,32 @@ where let right_padding = if has_right_edge { padding.right } else { 0.0 }; let left_border = if has_left_edge { border.left } else { 0.0 }; let right_border = if has_right_edge { border.right } else { 0.0 }; - let border_box = LayoutRect::new( + let mut border_box = LayoutRect::new( content_origin.x + fragment.rect.x + left_margin, content_origin.y + fragment.rect.y - padding.top - border.top, (fragment.rect.width - left_margin - right_margin).max(0.0), fragment.rect.height + padding.top + padding.bottom + border.top + border.bottom, ); + if fragment.rect.width == 0.0 + && inline_box + .source + .or(inline_box.owner) + .is_some_and(|source| world.inline_split_sources.contains(&source)) + && padding.top + padding.bottom + border.top + border.bottom == 0.0 + { + // Empty split continuations are zero-area boundaries of the block + // continuation, not font-height glyph boxes. Ordinary empty inlines + // retain their normal font metrics. + if let Some(line) = owner + .inline_layout + .as_ref() + .and_then(|context| context.fragments.lines.get(fragment.line_index)) + .filter(|line| line.rect.height == 0.0) + { + border_box.y = content_origin.y + line.rect.y; + border_box.height = 0.0; + } + } let padding_box = inset_rect( border_box, border.top, @@ -1347,10 +1502,86 @@ where } } +// Taffy 71bc149 compute/block.rs uses the *unclamped preferred size* for +// relative-inset definiteness, not used/min/max height. This narrow adapter +// mirrors common/aspect_ratio.rs::apply_preferred_aspect_ratio; it does not +// re-run layout or transfer min/max constraints. Taffy keeps that helper private. +fn block_relative_inset_preferred_size( + size: taffy::Size>, + size_is_auto: taffy::Size, + writing_mode: taffy::WritingMode, + block_auto_behavior: taffy::AutoSizeBehavior, + aspect_ratio: Option, + padding_border: taffy::Size, +) -> taffy::Size> { + let ratio_size = size.maybe_apply_aspect_ratio_with_box_sizing( + aspect_ratio, + taffy::BoxSizing::BorderBox, + padding_border, + ); + if block_auto_behavior != taffy::AutoSizeBehavior::StretchExplicit { + return ratio_size; + } + let source = writing_mode.to_logical(size); + let auto = writing_mode.to_logical(size_is_auto); + let mut resolved = writing_mode.to_logical(ratio_size); + if auto.block_size && source.block_size.is_none() && resolved.block_size.is_some() { + resolved.block_size = None; + } + writing_mode.to_physical(resolved) +} + #[cfg(test)] mod paint_space_tests { use super::*; + #[test] + fn split_block_relative_basis_keeps_preferred_ratio_and_stretch_provenance() { + use taffy::{AutoSizeBehavior, BoxSizing, ResolvedAspectRatio, Size, WritingMode}; + let ratio = ResolvedAspectRatio::new(2.0, BoxSizing::BorderBox); + let source = Size { + width: Some(200.0), + height: None, + }; + let auto = Size { + width: false, + height: true, + }; + let fit = block_relative_inset_preferred_size( + source, + auto, + WritingMode::HorizontalTb, + AutoSizeBehavior::FitContent, + ratio, + Size::ZERO, + ); + assert_eq!(fit.height, Some(100.0)); + let stretch = block_relative_inset_preferred_size( + source, + auto, + WritingMode::HorizontalTb, + AutoSizeBehavior::StretchExplicit, + ratio, + Size::ZERO, + ); + assert_eq!(stretch.height, None); + let explicit = block_relative_inset_preferred_size( + Size { + width: Some(200.0), + height: Some(200.0), + }, + Size { + width: false, + height: false, + }, + WritingMode::HorizontalTb, + AutoSizeBehavior::StretchExplicit, + ratio, + Size::ZERO, + ); + assert_eq!(explicit.height, Some(200.0)); + } + fn property_transform(transform: LayoutTransform2D) -> ResolvedLayoutTransform { ResolvedLayoutTransform { transform, diff --git a/moli-layout/src/taffy_tree.rs b/moli-layout/src/taffy_tree.rs index e6d92b304c..6da1b69e6e 100644 --- a/moli-layout/src/taffy_tree.rs +++ b/moli-layout/src/taffy_tree.rs @@ -78,9 +78,14 @@ impl PreparedWorldLayout { self.numeric_viewport_layout = world.viewport_layout.unrounded_layout; } - /// Clears only the cache entries on changed scrollbar boxes and their - /// numeric ancestors. The reusable mark table makes this proportional to - /// the affected paths rather than to every box in the document. + /// Clears the cache entries whose available space can change after a + /// scrollbar appears. + /// + /// A viewport gutter changes the initial containing block, so every box + /// is affected. A local gutter changes the scroll container's content box, + /// so its complete numeric subtree and ancestor path are affected. Keeping + /// descendant caches in either case can reuse a size-only result during + /// the corrective pass and leave nested boxes without final geometry. /// /// Taffy's next compute still starts at the viewport root, as its public /// API requires, but siblings outside these paths retain their cached @@ -99,23 +104,38 @@ impl PreparedWorldLayout { assert!(self.feedback_invalidation_worklist.is_empty()); self.feedback_invalidation_marks .resize(world.boxes.len(), false); - if viewport_changed { - self.feedback_invalidation_worklist.push(world.root); - } - self.feedback_invalidation_worklist - .extend(changed_boxes.iter().copied()); - let mut invalidated = Vec::new(); - while let Some(id) = self.feedback_invalidation_worklist.pop() { - if self.feedback_invalidation_marks[id.index()] { - continue; + if viewport_changed { + for index in 0..world.boxes.len() { + let id = LayoutBoxId::from_index(index); + self.feedback_invalidation_marks[index] = true; + invalidated.push(id); + world.boxes[index].clear_layout_caches(); } - self.feedback_invalidation_marks[id.index()] = true; - invalidated.push(id); - world.boxes[id.index()].clear_layout_caches(); - if let Some(parent) = world.boxes[id.index()].layout_parent { - self.feedback_invalidation_worklist.push(parent); + } else { + self.feedback_invalidation_worklist + .extend(changed_boxes.iter().copied()); + while let Some(id) = self.feedback_invalidation_worklist.pop() { + if self.feedback_invalidation_marks[id.index()] { + continue; + } + self.feedback_invalidation_marks[id.index()] = true; + invalidated.push(id); + world.boxes[id.index()].clear_layout_caches(); + self.feedback_invalidation_worklist + .extend(world.boxes[id.index()].layout_children.iter().copied()); } + + for changed in changed_boxes.iter().copied() { + let mut ancestor = world.boxes[changed.index()].layout_parent; + while let Some(id) = ancestor { + if !self.feedback_invalidation_marks[id.index()] { + self.feedback_invalidation_marks[id.index()] = true; + invalidated.push(id); + world.boxes[id.index()].clear_layout_caches(); + } + ancestor = world.boxes[id.index()].layout_parent; + } } if viewport_changed || !changed_boxes.is_empty() { world.viewport_layout.cache.clear(); @@ -1621,6 +1641,14 @@ where } fn compute_child_layout(&mut self, node_id: NodeId, inputs: LayoutInput) -> LayoutOutput { + if !self.is_viewport_taffy_node(node_id) + && inputs.run_mode == RunMode::PerformLayout + && let Some(slot) = self + .inline_split_parent_inputs + .get_mut(&LayoutBoxId::from_taffy(node_id)) + { + *slot = Some(inputs); + } // Float parents own horizontal margin subtraction in both the Taffy // block path and Moli's Parley IFC path. The generic intrinsic // resolver follows Taffy's ordinary child-input contract and subtracts @@ -1780,6 +1808,13 @@ where inputs: LayoutInput, block_context: Option<&mut BlockContext<'_>>, ) -> LayoutOutput { + if inputs.run_mode == RunMode::PerformLayout + && let Some(slot) = self + .inline_split_parent_inputs + .get_mut(&LayoutBoxId::from_taffy(node_id)) + { + *slot = Some(inputs); + } if self.should_hide(node_id, inputs) { return compute_hidden_layout(self, node_id); } diff --git a/moli-layout/src/world.rs b/moli-layout/src/world.rs index dd294f93da..d9c6a4c8a2 100644 --- a/moli-layout/src/world.rs +++ b/moli-layout/src/world.rs @@ -593,6 +593,14 @@ impl LayoutBox { } } +/// One source-owned middle continuation, with no numeric containing block. +#[derive(Debug)] +pub(crate) struct InlineSplitBlockRun { + pub(crate) preceding_inline: LayoutBoxId, + pub(crate) first: LayoutBoxId, + pub(crate) last: LayoutBoxId, +} + /// Entire short-lived sidecar used for one construction/layout demand. #[derive(Debug)] pub struct LayoutWorld @@ -602,6 +610,9 @@ where pub(crate) boxes: Vec>, pub(crate) source_mapping: HashMap, pub(crate) display_contents_mapping: HashMap>, + pub(crate) inline_split_sources: std::collections::HashSet, + pub(crate) inline_split_block_runs: Vec, + pub(crate) inline_split_parent_inputs: HashMap>, pub(crate) root: LayoutBoxId, /// The root is the document element only for a complete document source. /// Subtree and synthetic sources still use the same internal root slot. @@ -627,6 +638,9 @@ where boxes: vec![root], source_mapping: HashMap::new(), display_contents_mapping: HashMap::new(), + inline_split_sources: std::collections::HashSet::new(), + inline_split_block_runs: Vec::new(), + inline_split_parent_inputs: HashMap::new(), root: LayoutBoxId::from_index(0), root_is_document_element, viewport_scroll_policy: ViewportScrollPolicy::default(), @@ -905,6 +919,30 @@ where *ids = ids.iter().filter_map(|id| remap[id.index()]).collect(); !ids.is_empty() }); + self.inline_split_block_runs.retain_mut(|run| { + let (Some(preceding), Some(first), Some(last)) = ( + remap[run.preceding_inline.index()], + remap[run.first.index()], + remap[run.last.index()], + ) else { + return false; + }; + run.preceding_inline = preceding; + run.first = first; + run.last = last; + true + }); + self.inline_split_sources + .retain(|source| self.source_mapping.contains_key(source)); + self.inline_split_parent_inputs = self + .inline_split_block_runs + .iter() + .filter_map(|run| { + self.boxes[run.first.index()] + .parent + .map(|parent| (parent, None)) + }) + .collect(); } /// Validates graph ownership invariants without relying on allocator IDs. diff --git a/moli-layout/tests/phase4_layout_contract.rs b/moli-layout/tests/phase4_layout_contract.rs index f77a572fa6..e66c5ea00d 100644 --- a/moli-layout/tests/phase4_layout_contract.rs +++ b/moli-layout/tests/phase4_layout_contract.rs @@ -1564,28 +1564,29 @@ fn inline_blocks_use_their_internal_last_line_baseline_and_overflow_fallback() { .primary .insert(5, nav_item_style(BLUE, 19.0, Overflow::Hidden)); let fallback = render(&source, &mut styles, 300, 100); - let more = rect(&fallback, BLUE); - assert_close(more.y, 19.0); - assert_close(more.height, 42.0); - assert_close(rect(&fallback, RED).y, rect(&fallback, GREEN).y); - // A clipped inline-block uses its bottom margin edge as the baseline. - // Check the adjacent text's actual baselines: its box top depends on the - // platform font's ascent, even with an explicit font size and line height. - let baselines = fallback - .fragments - .iter() - .filter_map(|fragment| match fragment { - PaintFragment::GlyphRun(run) => run.glyphs_in_surface().first().map(|glyph| glyph.y), - _ => None, - }) - .collect::>(); - assert_eq!( - baselines.len(), - 3, - "each nav item should paint one text run" + let fallback_news = rect(&fallback, RED); + let fallback_hao = rect(&fallback, GREEN); + let fallback_more = rect(&fallback, BLUE); + assert_close(fallback_news.y, fallback_hao.y); + assert!(fallback_news.y > news.y); + assert_close(fallback_more.y, more.y); + + // The scrolling inline-block synthesizes its baseline from its margin-box + // edge. Increasing only its block-end padding must therefore move both + // text-backed peers by exactly the same amount while its own top remains + // fixed. This checks the fallback contract without baking a platform font's + // ascent into an absolute y coordinate. + styles + .primary + .insert(5, nav_item_style(BLUE, 29.0, Overflow::Hidden)); + let deeper_fallback = render(&source, &mut styles, 300, 100); + assert_close(rect(&deeper_fallback, RED).y, fallback_news.y + 10.0); + assert_close(rect(&deeper_fallback, GREEN).y, fallback_hao.y + 10.0); + assert_close(rect(&deeper_fallback, BLUE).y, fallback_more.y); + assert_close( + rect(&deeper_fallback, BLUE).height, + fallback_more.height + 10.0, ); - assert_close(baselines[0], more.y + more.height); - assert_close(baselines[1], more.y + more.height); } #[test] diff --git a/moli-layout/tests/phase5_output_contract.rs b/moli-layout/tests/phase5_output_contract.rs index 408f34afeb..c72577e99a 100644 --- a/moli-layout/tests/phase5_output_contract.rs +++ b/moli-layout/tests/phase5_output_contract.rs @@ -748,8 +748,8 @@ fn auto_scrollbar_feedback_reveals_the_perpendicular_axis() { let output = build(&source, &mut styles); assert_eq!(output.metrics.numeric_layout_pass_count, 3); assert_eq!( - output.metrics.numeric_feedback_invalidated_node_count, 4, - "each newly revealed axis invalidates only the scroller-to-root path" + output.metrics.numeric_feedback_invalidated_node_count, 6, + "each newly revealed axis invalidates the scroller subtree and root path" ); let metrics = output.element_metrics_for_source(1).unwrap(); assert_eq!( @@ -766,6 +766,180 @@ fn auto_scrollbar_feedback_reveals_the_perpendicular_axis() { assert!(extent.vertical_scrollbar.is_some()); } +#[test] +fn viewport_scrollbar_feedback_relayouts_nested_absolute_descendants() { + for (tall_height, expects_feedback) in [(100.0, false), (480.0, true)] { + let source = Source(vec![ + Node::element("root", vec![1, 6]), + Node::element("positioned-container", vec![2]), + Node::element("absolute-menu", vec![3]), + Node::element("menu-item", vec![4]), + Node::element("menu-link", vec![5]), + Node::text("menu-link-text", "New"), + Node::element("page-content", Vec::new()), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 320.0, 240.0)); + styles.0.insert( + 1, + fixed_size(LayoutDisplay::Block, 40.0, 25.0).with_position(LayoutPosition::Relative), + ); + styles.0.insert( + 2, + resolved( + LayoutDisplay::Block, + Style { + position: Position::Absolute, + inset: Rect { + left: length(0.0), + right: LengthPercentageAuto::auto(), + top: length(25.0), + bottom: LengthPercentageAuto::auto(), + }, + size: Size { + width: length(120.0), + height: Dimension::auto(), + }, + ..Style::default() + }, + ) + .with_position(LayoutPosition::Absolute), + ); + styles + .0 + .insert(3, resolved(LayoutDisplay::Block, Style::default())); + styles + .0 + .insert(4, fixed_size(LayoutDisplay::Block, 120.0, 35.0)); + styles.0.insert( + 5, + resolved(LayoutDisplay::Inline, Style::default()).with_text_metrics(14.0, 21.0), + ); + styles + .0 + .insert(6, fixed_size(LayoutDisplay::Block, 320.0, tall_height)); + + let output = build(&source, &mut styles); + assert_eq!( + output.metrics.numeric_layout_pass_count > 1, + expects_feedback, + "only the overflowing control should need scrollbar feedback" + ); + let link = output + .source_output(4) + .and_then(|source| source.principal_box) + .and_then(|id| output.box_geometry(id)) + .expect("the nested absolute descendant must retain a principal box"); + assert_rect(link.border_box, LayoutRect::new(0.0, 0.0, 120.0, 35.0)); + } +} + +#[test] +fn local_scrollbar_feedback_invalidates_only_the_changed_subtree_and_ancestor_path() { + for (content_height, expects_feedback) in [(50.0, false), (200.0, true)] { + let source = Source(vec![ + Node::element("root", vec![1, 7]), + Node::element("scroller", vec![2, 6]), + Node::element("absolute-menu", vec![3]), + Node::element("menu-item", vec![4]), + Node::element("menu-link", vec![5]), + Node::text("menu-link-text", "New"), + Node::element("scroll-content", Vec::new()), + Node::element("unrelated-fixed", Vec::new()), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 320.0, 240.0)); + styles.0.insert( + 1, + resolved( + LayoutDisplay::Block, + Style { + position: Position::Relative, + size: Size { + width: length(200.0), + height: length(100.0), + }, + overflow: Point { + x: Overflow::Scroll, + y: Overflow::Scroll, + }, + ..Style::default() + }, + ) + .with_position(LayoutPosition::Relative), + ); + styles.0.insert( + 2, + resolved( + LayoutDisplay::Block, + Style { + position: Position::Absolute, + inset: Rect { + left: length(0.0), + right: LengthPercentageAuto::auto(), + top: length(0.0), + bottom: LengthPercentageAuto::auto(), + }, + size: Size { + width: length(120.0), + height: Dimension::auto(), + }, + ..Style::default() + }, + ) + .with_position(LayoutPosition::Absolute), + ); + styles + .0 + .insert(3, resolved(LayoutDisplay::Block, Style::default())); + styles + .0 + .insert(4, fixed_size(LayoutDisplay::Block, 120.0, 35.0)); + styles.0.insert( + 5, + resolved(LayoutDisplay::Inline, Style::default()).with_text_metrics(14.0, 21.0), + ); + styles + .0 + .insert(6, fixed_size(LayoutDisplay::Block, 200.0, content_height)); + styles.0.insert( + 7, + fixed_size(LayoutDisplay::Block, 10.0, 10.0).with_position(LayoutPosition::Fixed), + ); + + let output = build(&source, &mut styles); + assert_eq!( + output.metrics.numeric_layout_pass_count > 1, + expects_feedback, + "a non-overflowing local scrollport must keep its first-pass caches" + ); + assert_eq!( + output.metrics.numeric_feedback_invalidated_node_count, + if expects_feedback { 12 } else { 0 }, + "each of two scrollbar turns must invalidate the five-box numeric scroller subtree and its root ancestor; inline text has no separate numeric box, and the unrelated fixed branch remains cached" + ); + let link = output + .source_output(4) + .and_then(|source| source.principal_box) + .and_then(|id| output.box_geometry(id)) + .expect("the nested local descendant must retain a principal box"); + assert_rect(link.border_box, LayoutRect::new(0.0, 0.0, 120.0, 35.0)); + assert_eq!( + output + .source_output(7) + .and_then(|source| source.principal_box) + .and_then(|id| output.box_geometry(id)) + .expect("the unrelated fixed branch must retain geometry") + .border_box, + LayoutRect::new(0.0, 0.0, 10.0, 10.0), + ); + } +} + #[test] fn scrollbar_feedback_rebreaks_the_reused_inline_layout_at_its_final_width() { const TEXT: &str = "alpha beta gamma delta epsilon zeta eta theta iota kappa"; @@ -804,6 +978,10 @@ fn scrollbar_feedback_rebreaks_the_reused_inline_layout_at_its_final_width() { feedback.element_metrics_for_source(1).unwrap().client_size, moli_layout::LayoutSize::new(85.0, 40.0), ); + let scroller_box = feedback.source_output(1).unwrap().principal_box.unwrap(); + let extent = feedback.scroll_extent(scroller_box).unwrap(); + assert!(extent.vertical_scrollbar.is_some()); + assert!(extent.horizontal_scrollbar.is_none()); let feedback_text = feedback.client_rects_for_source(2); // Lay out the same paragraph directly at the converged 85px content @@ -2632,6 +2810,583 @@ fn caret_query_uses_parley_cluster_sides_and_inline_direction() { assert_cluster_sides("א", true); } +#[test] +fn split_inline_block_continuation_keeps_its_middle_fragment() { + let source = Source(vec![ + Node::element("root", vec![1]), + Node::element("split-inline", vec![2, 3]), + Node::element("flex", Vec::new()), + Node::element("block", Vec::new()), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 200.0, 120.0)); + styles + .0 + .insert(1, resolved(LayoutDisplay::Inline, Style::default())); + styles + .0 + .insert(2, fixed_size(LayoutDisplay::Flex, 50.0, 20.0)); + styles + .0 + .insert(3, fixed_size(LayoutDisplay::Block, 80.0, 30.0)); + let output = build(&source, &mut styles); + let rects = output.client_rects_for_source(1); + assert_eq!(rects.len(), 3, "{rects:?}"); + let middle = rects[1].bounding_rect(); + assert_eq!(middle.width, 200.0, "{rects:?}"); + assert_eq!(middle.height, 50.0, "{rects:?}"); + assert_eq!(rects[0].bounding_rect().width, 0.0); + assert_eq!(rects[2].bounding_rect().width, 0.0); + assert_rect( + rects[0].bounding_rect(), + LayoutRect::new(0.0, 0.0, 0.0, 0.0), + ); + assert_rect( + rects[2].bounding_rect(), + LayoutRect::new(0.0, 50.0, 0.0, 0.0), + ); +} + +#[test] +fn split_inline_nested_and_transformed_continuations_keep_source_geometry() { + for nested in [false, true] { + for transformed_child in [false, true] { + let source = Source(vec![ + Node::element("root", vec![1]), + Node::element("outer", vec![2]), + Node::element("inner", vec![3]), + Node::element("block", Vec::new()), + ]); + let mut styles = Styles::default(); + styles.0.insert( + 0, + fixed_size(LayoutDisplay::Block, 200.0, 120.0) + .with_2d_transform(LayoutTransform2D::translation(10.0, 5.0)), + ); + styles + .0 + .insert(1, resolved(LayoutDisplay::Inline, Style::default())); + styles.0.insert( + 2, + resolved( + if nested { + LayoutDisplay::Inline + } else { + LayoutDisplay::Contents + }, + Style::default(), + ), + ); + let mut child = fixed_size(LayoutDisplay::Block, 50.0, 20.0); + if transformed_child { + child = child.with_2d_transform(LayoutTransform2D::translation(100.0, 10.0)); + } + styles.0.insert(3, child); + let output = build(&source, &mut styles); + for source_id in if nested { vec![1, 2] } else { vec![1] } { + let rects = output.client_rects_for_source(source_id); + assert_eq!( + rects.len(), + 3, + "nested={nested}, source={source_id}: {rects:?}" + ); + assert_rect( + rects[0].bounding_rect(), + LayoutRect::new(10.0, 5.0, 0.0, 0.0), + ); + assert_rect( + rects[1].bounding_rect(), + LayoutRect::new(10.0, 5.0, 200.0, 20.0), + ); + assert_rect( + rects[2].bounding_rect(), + LayoutRect::new(10.0, 25.0, 0.0, 0.0), + ); + } + } + } +} + +#[test] +fn split_inline_does_not_union_out_of_flow_descendants() { + let source = Source(vec![ + Node::element("root", vec![1]), + Node::element("inline", vec![2, 3]), + Node::element("block", Vec::new()), + Node::element("absolute", Vec::new()), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 200.0, 120.0)); + styles + .0 + .insert(1, resolved(LayoutDisplay::Inline, Style::default())); + styles + .0 + .insert(2, fixed_size(LayoutDisplay::Block, 50.0, 20.0)); + styles.0.insert( + 3, + fixed_size(LayoutDisplay::Block, 500.0, 800.0).with_position(LayoutPosition::Absolute), + ); + let output = build(&source, &mut styles); + let rects = output.client_rects_for_source(1); + assert_eq!(rects.len(), 3, "{rects:?}"); + assert_rect( + rects[1].bounding_rect(), + LayoutRect::new(0.0, 0.0, 200.0, 20.0), + ); +} + +#[test] +fn split_inline_percentage_block_height_uses_nonanonymous_containing_block() { + for nested in [false, true] { + for rule in 0..3 { + let source = Source(vec![ + Node::element("root", vec![1]), + Node::element("outer", if nested { vec![2] } else { vec![3] }), + Node::element("inner", vec![3]), + Node::element("block", Vec::new()), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 200.0, 200.0)); + for id in [1, 2] { + styles + .0 + .insert(id, resolved(LayoutDisplay::Inline, Style::default())); + } + let mut child = Style { + size: Size { + width: percent(0.5), + height: if rule == 2 { + length(150.0) + } else { + Dimension::auto() + }, + }, + ..Style::default() + }; + match rule { + 0 => child.size.height = percent(0.5), + 1 => child.min_size.height = percent(0.5), + _ => child.max_size.height = percent(0.5), + } + styles.0.insert(3, resolved(LayoutDisplay::Block, child)); + let output = build(&source, &mut styles); + assert_rect( + output.client_rects_for_source(3)[0].bounding_rect(), + LayoutRect::new(0.0, 0.0, 100.0, 100.0), + ); + let rects = output.client_rects_for_source(1); + assert_rect( + rects[1].bounding_rect(), + LayoutRect::new(0.0, 0.0, 200.0, 100.0), + ); + } + } +} + +#[test] +fn split_inline_middle_fragment_ignores_child_relative_insets() { + for parent_mode in 0..3 { + let fixed_height = parent_mode == 1; + for (top, bottom, expected_offset) in [ + (length(10.0), LengthPercentageAuto::auto(), 10.0), + (LengthPercentageAuto::auto(), length(10.0), -10.0), + ( + percent(0.1), + LengthPercentageAuto::auto(), + if fixed_height { 20.0 } else { 0.0 }, + ), + ( + LengthPercentageAuto::auto(), + percent(0.1), + if fixed_height { -20.0 } else { 0.0 }, + ), + ] { + let source = Source(vec![ + Node::element("root", vec![1]), + Node::element("inline", vec![2]), + Node::element("block", Vec::new()), + ]); + let mut styles = Styles::default(); + styles.0.insert( + 0, + resolved( + LayoutDisplay::Block, + Style { + size: Size { + width: length(200.0), + height: if fixed_height { + length(200.0) + } else { + Dimension::auto() + }, + }, + min_size: Size { + width: Dimension::auto(), + height: if parent_mode == 2 { + length(200.0) + } else { + Dimension::auto() + }, + }, + ..Style::default() + }, + ), + ); + styles + .0 + .insert(1, resolved(LayoutDisplay::Inline, Style::default())); + styles.0.insert( + 2, + resolved( + LayoutDisplay::Block, + Style { + size: Size { + width: length(100.0), + height: length(20.0), + }, + inset: Rect { + left: length(30.0), + top, + bottom, + ..Rect::auto() + }, + ..Style::default() + }, + ) + .with_position(LayoutPosition::Relative), + ); + let output = build(&source, &mut styles); + assert_rect( + output.client_rects_for_source(2)[0].bounding_rect(), + LayoutRect::new(30.0, expected_offset, 100.0, 20.0), + ); + let rects = output.client_rects_for_source(1); + assert_rect( + rects[1].bounding_rect(), + LayoutRect::new(0.0, 0.0, 200.0, 20.0), + ); + } + } +} + +#[test] +fn split_inline_relative_basis_tracks_clamped_and_ratio_sized_parents() { + for mode in 0..3 { + let source = Source(vec![ + Node::element("root", vec![1]), + Node::element("inline", vec![2]), + Node::element("child", Vec::new()), + ]); + let mut styles = Styles::default(); + let mut parent = Style { + size: Size { + width: length(200.0), + height: if mode == 2 { + Dimension::auto() + } else { + length(200.0) + }, + }, + ..Style::default() + }; + if mode == 0 { + parent.max_size.height = length(100.0); + } + if mode == 1 { + parent.min_size.height = length(300.0); + } + if mode == 2 { + parent.aspect_ratio = Some(2.0); + } + styles.0.insert(0, resolved(LayoutDisplay::Block, parent)); + styles + .0 + .insert(1, resolved(LayoutDisplay::Inline, Style::default())); + styles.0.insert( + 2, + resolved( + LayoutDisplay::Block, + Style { + size: Size { + width: length(100.0), + height: length(20.0), + }, + inset: Rect { + top: percent(0.5), + ..Rect::auto() + }, + ..Style::default() + }, + ) + .with_position(LayoutPosition::Relative), + ); + let output = build(&source, &mut styles); + let middle = output.client_rects_for_source(1)[1].bounding_rect(); + assert_rect(middle, LayoutRect::new(0.0, 0.0, 200.0, 20.0)); + } +} + +#[test] +fn split_inline_avatar_flex_and_title_inline_both_keep_nonempty_geometry() { + let source = Source(vec![ + Node::element("root", vec![1, 5]), + Node::element("avatar-cell", vec![2]), + Node::html_element("avatar-link", "a", vec![3]), + Node::element("avatar-flex", vec![4]), + Node::text("avatar-letter", "D"), + Node::html_element("title-link", "a", vec![6]), + Node::text("title", "Primer / design"), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 320.0, 240.0)); + styles + .0 + .insert(1, fixed_size(LayoutDisplay::Block, 48.0, 48.0)); + styles + .0 + .insert(3, fixed_size(LayoutDisplay::Flex, 48.0, 48.0)); + for id in [2, 4, 5, 6] { + styles + .0 + .insert(id, resolved(LayoutDisplay::Inline, Style::default())); + } + let output = build(&source, &mut styles); + let avatar = output.client_rects_for_source(2); + assert_eq!(avatar.len(), 3); + assert_rect( + avatar[1].bounding_rect(), + LayoutRect::new(0.0, 0.0, 48.0, 48.0), + ); + let title = output.client_rects_for_source(5); + assert!(!title.is_empty() && title.iter().any(|r| r.bounding_rect().width > 0.0)); +} + +#[test] +fn split_inline_two_block_runs_keep_content_fragment_order() { + let source = Source(vec![ + Node::element("root", vec![1]), + Node::element("inline", vec![2, 3, 4]), + Node::element("first", Vec::new()), + Node::text("between", "between"), + Node::element("second", Vec::new()), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 200.0, 200.0)); + styles + .0 + .insert(1, resolved(LayoutDisplay::Inline, Style::default())); + styles + .0 + .insert(2, fixed_size(LayoutDisplay::Block, 50.0, 20.0)); + styles + .0 + .insert(3, resolved(LayoutDisplay::Inline, Style::default())); + styles + .0 + .insert(4, fixed_size(LayoutDisplay::Block, 80.0, 30.0)); + let output = build(&source, &mut styles); + let rects = output.client_rects_for_source(1); + assert_eq!(rects.len(), 5, "{rects:?}"); + assert_eq!(rects[1].bounding_rect().height, 20.0); + assert!(rects[2].bounding_rect().width > 0.0); + assert_eq!(rects[3].bounding_rect().height, 30.0); + for pair in rects.windows(2) { + assert!( + pair[0].bounding_rect().y <= pair[1].bounding_rect().y, + "{rects:?}" + ); + } +} + +#[test] +fn split_inline_large_float_between_blocks_does_not_expand_middle_fragments() { + let source = Source(vec![ + Node::element("root", vec![1]), + Node::element("inline", vec![2, 3, 4]), + Node::element("first", Vec::new()), + Node::element("float", Vec::new()), + Node::element("second", Vec::new()), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 200.0, 200.0)); + styles + .0 + .insert(1, resolved(LayoutDisplay::Inline, Style::default())); + styles + .0 + .insert(2, fixed_size(LayoutDisplay::Block, 200.0, 20.0)); + styles.0.insert( + 3, + fixed_size(LayoutDisplay::Block, 500.0, 800.0) + .with_float(taffy::Float::Left, taffy::Clear::None), + ); + styles + .0 + .insert(4, fixed_size(LayoutDisplay::Block, 200.0, 30.0)); + let output = build(&source, &mut styles); + let rects = output.client_rects_for_source(1); + assert_eq!(rects.len(), 5, "{rects:?}"); + assert_rect( + rects[1].bounding_rect(), + LayoutRect::new(0.0, 0.0, 200.0, 20.0), + ); + assert_rect( + rects[2].bounding_rect(), + LayoutRect::new(500.0, 20.0, 0.0, 0.0), + ); + assert_rect( + rects[3].bounding_rect(), + LayoutRect::new(0.0, 20.0, 200.0, 30.0), + ); +} + +#[test] +fn split_inline_metadata_survives_discarded_table_children() { + let source = Source(vec![ + Node::element("root", vec![1, 4]), + Node::element("column-group", vec![2]), + Node::element("discarded", vec![3]), + Node::text("discarded-text", "discarded"), + Node::element("inline", vec![5]), + Node::element("block", Vec::new()), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 200.0, 200.0)); + styles.0.insert( + 1, + resolved(LayoutDisplay::TableColumnGroup, Style::default()), + ); + styles + .0 + .insert(2, fixed_size(LayoutDisplay::Block, 50.0, 20.0)); + styles + .0 + .insert(3, resolved(LayoutDisplay::Inline, Style::default())); + styles + .0 + .insert(4, resolved(LayoutDisplay::Inline, Style::default())); + styles + .0 + .insert(5, fixed_size(LayoutDisplay::Block, 50.0, 20.0)); + let output = build(&source, &mut styles); + let rects = output.client_rects_for_source(4); + assert_eq!(rects.len(), 3, "{rects:?}"); + assert_eq!(rects[1].bounding_rect().width, 200.0); + assert_eq!(rects[1].bounding_rect().height, 20.0); +} + +#[test] +fn split_inline_escaped_margins_keep_middle_flow_bounds() { + let source = Source(vec![ + Node::element("root", vec![1]), + Node::element("inline", vec![2]), + Node::element("block", Vec::new()), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 200.0, 200.0)); + styles + .0 + .insert(1, resolved(LayoutDisplay::Inline, Style::default())); + styles.0.insert( + 2, + resolved( + LayoutDisplay::Block, + Style { + size: Size { + width: length(100.0), + height: length(20.0), + }, + margin: Rect { + top: length(10.0), + bottom: length(10.0), + ..Rect::zero() + }, + ..Style::default() + }, + ), + ); + let output = build(&source, &mut styles); + assert_rect( + output.client_rects_for_source(2)[0].bounding_rect(), + LayoutRect::new(0.0, 10.0, 100.0, 20.0), + ); + let rects = output.client_rects_for_source(1); + assert_rect( + rects[0].bounding_rect(), + LayoutRect::new(0.0, 10.0, 0.0, 0.0), + ); + assert_rect( + rects[1].bounding_rect(), + LayoutRect::new(0.0, 10.0, 200.0, 20.0), + ); + assert_rect( + rects[2].bounding_rect(), + LayoutRect::new(0.0, 40.0, 0.0, 0.0), + ); +} + +#[test] +fn split_inline_middle_fragment_preserves_child_flow_and_margin_collapse() { + for margin in [0.0, 10.0, -5.0] { + let mut observed = Vec::new(); + for split in [false, true] { + let source = Source(vec![ + Node::element("root", if split { vec![1] } else { vec![2, 3] }), + Node::element("inline", vec![2, 3]), + Node::element("first", Vec::new()), + Node::element("second", Vec::new()), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 200.0, 120.0)); + styles + .0 + .insert(1, resolved(LayoutDisplay::Inline, Style::default())); + for id in [2, 3] { + styles.0.insert( + id, + resolved( + LayoutDisplay::Block, + Style { + size: Size { + width: percent(0.5), + height: length(20.0), + }, + margin: Rect { + top: length(margin), + bottom: length(margin), + ..Rect::zero() + }, + ..Style::default() + }, + ), + ); + } + let output = build(&source, &mut styles); + observed.push([2, 3].map(|id| output.client_rects_for_source(id)[0].bounding_rect())); + } + assert_eq!(observed[0], observed[1], "margin={margin}"); + } +} + #[test] fn split_inline_continuations_remain_mapped_to_the_originating_element() { let source = Source(vec![ @@ -2660,9 +3415,13 @@ fn split_inline_continuations_remain_mapped_to_the_originating_element() { let output = build(&source, &mut styles); let rects = output.client_rects_for_source(1); - assert_eq!(rects.len(), 2, "{rects:?}"); + assert_eq!(rects.len(), 3, "{rects:?}"); let first = rects[0].bounding_rect(); - let second = rects[1].bounding_rect(); + let middle = rects[1].bounding_rect(); + let second = rects[2].bounding_rect(); + assert_eq!(middle.width, 200.0); + assert_eq!(middle.height, 20.0); + assert!(first.y < middle.y && middle.y < second.y, "{rects:?}"); assert!(first.width > 0.0 && second.width > 0.0); assert!(second.y > first.y, "{rects:?}"); let union = output diff --git a/moli-page-types/src/lib.rs b/moli-page-types/src/lib.rs index 035247ba0d..e7664253cd 100644 --- a/moli-page-types/src/lib.rs +++ b/moli-page-types/src/lib.rs @@ -3287,6 +3287,14 @@ pub struct ChildFrameDocumentNetworkSnapshot { pub request_method: String, #[serde(default)] pub request_headers: Vec<(String, String)>, + /// Exact in-process transport observations for the actual child-navigation + /// hops. Serialized snapshots retain their historical lightweight shape. + #[serde(skip)] + pub network_observation_journal: moli_fetch::NetworkObservationJournal, + /// In-process browser-facing redirect metadata paired with the transport + /// journal. Serialized snapshots retain their historical lightweight shape. + #[serde(skip)] + pub redirect_chain: Vec, pub final_url: String, pub status: u16, #[serde(default)] diff --git a/moli-parser/src/html.rs b/moli-parser/src/html.rs index 2eb2c8dc8c..2c930383d8 100644 --- a/moli-parser/src/html.rs +++ b/moli-parser/src/html.rs @@ -438,6 +438,14 @@ impl HtmlParser { DocumentStream::new_parser_stream(final_url, self.scripting_enabled) } + /// Start a text document using the HTML tokenizer's plaintext state. + /// Subsequent chunks remain literal text inside the browser-owned `pre`. + pub fn start_text_document(&self, final_url: Url, content_type: &str) -> DocumentStream { + let mut stream = DocumentStream::new_parser_stream(final_url, self.scripting_enabled); + stream.inner.initialize_text_document(content_type); + stream + } + pub fn start_live_document_root( &self, final_url: Url, @@ -1837,6 +1845,44 @@ mod tests { const HTML_NS: &str = "http://www.w3.org/1999/xhtml"; const MATHML_NS: &str = "http://www.w3.org/1998/Math/MathML"; + #[test] + fn text_document_stream_preserves_literal_markup_and_leading_newline() { + let payload = "\nGülçek&"; + for content_type in ["text/plain", "application/json", "application/problem+json"] { + let mut stream = HtmlParser::SCRIPTING_ENABLED.start_text_document( + Url::parse("https://example.test/data").unwrap(), + content_type, + ); + for character in payload.chars() { + stream.feed(&character.to_string()); + } + let document = stream.finish(); + let pre = first_element_by_ns(&document, HTML_NS, "pre"); + assert_eq!(document.text_content(pre).as_deref(), Some(payload)); + assert_eq!(document.document().unwrap().content_type(), content_type); + for tag in ["b", "script"] { + assert!( + document + .elements_by_tag_name_ns( + document.document_node_id(), + Some(HTML_NS), + tag, + true + ) + .is_empty() + ); + } + } + } + + #[test] + fn html_document_still_parses_markup_and_entities() { + let document = parse_test_document("Gülçek&"); + let bold = first_element_by_ns(&document, HTML_NS, "b"); + assert_eq!(document.text_content(bold).as_deref(), Some("Gülçek&")); + assert_eq!(document.document().unwrap().content_type(), "text/html"); + } + fn parse_test_document(html: &str) -> NativeDom { HtmlParser::SCRIPTING_ENABLED.parse( Url::parse("https://example.test/").expect("test url"), diff --git a/moli-parser/src/session.rs b/moli-parser/src/session.rs index c172426d11..a05f6ba649 100644 --- a/moli-parser/src/session.rs +++ b/moli-parser/src/session.rs @@ -203,6 +203,21 @@ impl TokenSink for EmbedderPausingTreeBuilder { } impl HtmlParserSession { + pub(super) fn initialize_text_document(&mut self, content_type: &str) { + // Parse only the browser-owned shell. Response bytes enter the tokenizer + // after it has switched to plaintext, so tags and entities stay literal. + self.process(StrTendril::from(concat!( + "", + "
\n"
+        )));
+        self.tokenizer.set_plaintext_state();
+        let sink = self.tokenizer.sink.sink();
+        let mut host = sink.take_parser_stream_dom_host();
+        let document_handle = host.document_handle();
+        host.set_document_content_type_for_handle(document_handle, content_type);
+        sink.restore_parser_stream_dom_host(host);
+    }
+
     fn new(sink: DocumentSink, opts: ParseOpts) -> Self {
         let tree_builder = EmbedderPausingTreeBuilder::new(sink, opts.tree_builder);
         Self {
diff --git a/moli-parser/src/stream.rs b/moli-parser/src/stream.rs
index 7cee4c1469..7cea895c82 100644
--- a/moli-parser/src/stream.rs
+++ b/moli-parser/src/stream.rs
@@ -429,6 +429,10 @@ pub(crate) fn prepare_parser_script_handoff_for_static_document(
 }
 
 impl HtmlTreeSinkStream {
+    pub(super) fn initialize_text_document(&mut self, content_type: &str) {
+        self.parser.initialize_text_document(content_type);
+    }
+
     pub(super) fn from_target_with_scripting(
         target: ParserStreamHtmlTreeSinkTarget,
         scripting_enabled: bool,
diff --git a/moli-protocol-webdriver-classic/src/actions.rs b/moli-protocol-webdriver-classic/src/actions.rs
index 955cfec117..79a48a60e4 100644
--- a/moli-protocol-webdriver-classic/src/actions.rs
+++ b/moli-protocol-webdriver-classic/src/actions.rs
@@ -176,6 +176,15 @@ pub fn element_click_input_commands(
             0,
             Some(0),
         ),
+        dispatch_mouse_event_command(
+            context,
+            DevToolsMouseEventType::Moved,
+            DevToolsPointerType::Mouse,
+            point.x,
+            point.y,
+            0,
+            Some(0),
+        ),
         dispatch_mouse_event_command(
             context,
             DevToolsMouseEventType::Pressed,
diff --git a/moli-protocol/src/conn/browser_context/network_owner.rs b/moli-protocol/src/conn/browser_context/network_owner.rs
index b1f421834c..cde2ea3c29 100644
--- a/moli-protocol/src/conn/browser_context/network_owner.rs
+++ b/moli-protocol/src/conn/browser_context/network_owner.rs
@@ -134,6 +134,14 @@ impl TargetNetworkListenerOwnerMut<'_> {
         }
         self.remove_network_observation_cursor(listener_session_id.as_deref());
         self.remove_captured_response_body_visibility_for_session(listener_session_id.as_deref());
+        if !self.is_attached_session()
+            && let Some(primary_session_id) = self.target.session_id().map(str::to_owned)
+        {
+            // Primary commands share one listener whether routed on the root
+            // channel or its flattened session. Captured events can carry the
+            // explicit primary ID, so revoke both wire identities on disable.
+            self.remove_captured_response_body_visibility_for_session(Some(&primary_session_id));
+        }
         self.clear_network_observation_artifacts_if_unobserved();
         true
     }
diff --git a/moli-protocol/src/conn/page_state/fetch_state.rs b/moli-protocol/src/conn/page_state/fetch_state.rs
index 4846c82443..e34aeb519b 100644
--- a/moli-protocol/src/conn/page_state/fetch_state.rs
+++ b/moli-protocol/src/conn/page_state/fetch_state.rs
@@ -302,7 +302,7 @@ impl PageTargetHost {
         needs_fetch_navigation_request_id: bool,
     ) -> (String, Option, Option) {
         if clear_captured_response_bodies {
-            self.runtime_slot.clear_captured_response_bodies();
+            self.runtime_slot.prepare_response_bodies_for_navigation();
         }
         let mut allocator = self.runtime_slot.request_id_allocator();
         let document_loader_id =
diff --git a/moli-protocol/src/conn/runtime_load.rs b/moli-protocol/src/conn/runtime_load.rs
index 9bba2b0730..4ea23fe262 100644
--- a/moli-protocol/src/conn/runtime_load.rs
+++ b/moli-protocol/src/conn/runtime_load.rs
@@ -4097,6 +4097,47 @@ mod tests {
         assert!(format!("{error:#}").contains("main document body capture task failed"));
     }
 
+    #[tokio::test]
+    async fn response_capture_survives_ordinary_renderer_body_retirement() {
+        let cancellation = moli_fetch::FetchCancelHandle::new();
+        let (source_tx, source_rx) = tokio::sync::mpsc::unbounded_channel();
+        let (source_completion_tx, source_completion_rx) = tokio::sync::oneshot::channel();
+        let response = moli_fetch::StreamingRawResponse::new(
+            url::Url::parse("https://capture.test/document").unwrap(),
+            200,
+            Vec::new(),
+            None,
+            Vec::new(),
+            false,
+            Vec::new(),
+            source_rx,
+            cancellation.clone(),
+            source_completion_rx,
+        );
+        let (renderer_tx, renderer_rx) = tokio::sync::mpsc::channel(1);
+        let (renderer_completion_tx, renderer_completion_rx) = tokio::sync::oneshot::channel();
+        drop(renderer_rx);
+        drop(renderer_completion_rx);
+        let capture = super::spawn_streaming_body_capture(
+            response,
+            None,
+            renderer_tx,
+            renderer_completion_tx,
+        );
+        source_tx.send(b"prefix".to_vec()).unwrap();
+        source_tx
+            .send(b"captured after parser retirement".to_vec())
+            .unwrap();
+        drop(source_tx);
+        source_completion_tx.send(Ok(())).unwrap();
+        let body = capture.await.unwrap().unwrap();
+        assert_eq!(
+            body.materialize_bytes().unwrap().as_slice(),
+            b"prefixcaptured after parser retirement"
+        );
+        assert!(!cancellation.is_cancelled());
+    }
+
     #[test]
     fn decode_text_html_data_url_uses_data_url_processor() {
         assert_eq!(
diff --git a/moli-protocol/src/conn/state/page_slot.rs b/moli-protocol/src/conn/state/page_slot.rs
index 3ee27ba80a..61f7dd494a 100644
--- a/moli-protocol/src/conn/state/page_slot.rs
+++ b/moli-protocol/src/conn/state/page_slot.rs
@@ -722,6 +722,14 @@ impl TargetPageSlot {
             .is_some_and(|request| request.background_completion_pending)
     }
 
+    pub(crate) fn cancel_inflight_document_navigation(&self) {
+        if let Some(request) = self.pending_navigation_request.as_ref() {
+            // Keep the token installed: the existing completion path owns the
+            // aborted response and must still settle this exact navigation.
+            request.cancel();
+        }
+    }
+
     pub(crate) fn bind_pending_document_navigation_renderer_page(
         &mut self,
         token: &DocumentNavigationToken,
@@ -1580,6 +1588,31 @@ mod pending_renderer_page_tests {
         );
     }
 
+    #[test]
+    fn navigation_cancellation_preserves_completion_token_and_is_target_local() {
+        let mut slot = TargetPageSlot::default();
+        let token = slot.start_document_navigation("TID-1".to_owned(), "LOADER-1".to_owned());
+        let cancellation = slot
+            .document_navigation_cancellation_handle(&token)
+            .unwrap();
+        let mut peer = TargetPageSlot::default();
+        let peer_token = peer.start_document_navigation("TID-2".to_owned(), "LOADER-2".to_owned());
+        let peer_cancellation = peer
+            .document_navigation_cancellation_handle(&peer_token)
+            .unwrap();
+        slot.cancel_inflight_document_navigation();
+        assert!(cancellation.is_cancelled());
+        assert!(!peer_cancellation.is_cancelled());
+        assert!(slot.accepts_pending_document_navigation_event(&token));
+        let next = slot.start_document_navigation("TID-1".to_owned(), "LOADER-next".to_owned());
+        assert!(
+            !slot
+                .document_navigation_cancellation_handle(&next)
+                .unwrap()
+                .is_cancelled()
+        );
+    }
+
     #[test]
     fn navigation_binding_cannot_follow_a_superseding_navigation() {
         let mut slot = TargetPageSlot::default();
diff --git a/moli-protocol/src/conn/state/runtime_slot.rs b/moli-protocol/src/conn/state/runtime_slot.rs
index 665372e432..c4cdf43920 100644
--- a/moli-protocol/src/conn/state/runtime_slot.rs
+++ b/moli-protocol/src/conn/state/runtime_slot.rs
@@ -574,6 +574,10 @@ impl TargetRuntimeSlot {
         self.page_slot.has_inflight_background_navigation()
     }
 
+    pub(crate) fn cancel_inflight_document_navigation(&self) {
+        self.page_slot.cancel_inflight_document_navigation();
+    }
+
     pub(crate) fn accepts_document_body_completion_event(
         &self,
         token: &DocumentNavigationToken,
@@ -1133,6 +1137,19 @@ impl TargetRuntimeSlot {
         self.network_agent.clear_captured_response_bodies();
     }
 
+    pub(crate) fn configure_durable_response_bodies(
+        &mut self,
+        session_id: Option<&str>,
+        limits: Option,
+    ) {
+        self.network_agent
+            .configure_durable_response_bodies(session_id, limits);
+    }
+
+    pub(crate) fn prepare_response_bodies_for_navigation(&mut self) {
+        self.network_agent.prepare_response_bodies_for_navigation();
+    }
+
     pub(crate) fn clear_network_body_artifacts(&mut self) {
         self.network_agent.clear_body_artifacts();
     }
diff --git a/moli-protocol/src/domains/dom/tests/geometry.rs b/moli-protocol/src/domains/dom/tests/geometry.rs
index f672598b40..f1c56f7d0e 100644
--- a/moli-protocol/src/domains/dom/tests/geometry.rs
+++ b/moli-protocol/src/domains/dom/tests/geometry.rs
@@ -1660,7 +1660,7 @@ async fn scroll_into_view_if_needed_uses_first_rendered_child_of_display_content
 async fn scroll_into_view_if_needed_honors_stylesheet_display_and_visibility() {
     let mut ctx = TestContext::new();
     load_bc(&mut ctx, "BID-A");
-    let rows = "
row
".repeat(60); + let rows = "
row
".repeat(60); let url = format!( "data:text/html,{rows}
suppressed child
" ); @@ -1821,7 +1821,7 @@ async fn scroll_into_view_if_needed_observes_style_change_after_explicit_capture async fn scroll_into_view_if_needed_clamps_relative_rect_to_document_scroll_range() { let mut ctx = TestContext::new(); load_bc(&mut ctx, "BID-A"); - let rows = "
row
".repeat(60); + let rows = "
row
".repeat(60); let url = format!("data:text/html,{rows}
"); navigate_to_url_and_wait_for_load_async(&mut ctx, 44, url).await; ctx.capture_fixture_layout(None).await; diff --git a/moli-protocol/src/domains/emulation/tests.rs b/moli-protocol/src/domains/emulation/tests.rs index d8a83d17c8..76ac1936a8 100644 --- a/moli-protocol/src/domains/emulation/tests.rs +++ b/moli-protocol/src/domains/emulation/tests.rs @@ -573,7 +573,9 @@ async fn admitted_idle_override_is_visible_to_concurrent_same_site_navigation() listener, Router::new().route( "/", - get(|| async { "idle navigation" }), + get(|| async { + axum::response::Html("idle navigation") + }), ), ) .await @@ -644,7 +646,9 @@ async fn idle_override_updates_idle_detector_and_clear_restores_actual_state() { listener, Router::new().route( "/", - get(|| async { "idle detector" }), + get(|| async { + axum::response::Html("idle detector") + }), ), ) .await @@ -2219,7 +2223,7 @@ async fn emulation_async_dispatch_updates_live_page_user_agent_and_xhr_header() #[tokio::test(flavor = "multi_thread")] async fn emulation_async_dispatch_updates_live_page_surface_without_mutating_accept_language() { async fn page_handler() -> impl IntoResponse { - "ok" + axum::response::Html("ok") } async fn xhr_handler( @@ -2711,9 +2715,9 @@ async fn locale_override_updates_intl_without_mutating_language_surfaces() { .get(axum::http::header::ACCEPT_LANGUAGE) .and_then(|value| value.to_str().ok()) .unwrap_or(""); - format!( + axum::response::Html(format!( "" - ) + )) } let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -2767,9 +2771,9 @@ async fn bidi_user_context_locale_composes_with_user_agent_on_all_identity_surfa .get(axum::http::header::ACCEPT_LANGUAGE) .and_then(|value| value.to_str().ok()) .unwrap_or(""); - format!( + axum::response::Html(format!( "" - ) + )) } let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/moli-protocol/src/domains/network.rs b/moli-protocol/src/domains/network.rs index a29076e2f5..c9061abcdc 100644 --- a/moli-protocol/src/domains/network.rs +++ b/moli-protocol/src/domains/network.rs @@ -504,6 +504,14 @@ fn start_set_network_domain_enabled_command( cmd: &Cmd<'_>, enabled: bool, ) -> NetworkCommandTaskStep { + let durable_limits = if enabled { + match settings::durable_body_limits(cmd) { + Ok(limits) => limits, + Err(plan) => return NetworkCommandTaskStep::Complete(plan), + } + } else { + None + }; let updated = if enabled { conn.enable_network_listener_for_session_owner(cmd.session_id) } else { @@ -516,6 +524,10 @@ fn start_set_network_domain_enabled_command( )); } + if enabled && let Ok(slot) = conn.runtime_session_owner_slot_mut(cmd.session_id) { + slot.configure_durable_response_bodies(cmd.session_id, durable_limits); + } + let kind = if enabled { PendingNetworkCommandKind::Enable } else { diff --git a/moli-protocol/src/domains/network/agent.rs b/moli-protocol/src/domains/network/agent.rs index 92e1d120b1..adfa389054 100644 --- a/moli-protocol/src/domains/network/agent.rs +++ b/moli-protocol/src/domains/network/agent.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; use moli_bounded_buffer::{BoundedByteBuffer, ByteLimits, InsertOutcome}; use moli_core::page::{ScriptNetworkOutputItem, SubresourceNetworkRequestHandle}; @@ -15,6 +15,8 @@ use super::{ const RESPONSE_BODY_BUFFER_MAX_TOTAL_BYTES: usize = 20_000_000; const RESPONSE_BODY_BUFFER_MAX_ENTRY_BYTES: usize = 2_000_000; +// Bound zero-byte bodies and payload-free failure/eviction metadata as well. +const DURABLE_RESPONSE_BODY_MAX_ENTRIES: usize = 4096; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RendererSubresourceTeardownDisposition { @@ -705,6 +707,24 @@ impl TargetNetworkAgentState { .clear_captured_response_bodies(); } + pub(crate) fn configure_durable_response_bodies( + &mut self, + session_id: Option<&str>, + limits: Option, + ) { + self.artifacts + .body_artifacts + .captured_response_bodies + .configure_durable(session_id, limits); + } + + pub(crate) fn prepare_response_bodies_for_navigation(&mut self) { + self.artifacts + .body_artifacts + .captured_response_bodies + .prepare_navigation(); + } + pub(crate) fn clear_body_artifacts(&mut self) { self.artifacts.body_artifacts.clear_session_scoped(); } @@ -1336,6 +1356,9 @@ struct CapturedResponseBodyStore { /// `buffered_bodies`, making the bounded buffer the single body owner. bodies: HashMap, buffered_bodies: BoundedByteBuffer, + durable_sessions: HashMap, ByteLimits>, + durable_entry_order: VecDeque, + retained_request_ids: HashSet, } impl Default for CapturedResponseBodyStore { @@ -1352,9 +1375,121 @@ impl CapturedResponseBodyStore { Self { bodies: HashMap::new(), buffered_bodies: BoundedByteBuffer::new(limits), + durable_sessions: HashMap::new(), + durable_entry_order: VecDeque::new(), + retained_request_ids: HashSet::new(), } } + fn configure_durable(&mut self, session_id: Option<&str>, limits: Option) { + let session = session_id.map(str::to_owned); + if let Some(limits) = limits { + self.durable_sessions.insert(session, limits); + } else { + self.durable_sessions.remove(&session); + // Turning off durability revokes only old-document claims, not + // ordinary access to responses from the current document. + for request_id in self.retained_request_ids.clone() { + if let Some(body) = self.bodies.get_mut(&request_id) + && !body.remove_session_visibility(session_id) + { + self.bodies.remove(&request_id); + } + if let Some(body) = self.buffered_bodies.get_mut(&request_id) + && !body.remove_session_visibility(session_id) + { + self.buffered_bodies.remove(&request_id); + } + } + } + // One target owns one payload store. Multiple clients cannot multiply + // its budget; each requested budget is an upper bound, not a reserve. + let limits = self.durable_sessions.values().fold( + ByteLimits::new( + RESPONSE_BODY_BUFFER_MAX_TOTAL_BYTES, + RESPONSE_BODY_BUFFER_MAX_ENTRY_BYTES, + ), + |acc, limits| { + ByteLimits::new( + acc.max_total_bytes.min(limits.max_total_bytes), + acc.max_entry_bytes.min(limits.max_entry_bytes), + ) + }, + ); + for (id, mut body) in self.buffered_bodies.set_limits(limits) { + body.mark_evicted(); + self.bodies.insert(id, body); + } + if self.durable_sessions.is_empty() { + self.durable_entry_order.clear(); + self.retained_request_ids.clear(); + } else { + let mut untracked = self + .bodies + .keys() + .chain(self.buffered_bodies.iter().map(|(id, _)| id)) + .filter(|id| !self.durable_entry_order.contains(id)) + .cloned() + .collect::>(); + untracked.sort(); + self.durable_entry_order.extend(untracked); + self.trim_durable_entries(); + } + } + + fn track_durable_entry(&mut self, request_id: &str) { + if self.durable_sessions.is_empty() { + return; + } + self.durable_entry_order.retain(|id| id != request_id); + self.durable_entry_order.push_back(request_id.to_owned()); + self.trim_durable_entries(); + } + + fn trim_durable_entries(&mut self) { + while self.durable_entry_order.len() > DURABLE_RESPONSE_BODY_MAX_ENTRIES { + if let Some(id) = self.durable_entry_order.pop_front() { + self.bodies.remove(&id); + self.buffered_bodies.remove(&id); + self.retained_request_ids.remove(&id); + } + } + } + + fn prepare_navigation(&mut self) { + if self.durable_sessions.is_empty() { + self.clear(); + return; + } + let retain = |body: &mut CapturedResponseBody| { + body.session_ids + .retain(|id| self.durable_sessions.contains_key(id)); + // CDP durability does not opt BiDi collectors into retention. + body.collector_ids.clear(); + !body.session_ids.is_empty() + && !matches!(body.state, CapturedResponseBodyState::Pending) + }; + self.bodies.retain(|_, body| retain(body)); + let ids = self + .buffered_bodies + .iter() + .map(|(id, _)| id.clone()) + .collect::>(); + for id in ids { + if !self.buffered_bodies.get_mut(&id).is_some_and(retain) { + self.buffered_bodies.remove(&id); + } + } + self.retained_request_ids = self + .bodies + .keys() + .chain(self.buffered_bodies.iter().map(|(id, _)| id)) + .cloned() + .collect(); + self.durable_entry_order + .retain(|id| self.retained_request_ids.contains(id)); + } + #[cfg(test)] pub(crate) fn insert( &mut self, @@ -1380,6 +1515,7 @@ impl CapturedResponseBodyStore { collection_was_gated: bool, ) { let byte_len = body.len(); + let tracked_request_id = request_id.clone(); let captured = CapturedResponseBody::from_captured_body_with_collector_scope( body, session_ids, @@ -1402,6 +1538,7 @@ impl CapturedResponseBodyStore { self.bodies.insert(request_id, rejected_body); } } + self.track_durable_entry(&tracked_request_id); } pub(crate) fn insert_pending_with_collector_scope( @@ -1416,6 +1553,7 @@ impl CapturedResponseBodyStore { { return; } + let tracked_request_id = request_id.clone(); self.bodies.insert( request_id, CapturedResponseBody::pending_with_collector_scope( @@ -1424,6 +1562,7 @@ impl CapturedResponseBodyStore { collection_was_gated, ), ); + self.track_durable_entry(&tracked_request_id); } pub(crate) fn insert_failed_with_collector_scope( @@ -1434,6 +1573,7 @@ impl CapturedResponseBodyStore { collector_ids: impl IntoIterator, collection_was_gated: bool, ) { + let tracked_request_id = request_id.clone(); self.buffered_bodies.remove(&request_id); self.bodies.insert( request_id, @@ -1444,6 +1584,7 @@ impl CapturedResponseBodyStore { collection_was_gated, ), ); + self.track_durable_entry(&tracked_request_id); } pub(crate) fn get(&self, request_id: &str) -> Option<&CapturedResponseBody> { @@ -1463,6 +1604,8 @@ impl CapturedResponseBodyStore { pub(crate) fn clear(&mut self) { self.bodies.clear(); self.buffered_bodies.clear(); + self.durable_entry_order.clear(); + self.retained_request_ids.clear(); } #[cfg(test)] @@ -1476,6 +1619,7 @@ impl CapturedResponseBodyStore { } pub(crate) fn remove_session_visibility(&mut self, session_id: Option<&str>) { + self.configure_durable(session_id, None); self.bodies .retain(|_, body| body.remove_session_visibility(session_id)); @@ -1959,6 +2103,105 @@ mod tests { ); } + #[test] + fn durable_response_body_store_bounds_payload_metadata_and_zero_byte_entries() { + let mut store = CapturedResponseBodyStore::default(); + store.configure_durable( + Some("owner"), + Some(moli_bounded_buffer::ByteLimits::new(5, 3)), + ); + store.insert("a".into(), "aaa".into(), [Some("owner".into())]); + store.insert("b".into(), "bbb".into(), [Some("owner".into())]); + assert!(store.get("a").unwrap().body_bytes_limited(10).is_err()); + assert_eq!(store.buffered_body_bytes(), 3); + store.insert("oversize".into(), "xxxx".into(), [Some("owner".into())]); + assert!( + store + .get("oversize") + .unwrap() + .body_bytes_limited(10) + .is_err() + ); + for n in 0..super::DURABLE_RESPONSE_BODY_MAX_ENTRIES + 10 { + store.insert(format!("empty-{n}"), String::new(), [Some("owner".into())]); + store.insert_failed_with_collector_scope( + format!("failed-{n}"), + "failed".into(), + [Some("owner".into())], + [], + false, + ); + } + assert!( + store.bodies.len() + store.buffered_bodies.len() + <= super::DURABLE_RESPONSE_BODY_MAX_ENTRIES + ); + assert_eq!( + store.durable_entry_order.len(), + super::DURABLE_RESPONSE_BODY_MAX_ENTRIES + ); + assert!(!store.contains_key("a")); + store.prepare_navigation(); + store.remove_session_visibility(Some("owner")); + assert!(store.is_empty()); + assert!(store.durable_sessions.is_empty()); + assert!(store.retained_request_ids.is_empty()); + assert!(store.durable_entry_order.is_empty()); + } + + #[test] + fn durable_response_body_store_navigation_preserves_only_existing_opted_claims() { + let mut store = CapturedResponseBodyStore::default(); + store.configure_durable( + Some("a"), + Some(moli_bounded_buffer::ByteLimits::new(100, 50)), + ); + store.configure_durable( + Some("b"), + Some(moli_bounded_buffer::ByteLimits::new(80, 40)), + ); + store.insert( + "old".into(), + "body".into(), + [Some("a".into()), Some("ordinary".into())], + ); + store.insert_pending_with_collector_scope("pending".into(), [Some("a".into())], [], false); + store.prepare_navigation(); + let body = store.get("old").unwrap(); + assert!(body.is_visible_to_session(Some("a"))); + assert!(!body.is_visible_to_session(Some("b"))); + assert!(!body.is_visible_to_session(Some("ordinary"))); + assert!(!store.contains_key("pending")); + assert_eq!( + store.buffered_bodies.limits(), + moli_bounded_buffer::ByteLimits::new(80, 40) + ); + store.insert("current".into(), "current".into(), [Some("a".into())]); + store.configure_durable(Some("a"), None); + assert!(!store.contains_key("old")); + assert!(store.contains_key("current")); + store.prepare_navigation(); + assert!(store.is_empty()); + let other_target = CapturedResponseBodyStore::default(); + assert!(!other_target.contains_key("current")); + } + + #[test] + fn durable_response_body_default_navigation_still_clears_and_budget_shrink_releases_payloads() { + let mut store = CapturedResponseBodyStore::default(); + store.insert("ordinary".into(), "body".into(), [None]); + store.prepare_navigation(); + assert!(store.is_empty()); + store.configure_durable(None, Some(moli_bounded_buffer::ByteLimits::new(20, 10))); + store.insert("a".into(), "aaaa".into(), [None]); + store.insert("b".into(), "bbbb".into(), [None]); + store.configure_durable(None, Some(moli_bounded_buffer::ByteLimits::new(5, 3))); + assert_eq!(store.buffered_body_bytes(), 0); + store.clear(); + assert!(store.is_empty()); + assert!(store.retained_request_ids.is_empty()); + } + #[test] fn response_body_store_marks_oldest_payload_evicted_when_total_budget_fills() { let mut store = diff --git a/moli-protocol/src/domains/network/backlog.rs b/moli-protocol/src/domains/network/backlog.rs index 908a8c3d6b..8f02eeeaff 100644 --- a/moli-protocol/src/domains/network/backlog.rs +++ b/moli-protocol/src/domains/network/backlog.rs @@ -261,6 +261,7 @@ fn emit_complete_subresource_network_delivery_record( .then(|| output.request_cookie_report()) .flatten(), &[], + true, ); } } @@ -312,6 +313,7 @@ fn emit_complete_subresource_network_delivery_record( !redirect.cookie_set_reports.is_empty(), redirect.request_cookie_report.as_ref(), &[], + true, ); emit_redirect_response_received_extra_info( out, @@ -453,6 +455,10 @@ fn emit_staged_subresource_request_started( false, output.request_cookie_report(), &[], + // Cookie selection precedes transport header generation. Keep + // it in the main event, but defer ExtraInfo until completion + // supplies the actual headers (or a terminal fallback). + false, ); } } @@ -528,6 +534,7 @@ fn emit_staged_subresource_response_started( !redirect.cookie_set_reports.is_empty(), redirect.request_cookie_report.as_ref(), &[], + true, ); emit_redirect_response_received_extra_info( out, diff --git a/moli-protocol/src/domains/network/events.rs b/moli-protocol/src/domains/network/events.rs index 9701312e93..e611ed542e 100644 --- a/moli-protocol/src/domains/network/events.rs +++ b/moli-protocol/src/domains/network/events.rs @@ -279,6 +279,7 @@ pub(crate) fn emit_request_will_be_sent( redirect_has_extra_info: bool, cookie_access_report: Option<&moli_cookie_jar::StoredCookieQueryReport>, blocked_intercepts: &[DevToolsNetworkInterceptId], + emit_cookie_extra_info: bool, ) { if redirect_response.is_some_and(|(_, _, _, from_cache, _)| from_cache) { emit_request_served_from_cache(out, session_id, request_id); @@ -343,7 +344,7 @@ pub(crate) fn emit_request_will_be_sent( }), session_id, ); - if let Some(cookie_access_report) = cookie_access_report { + if emit_cookie_extra_info && let Some(cookie_access_report) = cookie_access_report { emit_request_will_be_sent_extra_info( out, session_id, @@ -1536,6 +1537,7 @@ mod tests { false, None, &[], + true, ); assert_eq!(events.len(), 2); diff --git a/moli-protocol/src/domains/network/main_document_progress/mod.rs b/moli-protocol/src/domains/network/main_document_progress/mod.rs index 7764c91207..4cd2a16416 100644 --- a/moli-protocol/src/domains/network/main_document_progress/mod.rs +++ b/moli-protocol/src/domains/network/main_document_progress/mod.rs @@ -350,17 +350,26 @@ fn materialize_navigation_load_outcome( materialize_download_navigation_progress(conn, state, *navigation), ), NavigationLoadOutcome::NetworkFailure(error_text) => { + let document_policy = failed_navigation_document_policy(&error_text); MaterializedNavigationLoadOutcome::Failed(materialize_failed_navigation_progress( conn, state, error_text, - FailedNavigationDocumentPolicy::InvalidateCommittedDocument, + document_policy, FailedNavigationResponseMode::CdpErrorTextResult, )) } } } +fn failed_navigation_document_policy(error_text: &str) -> FailedNavigationDocumentPolicy { + if error_text == moli_fetch::NET_ERR_ABORTED_ERROR_TEXT { + FailedNavigationDocumentPolicy::PreserveCommittedDocument + } else { + FailedNavigationDocumentPolicy::InvalidateCommittedDocument + } +} + pub(crate) fn materialize_navigation_load_result( conn: &mut CdpConnection, state: &NavigationDispatchState, @@ -611,7 +620,7 @@ impl MainDocumentBodyProgressSource { response_headers, response_cookie_reports, network_observation_journal, - redirect_chain.len(), + redirect_chain, network_extra_info_available, response_from_cache, negotiated_http_version, @@ -704,7 +713,7 @@ impl MainDocumentBodyProgressSource { }; let final_exchange = navigation_exchange_group( network_observation_journal, - redirect_chain.len(), + redirect_chain, redirect_chain.len(), ) .and_then(|group| group.last()); @@ -721,6 +730,7 @@ impl MainDocumentBodyProgressSource { redirect_chain, network_observation_journal, final_network_extra_info_available, + SubresourceRequestInitiatorType::Other, ); live_source.send_progress_events(MainDocumentProgressPhase::RequestStarted, events); } @@ -759,7 +769,7 @@ impl MainDocumentBodyProgressSource { if network_extra_info_available { let (response_status, response_headers) = observed_response_metadata( network_observation_journal, - redirect_chain.len(), + redirect_chain, redirect_chain.len(), response_status, response_headers, @@ -944,6 +954,7 @@ impl MainDocumentLiveNetworkProgressSource { redirect_chain, network_observation_journal, final_network_extra_info_available, + SubresourceRequestInitiatorType::Other, ); self.send_progress_events(MainDocumentProgressPhase::RequestStarted, events); } @@ -967,6 +978,7 @@ impl MainDocumentLiveNetworkProgressSource { redirect_chain, network_observation_journal, final_network_extra_info_available, + SubresourceRequestInitiatorType::Other, ); self.send_progress_events_into_output( MainDocumentProgressPhase::RequestStarted, @@ -984,13 +996,15 @@ impl MainDocumentLiveNetworkProgressSource { redirect_chain: &[RedirectInfo], network_observation_journal: &NetworkObservationJournal, final_network_extra_info_available: bool, + request_initiator_type: SubresourceRequestInitiatorType, ) -> Vec { let mut events = Vec::new(); - let initial_network_extra_info_available = !network_observation_journal.is_empty() - || redirect_chain - .first() - .is_some_and(|redirect| redirect.network_extra_info_available) - || (redirect_chain.is_empty() && final_network_extra_info_available); + let initial_network_extra_info_available = + navigation_exchange_group(network_observation_journal, redirect_chain, 0).is_some() + || redirect_chain + .first() + .is_some_and(|redirect| redirect.network_extra_info_available) + || (redirect_chain.is_empty() && final_network_extra_info_available); if initial_network_extra_info_available { let initial_request_cookie_report = if redirect_chain.is_empty() { final_request_cookie_report @@ -1003,7 +1017,7 @@ impl MainDocumentLiveNetworkProgressSource { self.progress_target(), observed_request_headers( network_observation_journal, - redirect_chain.len(), + redirect_chain, 0, &self.initial_request_headers, ), @@ -1012,15 +1026,27 @@ impl MainDocumentLiveNetworkProgressSource { } for (index, redirect) in redirect_chain.iter().enumerate() { let target = self.progress_target(); + let request_method = observed_request_method( + network_observation_journal, + redirect_chain, + index + 1, + final_request_method, + ); + let request_headers = observed_request_headers( + network_observation_journal, + redirect_chain, + index + 1, + final_request_headers, + ); let observed_response_available = - navigation_exchange_group(network_observation_journal, redirect_chain.len(), index) + navigation_exchange_group(network_observation_journal, redirect_chain, index) .and_then(|group| group.last()) .and_then(NetworkExchangeObservation::response) .is_some(); if observed_response_available && !redirect.network_extra_info_available { let (status, headers) = observed_response_metadata( network_observation_journal, - redirect_chain.len(), + redirect_chain, index, redirect.status, &redirect.headers, @@ -1037,10 +1063,12 @@ impl MainDocumentLiveNetworkProgressSource { events.push(MainDocumentNavigationProgressEvent::RequestWillBeSent { target: target.clone(), url: redirect.to_url.clone(), - method: final_request_method.to_owned(), - request_body: request_body.map(str::to_owned), - request_headers: final_request_headers.to_vec(), - request_initiator_type: SubresourceRequestInitiatorType::Other, + method: request_method.clone(), + request_body: (request_method == final_request_method) + .then(|| request_body.map(str::to_owned)) + .flatten(), + request_headers, + request_initiator_type, redirect_response: Box::new(Some(MainDocumentRedirectResponse { url: redirect.from_url.clone(), status: redirect.status, @@ -1061,7 +1089,7 @@ impl MainDocumentLiveNetworkProgressSource { if observed_response_available && redirect.network_extra_info_available { let (status, headers) = observed_response_metadata( network_observation_journal, - redirect_chain.len(), + redirect_chain, index, redirect.status, &redirect.headers, @@ -1075,14 +1103,13 @@ impl MainDocumentLiveNetworkProgressSource { }, ); } - let request_network_extra_info_available = network_observation_journal - .exchanges() - .get(index + 1) - .is_some() - || redirect_chain - .get(index + 1) - .is_some_and(|next| next.network_extra_info_available) - || (index + 1 == redirect_chain.len() && final_network_extra_info_available); + let request_network_extra_info_available = + navigation_exchange_group(network_observation_journal, redirect_chain, index + 1) + .is_some() + || redirect_chain + .get(index + 1) + .is_some_and(|next| next.network_extra_info_available) + || (index + 1 == redirect_chain.len() && final_network_extra_info_available); if request_network_extra_info_available { let cookie_report = redirect.request_cookie_report.clone().or_else(|| { (index + 1 == redirect_chain.len()) @@ -1093,7 +1120,7 @@ impl MainDocumentLiveNetworkProgressSource { target, observed_request_headers( network_observation_journal, - redirect_chain.len(), + redirect_chain, index + 1, final_request_headers, ), @@ -1111,15 +1138,15 @@ impl MainDocumentLiveNetworkProgressSource { response_headers: &[(String, Vec)], response_cookie_reports: &[StoredCookieSetReport], network_observation_journal: &NetworkObservationJournal, - redirect_count: usize, + redirect_chain: &[RedirectInfo], network_extra_info_available: bool, response_from_cache: bool, negotiated_http_version: Option, ) { let (extra_info_status, extra_info_headers) = observed_response_metadata( network_observation_journal, - redirect_count, - redirect_count, + redirect_chain, + redirect_chain.len(), response_status, response_headers, ); @@ -1354,32 +1381,85 @@ pub(crate) fn emit_child_document_navigation_network_background_events( frame_id: frame_id.to_owned(), timestamp, }; + let redirect_count = network.redirect_chain.len(); + let request_method = observed_request_method( + &network.network_observation_journal, + &network.redirect_chain, + 0, + &network.request_method, + ); + let request_headers = observed_request_headers( + &network.network_observation_journal, + &network.redirect_chain, + 0, + &network.request_headers, + ); let mut output = MainDocumentProgressOutputTarget::background_events(out); output.emit_event(MainDocumentNavigationProgressEvent::RequestWillBeSent { target: target.clone(), url: request_url, - method: network.request_method.clone(), + method: request_method, request_body: None, - request_headers: network.request_headers.clone(), + request_headers, request_initiator_type: SubresourceRequestInitiatorType::Parser, redirect_response: Box::new(None), redirect_has_extra_info: false, cookie_access_report: None, }); + let source = MainDocumentLiveNetworkProgressSource { + sender: None, + progress_queue: MainDocumentProgressQueueHandle::from_source( + MainDocumentProgressSource::streaming(), + ), + session_ids: session_ids.clone(), + request_id: request_id.to_owned(), + loader_id: loader_id.to_owned(), + frame_id: frame_id.to_owned(), + timestamp, + initial_request_headers: network.request_headers.clone(), + initial_request_cookie_report: None, + }; + for event in source.redirect_request_events( + &network.request_method, + None, + &network.request_headers, + None, + &network.redirect_chain, + &network.network_observation_journal, + !network.network_observation_journal.is_empty(), + SubresourceRequestInitiatorType::Parser, + ) { + output.emit_event(event); + } + let (extra_info_status, extra_info_headers) = observed_response_metadata( + &network.network_observation_journal, + &network.redirect_chain, + redirect_count, + network.status, + &network.response_headers, + ); + let network_extra_info_available = navigation_exchange_group( + &network.network_observation_journal, + &network.redirect_chain, + redirect_count, + ) + .and_then(|group| group.last()) + .and_then(NetworkExchangeObservation::response) + .is_some(); output.emit_event(MainDocumentNavigationProgressEvent::ResponseReceived { target: target.clone(), final_url, status: network.status, headers: network.response_headers.clone(), cookie_set_reports: Vec::new(), - extra_info_status: network.status, - extra_info_headers: network.response_headers.clone(), - network_extra_info_available: false, - emit_extra_info: false, + extra_info_status, + extra_info_headers, + network_extra_info_available, + emit_extra_info: network_extra_info_available, encoded_data_length: 0, from_cache: network.from_cache, negotiated_http_version: None, - has_extra_info: false, + has_extra_info: network_extra_info_available, }); record_child_document_response_body( conn, @@ -1634,7 +1714,12 @@ impl CompletedMainDocumentProgressContext { cookie_access_report: initial_request_cookie_report.clone(), }); } - let initial_network_extra_info_available = !events.network_observation_journal.is_empty() + let initial_network_extra_info_available = navigation_exchange_group( + &events.network_observation_journal, + &events.redirect_chain, + 0, + ) + .is_some() || events .redirect_chain .first() @@ -1645,7 +1730,7 @@ impl CompletedMainDocumentProgressContext { target.clone(), observed_request_headers( &events.network_observation_journal, - events.redirect_chain.len(), + &events.redirect_chain, 0, &self.request_headers, ), @@ -1653,9 +1738,21 @@ impl CompletedMainDocumentProgressContext { )); } for (index, redirect) in events.redirect_chain.iter().enumerate() { + let request_method = observed_request_method( + &events.network_observation_journal, + &events.redirect_chain, + index + 1, + &events.request_method, + ); + let request_headers = observed_request_headers( + &events.network_observation_journal, + &events.redirect_chain, + index + 1, + &events.request_headers, + ); let observed_response_available = navigation_exchange_group( &events.network_observation_journal, - events.redirect_chain.len(), + &events.redirect_chain, index, ) .and_then(|group| group.last()) @@ -1664,7 +1761,7 @@ impl CompletedMainDocumentProgressContext { if observed_response_available && !redirect.network_extra_info_available { let (status, headers) = observed_response_metadata( &events.network_observation_journal, - events.redirect_chain.len(), + &events.redirect_chain, index, redirect.status, &redirect.headers, @@ -1681,9 +1778,11 @@ impl CompletedMainDocumentProgressContext { progress_events.push(MainDocumentNavigationProgressEvent::RequestWillBeSent { target: target.clone(), url: redirect.to_url.clone(), - method: events.request_method.clone(), - request_body: self.request_body.clone(), - request_headers: events.request_headers.clone(), + method: request_method.clone(), + request_body: (request_method == self.request_method) + .then(|| self.request_body.clone()) + .flatten(), + request_headers, request_initiator_type: SubresourceRequestInitiatorType::Other, redirect_response: Box::new(Some(MainDocumentRedirectResponse { url: redirect.from_url.clone(), @@ -1705,7 +1804,7 @@ impl CompletedMainDocumentProgressContext { if observed_response_available && redirect.network_extra_info_available { let (status, headers) = observed_response_metadata( &events.network_observation_journal, - events.redirect_chain.len(), + &events.redirect_chain, index, redirect.status, &redirect.headers, @@ -1719,11 +1818,12 @@ impl CompletedMainDocumentProgressContext { }, ); } - let request_network_extra_info_available = events - .network_observation_journal - .exchanges() - .get(index + 1) - .is_some() + let request_network_extra_info_available = navigation_exchange_group( + &events.network_observation_journal, + &events.redirect_chain, + index + 1, + ) + .is_some() || events .redirect_chain .get(index + 1) @@ -1740,7 +1840,7 @@ impl CompletedMainDocumentProgressContext { target.clone(), observed_request_headers( &events.network_observation_journal, - events.redirect_chain.len(), + &events.redirect_chain, index + 1, &events.request_headers, ), @@ -1762,7 +1862,7 @@ impl CompletedMainDocumentProgressContext { }; let (extra_info_status, extra_info_headers) = observed_response_metadata( &events.network_observation_journal, - events.redirect_chain.len(), + &events.redirect_chain, events.redirect_chain.len(), events.response_status, &events.response_headers, @@ -1907,62 +2007,107 @@ fn request_extra_info_event( } } -fn navigation_exchange_group( - journal: &NetworkObservationJournal, - redirect_count: usize, +fn navigation_exchange_group<'a>( + journal: &'a NetworkObservationJournal, + redirect_chain: &[impl RedirectTransportBoundary], hop_index: usize, -) -> Option<&[NetworkExchangeObservation]> { +) -> Option<&'a [NetworkExchangeObservation]> { // A truncated journal still proves that a transport exchange happened, but // no longer provides a trustworthy tail for redirect-hop correlation. if journal.truncated() { return None; } - if hop_index > redirect_count { + if hop_index > redirect_chain.len() { return None; } let exchanges = journal.exchanges(); let mut group_start = 0; - let mut current_hop = 0; - for (index, exchange) in exchanges.iter().enumerate() { - let ends_redirect_hop = exchange.response().is_some_and(|response| { - matches!(response.status(), 301 | 302 | 303 | 307 | 308) - || response.headers().iter().any(|(name, value)| { - name.eq_ignore_ascii_case("critical-ch") && !value.trim_ascii().is_empty() - }) - }); - if !ends_redirect_hop || current_hop >= redirect_count { + for (redirect_index, redirect) in redirect_chain.iter().enumerate() { + let Some(expected_status) = redirect.transport_response_status() else { + if redirect_index == hop_index { + return None; + } continue; + }; + let group_end = exchanges[group_start..] + .iter() + .position(|exchange| { + exchange + .response() + .is_some_and(|response| response.status() == expected_status) + })? + .saturating_add(group_start); + if redirect_index == hop_index { + return Some(&exchanges[group_start..=group_end]); } - if current_hop == hop_index { - return Some(&exchanges[group_start..=index]); - } - current_hop += 1; - group_start = index.saturating_add(1); + group_start = group_end.saturating_add(1); + } + (hop_index == redirect_chain.len() && group_start < exchanges.len()) + .then_some(&exchanges[group_start..]) +} + +trait RedirectTransportBoundary { + fn transport_response_status(&self) -> Option; +} + +impl RedirectTransportBoundary for RedirectInfo { + fn transport_response_status(&self) -> Option { + self.response_extra_info + .as_ref() + .map(|response| response.status) + .or_else(|| { + (self.source == moli_fetch::RedirectSource::Network && !self.from_cache) + .then_some(self.status) + }) + } +} + +impl RedirectTransportBoundary for NavigationRedirect { + fn transport_response_status(&self) -> Option { + self.response_extra_info + .as_ref() + .map(|response| response.status) + .or_else(|| { + (self.source == moli_fetch::RedirectSource::Network && !self.from_cache) + .then_some(self.status) + }) } - (current_hop == hop_index && group_start < exchanges.len()).then_some(&exchanges[group_start..]) } fn observed_request_headers( journal: &NetworkObservationJournal, - redirect_count: usize, + redirect_chain: &[impl RedirectTransportBoundary], hop_index: usize, fallback: &[(String, String)], ) -> Vec<(String, String)> { - navigation_exchange_group(journal, redirect_count, hop_index) + navigation_exchange_group(journal, redirect_chain, hop_index) .and_then(|group| group.first()) .map(|exchange| exchange.request().headers().to_vec()) .unwrap_or_else(|| fallback.to_vec()) } +fn observed_request_method( + journal: &NetworkObservationJournal, + redirect_chain: &[impl RedirectTransportBoundary], + hop_index: usize, + fallback: &str, +) -> String { + navigation_exchange_group(journal, redirect_chain, hop_index) + .and_then(|group| group.first()) + .and_then(|exchange| exchange.request().method()) + .unwrap_or(fallback) + .to_owned() +} + fn observed_response_metadata( journal: &NetworkObservationJournal, - redirect_count: usize, + redirect_chain: &[impl RedirectTransportBoundary], hop_index: usize, fallback_status: u16, fallback_headers: &[(String, Vec)], ) -> (u16, Vec<(String, Vec)>) { - navigation_exchange_group(journal, redirect_count, hop_index) + navigation_exchange_group(journal, redirect_chain, hop_index) .and_then(|group| group.last()) .and_then(NetworkExchangeObservation::response) .map(|response| (response.status(), response.headers().to_vec())) diff --git a/moli-protocol/src/domains/network/main_document_progress/tests.rs b/moli-protocol/src/domains/network/main_document_progress/tests.rs index d5e79a961b..9241cf0d9b 100644 --- a/moli-protocol/src/domains/network/main_document_progress/tests.rs +++ b/moli-protocol/src/domains/network/main_document_progress/tests.rs @@ -4,7 +4,8 @@ use moli_cookie_jar::{ }; use moli_fetch::{ NegotiatedHttpVersion, NetworkExchangeObservation, NetworkObservationJournal, - NetworkRequestObservation, NetworkResponseObservation, RedirectInfo, + NetworkRequestExtraInfo, NetworkRequestObservation, NetworkResponseExtraInfo, + NetworkResponseObservation, RedirectInfo, }; use serde_json::{Value, json}; use tokio::sync::mpsc::unbounded_channel; @@ -18,6 +19,24 @@ use super::gate::{ }; use super::*; +#[test] +fn aborted_navigation_preserves_document_without_changing_other_failure_policies() { + assert_eq!( + failed_navigation_document_policy(moli_fetch::NET_ERR_ABORTED_ERROR_TEXT), + FailedNavigationDocumentPolicy::PreserveCommittedDocument + ); + for error in [ + "net::ERR_CONNECTION_REFUSED", + "net::ERR_TIMED_OUT", + "net::ERR_HTTP_RESPONSE_CODE_FAILURE", + ] { + assert_eq!( + failed_navigation_document_policy(error), + FailedNavigationDocumentPolicy::InvalidateCommittedDocument + ); + } +} + fn completed_events() -> CompletedMainDocumentNetworkEvents { CompletedMainDocumentNetworkEvents::new( "GET".to_owned(), @@ -51,6 +70,25 @@ fn observation_journal( ) } +fn response_extra_info( + request_headers: Vec<(String, String)>, + status: u16, + response_headers: Vec<(String, String)>, +) -> NetworkResponseExtraInfo { + NetworkResponseExtraInfo { + request_extra_info: NetworkRequestExtraInfo { + headers: request_headers, + cookie_report: StoredCookieQueryReport::default(), + }, + status, + headers: response_headers + .into_iter() + .map(|(name, value)| (name, value.into_bytes())) + .collect(), + cookie_set_reports: Vec::new(), + } +} + fn completed_progress_context() -> CompletedMainDocumentProgressContext { CompletedMainDocumentProgressContext::new( vec![Some("SID-1".to_owned())], @@ -428,25 +466,36 @@ fn live_progress_source_serializes_through_progress_emissions() { from_cache: false, negotiated_http_version: None, }; - let journal = observation_journal(vec![ - ( - vec![("Host".to_owned(), "example.test".to_owned())], - 302, - vec![("Location".to_owned(), final_url.to_string())], + let journal = NetworkObservationJournal::from_exchanges(vec![ + NetworkExchangeObservation::new( + NetworkRequestObservation::new_with_method( + "POST", + vec![("Host".to_owned(), "example.test".to_owned())], + ), + Some(NetworkResponseObservation::new( + 302, + vec![("Location".to_owned(), final_url.to_string().into_bytes())], + )), ), - ( - vec![("Host".to_owned(), "example.test".to_owned())], - 200, - vec![("Content-Type".to_owned(), "text/html".to_owned())], + NetworkExchangeObservation::new( + NetworkRequestObservation::new_with_method( + "GET", + vec![("Host".to_owned(), "example.test".to_owned())], + ), + Some(NetworkResponseObservation::new( + 200, + vec![("Content-Type".to_owned(), b"text/html".to_vec())], + )), ), ]); + let redirects = vec![redirect]; source.emit_redirect_requests( - "GET", - None, + "POST", + Some("account=1"), &[("Accept".to_owned(), "text/html".to_owned())], None, - &[redirect], + &redirects, &journal, true, ); @@ -456,7 +505,7 @@ fn live_progress_source_serializes_through_progress_emissions() { &[("Content-Type".to_owned(), b"text/html".to_vec())], &[], &journal, - 1, + &redirects, true, false, None, @@ -484,6 +533,11 @@ fn live_progress_source_serializes_through_progress_emissions() { json!("Network.requestWillBeSent") ); assert_eq!(redirect_request["params"]["requestId"], json!("REQ-1")); + assert_eq!( + redirect_request["params"]["request"]["method"], + json!("GET") + ); + assert!(redirect_request["params"]["request"]["postData"].is_null()); assert_eq!( redirect_request["params"]["redirectResponse"]["status"], json!(302) @@ -555,6 +609,135 @@ fn live_progress_source_serializes_through_progress_emissions() { assert_eq!(finished["params"]["encodedDataLength"], json!(17)); } +#[test] +fn redirect_request_preserves_post_body_when_transport_preserves_method() { + let (source, mut receiver) = live_progress_source(); + let final_url = Url::parse("http://example.test/final").unwrap(); + let redirect = RedirectInfo { + source: moli_fetch::RedirectSource::Network, + from_url: Url::parse("http://example.test/start").unwrap(), + to_url: final_url.clone(), + status: 307, + headers: vec![("Location".to_owned(), final_url.to_string().into_bytes())], + network_extra_info_available: true, + request_extra_info: None, + response_extra_info: None, + redirect_has_extra_info: true, + request_cookie_report: None, + cookie_set_reports: Vec::new(), + from_cache: false, + negotiated_http_version: None, + }; + let journal = NetworkObservationJournal::from_exchanges(vec![ + NetworkExchangeObservation::new( + NetworkRequestObservation::new_with_method("POST", Vec::new()), + Some(NetworkResponseObservation::new(307, Vec::new())), + ), + NetworkExchangeObservation::new( + NetworkRequestObservation::new_with_method("POST", Vec::new()), + Some(NetworkResponseObservation::new(200, Vec::new())), + ), + ]); + + source.emit_redirect_requests( + "POST", + Some("account=1"), + &[], + None, + &[redirect], + &journal, + true, + ); + + let _initial_request_extra = receiver + .try_recv() + .expect("initial request extra info event"); + let (redirect_request, _) = receiver + .try_recv() + .expect("redirect request event") + .into_parts(); + assert_eq!( + redirect_request["params"]["request"]["method"], + json!("POST") + ); + assert_eq!( + redirect_request["params"]["request"]["postData"], + json!("account=1") + ); +} + +#[test] +fn observed_request_headers_do_not_restore_a_referrer_removed_by_transport_policy() { + let journal = NetworkObservationJournal::from_exchanges(vec![NetworkExchangeObservation::new( + NetworkRequestObservation::new_with_method( + "GET", + vec![("Host".to_owned(), "example.test".to_owned())], + ), + Some(NetworkResponseObservation::new(200, Vec::new())), + )]); + + let headers = super::observed_request_headers( + &journal, + &[] as &[RedirectInfo], + 0, + &[("Referer".to_owned(), "https://stale.test/".to_owned())], + ); + + assert_eq!( + headers, + vec![("Host".to_owned(), "example.test".to_owned())] + ); +} + +#[test] +fn truncated_observation_journal_falls_back_instead_of_guessing_redirect_alignment() { + let final_url = Url::parse("http://example.test/final").unwrap(); + let redirect = RedirectInfo { + source: moli_fetch::RedirectSource::Network, + from_url: Url::parse("http://example.test/start").unwrap(), + to_url: final_url.clone(), + status: 302, + headers: vec![("Location".to_owned(), final_url.to_string().into_bytes())], + network_extra_info_available: true, + request_extra_info: None, + response_extra_info: None, + redirect_has_extra_info: true, + request_cookie_report: None, + cookie_set_reports: Vec::new(), + from_cache: false, + negotiated_http_version: None, + }; + let journal = NetworkObservationJournal::from_exchanges( + (0..33) + .map(|index| { + NetworkExchangeObservation::new( + NetworkRequestObservation::new_with_method( + "GET", + vec![("X-Wire-Hop".to_owned(), index.to_string())], + ), + Some(NetworkResponseObservation::new(200, Vec::new())), + ) + }) + .collect(), + ); + assert!(journal.truncated()); + + let fallback_headers = vec![("X-Logical-Hop".to_owned(), "final".to_owned())]; + assert_eq!( + super::observed_request_headers( + &journal, + std::slice::from_ref(&redirect), + 1, + &fallback_headers + ), + fallback_headers + ); + assert_eq!( + super::observed_request_method(&journal, &[redirect], 1, "POST"), + "POST" + ); +} + #[test] fn completed_body_http_response_emits_correlated_empty_cookie_extra_info() { let final_url = Url::parse("http://example.test/final").unwrap(); @@ -785,19 +968,46 @@ fn completed_body_http_redirect_emits_correlated_no_cookie_extra_info() { from_cache: false, negotiated_http_version: None, }]; - events = events.with_network_observation_journal(observation_journal(vec![ - ( - vec![("X-Request-Hop".to_owned(), "initial".to_owned())], - 302, - vec![("X-Response-Hop".to_owned(), "redirect".to_owned())], - ), - ( - vec![("X-Request-Hop".to_owned(), "final".to_owned())], - 200, - vec![("X-Response-Hop".to_owned(), "final".to_owned())], - ), - ])); - let batches = completed_progress_context().event_batches(&events, &final_url, 17); + events.request_method = "POST".to_owned(); + events = + events.with_network_observation_journal(NetworkObservationJournal::from_exchanges(vec![ + NetworkExchangeObservation::new( + NetworkRequestObservation::new_with_method( + "POST", + vec![("X-Request-Hop".to_owned(), "initial".to_owned())], + ), + Some(NetworkResponseObservation::new( + 302, + vec![("X-Response-Hop".to_owned(), b"redirect".to_vec())], + )), + ), + NetworkExchangeObservation::new( + NetworkRequestObservation::new_with_method( + "GET", + vec![("X-Request-Hop".to_owned(), "final".to_owned())], + ), + Some(NetworkResponseObservation::new( + 200, + vec![("X-Response-Hop".to_owned(), b"final".to_vec())], + )), + ), + ])); + let context = CompletedMainDocumentProgressContext::new( + vec![Some("SID-1".to_owned())], + Some("REQ-1".to_owned()), + false, + Url::parse("http://example.test/start").unwrap(), + "POST".to_owned(), + Some("account=1".to_owned()), + vec![( + "Content-Type".to_owned(), + "application/x-www-form-urlencoded".to_owned(), + )], + "LOADER-1".to_owned(), + "FRAME-1".to_owned(), + 12.5, + ); + let batches = context.event_batches(&events, &final_url, 17); let mut drain = MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); @@ -838,6 +1048,11 @@ fn completed_body_http_redirect_emits_correlated_no_cookie_extra_info() { redirected_request["params"]["redirectHasExtraInfo"], json!(true) ); + assert_eq!( + redirected_request["params"]["request"]["method"], + json!("GET") + ); + assert!(redirected_request["params"]["request"]["postData"].is_null()); let request_extras = out .iter() .filter(|message| message["method"] == json!("Network.requestWillBeSentExtraInfo")) @@ -949,7 +1164,14 @@ fn critical_client_hint_restart_keeps_discarded_response_extra_info_separate_fro )], network_extra_info_available: false, request_extra_info: None, - response_extra_info: None, + response_extra_info: Some(response_extra_info( + vec![("Sec-CH-UA".to_owned(), "\"Chromium\";v=\"145\"".to_owned())], + 403, + vec![ + ("Accept-CH".to_owned(), "Sec-CH-UA-Arch".to_owned()), + ("Critical-CH".to_owned(), "Sec-CH-UA-Arch".to_owned()), + ], + )), redirect_has_extra_info: false, request_cookie_report: None, cookie_set_reports: Vec::new(), @@ -1013,6 +1235,69 @@ fn critical_client_hint_restart_keeps_discarded_response_extra_info_separate_fro assert_eq!(relevant[6]["params"]["hasExtraInfo"], json!(true)); } +#[test] +fn transportless_https_upgrade_attributes_wire_headers_to_upgraded_request() { + let initial_url = Url::parse("http://example.test/start").unwrap(); + let final_url = Url::parse("https://example.test/start").unwrap(); + let journal = NetworkObservationJournal::from_exchanges(vec![NetworkExchangeObservation::new( + NetworkRequestObservation::new_with_method( + "GET", + vec![("X-Wire-Upgraded".to_owned(), "yes".to_owned())], + ), + Some(NetworkResponseObservation::new( + 200, + vec![("Content-Type".to_owned(), b"text/html".to_vec())], + )), + )]); + let mut events = completed_events().with_network_observation_journal(journal); + events.network_extra_info_available = true; + events.redirect_chain = vec![moli_core::page::NavigationRedirect { + source: moli_fetch::RedirectSource::Internal, + from_url: initial_url, + to_url: final_url.clone(), + status: 307, + headers: vec![("Location".to_owned(), final_url.to_string().into_bytes())], + network_extra_info_available: false, + request_extra_info: None, + response_extra_info: None, + redirect_has_extra_info: false, + request_cookie_report: None, + cookie_set_reports: Vec::new(), + from_cache: false, + negotiated_http_version: Some(NegotiatedHttpVersion::Http11), + }]; + + let batches = completed_progress_context().event_batches(&events, &final_url, 17); + let mut drain = + MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); + drain.mark_output_visible_until(MainDocumentProgressOutputBoundary::BodyFinishedVisible); + let out = drain_into_protocol_messages(&mut drain); + + let redirected_request = out + .iter() + .filter(|message| message["method"] == json!("Network.requestWillBeSent")) + .nth(1) + .expect("upgraded request"); + assert_eq!( + redirected_request["params"]["request"]["headers"]["X-Wire-Upgraded"], + json!("yes"), + "the sole wire exchange belongs to the post-upgrade HTTPS request" + ); + let request_extras = out + .iter() + .filter(|message| message["method"] == json!("Network.requestWillBeSentExtraInfo")) + .collect::>(); + assert_eq!( + request_extras.len(), + 1, + "a transportless internal hop must not receive wire ExtraInfo" + ); + assert_eq!( + request_extras[0]["params"]["headers"]["X-Wire-Upgraded"], + json!("yes") + ); +} + #[test] fn completed_body_uses_negotiated_protocol_for_redirect_and_final_response() { let final_url = Url::parse("https://example.test/final").unwrap(); diff --git a/moli-protocol/src/domains/network/output_queue.rs b/moli-protocol/src/domains/network/output_queue.rs index 90e6199a43..5f8cd11179 100644 --- a/moli-protocol/src/domains/network/output_queue.rs +++ b/moli-protocol/src/domains/network/output_queue.rs @@ -2976,17 +2976,18 @@ impl TargetNetworkOutputQueue { let Some(request) = self.staged_subresource_requests.get(&handle) else { return; }; - if request.request_cookie_report().is_some() { - return; - } let request_headers = network_request_headers .unwrap_or_else(|| request.request_headers()) .to_vec(); - let Some(request_cookie_report) = request_cookie_report.cloned().or_else(|| { - network_request_headers - .is_some() - .then(StoredCookieQueryReport::default) - }) else { + let Some(request_cookie_report) = request_cookie_report + .cloned() + .or_else(|| request.request_cookie_report().cloned()) + .or_else(|| { + network_request_headers + .is_some() + .then(StoredCookieQueryReport::default) + }) + else { return; }; self.append_subresource_request_extra_info( @@ -3686,6 +3687,60 @@ mod tests { )); } + #[test] + fn staged_cookie_report_does_not_suppress_observed_transport_headers() { + let handle = SubresourceNetworkRequestHandle::new(81); + let document_url = Url::parse("https://example.com/page").unwrap(); + let request_url = Url::parse("https://example.com/api").unwrap(); + let request = SubresourceRequestStarted::new( + handle, + Some("FRAME-1".to_owned()), + document_url, + request_url.clone(), + "POST".to_owned(), + vec![("Content-Type".to_owned(), "text/plain".to_owned())], + Some("body".to_owned()), + SubresourceResourceType::Fetch, + SubresourceRequestInitiatorType::Script, + Some(StoredCookieQueryReport::default()), + ); + let headers = vec![ + ("Referer".to_owned(), "https://example.com/page".to_owned()), + ("User-Agent".to_owned(), "Moli/Test".to_owned()), + ]; + let record = subresource_record(SubresourceResourceType::Fetch, request_url.as_str()) + .with_request_handle(handle) + .with_network_request_headers(Some(headers.clone())); + let mut queue = TargetNetworkOutputQueue::default(); + append_concrete_items_for_test( + &mut queue, + &[ + ScriptNetworkOutputItem::SubresourceRequestStarted(Box::new(request)), + ScriptNetworkOutputItem::SubresourceNetworkRecord(Box::new(record)), + ], + "LOADER-1", + ); + let activity = PendingSubresourceNetworkActivity::from_sessions(vec![ + PendingSubresourceNetworkActivitySession::new(None, 0), + ]); + let mut request_ids = StableSubresourceHandleRequestIds::default(); + let snapshot = pending_delivery_snapshot(&queue, activity, None, &mut request_ids).unwrap(); + let outputs = subresource_outputs(&snapshot); + let extras = outputs + .iter() + .filter_map(|output| match output { + TargetSubresourceNetworkDeliveryOutput::RequestExtraInfo(output) => Some(output), + _ => None, + }) + .collect::>(); + assert_eq!( + extras.len(), + 1, + "cookie metadata is not transport-header evidence" + ); + assert_eq!(extras[0].output().request_headers(), headers.as_slice()); + } + #[test] fn staged_request_followed_by_redirect_complete_record_preserves_redirect_chain() { let handle = SubresourceNetworkRequestHandle::new(7); diff --git a/moli-protocol/src/domains/network/settings.rs b/moli-protocol/src/domains/network/settings.rs index 61ab1d1270..9ebfc7cb3b 100644 --- a/moli-protocol/src/domains/network/settings.rs +++ b/moli-protocol/src/domains/network/settings.rs @@ -13,6 +13,35 @@ struct SetBlockedUrlsParams { urls: Vec, } +#[derive(Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DurableBodyParams { + #[serde(default)] + enable_durable_messages: bool, + max_total_buffer_size: Option, + max_resource_buffer_size: Option, +} + +pub(super) fn durable_body_limits( + cmd: &Cmd<'_>, +) -> Result, CommandOutputPlan> { + let params = cmd + .get_params::() + .map_err(|_| CommandOutputPlan::error(-32602, "InvalidParams"))? + .unwrap_or_default(); + if !params.enable_durable_messages { + return Ok(None); + } + let Some(total) = params.max_total_buffer_size.filter(|size| *size > 0) else { + return Err(CommandOutputPlan::error(-32602, "InvalidParams")); + }; + let resource = params.max_resource_buffer_size.unwrap_or(2_000_000); + if resource == 0 { + return Err(CommandOutputPlan::error(-32602, "InvalidParams")); + } + Ok(Some(moli_bounded_buffer::ByteLimits::new(total, resource))) +} + pub(super) fn enabled_command_output_plan( conn: &mut CdpConnection, session_id: Option<&str>, diff --git a/moli-protocol/src/domains/network/tests/response_body.rs b/moli-protocol/src/domains/network/tests/response_body.rs index 4c31aba9fd..855a8467ad 100644 --- a/moli-protocol/src/domains/network/tests/response_body.rs +++ b/moli-protocol/src/domains/network/tests/response_body.rs @@ -15,6 +15,168 @@ use crate::domains::network::{ use super::*; +#[tokio::test(flavor = "multi_thread")] +async fn durable_response_body_survives_navigation_only_for_opted_session() { + let mut ctx = TestContext::new(); + let mut bc = BrowserContext::new("BID-durable".into()); + bc.set_active_target_id("TID-durable".to_owned()); + bc.attach_active_session("SID-durable".to_owned()); + assert!(bc.assign_attached_session_to_target("TID-durable", "SID-ordinary".to_owned())); + ctx.conn.install_browser_context_fixture_for_test(bc); + for (id, session, params) in [ + ( + 79_100, + "SID-durable", + json!({"enableDurableMessages":true,"maxTotalBufferSize":1024,"maxResourceBufferSize":128}), + ), + (79_101, "SID-ordinary", json!({})), + ] { + ctx.process_async( + json!({"id":id,"method":"Network.enable","sessionId":session,"params":params}), + ) + .await; + ctx.expect_result(id, json!({}), Some(session)); + } + let bc = ctx.conn.browser_context.as_mut().unwrap(); + bc.record_captured_response_body( + "REQ-durable".into(), + "retained".into(), + [Some("SID-durable".into()), Some("SID-ordinary".into())], + ); + bc.active_page_target_mut() + .prepare_document_navigation_request_ids( + &mut crate::conn::ConnectionNetworkRequestIdAllocator::default(), + true, + true, + false, + ); + ctx.process_async(json!({"id":79_102,"method":"Network.getResponseBody","sessionId":"SID-durable","params":{"requestId":"REQ-durable"}})).await; + ctx.expect_result( + 79_102, + json!({"body":"retained","base64Encoded":false}), + Some("SID-durable"), + ); + ctx.process_async(json!({"id":79_103,"method":"Network.getResponseBody","sessionId":"SID-ordinary","params":{"requestId":"REQ-durable"}})).await; + ctx.expect_error(79_103, -32000, "No resource with given identifier found"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn durable_response_body_requires_explicit_positive_total_budget() { + let mut ctx = TestContext::new(); + let mut bc = BrowserContext::new("BID-durable".into()); + bc.set_active_target_id("TID-durable".to_owned()); + bc.attach_active_session("SID-durable".to_owned()); + ctx.conn.install_browser_context_fixture_for_test(bc); + for (index, params) in [ + json!({"enableDurableMessages":true}), + json!({"enableDurableMessages":true,"maxTotalBufferSize":0}), + json!({"enableDurableMessages":true,"maxTotalBufferSize":-1}), + json!({"enableDurableMessages":true,"maxTotalBufferSize":100,"maxResourceBufferSize":0}), + json!({"enableDurableMessages":"true","maxTotalBufferSize":100}), + ] + .into_iter() + .enumerate() + { + let id = 79_104 + index as u64; + ctx.process_async( + json!({"id":id,"method":"Network.enable","sessionId":"SID-durable","params":params}), + ) + .await; + ctx.expect_error(id, -32602, "InvalidParams"); + } + assert!( + !ctx.conn + .browser_context + .as_ref() + .unwrap() + .active_page_target() + .runtime_slot + .has_network_event_listeners() + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn durable_response_body_disable_detach_close_and_other_target_do_not_leak() { + let mut ctx = TestContext::new(); + let mut bc = BrowserContext::new("BID-durable".into()); + bc.set_active_target_id("TID-durable".to_owned()); + bc.attach_active_session("SID-owner".to_owned()); + assert!(bc.assign_attached_session_to_target("TID-durable", "SID-detach".to_owned())); + bc.insert_page_target_host(PageTargetHost::new( + "TID-other".into(), + Some("SID-other".into()), + TargetIdentityState::about_blank(), + TargetPageSlot::empty_for_test_fixture(), + )); + ctx.conn.install_browser_context_fixture_for_test(bc); + let params = + json!({"enableDurableMessages":true,"maxTotalBufferSize":1024,"maxResourceBufferSize":128}); + for (index, session) in ["SID-owner", "SID-detach", "SID-other"] + .into_iter() + .enumerate() + { + let id = 79_200 + index as u64; + ctx.process_async( + json!({"id":id,"method":"Network.enable","sessionId":session,"params":params}), + ) + .await; + ctx.expect_result(id, json!({}), Some(session)); + } + let bc = ctx.conn.browser_context.as_mut().unwrap(); + bc.page_target_mut("TID-durable") + .unwrap() + .runtime_slot + .record_captured_response_body( + "REQ-old".into(), + "retained".into(), + [Some("SID-owner".into()), Some("SID-detach".into())], + ); + bc.page_target_mut("TID-durable") + .unwrap() + .prepare_document_navigation_request_ids( + &mut crate::conn::ConnectionNetworkRequestIdAllocator::default(), + true, + true, + false, + ); + ctx.process_async(json!({"id":79_203,"method":"Network.getResponseBody","sessionId":"SID-other","params":{"requestId":"REQ-old"}})).await; + ctx.expect_error(79_203, -32000, "No resource with given identifier found"); + ctx.process_async(json!({"id":79_204,"method":"Network.disable","sessionId":"SID-owner"})) + .await; + ctx.expect_result(79_204, json!({}), Some("SID-owner")); + ctx.process_async( + json!({"id":79_205,"method":"Network.enable","sessionId":"SID-owner","params":params}), + ) + .await; + ctx.expect_result(79_205, json!({}), Some("SID-owner")); + ctx.process_async(json!({"id":79_206,"method":"Network.getResponseBody","sessionId":"SID-owner","params":{"requestId":"REQ-old"}})).await; + ctx.expect_error(79_206, -32000, "No resource with given identifier found"); + ctx.process_async( + json!({"id":79_207,"method":"Target.detachFromTarget","params":{"sessionId":"SID-detach"}}), + ) + .await; + ctx.expect_result(79_207, json!({}), None); + assert!( + ctx.conn + .browser_context + .as_ref() + .unwrap() + .page_target("TID-durable") + .unwrap() + .runtime_slot + .captured_response_body("REQ-old") + .is_none() + ); + ctx.process_async( + json!({"id":79_208,"method":"Target.closeTarget","params":{"targetId":"TID-durable"}}), + ) + .await; + ctx.expect_result(79_208, json!({"success":true}), None); + let bc = ctx.conn.browser_context.as_ref().unwrap(); + assert!(bc.page_target("TID-durable").is_none()); + assert!(bc.page_target("TID-other").is_some()); +} + fn bidi_network_context(session_id: &str) -> crate::devtools_runtime::DevToolsCommandContext { crate::devtools_runtime::DevToolsCommandContext { protocol: crate::devtools_runtime::DevToolsProtocol::WebDriverBidi, diff --git a/moli-protocol/src/domains/page.rs b/moli-protocol/src/domains/page.rs index 68ce479c74..12ee4b28ce 100644 --- a/moli-protocol/src/domains/page.rs +++ b/moli-protocol/src/domains/page.rs @@ -4170,6 +4170,8 @@ mod producer_tests { request_url: "https://example.test/child".to_owned(), request_method: "GET".to_owned(), request_headers: vec![("Accept".to_owned(), "text/html".to_owned())], + network_observation_journal: Default::default(), + redirect_chain: Vec::new(), final_url: "https://example.test/child".to_owned(), status: 200, response_headers: vec![("Content-Type".to_owned(), b"text/html".to_vec())], @@ -4308,6 +4310,8 @@ mod producer_tests { request_url: "https://example.test/retired-child".to_owned(), request_method: "GET".to_owned(), request_headers: Vec::new(), + network_observation_journal: Default::default(), + redirect_chain: Vec::new(), final_url: "https://example.test/retired-child".to_owned(), status: 200, response_headers: vec![("Content-Type".to_owned(), b"text/html".to_vec())], @@ -4416,6 +4420,8 @@ mod producer_tests { request_url: "https://example.test/legacy-child".to_owned(), request_method: "GET".to_owned(), request_headers: Vec::new(), + network_observation_journal: Default::default(), + redirect_chain: Vec::new(), final_url: "https://example.test/legacy-child".to_owned(), status: 200, response_headers: vec![("Content-Type".to_owned(), b"text/html".to_vec())], @@ -4457,6 +4463,110 @@ mod producer_tests { ); } + #[tokio::test(flavor = "multi_thread")] + async fn child_document_network_reports_actual_redirect_methods_and_request_headers() { + let mut conn = CdpConnection::default(); + let mut bc = BrowserContext::new("BID-1".into()); + bc.set_active_target_id("TID-1"); + bc.attach_active_session("SID-1"); + conn.install_browser_context_fixture_for_test(bc); + assert!(conn.enable_network_listener_for_session_owner(Some("SID-1"))); + let final_url = url::Url::parse("https://example.test/done").unwrap(); + let snapshot = ChildFrameDocumentNetworkSnapshot { + request_url: "https://example.test/profile".to_owned(), + request_method: "POST".to_owned(), + request_headers: Vec::new(), + network_observation_journal: moli_fetch::NetworkObservationJournal::from_exchanges( + vec![ + moli_fetch::NetworkExchangeObservation::new( + moli_fetch::NetworkRequestObservation::new_with_method( + "POST", + vec![( + "Referer".to_owned(), + "https://example.test/source".to_owned(), + )], + ), + Some(moli_fetch::NetworkResponseObservation::new( + 302, + vec![("Location".to_owned(), final_url.to_string().into_bytes())], + )), + ), + moli_fetch::NetworkExchangeObservation::new( + moli_fetch::NetworkRequestObservation::new_with_method( + "GET", + vec![( + "Referer".to_owned(), + "https://example.test/source".to_owned(), + )], + ), + Some(moli_fetch::NetworkResponseObservation::new( + 200, + vec![("Content-Type".to_owned(), b"text/html".to_vec())], + )), + ), + ], + ), + redirect_chain: vec![moli_fetch::RedirectInfo { + source: moli_fetch::RedirectSource::Network, + from_url: url::Url::parse("https://example.test/profile").unwrap(), + to_url: final_url.clone(), + status: 302, + headers: vec![("Location".to_owned(), final_url.to_string().into_bytes())], + network_extra_info_available: true, + request_extra_info: None, + response_extra_info: None, + redirect_has_extra_info: true, + request_cookie_report: None, + cookie_set_reports: Vec::new(), + from_cache: false, + negotiated_http_version: None, + }], + final_url: final_url.to_string(), + status: 200, + response_headers: vec![("Content-Type".to_owned(), b"text/html".to_vec())], + encoded_data_length: 0, + response_body: None, + from_cache: false, + }; + let mut background_events = Vec::new(); + + crate::domains::network::emit_child_document_navigation_network_background_events( + &mut conn, + &mut background_events, + Some("SID-1"), + "CHILD-FRAME-REDIRECT", + "LID-CHILD-REDIRECT", + "LID-CHILD-REDIRECT", + 12.5, + &snapshot, + ); + + let messages = protocol_messages_from_background_events(background_events); + let requests = messages + .iter() + .filter(|message| message["method"] == json!("Network.requestWillBeSent")) + .collect::>(); + assert_eq!(requests.len(), 2, "redirect hop must remain observable"); + assert_eq!(requests[0]["params"]["request"]["method"], json!("POST")); + assert_eq!(requests[1]["params"]["request"]["method"], json!("GET")); + assert_eq!( + requests[1]["params"]["redirectResponse"]["status"], + json!(302) + ); + assert_eq!( + requests[0]["params"]["request"]["headers"]["Referer"], + json!("https://example.test/source") + ); + let request_extra = messages + .iter() + .filter(|message| message["method"] == json!("Network.requestWillBeSentExtraInfo")) + .collect::>(); + assert_eq!(request_extra.len(), 2); + assert!(request_extra.iter().all(|message| { + message["params"]["headers"]["Referer"] == json!("https://example.test/source") + })); + } + #[tokio::test(flavor = "multi_thread")] async fn child_frame_activity_drain_preserves_prepared_attachment_only_token() { let mut conn = CdpConnection::default(); diff --git a/moli-protocol/src/domains/page/termination.rs b/moli-protocol/src/domains/page/termination.rs index 6fb233e3ac..a90b721ae5 100644 --- a/moli-protocol/src/domains/page/termination.rs +++ b/moli-protocol/src/domains/page/termination.rs @@ -395,11 +395,13 @@ pub(super) async fn complete_stop_loading_command_dispatch( owner: &CommandOwnerScope, ) -> PageCommandTaskStep { let mut out = Vec::new(); - if let Ok(slot) = conn.runtime_session_owner_slot_mut_for_owner(owner) - && let Some(page) = slot.loaded_page_mut() - && let Err(error) = page.stop_document_lifecycle_async().await - { - tracing::debug!(%error, "failed to stop renderer document lifecycle"); + if let Ok(slot) = conn.runtime_session_owner_slot_mut_for_owner(owner) { + slot.cancel_inflight_document_navigation(); + if let Some(page) = slot.loaded_page_mut() + && let Err(error) = page.stop_document_lifecycle_async().await + { + tracing::debug!(%error, "failed to stop renderer document lifecycle"); + } } let ( pending_navigations, diff --git a/moli-protocol/src/domains/page/tests/lifecycle.rs b/moli-protocol/src/domains/page/tests/lifecycle.rs index 450912689a..ea7c311b90 100644 --- a/moli-protocol/src/domains/page/tests/lifecycle.rs +++ b/moli-protocol/src/domains/page/tests/lifecycle.rs @@ -2391,11 +2391,15 @@ async fn network_navigations_use_unique_document_loader_ids() { axum::Router::new() .route( "/first", - axum::routing::get(|| async { "
first
" }), + axum::routing::get(|| async { + axum::response::Html("
first
") + }), ) .route( "/second", - axum::routing::get(|| async { "
second
" }), + axum::routing::get(|| async { + axum::response::Html("
second
") + }), ), ) .await @@ -2474,11 +2478,15 @@ async fn navigations_without_network_domain_still_use_unique_loader_ids() { axum::Router::new() .route( "/first", - axum::routing::get(|| async { "
first
" }), + axum::routing::get(|| async { + axum::response::Html("
first
") + }), ) .route( "/second", - axum::routing::get(|| async { "
second
" }), + axum::routing::get(|| async { + axum::response::Html("
second
") + }), ), ) .await diff --git a/moli-protocol/src/domains/page/tests/navigation.rs b/moli-protocol/src/domains/page/tests/navigation.rs index dbd5ae9349..47c9f1d159 100644 --- a/moli-protocol/src/domains/page/tests/navigation.rs +++ b/moli-protocol/src/domains/page/tests/navigation.rs @@ -6414,6 +6414,373 @@ async fn stop_loading_aborts_paused_request_stage_navigation() { ); } } +#[tokio::test(flavor = "multi_thread")] +async fn stop_loading_cancels_inflight_unpaused_navigation_transport() { + let mut ctx = TestContext::new(); + load_bc_with_session(&mut ctx, "BID-1", "TID-1", "SID-1", "about:blank"); + let token = ctx + .conn + .browser_context + .as_mut() + .unwrap() + .start_document_navigation_for_active_target("LOADER-inflight-stop".to_owned()) + .unwrap(); + let cancellation = ctx + .conn + .document_navigation_cancellation_handle(&token) + .unwrap(); + ctx.conn.arm_background_navigation_completion(&token, None); + assert!(!cancellation.is_cancelled()); + + ctx.process_async(json!({ + "id": 901, + "method": "Page.stopLoading", + "sessionId": "SID-1" + })) + .await; + ctx.expect_result(901, json!({}), Some("SID-1")); + assert!( + cancellation.is_cancelled(), + "stopLoading must cancel an ordinary HTTP navigation, not only Fetch-paused requests" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn stop_loading_before_response_preserves_document_and_allows_next_navigation() { + assert_stopped_provisional_navigation_preserves_document(None).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn stop_loading_during_xml_body_preserves_document_and_allows_next_navigation() { + assert_stopped_provisional_navigation_preserves_document(Some((200, "application/xml"))).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn stop_loading_during_http_error_body_preserves_document_and_allows_next_navigation() { + assert_stopped_provisional_navigation_preserves_document(Some((500, "text/html"))).await; +} + +async fn assert_stopped_provisional_navigation_preserves_document( + response_head: Option<(u16, &'static str)>, +) { + let request_received = std::sync::Arc::new(tokio::sync::Notify::new()); + let release_response = std::sync::Arc::new(tokio::sync::Notify::new()); + let handler_received = request_received.clone(); + let handler_release = release_response.clone(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let app = axum::Router::new() + .route( + "/held", + axum::routing::get(move || { + let received = handler_received.clone(); + let release = handler_release.clone(); + async move { + received.notify_one(); + let (status, content_type) = if let Some(head) = response_head { + head + } else { + release.notified().await; + return axum::http::Response::builder() + .header("content-type", "text/html") + .body(axum::body::Body::from("cancelled response")) + .unwrap(); + }; + let body = futures_util::stream::once(async move { + release.notified().await; + Ok::<_, std::convert::Infallible>(axum::body::Bytes::from_static( + b"cancelled response", + )) + }); + axum::http::Response::builder() + .status(status) + .header("content-type", content_type) + .body(axum::body::Body::from_stream(body)) + .unwrap() + } + }), + ) + .route( + "/ready", + axum::routing::get(|| async { + axum::response::Html("subsequent document") + }), + ); + axum::serve(listener, app).await.unwrap(); + }); + + let mut ctx = TestContext::new(); + load_bc_with_session(&mut ctx, "BID-1", "TID-1", "SID-1", "about:blank"); + let initial_url = "data:text/html,previous committed document"; + ctx.install_navigation_fixture_for_session_owner(initial_url, Some("SID-1")) + .await; + wait_until_renderer_document_load(&mut ctx, Some("SID-1"), "TID-1", LOADER_ID).await; + for (id, method) in [ + (910, "Page.enable"), + (911, "DOM.enable"), + (912, "Network.enable"), + (913, "Runtime.enable"), + ] { + ctx.process_async(json!({ + "id": id, + "method": method, + "sessionId": "SID-1" + })) + .await; + assert_eq!(take_response_by_id(&mut ctx, id)["result"], json!({})); + } + let previous_html = loaded_page_html_for_test(&mut ctx).await; + ctx.sent.clear(); + ctx.enable_background_navigation_scheduler_for_test(); + + tokio::task::LocalSet::new() + .run_until(async { + ctx.process_async(json!({ + "id": 914, + "method": "Page.navigate", + "sessionId": "SID-1", + "params": { "url": format!("http://{addr}/held") } + })) + .await; + tokio::time::timeout( + std::time::Duration::from_secs(5), + request_received.notified(), + ) + .await + .expect("the ordinary HTTP navigation should reach the held response"); + if let Some((status, _)) = response_head { + wait_until_scheduler_message( + &mut ctx, + "held document response headers", + |message| { + message["method"] == json!("Network.responseReceived") + && message["params"]["type"] == json!("Document") + && message["params"]["response"]["status"] == json!(status) + }, + ) + .await; + } else { + assert!(ctx.sent.iter().all(|message| message["id"] != json!(914))); + } + + ctx.process_and_wait_for_response_async(json!({ + "id": 915, + "method": "Page.stopLoading", + "sessionId": "SID-1" + })) + .await; + assert_eq!(take_response_by_id(&mut ctx, 915)["result"], json!({})); + wait_until_scheduler_message(&mut ctx, "cancelled navigation response", |message| { + message["id"] == json!(914) + }) + .await; + let navigation = take_response_by_id(&mut ctx, 914); + assert_eq!(navigation["result"]["frameId"], json!("TID-1")); + if response_head.is_some_and(|(status, _)| status == 200) { + // Successful response headers acknowledge navigation before + // XML buffering completes; cancellation must not send a + // second reply or replace the still-committed old Document. + assert!(navigation["result"].get("errorText").is_none()); + } else { + assert_eq!(navigation["result"]["errorText"], json!("net::ERR_ABORTED")); + } + wait_until_scheduler_message( + &mut ctx, + "cancelled navigation network event", + |message| { + message["method"] == json!("Network.loadingFailed") + && message["params"]["errorText"] == json!("net::ERR_ABORTED") + }, + ) + .await; + let failure = ctx + .sent + .iter() + .find(|message| message["method"] == json!("Network.loadingFailed")) + .expect("cancelled request failure"); + assert_eq!(failure["params"]["canceled"], json!(true)); + assert_eq!(failure["params"]["type"], json!("Document")); + assert!(ctx.sent.iter().all(|message| message["id"] != json!(914))); + assert_eq!(loaded_page_html_for_test(&mut ctx).await, previous_html); + assert_eq!( + ctx.conn.browser_context.as_ref().unwrap().target_url(), + initial_url + ); + for method in [ + "Page.frameNavigated", + "DOM.documentUpdated", + "Runtime.executionContextsCleared", + "Runtime.executionContextCreated", + "Page.domContentEventFired", + "Page.loadEventFired", + ] { + assert!( + ctx.sent + .iter() + .all(|message| message["method"] != json!(method)), + "cancelled pre-response navigation must not replace the document: {method}" + ); + } + + // The response remains held until cancellation has completed. No + // server delay or timing race can masquerade as transport abort. + release_response.notify_one(); + ctx.sent.clear(); + ctx.process_and_wait_for_response_async(json!({ + "id": 916, + "method": "Page.navigate", + "sessionId": "SID-1", + "params": { "url": format!("http://{addr}/ready") } + })) + .await; + let navigation = take_response_by_id(&mut ctx, 916); + assert!(navigation["result"].get("errorText").is_none()); + wait_until_frame_stopped_loading(&mut ctx, "TID-1").await; + let html = loaded_page_html_for_test(&mut ctx).await; + assert!(html.contains("subsequent document")); + assert!(!html.contains("cancelled response")); + assert_eq!( + ctx.conn.browser_context.as_ref().unwrap().target_url(), + format!("http://{addr}/ready") + ); + }) + .await; + server.abort(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn stop_loading_after_commit_cancels_transport_without_replacing_partial_document() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let prefix_parsed = std::sync::Arc::new(tokio::sync::Notify::new()); + let transport_closed = std::sync::Arc::new(tokio::sync::Notify::new()); + let server_parsed = prefix_parsed.clone(); + let server_closed = transport_closed.clone(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + loop { + let (mut socket, _) = listener.accept().await.unwrap(); + let parsed = server_parsed.clone(); + let closed = server_closed.clone(); + tokio::spawn(async move { + let mut request = Vec::new(); + let mut byte = [0_u8; 1]; + while !request.ends_with(b"\r\n\r\n") { + if socket.read(&mut byte).await.unwrap() == 0 { + return; + } + request.push(byte[0]); + } + if request.starts_with(b"GET /stream ") { + let prefix = b"
committed prefix
"; + let tail = b"
unreceived tail
"; + let headers = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + prefix.len() + tail.len() + ); + socket.write_all(headers.as_bytes()).await.unwrap(); + socket.write_all(prefix).await.unwrap(); + socket.flush().await.unwrap(); + // The server never supplies EOF or the tail. Completion + // here can only come from the client's transport close. + let read = socket.read(&mut byte).await; + assert!( + matches!(read, Ok(0)) || read.is_err(), + "client must close the held transfer: {read:?}" + ); + closed.notify_one(); + } else if request.starts_with(b"GET /parsed ") { + socket + .write_all(b"HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + parsed.notify_one(); + } else { + socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 26\r\nConnection: close\r\n\r\ncomplete page").await.unwrap(); + } + }); + } + }); + let mut ctx = TestContext::new(); + load_bc_with_session(&mut ctx, "BID-1", "TID-1", "SID-1", "about:blank"); + ctx.enable_page_events_for_test(Some("SID-1")); + ctx.enable_background_navigation_scheduler_for_test(); + tokio::task::LocalSet::new() + .run_until(async { + ctx.process_and_wait_for_response_async(json!({ + "id": 920, "method": "Page.navigate", "sessionId": "SID-1", + "params": { "url": format!("http://{addr}/stream") } + })) + .await; + assert!( + take_response_by_id(&mut ctx, 920)["result"] + .get("errorText") + .is_none() + ); + wait_until_scheduler_message(&mut ctx, "streaming document commit", |message| { + message["method"] == json!("Page.frameNavigated") + }) + .await; + tokio::time::timeout(std::time::Duration::from_secs(5), prefix_parsed.notified()) + .await + .expect("committed prefix script must execute before stopping"); + assert!( + loaded_page_html_for_test(&mut ctx) + .await + .contains("committed prefix") + ); + ctx.sent.clear(); + ctx.process_and_wait_for_response_async(json!({ + "id": 921, "method": "Page.stopLoading", "sessionId": "SID-1" + })) + .await; + assert_eq!(take_response_by_id(&mut ctx, 921)["result"], json!({})); + tokio::time::timeout( + std::time::Duration::from_secs(5), + transport_closed.notified(), + ) + .await + .expect("explicit document stop must cancel the committed response transport"); + let html = loaded_page_html_for_test(&mut ctx).await; + assert!(html.contains("committed prefix")); + assert!(!html.contains("unreceived tail")); + assert_eq!( + ctx.conn.browser_context.as_ref().unwrap().target_url(), + format!("http://{addr}/stream") + ); + assert!( + ctx.sent + .iter() + .all(|message| message["method"] != json!("Page.frameNavigated")) + ); + + ctx.sent.clear(); + ctx.process_and_wait_for_response_async(json!({ + "id": 922, "method": "Page.navigate", "sessionId": "SID-1", + "params": { "url": format!("http://{addr}/complete") } + })) + .await; + assert!( + take_response_by_id(&mut ctx, 922)["result"] + .get("errorText") + .is_none() + ); + wait_until_frame_stopped_loading(&mut ctx, "TID-1").await; + let completed_html = loaded_page_html_for_test(&mut ctx).await; + assert!(completed_html.contains("complete page")); + ctx.process_and_wait_for_response_async(json!({ + "id": 923, "method": "Page.stopLoading", "sessionId": "SID-1" + })) + .await; + assert_eq!(take_response_by_id(&mut ctx, 923)["result"], json!({})); + assert_eq!(loaded_page_html_for_test(&mut ctx).await, completed_html); + }) + .await; + server.abort(); +} + #[tokio::test(flavor = "multi_thread")] async fn stop_loading_without_browser_context_returns_empty_result() { let mut ctx = TestContext::new(); diff --git a/moli-protocol/src/domains/page/tests/runtime.rs b/moli-protocol/src/domains/page/tests/runtime.rs index ab651a2095..1a1e48497d 100644 --- a/moli-protocol/src/domains/page/tests/runtime.rs +++ b/moli-protocol/src/domains/page/tests/runtime.rs @@ -1269,20 +1269,22 @@ async fn runtime_evaluate_child_history_back_emits_child_frame_navigation_and_li .route( "/main", axum::routing::get(|| async { - "" + axum::response::Html( + "", + ) }), ) .route( "/initial", - axum::routing::get(|| async { "initial" }), + axum::routing::get(|| async { axum::response::Html("initial") }), ) .route( "/history-a", - axum::routing::get(|| async { "history-a" }), + axum::routing::get(|| async { axum::response::Html("history-a") }), ) .route( "/history-b", - axum::routing::get(|| async { "history-b" }), + axum::routing::get(|| async { axum::response::Html("history-b") }), ), ) .await diff --git a/moli-protocol/src/domains/target/tests/tests_background_staging.rs b/moli-protocol/src/domains/target/tests/tests_background_staging.rs index e52b86f54e..49bebc255a 100644 --- a/moli-protocol/src/domains/target/tests/tests_background_staging.rs +++ b/moli-protocol/src/domains/target/tests/tests_background_staging.rs @@ -3037,9 +3037,9 @@ async fn same_context_background_session_can_clear_its_own_locale_before_activat .get(axum::http::header::ACCEPT_LANGUAGE) .and_then(|value| value.to_str().ok()) .unwrap_or(""); - format!( + axum::response::Html(format!( "" - ) + )) } let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -3204,7 +3204,9 @@ async fn same_context_background_session_can_clear_its_own_locale_before_activat #[tokio::test(flavor = "multi_thread")] async fn same_context_background_session_can_clear_its_own_timezone_before_activation() { async fn handler() -> impl IntoResponse { - "" + axum::response::Html( + "", + ) } let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/moli-protocol/src/domains/target/worker_target.rs b/moli-protocol/src/domains/target/worker_target.rs index 9d62ad617f..95999a72bc 100644 --- a/moli-protocol/src/domains/target/worker_target.rs +++ b/moli-protocol/src/domains/target/worker_target.rs @@ -765,6 +765,7 @@ fn register_dedicated_worker_target( false, None, &[], + true, ); if !events.is_empty() { outputs.push(WorkerTargetLifecycleOutput::DedicatedWorkerEvents { @@ -3369,6 +3370,7 @@ fn emit_service_worker_fetch_diagnostic_events( false, None, &[], + true, ); tag_service_worker_fetch_diagnostic_event(out.get_mut(request_event_index), diagnostic); diff --git a/moli-renderer-v8/src/document_runtime/mutation_commands.rs b/moli-renderer-v8/src/document_runtime/mutation_commands.rs index fb07b7ff08..5ac5815176 100644 --- a/moli-renderer-v8/src/document_runtime/mutation_commands.rs +++ b/moli-renderer-v8/src/document_runtime/mutation_commands.rs @@ -1812,7 +1812,7 @@ pub(super) fn finish_runtime_mutation_effects( owner = ?binding.owner(), element = ?binding.element(), load_delay_token = ?binding.load_delay_token(), - settled, + ?settled, "settled invalidated connected-style lease at mutation commit" ); } diff --git a/moli-renderer-v8/src/frame_owner_model.rs b/moli-renderer-v8/src/frame_owner_model.rs index 8418fe3108..0086c5aee8 100644 --- a/moli-renderer-v8/src/frame_owner_model.rs +++ b/moli-renderer-v8/src/frame_owner_model.rs @@ -75,7 +75,8 @@ pub(crate) use lifecycle_tasks::{ MainDocumentImageLoadDelayBinding, MainDocumentInteractiveLifecycleAction, MainDocumentMediaLoadDelayBinding, MainDocumentScriptLoadDelayKind, MainDocumentScriptLoadDelayLease, MainDocumentScriptLoadDelayRelease, - MainDocumentStyleLoadEventBinding, StylesheetSubresourceLoadDelayBinding, + MainDocumentStyleLoadEventBinding, MainDocumentStyleLoadEventSettlement, + StylesheetSubresourceLoadDelayBinding, }; pub(crate) use load_delivery_tasks::{ FrameDocumentLoadDeliveryAction, FrameDocumentLoadDeliveryAdmission, diff --git a/moli-renderer-v8/src/frame_owner_model/lifecycle_blockers.rs b/moli-renderer-v8/src/frame_owner_model/lifecycle_blockers.rs index f040b53749..55197a2331 100644 --- a/moli-renderer-v8/src/frame_owner_model/lifecycle_blockers.rs +++ b/moli-renderer-v8/src/frame_owner_model/lifecycle_blockers.rs @@ -94,6 +94,11 @@ impl DocumentLifecycleBlockers { self.window_load.clear(); } + pub(super) fn cancel_for_stop(&mut self) { + self.parser_deferred_scripts.clear(); + self.window_load.cancel(); + } + #[cfg(test)] pub(super) fn len(&self) -> usize { self.parser_deferred_scripts.len() + self.window_load.len() diff --git a/moli-renderer-v8/src/frame_owner_model/lifecycle_tasks.rs b/moli-renderer-v8/src/frame_owner_model/lifecycle_tasks.rs index 6a624156a0..5d122d995f 100644 --- a/moli-renderer-v8/src/frame_owner_model/lifecycle_tasks.rs +++ b/moli-renderer-v8/src/frame_owner_model/lifecycle_tasks.rs @@ -81,6 +81,13 @@ impl MainDocumentScriptLoadDelayLease { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum MainDocumentStyleLoadEventSettlement { + NotOwned, + Released, + CancelledAfterStop, +} + /// Exact main-document ownership for a connected ``; + document.body.innerHTML = '
'; + document.getElementById('existing').getBoundingClientRect().width; + globalThis.menu = document.createElement('div'); + menu.id = 'menu'; + globalThis.candidate = document.createElement('button'); + candidate.id = 'candidate'; + candidate.textContent = 'Abishek S abisubramanya27'; + menu.appendChild(candidate); + 'installed'; + "#, + ) + .expect("fixture and initial layout should evaluate"); + + let passes_after_initial_layout = vm.layout_pass_observability_for_test().1; + assert_eq!( + vm.eval("candidate.offsetParent === null && candidate.offsetWidth === 0") + .unwrap(), + "true", + "geometry for a detached candidate must remain empty" + ); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes_after_initial_layout, + "building and querying a detached subtree must not invalidate live layout" + ); + + vm.eval("document.body.appendChild(menu); 'connected'") + .expect("connected insertion should evaluate"); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes_after_initial_layout, + "the connected insertion must mark layout dirty without rebuilding it eagerly" + ); + + let result = vm + .eval( + r#" + (() => { + const parent = candidate.offsetParent; + const width = candidate.offsetWidth; + return JSON.stringify({ parentIsMenu: parent === menu, width }); + })() + "#, + ) + .expect("offsetParent-first geometry read should evaluate"); + assert_eq!( + result, r#"{"parentIsMenu":true,"width":240}"#, + "offsetParent must refresh a dirty tree before answering for the inserted element" + ); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes_after_initial_layout + 1, + "offsetParent and the following width read should share one fresh pass" + ); + + assert_eq!( + vm.eval("candidate.offsetParent === menu && candidate.offsetWidth === 240") + .unwrap(), + "true" + ); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes_after_initial_layout + 1, + "clean geometry reads must reuse the refreshed tree" + ); +} + +#[test] +fn geometry_reuses_clean_layout_after_detached_subtree_mutations() { + let mut vm = new_storage_test_vm("https://layout-detached.test/"); + vm.eval( + r#" + if (!document.documentElement) document.appendChild(document.createElement('html')); + if (!document.body) document.documentElement.appendChild(document.createElement('body')); + document.body.innerHTML = '
'; + globalThis.target = document.getElementById('target'); + globalThis.detached = document.createElement('div'); + target.getBoundingClientRect().width; + "#, + ) + .expect("fixture should evaluate"); + let passes = vm.layout_pass_observability_for_test().1; + for mutation in [ + "detached.textContent = 'not on the page'", + "detached.appendChild(document.createElement('span'))", + "detached.style.width = '300px'", + "detached.setAttribute('data-wide', '')", + "detached.firstChild.data = 'changed text'", + ] { + vm.eval(mutation) + .expect("detached mutation should evaluate"); + assert_eq!( + vm.eval("target.getBoundingClientRect().width").unwrap(), + "40" + ); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes, + "detached mutation must not invalidate page layout: {mutation}" + ); + } +} + +#[test] +fn geometry_refreshes_pseudo_class_inputs_on_demand() { + let mut vm = new_storage_test_vm("https://layout-state.test/"); + vm.eval(r#" + if (!document.documentElement) document.appendChild(document.createElement('html')); + if (!document.body) document.documentElement.appendChild(document.createElement('body')); + document.body.innerHTML = ''; + globalThis.target = document.getElementById('target'); + target.getBoundingClientRect().width; + "#).expect("fixture should evaluate"); + let mut passes = vm.layout_pass_observability_for_test().1; + for (mutation, expected) in [ + ("target.focus({preventScroll: true})", "80"), + ("target.checked = true", "120"), + ("target.checked = false", "80"), + ("target.blur()", "40"), + ] { + vm.eval(mutation).expect("element state should change"); + assert_eq!(vm.layout_pass_observability_for_test().1, passes); + assert_eq!( + vm.eval("target.getBoundingClientRect().width").unwrap(), + expected, + "geometry must reflect changed pseudo class: {mutation}" + ); + passes += 1; + assert_eq!(vm.layout_pass_observability_for_test().1, passes); + assert_eq!( + vm.eval("target.getBoundingClientRect().width").unwrap(), + expected + ); + assert_eq!(vm.layout_pass_observability_for_test().1, passes); + } +} + +#[test] +fn geometry_refreshes_attribute_selectors_and_cssom_on_demand() { + let mut vm = new_storage_test_vm("https://layout-inputs.test/"); + vm.eval(r#" + if (!document.documentElement) document.appendChild(document.createElement('html')); + if (!document.body) document.documentElement.appendChild(document.createElement('body')); + document.body.innerHTML = '
'; + globalThis.target = document.getElementById('target'); + target.getBoundingClientRect().width; + "#).expect("fixture should evaluate"); + let mut passes = vm.layout_pass_observability_for_test().1; + for (mutation, expected) in [ + ("target.setAttribute('data-wide', '')", "80"), + ( + "document.getElementById('rules').sheet.insertRule('#target[data-wide] { width: 120px }', 2)", + "120", + ), + ("target.removeAttribute('data-wide')", "40"), + ] { + vm.eval(mutation).expect("layout input should change"); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes, + "input changes must not eagerly lay out: {mutation}" + ); + assert_eq!( + vm.eval("target.getBoundingClientRect().width").unwrap(), + expected + ); + passes += 1; + assert_eq!(vm.layout_pass_observability_for_test().1, passes); + assert_eq!( + vm.eval("target.getBoundingClientRect().width").unwrap(), + expected + ); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes, + "clean geometry must be reusable across script turns" + ); + } +} + +#[test] +fn geometry_refreshes_inserted_and_existing_nodes_on_demand() { + let mut vm = new_storage_test_vm("https://layout-mutation.test/"); + + vm.eval( + r#" + (() => { + if (!document.documentElement) { + document.appendChild(document.createElement("html")); + } + if (!document.body) { + document.documentElement.appendChild(document.createElement("body")); + } + document.body.innerHTML = '
Marker
'; + return document.getElementById("marker").getBoundingClientRect().top; + })() + "#, + ) + .expect("initial layout read should evaluate"); + + let before_top = vm + .eval("document.getElementById('marker').getBoundingClientRect().top") + .expect("initial marker geometry should evaluate") + .parse::() + .expect("initial marker top should be numeric"); + + let passes_before = vm.layout_pass_observability_for_test().1; + vm.eval( + r#"document.getElementById("content").insertAdjacentHTML( + "afterbegin", + '
' + )"#, + ) + .expect("connected insertion should evaluate"); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes_before, + "a DOM mutation alone must not run layout" + ); + assert!( + vm.layout_snapshot_cache_observability_for_test() + .3 + .is_some(), + "mutation should retain the sampled frozen tree" + ); + + let result = vm + .eval( + r#" + (() => { + const rect = document.getElementById("target").getBoundingClientRect(); + const marker = document.getElementById("marker").getBoundingClientRect(); + return JSON.stringify({ target: [rect.width, rect.height], markerTop: marker.top }); + })() + "#, + ) + .expect("geometry after a connected insertion should evaluate"); + + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes_before + 1, + "all post-mutation geometry reads should share one fresh pass" + ); + + let result: serde_json::Value = + serde_json::from_str(&result).expect("geometry result should be JSON"); + assert!( + result["target"][0] + .as_f64() + .is_some_and(|width| width > 0.0) + && result["target"][1] + .as_f64() + .is_some_and(|height| height > 0.0), + "inserted content should receive real geometry: {result}" + ); + assert!( + result["markerTop"] + .as_f64() + .is_some_and(|top| top > before_top), + "existing geometry must not be reused after a preceding sibling is inserted: {result}" + ); + vm.eval("const content = document.getElementById('content'); content.firstElementChild.remove(); content.firstElementChild.remove()") + .expect("connected removals should evaluate"); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes_before + 1, + "removal itself must not run layout" + ); + assert_eq!( + vm.eval("document.getElementById('marker').getBoundingClientRect().top") + .unwrap() + .parse::() + .unwrap(), + before_top, + "removing previously connected siblings must invalidate old geometry" + ); + assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 2); +} + #[test] fn svg_create_rect_supports_capability_detection_and_detached_float_values() { let mut vm = new_storage_test_vm("https://svg-rect.test/"); diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/computed_style.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/computed_style.rs index f33190fe43..9a996b56d1 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/computed_style.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/computed_style.rs @@ -47,6 +47,191 @@ fn content_alt_counters_compute_from_stylesheets_variables_and_cssom_updates() { ); } +#[test] +fn root_font_relative_units_follow_each_documents_live_root_style() { + let mut vm = new_parsed_test_vm( + "https://root-font-relative-units.test/", + r#" + + +
+ + "#, + ); + + let result = vm + .eval( + r#" +(() => { + const root = document.documentElement; + const target = document.getElementById('target'); + const values = () => { + const targetStyle = getComputedStyle(target); + const targetValues = [targetStyle.width, targetStyle.height, targetStyle.fontSize]; + const rootFontSize = getComputedStyle(root).fontSize; + return [rootFontSize, ...targetValues].join(','); + }; + const initial = values(); + + root.style.fontSize = '20px'; + const updated = values(); + + root.style.fontSize = '0.625rem'; + const selfRelative = values(); + + root.style.lineHeight = '3'; + const updatedLineHeight = values(); + root.style.lineHeight = '2'; + + const frame = document.createElement('iframe'); + document.body.appendChild(frame); + const childDocument = frame.contentWindow.document; + childDocument.open(); + childDocument.write('
'); + childDocument.close(); + const childWidth = frame.contentWindow + .getComputedStyle(childDocument.getElementById('child')) + .width; + + return [initial, updated, selfRelative, updatedLineHeight, childWidth, values()].join('|'); +})() +"#, + ) + .expect("root font-relative units should track isolated live root styles"); + + assert_eq!( + result, + "10px,88px,20px,14px|20px,176px,40px,28px|10px,88px,20px,14px|10px,88px,30px,14px|70px|10px,88px,20px,14px" + ); +} + +#[test] +fn replacing_document_root_replaces_retained_root_font_state() { + let mut vm = new_parsed_test_vm( + "https://replaced-root-font-state.test/", + r#" +
+ "#, + ); + + let initial = vm + .eval("getComputedStyle(document.getElementById('target')).width") + .expect("initial descendant should resolve root-relative width"); + assert_eq!(initial, "20px"); + + let replaced = vm + .eval( + r#" +(() => { + const nextRoot = document.createElement('html'); + nextRoot.style.fontSize = '7px'; + const nextBody = document.createElement('body'); + const nextTarget = document.createElement('div'); + nextTarget.id = 'next-target'; + nextTarget.style.width = '2rem'; + nextBody.appendChild(nextTarget); + nextRoot.appendChild(nextBody); + document.replaceChild(nextRoot, document.documentElement); + return getComputedStyle(nextTarget).width; +})() +"#, + ) + .expect("new descendant should replace the retained root state"); + assert_eq!(replaced, "14px"); + + vm.set_viewport_surface(Some(crate::protocol_types::ViewportSurface { + inner_width: 800, + inner_height: 600, + outer_width: 800, + outer_height: 600, + device_pixel_ratio: 1.0, + screen_width: 1920, + screen_height: 1080, + screen_avail_width: 1920, + screen_avail_height: 1040, + ..Default::default() + })) + .expect("viewport device should rebuild after root replacement"); + let rebuilt = vm + .eval("getComputedStyle(document.getElementById('next-target')).width") + .expect("rebuilt device should retain the replacement root state"); + assert_eq!(rebuilt, "14px"); +} + +#[test] +fn root_font_relative_units_survive_viewport_device_rebuilds() { + let mut vm = new_parsed_test_vm( + "https://root-rem-device-rebuild.test/", + "", + ); + let surface = |inner_width| crate::protocol_types::ViewportSurface { + inner_width, + inner_height: 600, + outer_width: inner_width, + outer_height: 600, + device_pixel_ratio: 1.0, + screen_width: 1920, + screen_height: 1080, + screen_avail_width: 1920, + screen_avail_height: 1040, + ..Default::default() + }; + vm.set_viewport_surface(Some(surface(1000))) + .expect("initial viewport should update"); + + let wide = vm + .eval( + r#" +(() => { + const style = document.createElement('style'); + style.textContent = ` + html { font-size: 10px; } + @media (min-width: 800px) { html { font-size: 20px; } } + #root-rem-target { width: 2rem; } + `; + document.head.appendChild(style); + const target = document.createElement('div'); + target.id = 'root-rem-target'; + document.body.appendChild(target); + globalThis.__rootRemTarget = target; + const width = getComputedStyle(target).width; + return [width, getComputedStyle(document.documentElement).fontSize].join('|'); +})() +"#, + ) + .expect("wide root rem styles should resolve"); + assert_eq!(wide, "40px|20px"); + + vm.set_viewport_surface(Some(surface(500))) + .expect("narrow viewport should update"); + let narrow = vm + .eval( + "[getComputedStyle(__rootRemTarget).width, getComputedStyle(document.documentElement).fontSize].join('|')", + ) + .expect("narrow root rem styles should resolve"); + assert_eq!(narrow, "20px|10px"); + + vm.set_viewport_surface(Some(surface(1000))) + .expect("restored viewport should update"); + let restored = vm + .eval( + "[getComputedStyle(__rootRemTarget).width, getComputedStyle(document.documentElement).fontSize].join('|')", + ) + .expect("restored root rem styles should resolve"); + assert_eq!(restored, "40px|20px"); + + vm.eval("document.documentElement.style.fontSize = '30px'; true") + .expect("pending root font mutation should apply"); + vm.set_viewport_surface(Some(surface(900))) + .expect("device should rebuild while the root mutation is pending"); + let pending_mutation = vm + .eval( + "[getComputedStyle(__rootRemTarget).width, getComputedStyle(document.documentElement).fontSize].join('|')", + ) + .expect("descendant should observe the pending root mutation after device rebuild"); + assert_eq!(pending_mutation, "60px|30px"); +} + #[test] fn element_current_css_zoom_observes_fresh_effective_style_for_rendered_boxes() { let mut vm = new_parsed_test_vm( diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom.rs index 08d2bdb924..2b67228d69 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom.rs @@ -8697,8 +8697,11 @@ for (let index = 0; index < 128; index++) { ); assert_eq!(layout_after_first.1, layout_before.1 + 1); assert_eq!(layout_after_repeated.1, layout_after_first.1); - assert_eq!(layout_after_second.1, layout_after_first.1); - assert_eq!(layout_after_stylesheet_mutation.1, layout_after_first.1); + assert_eq!(layout_after_second.1, layout_after_first.1 + 1); + assert_eq!( + layout_after_stylesheet_mutation.1, + layout_after_second.1 + 1 + ); assert_eq!( update_materializations_after_first.saturating_sub(update_materializations_before), 1, @@ -8765,13 +8768,25 @@ fn inner_text_new_sources_wait_for_a_fresh_paint_layout() { ); assert_eq!(vm.layout_pass_observability_for_test().1, passes_before); + vm.eval("const added = document.createElement('span'); added.textContent = 'b'; target.append(added)") + .expect("text insertion should evaluate"); assert_eq!( - vm.eval( - "const added = document.createElement('span'); added.textContent = 'b'; target.append(added); target.innerText", - ) - .expect("the warm innerText read should evaluate"), - "a", - "a text source absent from the latest frozen layout tree remains unrendered until refresh" + vm.layout_pass_observability_for_test().1, + passes_before + 1, + "text insertion alone must not run layout" + ); + assert_eq!( + vm.eval("target.innerText") + .expect("the warm innerText read should evaluate"), + "ab", + "innerText must sample newly connected text without requiring a screenshot" + ); + assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 2); + assert_eq!(vm.eval("target.innerText").unwrap(), "ab"); + assert_eq!( + vm.layout_pass_observability_for_test().1, + passes_before + 2, + "repeated innerText should reuse clean geometry across turns" ); assert_eq!(vm.layout_pass_observability_for_test().1, passes_before); 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 3d9c7a22d4..468500dacc 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 @@ -2,6 +2,7 @@ use super::*; mod dialog; mod event_activation; +mod named_lookup; #[test] fn object_set_custom_validity_updates_validity_state() { diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms/named_lookup.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms/named_lookup.rs new file mode 100644 index 0000000000..b5177adadf --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms/named_lookup.rs @@ -0,0 +1,255 @@ +use super::*; + +#[test] +fn form_named_lookup_misses_do_not_enumerate_or_traverse_controls() { + let mut vm = new_parsed_test_vm( + "https://form-lookup-work.test/", + "", + ); + vm.exec( + r#" + globalThis.lookupForms = []; + for (let i = 0; i < 32; ++i) { + const form = document.body.appendChild(document.createElement('form')); + form.innerHTML = '
text
'; + lookupForms.push(form); + } + lookupForms[0].absentLookupKey; + "#, + None, + ) + .expect("prepare named lookup workload"); + crate::native_bridge::element::take_form_lookup_work_for_test(); + let result = vm + .eval( + r#" + (() => { + for (const form of lookupForms) { + for (let i = 0; i < 8; ++i) { + if (form.absentLookupKey !== undefined) throw Error('getter'); + if ('absentLookupKey' in form) throw Error('query'); + if (Object.getOwnPropertyDescriptor(form, 'absentLookupKey') !== undefined) + throw Error('descriptor'); + } + } + return 'ok'; + })() + "#, + ) + .expect("misses should preserve ordinary property semantics"); + assert_eq!(result, "ok"); + assert_eq!( + crate::native_bridge::element::take_form_lookup_work_for_test(), + (0, 0) + ); + + // Positive control: the counter must observe real supported-property work. + assert_eq!(vm.eval("lookupForms[0].present.tagName").unwrap(), "INPUT"); + let (traversals, enumerations) = + crate::native_bridge::element::take_form_lookup_work_for_test(); + assert!(traversals > 0); + assert_eq!(enumerations, 0); + vm.eval("Object.getOwnPropertyNames(lookupForms[0]).length") + .unwrap(); + assert!(crate::native_bridge::element::take_form_lookup_work_for_test().1 > 0); +} + +#[test] +fn form_named_lookup_uncontested_own_properties_do_not_invoke_accessors_during_queries() { + let mut vm = new_parsed_test_vm( + "https://form-lookup-expando.test/", + "", + ); + let result = vm.eval(r#" + (() => { + const f = document.body.appendChild(document.createElement('form')); + let reads = 0; + let writes = 0; + Object.defineProperty(f, 'ownData', {value: 17, writable: true, configurable: true}); + Object.defineProperty(f, 'ownAccessor', { + get() { ++reads; return 23; }, set(v) { writes += v; }, configurable: true + }); + f.innerHTML = ''; + const proto = Object.create(Object.getPrototypeOf(f)); + Object.defineProperty(proto, 'protoKey', {value: 'prototype'}); + Object.setPrototypeOf(f, proto); + if (!('ownAccessor' in f) || reads !== 0) throw Error('query called getter'); + const descriptor = Object.getOwnPropertyDescriptor(f, 'ownAccessor'); + if (typeof descriptor.get !== 'function' || reads !== 0) throw Error('descriptor called getter'); + if (f.ownAccessor !== 23 || reads !== 1) throw Error('accessor value'); + f.ownAccessor = 3; + if (writes !== 3 || reads !== 1) throw Error('accessor setter'); + if (f.ownData !== 17 || f.protoKey !== 'prototype') throw Error('own/prototype values'); + if (Object.getOwnPropertyDescriptor(f, 'protoKey') !== undefined) throw Error('prototype is not own'); + if (!delete f.ownData || f.ownData !== undefined) throw Error('ordinary data deletion'); + if (!delete f.ownAccessor || f.ownAccessor !== undefined || reads !== 1) + throw Error('ordinary accessor deletion'); + return 'ok'; + })() + "#).expect("own properties must not be confused with intercepted or inherited properties"); + assert_eq!(result, "ok"); +} + +#[test] +fn form_named_lookup_keeps_native_overrides_descriptors_and_index_boundaries() { + let mut vm = new_parsed_test_vm( + "https://form-lookup-boundaries.test/", + r#"
"#, + ); + let result = vm.eval(r#" + (() => { + const f = document.getElementById('f'); + const inputs = document.querySelectorAll('input'); + for (let i = 0; i < inputs.length; ++i) { + const name = inputs[i].name; + if (f[name] !== inputs[i] || !(name in f)) throw Error('native or named override: ' + name); + if (Object.getOwnPropertyDescriptor(f, name).value !== inputs[i]) throw Error('descriptor'); + } + if (f[0] !== inputs[0] || f['01'] !== inputs[5]) throw Error('index routing'); + const symbol = Symbol('plain'); + f[symbol] = 9; + if (f[symbol] !== 9 || !delete f[symbol]) throw Error('symbol'); + return 'ok'; + })() + "#).expect("named overrides and property operations should keep their existing contract"); + assert_eq!(result, "ok"); +} + +#[test] +fn form_named_lookup_index_misses_become_hits_and_preserve_past_names() { + let mut vm = new_parsed_test_vm( + "https://form-lookup-mutations.test/", + r#"
"#, + ); + let result = vm + .eval( + r#" + (() => { + const f = document.getElementById('owner'); + const other = document.getElementById('other'); + if (f.lateKey !== undefined || 'lateKey' in f) throw Error('initial miss'); + const input = document.createElement('input'); + input.name = 'lateKey'; + f.appendChild(input); + if (f.lateKey !== input) throw Error('insert after miss'); + input.name = 'renamedKey'; + if (f.renamedKey !== input || f.lateKey !== input) throw Error('rename/past name'); + input.removeAttribute('name'); + if (f.lateKey !== input) throw Error('global index miss discarded past name'); + other.appendChild(input); + if (f.lateKey !== undefined) throw Error('past owner invalidation'); + input.id = 'lateId'; + input.setAttribute('form', 'owner'); + if (f.lateId !== input || other.lateId !== undefined) throw Error('external form owner'); + input.removeAttribute('id'); + input.setAttribute('form', 'missing'); + if (f.lateId !== undefined) throw Error('removed id and owner'); + const decoy = document.body.appendChild(document.createElement('div')); + decoy.id = 'decoyKey'; + if (f.decoyKey !== undefined) throw Error('global hit must not bypass control predicate'); + const img = f.appendChild(document.createElement('img')); + img.name = 'imageKey'; + if (f.imageKey !== img) throw Error('image fallback'); + const real = f.appendChild(document.createElement('input')); + real.name = 'imageKey'; + if (f.imageKey !== real) throw Error('control wins over image'); + return 'ok'; + })() + "#, + ) + .expect("named lookup must remain live without caching misses"); + assert_eq!(result, "ok"); +} + +#[test] +fn form_named_lookup_candidates_include_detached_shadow_and_child_documents() { + let mut vm = new_parsed_test_vm( + "https://form-lookup-scopes.test/", + "", + ); + let result = vm.eval(r#" + (() => { + function check(doc, parent, key) { + const form = doc.createElement('form'); + if (form[key] !== undefined) throw Error('initial scope miss'); + const input = doc.createElement('input'); + input.name = key; + form.appendChild(input); + if (parent) parent.appendChild(form); + if (form[key] !== input || !(key in form) || Object.getOwnPropertyDescriptor(form, key).value !== input) + throw Error('scope candidate lost: ' + key); + return form; + } + check(document, null, 'detachedKey'); + const host = document.body.appendChild(document.createElement('div')); + const shadow = host.attachShadow({mode: 'closed'}); + check(document, shadow, 'shadowKey'); + const child = document.body.appendChild(document.createElement('iframe')).contentWindow; + const childForm = check(child.document, child.document.body, 'childKey'); + if (Object.getPrototypeOf(childForm.childKey) !== child.HTMLInputElement.prototype) throw Error('child realm'); + return 'ok'; + })() + "#).expect("global candidate miss check must include every document and tree scope"); + assert_eq!(result, "ok"); +} + +#[test] +#[ignore = "known baseline detached-document named-property limitation, including adopted wrappers"] +fn form_named_lookup_detached_document_and_adopted_wrappers() { + let mut vm = new_parsed_test_vm( + "https://form-lookup-detached-document.test/", + "", + ); + // These expectations pass in Chromium. Both cases already fail on the + // pre-optimization Moli binary; keep them visible without treating that + // separate compatibility defect as a performance regression or a pass. + let result = vm.eval(r#" + (() => { + const results=[]; + for (const adopted of [false,true]) { + const doc=document.implementation.createHTMLDocument('detached'); + const form=doc.body.appendChild(doc.createElement('form')); + if (form.otherDocumentKey !== undefined) throw Error('initial miss'); + const input=form.appendChild(doc.createElement('input')); + input.name='otherDocumentKey'; + if (adopted) document.body.appendChild(document.adoptNode(form)); + const d=Object.getOwnPropertyDescriptor(form,'otherDocumentKey'); + results.push(form.otherDocumentKey===input, 'otherDocumentKey' in form, !!d&&d.value===input); + } + if (!results.every(Boolean)) throw Error('detached-document or adopted named property unavailable'); + return 'ok'; + })() + "#).expect("detached-document wrappers should expose named controls before and after adoption"); + assert_eq!(result, "ok"); +} + +#[test] +fn form_named_lookup_candidates_do_not_cache_custom_element_eligibility() { + let mut vm = new_parsed_test_vm( + "https://form-lookup-custom.test/", + r#"
"#, + ); + let result = vm.eval(r#" + (() => { + const f = document.getElementById('owner'); + if (f.faceKey !== undefined) throw Error('initial miss'); + const face = document.createElement('named-lookup-face'); + face.setAttribute('name', 'faceKey'); + f.appendChild(face); + if (f.faceKey !== undefined) throw Error('unregistered candidate'); + customElements.define('named-lookup-face', class extends HTMLElement { + static formAssociated = true; + constructor() { super(); this.attachInternals(); } + }); + if (f.faceKey !== face || !('faceKey' in f) || Object.getOwnPropertyDescriptor(f, 'faceKey').value !== face) + throw Error('upgrade must change eligibility without id/name mutation'); + const notFace = document.createElement('named-lookup-ordinary'); + notFace.setAttribute('name', 'ordinaryKey'); + f.appendChild(notFace); + customElements.define('named-lookup-ordinary', class extends HTMLElement {}); + if (f.ordinaryKey !== undefined) throw Error('non form-associated custom element'); + return 'ok'; + })() + "#).expect("named candidates are not a cached form eligibility decision"); + assert_eq!(result, "ok"); +} diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs index 3a2ea2cd98..25128bf4d2 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs @@ -1464,7 +1464,7 @@ return [ } #[test] -fn geometry_getters_reuse_latest_layout_across_nodes_and_mutation() { +fn geometry_getters_refresh_after_mutation_and_reuse_clean_layout() { let mut vm = new_storage_test_vm("https://oneshot-layout-demand.test/"); let passes_before = vm ._context_host @@ -1514,7 +1514,7 @@ yield; // Publish this scene before reading its geometry. ) .expect("latest layout snapshot reads should evaluate"); - assert_eq!(result, "38|8|38|8|38|38|0"); + assert_eq!(result, "38|8|38|8|38|43|8"); let passes = vm ._context_host .borrow() diff --git a/moli-renderer-v8/src/style_engine/computed.rs b/moli-renderer-v8/src/style_engine/computed.rs index 7161ee4414..3443630189 100644 --- a/moli-renderer-v8/src/style_engine/computed.rs +++ b/moli-renderer-v8/src/style_engine/computed.rs @@ -4,6 +4,7 @@ use dom::ElementState as StyloElementState; use style::{ Atom, animation::DocumentAnimationSet, + computed_value_flags::ComputedValueFlags, context::{ QuirksMode, RegisteredSpeculativePainter, RegisteredSpeculativePainters, SharedStyleContext, StyleContext, StyleSystemOptions, ThreadLocalStyleContext, @@ -49,6 +50,7 @@ use super::{ cache::ComputedElementStyleCacheKey, document_world::DocumentStyleWorld, lazy_invalidation::StyleValidationPathEntry, + retained::synchronize_root_style_device, source_lifecycle::StyleSourceDocumentContext, world_key::StyleWorldKey, world_lifecycle::{ @@ -928,12 +930,13 @@ fn resolve_element_styles( shared: &shared, thread_local: &mut thread_local, }; - let ancestor_resolution_count = materialize_ancestor_styles_for_resolution( - &mut context, - element, - world, - validation_path, - ); + let (ancestor_resolution_count, mut resolved_root_style) = + materialize_ancestor_styles_for_resolution( + &mut context, + element, + world, + validation_path, + ); let target = DomHandle::new(element.as_node().debug_id()); world.pseudo_style_cache.invalidate_handles([target]); // `resolve_style` is the single-node initial-style API. Keep dirty @@ -949,6 +952,14 @@ fn resolve_element_styles( pseudo_element, None, ); + if let Some(root_style) = styles.primary.as_ref().filter(|computed| { + computed + .flags + .contains(ComputedValueFlags::IS_ROOT_ELEMENT_STYLE) + }) { + synchronize_root_style_device(context.shared.stylist.device(), root_style); + resolved_root_style = Some(root_style.clone()); + } unsafe { let mut data = element.ensure_data(); data.styles = styles.clone(); @@ -970,6 +981,9 @@ fn resolve_element_styles( world .document_state .note_element_style_resolutions(ancestor_resolution_count.saturating_add(1)); + if let Some(root_style) = resolved_root_style { + *retained.root_style.borrow_mut() = Some(root_style); + } styles }); world @@ -983,7 +997,7 @@ fn materialize_ancestor_styles_for_resolution( element: E, world: &DocumentStyleWorld, validation_path: Option<&[StyleValidationPathEntry]>, -) -> u64 +) -> (u64, Option>) where E: TElement, { @@ -1010,6 +1024,7 @@ where .note_ancestor_style_validation_visits(ancestors.len() as u64); let mut resolution_count = 0_u64; + let mut resolved_root_style = None; let mut force_descendants = false; for (ancestor, required_generation) in ancestors { let handle = DomHandle::new(ancestor.as_node().debug_id()); @@ -1028,6 +1043,14 @@ where ancestor.ensure_data().styles = ElementStyles::default(); } let styles = resolve_style(context, ancestor, RuleInclusion::All, None, None); + if let Some(root_style) = styles.primary.as_ref().filter(|computed| { + computed + .flags + .contains(ComputedValueFlags::IS_ROOT_ELEMENT_STYLE) + }) { + synchronize_root_style_device(context.shared.stylist.device(), root_style); + resolved_root_style = Some(root_style.clone()); + } resolution_count = resolution_count.saturating_add(1); unsafe { let mut data = ancestor.ensure_data(); @@ -1040,7 +1063,7 @@ where .mark_element_current(handle, required_generation); force_descendants = true; } - resolution_count + (resolution_count, resolved_root_style) } /// Maps the memoized DOM path suffix back to Stylo ancestors. diff --git a/moli-renderer-v8/src/style_engine/retained.rs b/moli-renderer-v8/src/style_engine/retained.rs index 51fd1b51c8..f860090395 100644 --- a/moli-renderer-v8/src/style_engine/retained.rs +++ b/moli-renderer-v8/src/style_engine/retained.rs @@ -2,7 +2,9 @@ use std::collections::HashSet; use std::sync::atomic::{AtomicU64, Ordering}; use style::{ author_styles::AuthorStyles, + device::Device, invalidation::stylesheets::StylesheetInvalidationSet, + properties::ComputedValues, servo_arc::Arc as ServoArc, shared_lock::{SharedRwLock, StylesheetGuards}, stylesheets::{CustomMediaMap, DocumentStyleSheet, Origin, OriginSet, UrlExtraData}, @@ -165,6 +167,7 @@ pub(super) fn build_retained_style_system( stylist_identity: NEXT_STYLIST_IDENTITY.fetch_add(1, Ordering::Relaxed), key, stylist, + root_style: Default::default(), document_stylesheets, shadow_scopes, stylesheet_resources, @@ -383,6 +386,9 @@ fn update_document_scope( if device_changed { let device = new_style_device_with_viewport(key.style_viewport(), key.environment, key.quirks_mode); + if let Some(root_style) = retained.root_style.borrow().as_ref() { + synchronize_root_style_device(&device, root_style); + } let guard = shared_lock.read(); let guards = StylesheetGuards::same(&guard); device_affected_origins = retained.stylist.set_device(device, &guards); @@ -437,6 +443,32 @@ fn update_document_scope( } } +/// Synchronizes the state Stylo uses for root font-relative units. +/// +/// Full Stylo traversal performs this in `matching.rs`, but Moli deliberately +/// uses the single-node `resolve_style` API and therefore must mirror the +/// update when the root element is materialized. Keeping this helper at the +/// Device boundary also lets a rebuilt Device inherit the current root state +/// before any descendant is resolved against it. +pub(super) fn synchronize_root_style_device( + device: &Device, + root_style: &ServoArc, +) { + device.set_root_style(root_style); + let font = root_style.get_font(); + let font_size = font.clone_font_size().computed_size(); + device.set_root_font_size(root_style.effective_zoom.unzoom(font_size.px())); + + let line_height = device + .calc_line_height(font, root_style.writing_mode, None) + .0; + device.set_root_line_height(root_style.effective_zoom.unzoom(line_height.px())); + + if device.used_root_font_metrics() { + device.update_root_font_metrics(); + } +} + fn refresh_retained_derived_state( retained: &mut RetainedStyleSystem, device_changed: bool, diff --git a/moli-renderer-v8/src/style_engine/state.rs b/moli-renderer-v8/src/style_engine/state.rs index 6bb6016f8d..c6cdcc37fe 100644 --- a/moli-renderer-v8/src/style_engine/state.rs +++ b/moli-renderer-v8/src/style_engine/state.rs @@ -6,6 +6,7 @@ use std::{ use moli_selector::StyloSourceInvalidationFallbackReason; use selectors::context::SelectorCaches; use style::{ + properties::ComputedValues, servo_arc::Arc as ServoArc, stylist::{CascadeData, Stylist}, }; @@ -31,6 +32,11 @@ pub(super) struct RetainedStyleSystem { pub(super) stylist_identity: u64, pub(super) key: StyleWorldKey, pub(super) stylist: Stylist, + /// Last computed style for this Document's root element. Stylo normally + /// updates the Device's root-relative unit state during a full traversal; + /// Moli resolves demanded paths one element at a time, so retain the root + /// style explicitly both for those resolutions and for Device rebuilds. + pub(super) root_style: RefCell>>, pub(super) document_stylesheets: ActiveStylesheetCollection, pub(super) shadow_scopes: Vec, pub(super) stylesheet_resources: StylesheetResourceManifest, diff --git a/moli-renderer-v8/src/stylesheet_runtime/connected.rs b/moli-renderer-v8/src/stylesheet_runtime/connected.rs index 36eb0782a1..e77a561866 100644 --- a/moli-renderer-v8/src/stylesheet_runtime/connected.rs +++ b/moli-renderer-v8/src/stylesheet_runtime/connected.rs @@ -550,11 +550,11 @@ impl DocumentRuntime { owner = ?binding.owner(), element = ?binding.element(), load_delay_token = ?binding.load_delay_token(), - settled, + ?settled, reason, "settled main connected style load before event posting" ); - settled + settled != crate::frame_owner_model::MainDocumentStyleLoadEventSettlement::NotOwned } fn settle_connected_style_load_admission( diff --git a/moli/src/fetch_dump/tests.rs b/moli/src/fetch_dump/tests.rs index a1599c9890..e9ed136c6b 100644 --- a/moli/src/fetch_dump/tests.rs +++ b/moli/src/fetch_dump/tests.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use anyhow::Result; -use axum::{Router, routing::get}; +use axum::{Router, response::Html, routing::get}; use moli_core::{ LayoutPolicy, page::Page, @@ -36,7 +36,7 @@ async fn load_page_with_config( "/", get(move || { let body = Arc::clone(&server_body); - async move { (*body).clone() } + async move { Html((*body).clone()) } }), ); axum::serve(listener, app).await.unwrap(); diff --git a/moli/src/telemetry.rs b/moli/src/telemetry.rs index 830307bc79..97a30d277e 100644 --- a/moli/src/telemetry.rs +++ b/moli/src/telemetry.rs @@ -19,6 +19,7 @@ pub fn init(log_filter: &str) { .with_env_filter(filter) .with_target(false) .with_writer(std::io::stderr) + .log_internal_errors(false) .try_init(); } @@ -39,6 +40,9 @@ mod tests { struct SharedWriterGuard(Arc>>); + #[derive(Clone, Copy)] + struct BrokenWriter; + impl Write for SharedWriterGuard { fn write(&mut self, buffer: &[u8]) -> io::Result { self.0.lock().extend_from_slice(buffer); @@ -50,6 +54,30 @@ mod tests { } } + impl Write for BrokenWriter { + fn write(&mut self, _buffer: &[u8]) -> io::Result { + Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "diagnostic sink is gone", + )) + } + + fn flush(&mut self) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "diagnostic sink is gone", + )) + } + } + + impl<'writer> MakeWriter<'writer> for BrokenWriter { + type Writer = Self; + + fn make_writer(&'writer self) -> Self::Writer { + *self + } + } + impl<'writer> MakeWriter<'writer> for SharedWriter { type Writer = SharedWriterGuard; @@ -107,4 +135,20 @@ mod tests { assert!(output.contains("fallback-info")); assert!(!output.contains("dependency-warning")); } + + #[test] + fn broken_diagnostic_writer_does_not_panic() { + let subscriber = tracing_subscriber::fmt() + .without_time() + .with_ansi(false) + .with_target(false) + .with_writer(BrokenWriter) + .with_env_filter(first_party_filter("error")) + .log_internal_errors(false) + .finish(); + + tracing::subscriber::with_default(subscriber, || { + tracing::error!("broken diagnostic writer regression"); + }); + } } diff --git a/moli/tests/fetch_cli.rs b/moli/tests/fetch_cli.rs index 663aca3f5a..9dae576c9c 100644 --- a/moli/tests/fetch_cli.rs +++ b/moli/tests/fetch_cli.rs @@ -1315,6 +1315,73 @@ fn cli_dump_html_keeps_uncaught_script_errors_out_of_stdout() -> Result<()> { Ok(()) } +#[test] +fn cli_uncaught_script_error_is_logged_to_writable_stderr() -> Result<()> { + let output = Command::new(env!("CARGO_BIN_EXE_moli")) + .args([ + "fetch", + "--log-level", + "error", + "--wait-until", + "load", + "--timeout", + "5000", + "--dump", + "html", + "data:text/html,after", + ]) + .output()?; + + let stdout = clean_output(&output.stdout); + let stderr = clean_output(&output.stderr); + assert!(output.status.success(), "stdout={stdout}\nstderr={stderr}"); + assert!(stdout.contains("
after
")); + assert!(stderr.contains("Uncaught Error: stderr-control")); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn cli_uncaught_script_error_survives_broken_stderr_pipe() -> Result<()> { + use std::{fs::File, os::fd::FromRawFd, process::Stdio}; + + let mut pipe_fds = [0; 2]; + // SAFETY: `pipe_fds` points to two writable integers. Each returned file + // descriptor is transferred to exactly one owner or closed below. + if unsafe { libc::pipe(pipe_fds.as_mut_ptr()) } != 0 { + return Err(std::io::Error::last_os_error().into()); + } + // Closing the read endpoint makes the child's first diagnostic write + // return BrokenPipe, matching a launcher whose stderr reader disappeared. + unsafe { libc::close(pipe_fds[0]) }; + // SAFETY: the write descriptor is valid and ownership moves into `File`. + let broken_stderr = unsafe { File::from_raw_fd(pipe_fds[1]) }; + let output = Command::new(env!("CARGO_BIN_EXE_moli")) + .args([ + "fetch", + "--log-level", + "error", + "--wait-until", + "load", + "--timeout", + "5000", + "--dump", + "html", + "data:text/html,after", + ]) + .stderr(Stdio::from(broken_stderr)) + .output()?; + + let stdout = clean_output(&output.stdout); + assert_eq!( + output.status.code(), + Some(0), + "broken diagnostic pipe must not abort the browser: stdout={stdout}" + ); + assert!(stdout.contains("
after
")); + Ok(()) +} + #[test] fn cli_http_cache_dir_reuses_cached_response_across_processes() -> Result<()> { let runtime = tokio::runtime::Runtime::new()?; From c3cb5212a87d30744484d966d02b7b8b825dd5fd Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 17 Sep 2026 16:32:24 +0800 Subject: [PATCH 02/23] test(parser): remove unnecessary mutable binding --- moli-parser/src/html.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/moli-parser/src/html.rs b/moli-parser/src/html.rs index 2c930383d8..b42ec12cdd 100644 --- a/moli-parser/src/html.rs +++ b/moli-parser/src/html.rs @@ -1849,7 +1849,7 @@ mod tests { fn text_document_stream_preserves_literal_markup_and_leading_newline() { let payload = "\nGülçek&
"; for content_type in ["text/plain", "application/json", "application/problem+json"] { - let mut stream = HtmlParser::SCRIPTING_ENABLED.start_text_document( + let stream = HtmlParser::SCRIPTING_ENABLED.start_text_document( Url::parse("https://example.test/data").unwrap(), content_type, ); From 610e33373acbbbc6b1404165639e60fda3e7f1b3 Mon Sep 17 00:00:00 2001 From: kevin Date: Fri, 18 Sep 2026 07:18:03 +0800 Subject: [PATCH 03/23] fix(layout): exclude collapsed line-end spaces from overflow --- moli-layout/src/inline.rs | 70 +++++++++- moli-layout/tests/phase5_output_contract.rs | 134 +++++++++++++++++++- 2 files changed, 199 insertions(+), 5 deletions(-) diff --git a/moli-layout/src/inline.rs b/moli-layout/src/inline.rs index bb9a03586a..669dd62806 100644 --- a/moli-layout/src/inline.rs +++ b/moli-layout/src/inline.rs @@ -99,6 +99,9 @@ pub(crate) struct InlineTextUnit { pub(crate) ancestors: Vec, pub(crate) sources: Vec, pub(crate) control: bool, + /// A collapsed CSS space, removable again at a wrapped line boundary. + /// Preserved spaces and non-breaking spaces must retain their geometry. + pub(crate) collapsed_space: bool, pub(crate) break_spaces_opportunity: bool, } @@ -449,10 +452,28 @@ pub(crate) fn build_inline_fragments( for (line_index, line) in layout.lines().enumerate() { let metrics = line.metrics(); + let line_range = line.text_range(); + let trailing_start = overlapping_output_ranges(&context.text_units, &line_range) + .iter() + .rev() + .take_while(|unit| unit.control || unit.collapsed_space) + .filter(|unit| unit.collapsed_space) + .map(|unit| unit.output_range.start) + .last(); + let mut collapsed_trailing_advance = 0.0; + if let Some(start) = trailing_start { + for run in line.runs() { + for cluster in run.visual_clusters() { + if cluster.text_range().start >= start { + collapsed_trailing_advance += cluster.advance().max(0.0); + } + } + } + } let placement = line_placements .get(line_index) .filter(|placement| placement.line_index == line_index); - let line_rect = placement.map_or_else( + let mut line_rect = placement.map_or_else( || { PaintRect::new( metrics.inline_min_coord + metrics.offset, @@ -463,6 +484,14 @@ pub(crate) fn build_inline_fragments( }, |placement| placement.rect, ); + // Parley retains hanging spaces for shaping/line breaking. CSS + // collapsed line-end spaces contribute neither range/inline-box + // geometry nor scrollable overflow. Do not trim preserved or NBSP + // advances merely because Parley classifies them as whitespace. + line_rect.width = (line_rect.width - collapsed_trailing_advance).max(0.0); + if layout.is_rtl() { + line_rect.x += collapsed_trailing_advance; + } fragments.lines.push(InlineLineFragment { line_index, rect: line_rect, @@ -487,6 +516,9 @@ pub(crate) fn build_inline_fragments( let run_metrics = run.metrics(); for cluster in run.visual_clusters() { let range = cluster.text_range(); + if trailing_start.is_some_and(|start| range.start >= start) { + continue; + } let style_index = cluster .glyphs() .next() @@ -2258,6 +2290,7 @@ impl InlineNormalizer { ancestors: pending.ancestors, sources: pending.sources, control: false, + collapsed_space: true, break_spaces_opportunity: false, }, ); @@ -2359,6 +2392,7 @@ impl InlineNormalizer { ancestors: ancestors.to_vec(), sources, control, + collapsed_space: false, break_spaces_opportunity: false, }); } @@ -2747,6 +2781,40 @@ mod tests { normalizer.finish() } + #[test] + fn only_collapsed_spaces_are_marked_for_line_end_removal() { + let text = LayoutBoxId::from_index(1); + for mode in [ + InlineWhiteSpaceCollapse::Collapse, + InlineWhiteSpaceCollapse::PreserveBreaks, + InlineWhiteSpaceCollapse::Preserve, + InlineWhiteSpaceCollapse::BreakSpaces, + ] { + let input = normalize(&[(text, "A \u{a0} B")], mode, InlineTextTransform::None); + let collapsed = input + .units + .iter() + .filter(|unit| unit.collapsed_space) + .collect::>(); + assert_eq!( + collapsed.len(), + if matches!( + mode, + InlineWhiteSpaceCollapse::Collapse | InlineWhiteSpaceCollapse::PreserveBreaks + ) { + 2 + } else { + 0 + } + ); + assert!( + collapsed + .iter() + .all(|unit| &input.text[unit.output_range.clone()] == " ") + ); + } + } + #[test] fn preserve_merges_crlf_across_adjacent_text_nodes_with_both_origins() { let first = LayoutBoxId::from_index(1); diff --git a/moli-layout/tests/phase5_output_contract.rs b/moli-layout/tests/phase5_output_contract.rs index c72577e99a..d877144e0b 100644 --- a/moli-layout/tests/phase5_output_contract.rs +++ b/moli-layout/tests/phase5_output_contract.rs @@ -940,12 +940,45 @@ fn local_scrollbar_feedback_invalidates_only_the_changed_subtree_and_ancestor_pa } } +fn fixed_inline_font() -> (ResolvedLayoutStyle, DocumentLayoutServices) { + use style::values::computed::font::{ + FamilyName, FontFamily, FontFamilyList, FontFamilyNameSyntax, SingleFontFamily, + }; + let mut font = style::properties::style_structs::Font::initial_values(); + font.set_font_family(FontFamily { + families: FontFamilyList { + list: style::ArcSlice::from_iter(std::iter::once(SingleFontFamily::FamilyName( + FamilyName { + name: Atom::from("Moli Ahem"), + syntax: FontFamilyNameSyntax::Quoted, + }, + ))), + }, + is_system_font: false, + is_initial: false, + }); + let style = ResolvedLayoutStyle::from_stylo( + style::properties::ComputedValues::initial_values_with_font_override(font), + ); + let mut services = + DocumentLayoutServices::with_system_font_policy(moli_layout::SystemFontPolicy::Disabled); + services + .register_web_font(moli_layout::WebFontRegistration::new( + "fixed", + moli_layout::WebFontFace::new("Moli Ahem"), + include_bytes!("fixtures/moli-ahem.ttf").to_vec(), + )) + .unwrap(); + (style, services) +} + #[test] fn scrollbar_feedback_rebreaks_the_reused_inline_layout_at_its_final_width() { const TEXT: &str = "alpha beta gamma delta epsilon zeta eta theta iota kappa"; let source = Source(vec![ Node::element("root", vec![1]), Node::element("scroller", vec![2]), + Node::element("fixed-font", vec![3]), Node::text("text", TEXT), ]); let mut styles = Styles::default(); @@ -972,7 +1005,17 @@ fn scrollbar_feedback_rebreaks_the_reused_inline_layout_at_its_final_width() { ), ); - let feedback = build(&source, &mut styles); + // The old system-font fixture happened to fit on macOS but exposed + // hanging-space overflow on Linux. Bind shaping to the same test face. + let (font_style, mut fixed_services) = fixed_inline_font(); + styles.0.insert(2, font_style); + let feedback = build_layout_pass( + &source, + &mut styles, + &mut fixed_services, + LayoutPassRequest::new(LayoutViewport::new(320, 240, 1.0), LayoutFlushReason::Test), + ) + .unwrap(); assert_eq!(feedback.metrics.numeric_layout_pass_count, 2); assert_eq!( feedback.element_metrics_for_source(1).unwrap().client_size, @@ -982,7 +1025,11 @@ fn scrollbar_feedback_rebreaks_the_reused_inline_layout_at_its_final_width() { let extent = feedback.scroll_extent(scroller_box).unwrap(); assert!(extent.vertical_scrollbar.is_some()); assert!(extent.horizontal_scrollbar.is_none()); - let feedback_text = feedback.client_rects_for_source(2); + let feedback_text = feedback.text_range_rects(3, 0..TEXT.encode_utf16().count()); + assert!( + !feedback_text.is_empty(), + "compare real text fragments, not element-only client rects" + ); // Lay out the same paragraph directly at the converged 85px content // width. Its line fragments must match the scrollbar-corrected result; @@ -1000,8 +1047,87 @@ fn scrollbar_feedback_rebreaks_the_reused_inline_layout_at_its_final_width() { }, ), ); - let direct = build(&source, &mut styles); - assert_eq!(feedback_text, direct.client_rects_for_source(2)); + let direct = build_layout_pass( + &source, + &mut styles, + &mut fixed_services, + LayoutPassRequest::new(LayoutViewport::new(320, 240, 1.0), LayoutFlushReason::Test), + ) + .unwrap(); + assert_eq!( + feedback_text, + direct.text_range_rects(3, 0..TEXT.encode_utf16().count()) + ); + assert!( + feedback_text + .iter() + .all(|quad| quad.points.iter().all(|point| point.x <= 85.0)) + ); +} + +#[test] +fn inline_scroll_overflow_keeps_unbreakable_text_and_nbsp() { + for text in ["abcdefghijklmno", "alpha\u{a0}beta\u{a0}gamma"] { + let source = Source(vec![ + Node::element("root", vec![1]), + Node::element("scroller", vec![2]), + Node::element("fixed-font", vec![3]), + Node::text("text", text), + ]); + let (font_style, mut services) = fixed_inline_font(); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 320.0, 240.0)); + styles.0.insert( + 1, + resolved( + LayoutDisplay::Block, + Style { + size: Size { + width: length(85.0), + height: length(40.0), + }, + overflow: Point { + x: Overflow::Scroll, + y: Overflow::Scroll, + }, + ..Style::default() + }, + ), + ); + styles.0.insert(2, font_style); + let output = build_layout_pass( + &source, + &mut styles, + &mut services, + LayoutPassRequest::new(LayoutViewport::new(320, 240, 1.0), LayoutFlushReason::Test), + ) + .unwrap(); + let scroller = output.source_output(1).unwrap().principal_box.unwrap(); + assert!( + output + .scroll_extent(scroller) + .unwrap() + .horizontal_scrollbar + .is_some(), + "{text}" + ); + assert!( + output + .element_metrics_for_source(1) + .unwrap() + .scroll_size + .width + > 85.0, + "{text}" + ); + assert!( + !output + .text_range_rects(3, 0..text.encode_utf16().count()) + .is_empty() + ); + } } #[test] From 17ec7dc8520fbf88e4f823832af6ccd6269af63b Mon Sep 17 00:00:00 2001 From: kevin Date: Sun, 20 Sep 2026 16:56:02 +0800 Subject: [PATCH 04/23] fix(network): project redirected request metadata --- moli-layout/tests/phase5_output_contract.rs | 34 ++++++-- moli-protocol/src/domains/network.rs | 1 + moli-protocol/src/domains/network/backlog.rs | 25 ++++-- .../src/domains/network/redirect_request.rs | 79 +++++++++++++++++++ 4 files changed, 125 insertions(+), 14 deletions(-) create mode 100644 moli-protocol/src/domains/network/redirect_request.rs diff --git a/moli-layout/tests/phase5_output_contract.rs b/moli-layout/tests/phase5_output_contract.rs index d877144e0b..0206d65c28 100644 --- a/moli-layout/tests/phase5_output_contract.rs +++ b/moli-layout/tests/phase5_output_contract.rs @@ -974,12 +974,27 @@ fn fixed_inline_font() -> (ResolvedLayoutStyle, DocumentLayoutServices) { #[test] fn scrollbar_feedback_rebreaks_the_reused_inline_layout_at_its_final_width() { - const TEXT: &str = "alpha beta gamma delta epsilon zeta eta theta iota kappa"; + const TEXT: &str = concat!( + "alpha beta gamma delta epsilon zeta eta theta iota kappa ", + "supercalifragilisticexpialidocious", + ); + assert_scrollbar_feedback_inline_geometry(TEXT, true); +} + +#[test] +fn collapsed_line_end_spaces_do_not_create_horizontal_scrollbar_after_feedback() { + assert_scrollbar_feedback_inline_geometry( + "alpha beta gamma delta epsilon zeta eta theta iota kappa", + false, + ); +} + +fn assert_scrollbar_feedback_inline_geometry(text: &'static str, horizontal_overflow: bool) { let source = Source(vec![ Node::element("root", vec![1]), Node::element("scroller", vec![2]), Node::element("fixed-font", vec![3]), - Node::text("text", TEXT), + Node::text("text", text), ]); let mut styles = Styles::default(); styles @@ -995,8 +1010,8 @@ fn scrollbar_feedback_rebreaks_the_reused_inline_layout_at_its_final_width() { height: length(40.0), }, overflow: Point { - // Only vertical feedback is needed to narrow the text. - // Horizontal overflow depends on platform font advances. + // Only the vertical scrollbar narrows the text. A long + // word may overflow horizontally without a scrollbar. x: Overflow::Hidden, y: Overflow::Scroll, }, @@ -1025,7 +1040,8 @@ fn scrollbar_feedback_rebreaks_the_reused_inline_layout_at_its_final_width() { let extent = feedback.scroll_extent(scroller_box).unwrap(); assert!(extent.vertical_scrollbar.is_some()); assert!(extent.horizontal_scrollbar.is_none()); - let feedback_text = feedback.text_range_rects(3, 0..TEXT.encode_utf16().count()); + assert!(!extent.allows_user_scroll_x); + let feedback_text = feedback.text_range_rects(3, 0..text.encode_utf16().count()); assert!( !feedback_text.is_empty(), "compare real text fragments, not element-only client rects" @@ -1056,12 +1072,14 @@ fn scrollbar_feedback_rebreaks_the_reused_inline_layout_at_its_final_width() { .unwrap(); assert_eq!( feedback_text, - direct.text_range_rects(3, 0..TEXT.encode_utf16().count()) + direct.text_range_rects(3, 0..text.encode_utf16().count()) ); - assert!( + assert_eq!( feedback_text .iter() - .all(|quad| quad.points.iter().all(|point| point.x <= 85.0)) + .any(|quad| quad.points.iter().any(|point| point.x > 85.0)), + horizontal_overflow, + "only the unbreakable final word should retain horizontal overflow" ); } diff --git a/moli-protocol/src/domains/network.rs b/moli-protocol/src/domains/network.rs index c9061abcdc..0f2b86242d 100644 --- a/moli-protocol/src/domains/network.rs +++ b/moli-protocol/src/domains/network.rs @@ -44,6 +44,7 @@ mod load_resource; mod main_document_progress; mod output; mod output_queue; +mod redirect_request; mod response_body; pub(crate) mod settings; #[cfg(test)] diff --git a/moli-protocol/src/domains/network/backlog.rs b/moli-protocol/src/domains/network/backlog.rs index 8f02eeeaff..c2653ae660 100644 --- a/moli-protocol/src/domains/network/backlog.rs +++ b/moli-protocol/src/domains/network/backlog.rs @@ -24,6 +24,7 @@ use super::output_queue::{ TargetSubresourceRequestNetworkDeliveryOutput, TargetSubresourceResponseNetworkDeliveryOutput, TargetWebSocketDeliveryRecord, TargetWebSocketLifecycleDeliveryKind, }; +use super::redirect_request::RedirectRequest; pub(crate) struct NetworkBacklogProjectionContext<'a> { owner: CommandOwnerScope, @@ -287,7 +288,13 @@ fn emit_complete_subresource_network_delivery_record( response_headers, response_body_len, } => { + let mut redirected_request = RedirectRequest::new( + output.method(), + output.request_body(), + output.request_headers(), + ); for redirect in redirect_chain { + redirected_request.follow(redirect.status); for event_session_id in event_session_ids { emit_request_will_be_sent( out, @@ -298,9 +305,9 @@ fn emit_complete_subresource_network_delivery_record( timestamp, record_document_url, &redirect.to_url, - output.method(), - output.request_body(), - output.request_headers(), + redirected_request.method, + redirected_request.body, + &redirected_request.headers, resource_type, output.request_initiator_type(), Some(( @@ -508,7 +515,13 @@ fn emit_staged_subresource_response_started( let loader_id = request.loader_id(); let timestamp = base_timestamp + ((output.index() + 1) as f64 * 0.000_001); let resource_type = request.resource_type().into(); + let mut redirected_request = RedirectRequest::new( + request.method(), + request.request_body(), + request.request_headers(), + ); for redirect in output.redirect_chain() { + redirected_request.follow(redirect.status); for event_session_id in event_session_ids { emit_request_will_be_sent( out, @@ -519,9 +532,9 @@ fn emit_staged_subresource_response_started( timestamp, request.document_url(), &redirect.to_url, - request.method(), - request.request_body(), - request.request_headers(), + redirected_request.method, + redirected_request.body, + &redirected_request.headers, resource_type, request.request_initiator_type(), Some(( diff --git a/moli-protocol/src/domains/network/redirect_request.rs b/moli-protocol/src/domains/network/redirect_request.rs new file mode 100644 index 0000000000..01dbebf0d2 --- /dev/null +++ b/moli-protocol/src/domains/network/redirect_request.rs @@ -0,0 +1,79 @@ +/// Project the request metadata for successive followed redirect hops. +/// +/// This mirrors FetchRequest::apply_redirect_status: the emitted CDP request +/// must describe the new hop, not repeat the original POST after it became GET. +pub(super) struct RedirectRequest<'a> { + pub(super) method: &'a str, + pub(super) body: Option<&'a str>, + pub(super) headers: Vec<(String, String)>, +} + +impl<'a> RedirectRequest<'a> { + pub(super) fn new( + method: &'a str, + body: Option<&'a str>, + headers: &[(String, String)], + ) -> Self { + Self { + method, + body, + headers: headers.to_vec(), + } + } + + pub(super) fn follow(&mut self, status: u16) { + let becomes_get = matches!(status, 301 | 302) && self.method.eq_ignore_ascii_case("POST") + || status == 303 + && !self.method.eq_ignore_ascii_case("GET") + && !self.method.eq_ignore_ascii_case("HEAD"); + if becomes_get { + self.method = "GET"; + self.body = None; + self.headers.retain(|(name, _)| { + !matches!( + name.to_ascii_lowercase().as_str(), + "content-encoding" + | "content-language" + | "content-length" + | "content-location" + | "content-type" + ) + }); + } + } +} + +#[cfg(test)] +mod tests { + use super::RedirectRequest; + + #[test] + fn redirect_status_method_matrix_matches_fetch_semantics() { + for status in [301, 302, 303, 307, 308] { + for method in ["GET", "HEAD", "POST", "PUT", "DELETE", "post"] { + let headers = vec![ + ("Content-Type".into(), "text/plain".into()), + ("CONTENT-LENGTH".into(), "5".into()), + ("Accept".into(), "*/*".into()), + ]; + let mut request = RedirectRequest::new(method, Some("value"), &headers); + request.follow(status); + let changed = matches!(status, 301 | 302) && method.eq_ignore_ascii_case("POST") + || status == 303 && !matches!(method, "GET" | "HEAD"); + assert_eq!(request.method, if changed { "GET" } else { method }); + assert_eq!(request.body, if changed { None } else { Some("value") }); + assert_eq!(request.headers.len(), if changed { 1 } else { 3 }); + assert_eq!(headers.len(), 3, "original metadata stays unchanged"); + } + } + } + + #[test] + fn redirected_get_does_not_recover_original_post_on_later_307() { + let mut request = RedirectRequest::new("POST", Some("value"), &[]); + request.follow(302); + request.follow(307); + assert_eq!(request.method, "GET"); + assert_eq!(request.body, None); + } +} From 725b81f8848160405f66df46763db70d96426181 Mon Sep 17 00:00:00 2001 From: kevin Date: Sun, 20 Sep 2026 19:03:55 +0800 Subject: [PATCH 05/23] fix(browser): address activation and document review regressions --- .github/actions/use-ci-release/action.yml | 15 +++ .github/scripts/unpack-ci-release.sh | 3 + .github/scripts/unpack-ci-release.test.cjs | 60 ++++++++++++ moli-bounded-buffer/src/buffer.rs | 29 +++--- moli-bounded-buffer/src/tests.rs | 16 ++++ moli-layout/src/inline.rs | 28 +++++- moli-layout/src/projection.rs | 72 +++++++------- moli-layout/src/taffy_tree.rs | 60 ++++++------ moli-layout/tests/phase5_output_contract.rs | 55 +++++++++-- moli-parser/src/html.rs | 53 ++++++++++- moli-parser/src/session.rs | 11 +-- moli-parser/src/stream.rs | 16 +++- .../src/protocol_server/tests/classic.rs | 93 +++++++++++++++++++ moli-protocol/src/domains/network/agent.rs | 75 +++++++++------ moli-renderer-v8/src/dom_parser.rs | 6 +- moli-renderer-v8/src/live_document_parser.rs | 15 +++ .../context_host/child_documents/commit.rs | 1 + .../child_documents/live_parser.rs | 8 ++ .../context_host/child_documents/loads.rs | 23 ++++- .../src/runtime/page_vm/tests/fetch_xhr.rs | 6 +- .../src/runtime/phase_one/streaming.rs | 8 +- moli-web-mime/src/classification.rs | 8 ++ moli-web-mime/src/lib.rs | 6 +- moli-web-mime/src/tests.rs | 22 +++++ 24 files changed, 546 insertions(+), 143 deletions(-) create mode 100644 .github/scripts/unpack-ci-release.test.cjs diff --git a/.github/actions/use-ci-release/action.yml b/.github/actions/use-ci-release/action.yml index 9b4582de76..5c305e9882 100644 --- a/.github/actions/use-ci-release/action.yml +++ b/.github/actions/use-ci-release/action.yml @@ -13,6 +13,21 @@ runs: using: composite steps: - name: Download release artifact + id: download + continue-on-error: true + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: moli-release-${{ inputs.side }} + path: target/ci-artifacts/${{ inputs.side }} + - name: Check downloaded archive completeness + id: archive + continue-on-error: true + shell: bash + env: + RELEASE_SIDE: ${{ inputs.side }} + run: gzip -t "target/ci-artifacts/$RELEASE_SIDE/moli-release-$RELEASE_SIDE.tar.gz" + - name: Retry incomplete release download + if: steps.download.outcome != 'success' || steps.archive.outcome != 'success' uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: moli-release-${{ inputs.side }} diff --git a/.github/scripts/unpack-ci-release.sh b/.github/scripts/unpack-ci-release.sh index 1311d6fc45..6f734de81f 100755 --- a/.github/scripts/unpack-ci-release.sh +++ b/.github/scripts/unpack-ci-release.sh @@ -16,6 +16,9 @@ esac release_name="moli-release-$side" archive="target/ci-artifacts/$side/$release_name.tar.gz" test -f "$archive" +# A download action can terminate with a partial extracted archive. Reject it +# before touching the executable directory, including after the bounded retry. +gzip -t "$archive" mkdir -p target/ci-bin tar -xzf "$archive" -C target/ci-bin (cd "target/ci-bin/$release_name" && sha256sum --check SHA256SUMS) diff --git a/.github/scripts/unpack-ci-release.test.cjs b/.github/scripts/unpack-ci-release.test.cjs new file mode 100644 index 0000000000..4a18b374c5 --- /dev/null +++ b/.github/scripts/unpack-ci-release.test.cjs @@ -0,0 +1,60 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const { createHash } = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const script = path.join(__dirname, 'unpack-ci-release.sh'); + +function fixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'moli-ci-release-test-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const packageDir = path.join(root, 'package', 'moli-release-head'); + const archive = path.join(root, 'target', 'ci-artifacts', 'head', 'moli-release-head.tar.gz'); + fs.mkdirSync(packageDir, { recursive: true }); + fs.mkdirSync(path.dirname(archive), { recursive: true }); + const binary = Buffer.from('test release binary'); + fs.writeFileSync(path.join(packageDir, 'moli'), binary); + fs.writeFileSync(path.join(packageDir, 'revision.txt'), 'expected-revision\n'); + fs.writeFileSync(path.join(packageDir, 'SHA256SUMS'), + `${createHash('sha256').update(binary).digest('hex')} moli\n`); + function pack() { + const result = spawnSync('tar', ['-czf', archive, '-C', path.join(root, 'package'), 'moli-release-head']); + assert.equal(result.status, 0, String(result.stderr)); + } + pack(); + return { root, archive, packageDir, pack }; +} + +function unpack(root, revision = 'expected-revision') { + return spawnSync('bash', [script, 'head', revision], { cwd: root, encoding: 'utf8' }); +} + +test('valid archive passes payload and exact revision verification', (t) => { + const { root } = fixture(t); + const result = unpack(root); + assert.equal(result.status, 0, result.stderr); + assert.equal(fs.readFileSync(path.join(root, 'target/ci-bin/moli-release-head/moli'), 'utf8'), 'test release binary'); +}); + +test('truncated download cannot write a partial executable; redownload recovers', (t) => { + const { root, archive } = fixture(t); + const complete = fs.readFileSync(archive); + fs.writeFileSync(archive, complete.subarray(0, complete.length - 16)); + assert.notEqual(unpack(root).status, 0); + assert.equal(fs.existsSync(path.join(root, 'target/ci-bin')), false); + fs.writeFileSync(archive, complete); + assert.equal(unpack(root).status, 0); +}); + +test('complete downloads still reject wrong revisions and altered payloads', (t) => { + const { root, packageDir, pack } = fixture(t); + assert.notEqual(unpack(root, 'wrong-revision').status, 0); + fs.writeFileSync(path.join(packageDir, 'moli'), 'tampered payload'); + pack(); + assert.notEqual(unpack(root).status, 0); +}); diff --git a/moli-bounded-buffer/src/buffer.rs b/moli-bounded-buffer/src/buffer.rs index d5a415e6e7..ed0fa4ed35 100644 --- a/moli-bounded-buffer/src/buffer.rs +++ b/moli-bounded-buffer/src/buffer.rs @@ -70,22 +70,23 @@ where /// longer fit. Retained entries keep their original insertion order. pub fn set_limits(&mut self, limits: ByteLimits) -> Vec<(K, V)> { self.limits = limits; - let oversized = self - .insertion_order - .iter() - .filter(|key| { - self.entries - .get(*key) - .is_some_and(|entry| entry.byte_len > limits.max_entry_bytes) - }) - .cloned() - .collect::>(); let mut evicted = Vec::new(); - for key in oversized { - if let Some(value) = self.remove(&key) { - evicted.push((key, value)); + // Filter the order once. Calling remove for every oversized entry + // would rescan the whole queue on each removal when budgets shrink. + self.insertion_order.retain(|key| { + if self + .entries + .get(key) + .is_none_or(|entry| entry.byte_len <= limits.max_entry_bytes) + { + return true; } - } + if let Some(entry) = self.entries.remove(key) { + self.used_bytes -= entry.byte_len; + evicted.push((key.clone(), entry.value)); + } + false + }); while self.used_bytes > limits.max_total_bytes { if let Some(entry) = self.pop_oldest() { evicted.push(entry); diff --git a/moli-bounded-buffer/src/tests.rs b/moli-bounded-buffer/src/tests.rs index d4558052ce..0f3b57d566 100644 --- a/moli-bounded-buffer/src/tests.rs +++ b/moli-bounded-buffer/src/tests.rs @@ -1,5 +1,21 @@ use super::{BoundedByteBuffer, ByteLimits, InsertOutcome}; +#[test] +fn shrinking_entry_limit_keeps_interleaved_survivors_in_fifo_order() { + let mut buffer = BoundedByteBuffer::new(ByteLimits::new(100, 20)); + for (key, bytes) in [(0, 10), (1, 1), (2, 10), (3, 1), (4, 10)] { + buffer.insert(key, key, bytes); + } + assert_eq!( + buffer.set_limits(ByteLimits::new(100, 1)), + vec![(0, 0), (2, 2), (4, 4)] + ); + assert_eq!(buffer.used_bytes(), 2); + assert_eq!(buffer.set_limits(ByteLimits::new(1, 1)), vec![(1, 1)]); + assert_eq!(buffer.get(&3), Some(&3)); + assert_eq!(buffer.len(), 1); +} + #[test] fn changing_limits_evicts_oversized_then_oldest_without_reordering_survivors() { let mut buffer = BoundedByteBuffer::new(ByteLimits::new(20, 10)); diff --git a/moli-layout/src/inline.rs b/moli-layout/src/inline.rs index 669dd62806..b1129d46f9 100644 --- a/moli-layout/src/inline.rs +++ b/moli-layout/src/inline.rs @@ -449,14 +449,40 @@ pub(crate) fn build_inline_fragments( .iter() .map(text_style_paint_outsets) .collect::>(); + // Inline objects occupy positions between text units, not text bytes. + // Retain their logical offsets so spaces before an atomic box cannot be + // mistaken for line-end whitespace (including in bidi-reordered lines). + let atomic_offsets: HashMap<_, _> = layout + .inline_boxes() + .iter() + .filter(|inline_box| { + context + .object(inline_box.id) + .is_some_and(|object| object.role == InlineObjectRole::Atomic) + }) + .map(|inline_box| (inline_box.id, inline_box.index)) + .collect(); for (line_index, line) in layout.lines().enumerate() { let metrics = line.metrics(); let line_range = line.text_range(); + let last_atomic_offset = line + .items() + .filter_map(|item| match item { + PositionedLayoutItem::InlineBox(positioned) => { + atomic_offsets.get(&positioned.id).copied() + } + PositionedLayoutItem::GlyphRun(_) => None, + }) + .max() + .unwrap_or(line_range.start); let trailing_start = overlapping_output_ranges(&context.text_units, &line_range) .iter() .rev() - .take_while(|unit| unit.control || unit.collapsed_space) + .take_while(|unit| { + unit.output_range.start >= last_atomic_offset + && (unit.control || unit.collapsed_space) + }) .filter(|unit| unit.collapsed_space) .map(|unit| unit.output_range.start) .last(); diff --git a/moli-layout/src/projection.rs b/moli-layout/src/projection.rs index c4e79df6f2..cb65f5b0c6 100644 --- a/moli-layout/src/projection.rs +++ b/moli-layout/src/projection.rs @@ -767,37 +767,7 @@ where let bottom = last.final_layout.location.y - last_offset.y + last.final_layout.size.height; let rect = LayoutRect::new(content.x, top, content.width, (bottom - top).max(0.0)); - // A zero-area leading continuation follows escaped leading - // margins into the first block's flow position. It carries no - // line strut of its own and must not remain at the pre-collapse y. - for id in &self.boxes[run.preceding_inline.index()].fragments { - let existing = &mut self.fragments[id.index()]; - if matches!(existing.kind, LayoutFragmentKind::InlineBox { .. }) - && existing.rect.width == 0.0 - && existing.rect.height == 0.0 - { - let original = self.coordinate_spaces[existing.coordinate_space.index()] - .layout - .local_to_document_ignoring_css_transforms - .map_point(LayoutPoint::new(existing.rect.x, existing.rect.y)); - let x = self.coordinate_spaces[parent.index() + 1] - .layout - .local_to_document_ignoring_css_transforms - .inverse() - .map(|inverse| inverse.map_point(original).x) - .unwrap_or(existing.rect.x); - let boundary = LayoutRect::new(x, top, 0.0, 0.0); - existing.rect = boundary; - existing.coordinate_space = - LayoutCoordinateSpaceId::from_index(parent.index() + 1); - existing.box_model = Some(LayoutFragmentBoxModel { - content: boundary, - padding: boundary, - border: boundary, - margin: boundary, - }); - } - } + self.reposition_empty_inline_continuation(run.preceding_inline, parent, top); let fragment = self.push_fragment(LayoutFragment { id: LayoutFragmentId::from_index(0), kind: LayoutFragmentKind::Box { @@ -818,6 +788,46 @@ where } } + /// An empty leading continuation follows escaped margins into the first + /// block's flow position; it carries no independent line strut. + fn reposition_empty_inline_continuation( + &mut self, + inline: LayoutBoxId, + parent: LayoutBoxId, + top: f32, + ) { + let coordinate_space = LayoutCoordinateSpaceId::from_index(parent.index() + 1); + let document_to_parent = self.coordinate_spaces[coordinate_space.index()] + .layout + .local_to_document_ignoring_css_transforms + .inverse(); + for id in &self.boxes[inline.index()].fragments { + let fragment = &mut self.fragments[id.index()]; + if !matches!(fragment.kind, LayoutFragmentKind::InlineBox { .. }) + || fragment.rect.width != 0.0 + || fragment.rect.height != 0.0 + { + continue; + } + let original = self.coordinate_spaces[fragment.coordinate_space.index()] + .layout + .local_to_document_ignoring_css_transforms + .map_point(LayoutPoint::new(fragment.rect.x, fragment.rect.y)); + let x = document_to_parent + .map(|inverse| inverse.map_point(original).x) + .unwrap_or(fragment.rect.x); + let boundary = LayoutRect::new(x, top, 0.0, 0.0); + fragment.rect = boundary; + fragment.coordinate_space = coordinate_space; + fragment.box_model = Some(LayoutFragmentBoxModel { + content: boundary, + padding: boundary, + border: boundary, + margin: boundary, + }); + } + } + fn split_block_relative_inset_basis(&self, parent: LayoutBoxId) -> taffy::Size> { let content = self.boxes[parent.index()].content_box; let Some(inputs) = self diff --git a/moli-layout/src/taffy_tree.rs b/moli-layout/src/taffy_tree.rs index 6da1b69e6e..e2327896a9 100644 --- a/moli-layout/src/taffy_tree.rs +++ b/moli-layout/src/taffy_tree.rs @@ -102,44 +102,44 @@ impl PreparedWorldLayout { N: Copy + Debug + Eq + Hash, { assert!(self.feedback_invalidation_worklist.is_empty()); + if viewport_changed { + for layout_box in &mut world.boxes { + layout_box.clear_layout_caches(); + } + world.viewport_layout.cache.clear(); + return world.boxes.len(); + } + if changed_boxes.is_empty() { + return 0; + } self.feedback_invalidation_marks .resize(world.boxes.len(), false); let mut invalidated = Vec::new(); - if viewport_changed { - for index in 0..world.boxes.len() { - let id = LayoutBoxId::from_index(index); - self.feedback_invalidation_marks[index] = true; - invalidated.push(id); - world.boxes[index].clear_layout_caches(); + self.feedback_invalidation_worklist + .extend(changed_boxes.iter().copied()); + while let Some(id) = self.feedback_invalidation_worklist.pop() { + if self.feedback_invalidation_marks[id.index()] { + continue; } - } else { + self.feedback_invalidation_marks[id.index()] = true; + invalidated.push(id); + world.boxes[id.index()].clear_layout_caches(); self.feedback_invalidation_worklist - .extend(changed_boxes.iter().copied()); - while let Some(id) = self.feedback_invalidation_worklist.pop() { - if self.feedback_invalidation_marks[id.index()] { - continue; - } - self.feedback_invalidation_marks[id.index()] = true; - invalidated.push(id); - world.boxes[id.index()].clear_layout_caches(); - self.feedback_invalidation_worklist - .extend(world.boxes[id.index()].layout_children.iter().copied()); - } + .extend(world.boxes[id.index()].layout_children.iter().copied()); + } - for changed in changed_boxes.iter().copied() { - let mut ancestor = world.boxes[changed.index()].layout_parent; - while let Some(id) = ancestor { - if !self.feedback_invalidation_marks[id.index()] { - self.feedback_invalidation_marks[id.index()] = true; - invalidated.push(id); - world.boxes[id.index()].clear_layout_caches(); - } - ancestor = world.boxes[id.index()].layout_parent; + for changed in changed_boxes.iter().copied() { + let mut ancestor = world.boxes[changed.index()].layout_parent; + while let Some(id) = ancestor { + if !self.feedback_invalidation_marks[id.index()] { + self.feedback_invalidation_marks[id.index()] = true; + invalidated.push(id); + world.boxes[id.index()].clear_layout_caches(); } + ancestor = world.boxes[id.index()].layout_parent; + } } - if viewport_changed || !changed_boxes.is_empty() { - world.viewport_layout.cache.clear(); - } + world.viewport_layout.cache.clear(); for id in invalidated.iter().copied() { self.feedback_invalidation_marks[id.index()] = false; } diff --git a/moli-layout/tests/phase5_output_contract.rs b/moli-layout/tests/phase5_output_contract.rs index 0206d65c28..7f972251b2 100644 --- a/moli-layout/tests/phase5_output_contract.rs +++ b/moli-layout/tests/phase5_output_contract.rs @@ -4,11 +4,11 @@ use moli_layout::{ DocumentLayoutServices, LayoutControlSurfaceHit, LayoutDisplay, LayoutElementCategory, LayoutElementSemantics, LayoutError, LayoutFlushReason, LayoutFragmentKind, LayoutNamespace, LayoutPaintedSurfaceHit, LayoutPassRequest, LayoutPassResult, LayoutPoint, LayoutPosition, - LayoutQuery, LayoutQueryAnswer, LayoutQueryBatch, LayoutRect, LayoutScrollbarColors, - LayoutScrollbarGutter, LayoutScrollbarPart, LayoutScrollbarWidth, LayoutSource, - LayoutSourceKind, LayoutStyleResolver, LayoutTransform2D, LayoutViewport, PaintBrush, - PaintCaptureRequest, PaintColor, PaintFragment, PaintShape, ResolvedLayoutElementStyles, - ResolvedLayoutStyle, build_layout_pass, + LayoutQuery, LayoutQueryAnswer, LayoutQueryBatch, LayoutRect, LayoutReplacedKind, + LayoutScrollbarColors, LayoutScrollbarGutter, LayoutScrollbarPart, LayoutScrollbarWidth, + LayoutSource, LayoutSourceKind, LayoutStyleResolver, LayoutTransform2D, LayoutViewport, + PaintBrush, PaintCaptureRequest, PaintColor, PaintFragment, PaintShape, + ResolvedLayoutElementStyles, ResolvedLayoutStyle, build_layout_pass, }; use style::Atom; use taffy::{ @@ -91,7 +91,7 @@ impl LayoutSource for Source { LayoutNamespace::Html, self.0[node].local_name, LayoutElementCategory::Generic, - None, + (self.0[node].local_name == "img").then_some(LayoutReplacedKind::Image), ) }) } @@ -2679,6 +2679,49 @@ fn inline_output_preserves_line_text_and_utf16_source_fragments() { assert_eq!(scroll_geometry.scroll_containers.len(), 1); } +#[test] +fn collapsed_space_before_atomic_inline_keeps_its_range_geometry() { + for local_name in ["span", "img"] { + for trailing_text in ["", " "] { + let source = Source(vec![ + Node::element("root", vec![1, 2, 3]), + Node::text("before", "a "), + Node::html_element("atomic", local_name, vec![]), + Node::text("after", trailing_text), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 200.0, 80.0)); + for node in [1, 3] { + styles + .0 + .insert(node, resolved(LayoutDisplay::Inline, Style::default())); + } + let display = if local_name == "img" { + LayoutDisplay::Inline + } else { + LayoutDisplay::InlineBlock + }; + styles.0.insert(2, fixed_size(display, 20.0, 20.0)); + let output = build(&source, &mut styles); + let space = output.text_range_rects(1, 1..2); + assert_eq!(space.len(), 1, "{local_name}, {trailing_text:?}"); + let space = space[0].bounding_rect(); + assert!( + space.width > 0.0, + "space before an atomic inline is not trailing" + ); + let atomic = output.client_rects_for_source(2)[0].bounding_rect(); + assert_close(space.x + space.width, atomic.x); + assert!( + output.text_range_rects(3, 0..1).is_empty(), + "line-end space still collapses" + ); + } + } +} + #[test] fn inline_offset_metrics_use_fragment_bounds_and_keep_empty_sibling_anchors() { let source = Source(vec![ diff --git a/moli-parser/src/html.rs b/moli-parser/src/html.rs index b42ec12cdd..a43cdf5ed3 100644 --- a/moli-parser/src/html.rs +++ b/moli-parser/src/html.rs @@ -27,7 +27,7 @@ use super::live_target::{ ParserDomMutationConsumer, ParserDomReadConsumer, ParserElementCreationConsumer, ParserMutationEffectConsumer, ParserMutationEffectDelivery, ParserRuntimeDomSinks, ParserStreamHtmlTreeSinkTarget, new_live_document_root_html_tree_sink_stream, - new_parser_stream_html_tree_sink_stream, + new_parser_stream_html_tree_sink_stream, new_parser_stream_html_tree_sink_target, }; use super::{ ParserSourcePosition, html_chunks, @@ -441,8 +441,41 @@ impl HtmlParser { /// Start a text document using the HTML tokenizer's plaintext state. /// Subsequent chunks remain literal text inside the browser-owned `pre`. pub fn start_text_document(&self, final_url: Url, content_type: &str) -> DocumentStream { - let mut stream = DocumentStream::new_parser_stream(final_url, self.scripting_enabled); - stream.inner.initialize_text_document(content_type); + let stream = + DocumentStream::new_text_document(new_parser_stream_html_tree_sink_target(final_url)); + stream.inner.initialize_text_document(); + let mut host = stream.inner.take_parser_stream_dom_host(); + let document_handle = host.document_handle(); + host.set_document_content_type_for_handle(document_handle, content_type); + stream.inner.restore_parser_stream_dom_host(host); + stream + } + + /// Initialize the same literal-text shell in an existing document. The + /// embedder owns that document's MIME metadata and live DOM callbacks. + pub fn start_live_text_document_root( + &self, + final_url: Url, + document_handle: NativeNodeId, + consumer: &mut T, + ) -> DocumentStream + where + T: ParserDomReadConsumer + + ParserDomMutationConsumer + + ParserMutationEffectConsumer + + ParserElementCreationConsumer, + { + let stream = DocumentStream::new_text_document( + ParserStreamHtmlTreeSinkTarget::new_live_document_root(final_url, document_handle), + ); + // SAFETY: the consumer is exclusively borrowed until the step guard + // removes the erased callbacks, including when initialization unwinds. + let sinks = unsafe { ParserRuntimeDomSinks::from_consumer(consumer) }; + stream.inner.enter_runtime_dom_sinks_parse_step(sinks); + { + let step = RuntimeDomSinksParserStep { stream: &stream }; + step.stream.inner.initialize_text_document(); + } stream } @@ -559,6 +592,17 @@ impl Default for ParserInputQueue { } impl DocumentStream { + fn new_text_document(target: ParserStreamHtmlTreeSinkTarget) -> Self { + let mut options = html_parse_opts_with_scripting(false); + // The byte decoder has already consumed the transport BOM. In plaintext + // every remaining U+FEFF is content, including at a chunk boundary. + options.tokenizer.discard_bom = false; + Self { + inner: HtmlTreeSinkStream::from_target_with_options(target, options), + input: RefCell::default(), + } + } + fn new_parser_stream(final_url: Url, scripting_enabled: bool) -> Self { Self { inner: new_parser_stream_html_tree_sink_stream(final_url, scripting_enabled), @@ -1847,7 +1891,8 @@ mod tests { #[test] fn text_document_stream_preserves_literal_markup_and_leading_newline() { - let payload = "\nGülçek&"; + let payload = + "\u{feff}\nGülçek&\u{feff}"; for content_type in ["text/plain", "application/json", "application/problem+json"] { let stream = HtmlParser::SCRIPTING_ENABLED.start_text_document( Url::parse("https://example.test/data").unwrap(), diff --git a/moli-parser/src/session.rs b/moli-parser/src/session.rs index a05f6ba649..2cf345dc00 100644 --- a/moli-parser/src/session.rs +++ b/moli-parser/src/session.rs @@ -203,7 +203,7 @@ impl TokenSink for EmbedderPausingTreeBuilder { } impl HtmlParserSession { - pub(super) fn initialize_text_document(&mut self, content_type: &str) { + pub(super) fn initialize_text_document(&self) { // Parse only the browser-owned shell. Response bytes enter the tokenizer // after it has switched to plaintext, so tags and entities stay literal. self.process(StrTendril::from(concat!( @@ -211,11 +211,6 @@ impl HtmlParserSession { "
\n"
         )));
         self.tokenizer.set_plaintext_state();
-        let sink = self.tokenizer.sink.sink();
-        let mut host = sink.take_parser_stream_dom_host();
-        let document_handle = host.document_handle();
-        host.set_document_content_type_for_handle(document_handle, content_type);
-        sink.restore_parser_stream_dom_host(host);
     }
 
     fn new(sink: DocumentSink, opts: ParseOpts) -> Self {
@@ -403,10 +398,10 @@ fn feed_with_definitive_encoding(
 
 pub(super) fn new_html_tree_sink_session(
     target: ParserStreamHtmlTreeSinkTarget,
-    scripting_enabled: bool,
+    options: ParseOpts,
 ) -> HtmlTreeSinkSession {
     let sink = DocumentSink::new(target);
-    let parser = HtmlParserSession::new(sink, html_parse_opts_with_scripting(scripting_enabled));
+    let parser = HtmlParserSession::new(sink, options);
     let script_input = ParserInputQueue::default();
 
     HtmlTreeSinkSession {
diff --git a/moli-parser/src/stream.rs b/moli-parser/src/stream.rs
index 7cea895c82..427c451ca7 100644
--- a/moli-parser/src/stream.rs
+++ b/moli-parser/src/stream.rs
@@ -429,15 +429,25 @@ pub(crate) fn prepare_parser_script_handoff_for_static_document(
 }
 
 impl HtmlTreeSinkStream {
-    pub(super) fn initialize_text_document(&mut self, content_type: &str) {
-        self.parser.initialize_text_document(content_type);
+    pub(super) fn initialize_text_document(&self) {
+        self.parser.initialize_text_document();
     }
 
     pub(super) fn from_target_with_scripting(
         target: ParserStreamHtmlTreeSinkTarget,
         scripting_enabled: bool,
     ) -> Self {
-        let session = new_html_tree_sink_session(target, scripting_enabled);
+        Self::from_target_with_options(
+            target,
+            super::session::html_parse_opts_with_scripting(scripting_enabled),
+        )
+    }
+
+    pub(super) fn from_target_with_options(
+        target: ParserStreamHtmlTreeSinkTarget,
+        options: html5ever::ParseOpts,
+    ) -> Self {
+        let session = new_html_tree_sink_session(target, options);
         Self {
             parser: session.parser,
             script_input: session.script_input,
diff --git a/moli-protocol-server/src/protocol_server/tests/classic.rs b/moli-protocol-server/src/protocol_server/tests/classic.rs
index afe032408a..6f40732dd3 100644
--- a/moli-protocol-server/src/protocol_server/tests/classic.rs
+++ b/moli-protocol-server/src/protocol_server/tests/classic.rs
@@ -16,6 +16,99 @@ async fn classic_capture_layout(app: Router, session_id: &str) {
     );
 }
 
+#[tokio::test]
+async fn webdriver_classic_document_mime_is_shared_by_main_and_child_documents() {
+    const PAYLOAD: &str =
+        "literal&Gülçek";
+    let cases = [
+        (
+            "plain",
+            "text/plain; charset=utf-8",
+            "text/plain",
+            true,
+            PAYLOAD,
+        ),
+        (
+            "plain-bom",
+            "text/plain; charset=utf-8",
+            "text/plain",
+            true,
+            "\u{feff}\u{feff}",
+        ),
+        (
+            "json",
+            "application/json",
+            "application/json",
+            true,
+            PAYLOAD,
+        ),
+        (
+            "javascript",
+            "text/javascript; charset=utf-8",
+            "text/javascript",
+            true,
+            PAYLOAD,
+        ),
+        (
+            "html",
+            "text/html; charset=utf-8",
+            "text/html",
+            false,
+            PAYLOAD,
+        ),
+    ];
+    let mut fixture = axum::Router::new();
+    for (name, mime, _, _, payload) in cases {
+        fixture = fixture.route(
+            &format!("/{name}"),
+            axum::routing::get(move || async move { ([(header::CONTENT_TYPE, mime)], payload) }),
+        );
+    }
+    fixture = fixture.route(
+        "/frame/{kind}",
+        axum::routing::get(
+            |axum::extract::Path(kind): axum::extract::Path| async move {
+                (
+                    [(header::CONTENT_TYPE, "text/html")],
+                    format!(""),
+                )
+            },
+        ),
+    );
+    let (addr, _server) = spawn_dedicated_fixture_server(fixture, "document-mime");
+    let app = build_router(test_state());
+    let session = classic_request_json(app.clone(), Method::POST, "/session").await;
+    let session_id = session["value"]["sessionId"].as_str().unwrap();
+    for (name, _, content_type, literal, payload) in cases {
+        for prefix in ["", "frame/"] {
+            let navigated = classic_request_json_with_body(
+                app.clone(),
+                Method::POST,
+                &format!("/session/{session_id}/url"),
+                json!({"url":format!("http://{addr}/{prefix}{name}")}),
+            )
+            .await;
+            assert_eq!(navigated, json!({"value":null}), "{prefix}{name}");
+            let observed = classic_request_json_with_body(
+                app.clone(), Method::POST, &format!("/session/{session_id}/execute/sync"),
+                json!({"script": "const w=document.querySelector('iframe')?.contentWindow ?? window; return [w.document.body.textContent,w.executed??null,w.document.querySelectorAll('script').length,w.document.contentType];", "args": []}),
+            ).await;
+            let expected = if literal {
+                json!([
+                    payload.strip_prefix('\u{feff}').unwrap_or(payload),
+                    null,
+                    0,
+                    content_type
+                ])
+            } else {
+                json!(["literal&Gülçek", 42, 1, content_type])
+            };
+            assert_eq!(observed["value"], expected, "{prefix}{name}");
+        }
+    }
+    classic_request_json(app, Method::DELETE, &format!("/session/{session_id}")).await;
+}
+
 #[tokio::test]
 async fn webdriver_classic_status_session_and_delete_routes_use_value_envelope() {
     let app = build_router(test_state());
diff --git a/moli-protocol/src/domains/network/agent.rs b/moli-protocol/src/domains/network/agent.rs
index adfa389054..eea278c026 100644
--- a/moli-protocol/src/domains/network/agent.rs
+++ b/moli-protocol/src/domains/network/agent.rs
@@ -1387,24 +1387,39 @@ impl CapturedResponseBodyStore {
             self.durable_sessions.insert(session, limits);
         } else {
             self.durable_sessions.remove(&session);
-            // Turning off durability revokes only old-document claims, not
-            // ordinary access to responses from the current document.
-            for request_id in self.retained_request_ids.clone() {
-                if let Some(body) = self.bodies.get_mut(&request_id)
-                    && !body.remove_session_visibility(session_id)
-                {
-                    self.bodies.remove(&request_id);
-                }
-                if let Some(body) = self.buffered_bodies.get_mut(&request_id)
-                    && !body.remove_session_visibility(session_id)
-                {
-                    self.buffered_bodies.remove(&request_id);
-                }
+            self.revoke_retained_visibility(session_id);
+        }
+        for (id, mut body) in self
+            .buffered_bodies
+            .set_limits(self.effective_durable_limits())
+        {
+            body.mark_evicted();
+            self.bodies.insert(id, body);
+        }
+        self.synchronize_durable_entries();
+    }
+
+    fn revoke_retained_visibility(&mut self, session_id: Option<&str>) {
+        // Revocation affects only old-document claims, not ordinary access to
+        // responses from the current document.
+        for request_id in &self.retained_request_ids {
+            if let Some(body) = self.bodies.get_mut(request_id)
+                && !body.remove_session_visibility(session_id)
+            {
+                self.bodies.remove(request_id);
+            }
+            if let Some(body) = self.buffered_bodies.get_mut(request_id)
+                && !body.remove_session_visibility(session_id)
+            {
+                self.buffered_bodies.remove(request_id);
             }
         }
+    }
+
+    fn effective_durable_limits(&self) -> ByteLimits {
         // One target owns one payload store. Multiple clients cannot multiply
         // its budget; each requested budget is an upper bound, not a reserve.
-        let limits = self.durable_sessions.values().fold(
+        self.durable_sessions.values().fold(
             ByteLimits::new(
                 RESPONSE_BODY_BUFFER_MAX_TOTAL_BYTES,
                 RESPONSE_BODY_BUFFER_MAX_ENTRY_BYTES,
@@ -1415,26 +1430,26 @@ impl CapturedResponseBodyStore {
                     acc.max_entry_bytes.min(limits.max_entry_bytes),
                 )
             },
-        );
-        for (id, mut body) in self.buffered_bodies.set_limits(limits) {
-            body.mark_evicted();
-            self.bodies.insert(id, body);
-        }
+        )
+    }
+
+    fn synchronize_durable_entries(&mut self) {
         if self.durable_sessions.is_empty() {
             self.durable_entry_order.clear();
             self.retained_request_ids.clear();
-        } else {
-            let mut untracked = self
-                .bodies
-                .keys()
-                .chain(self.buffered_bodies.iter().map(|(id, _)| id))
-                .filter(|id| !self.durable_entry_order.contains(id))
-                .cloned()
-                .collect::>();
-            untracked.sort();
-            self.durable_entry_order.extend(untracked);
-            self.trim_durable_entries();
+            return;
         }
+        let tracked: HashSet<_> = self.durable_entry_order.iter().collect();
+        let mut untracked = self
+            .bodies
+            .keys()
+            .chain(self.buffered_bodies.iter().map(|(id, _)| id))
+            .filter(|id| !tracked.contains(id))
+            .cloned()
+            .collect::>();
+        untracked.sort();
+        self.durable_entry_order.extend(untracked);
+        self.trim_durable_entries();
     }
 
     fn track_durable_entry(&mut self, request_id: &str) {
diff --git a/moli-renderer-v8/src/dom_parser.rs b/moli-renderer-v8/src/dom_parser.rs
index f1703887c6..e06055eb4d 100644
--- a/moli-renderer-v8/src/dom_parser.rs
+++ b/moli-renderer-v8/src/dom_parser.rs
@@ -367,7 +367,11 @@ pub(crate) fn preserve_decoded_bom_only_browsing_context_body<'a>(
     source: &'a str,
     content_type: Option<&str>,
 ) -> std::borrow::Cow<'a, str> {
-    if source == "\u{feff}" && !content_type.is_some_and(is_dom_parser_xml_mime) {
+    if source == "\u{feff}"
+        && !content_type.is_some_and(|mime| {
+            is_dom_parser_xml_mime(mime) || moli_web_mime::is_text_document_mime(mime)
+        })
+    {
         std::borrow::Cow::Borrowed("\u{feff}")
     } else {
         std::borrow::Cow::Borrowed(source)
diff --git a/moli-renderer-v8/src/live_document_parser.rs b/moli-renderer-v8/src/live_document_parser.rs
index 6a75cb4e6b..29ee44c5e9 100644
--- a/moli-renderer-v8/src/live_document_parser.rs
+++ b/moli-renderer-v8/src/live_document_parser.rs
@@ -778,6 +778,21 @@ impl DocumentParserSession {
         )
     }
 
+    pub(crate) fn start_finite_live_text_document(
+        document_url: Url,
+        document_handle: NativeNodeId,
+        owner: &mut impl LiveDocumentParserOwner,
+    ) -> Self {
+        Self::new_html(
+            HtmlParser::with_scripting_enabled(false).start_live_text_document_root(
+                document_url,
+                document_handle,
+                owner,
+            ),
+            DocumentParserLifetime::Finite,
+        )
+    }
+
     pub(crate) fn start_finite_live_xml_document(
         document_url: Url,
         document_handle: NativeNodeId,
diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_documents/commit.rs b/moli-renderer-v8/src/native_bridge/context_host/child_documents/commit.rs
index ee356deebe..a1d27afdb6 100644
--- a/moli-renderer-v8/src/native_bridge/context_host/child_documents/commit.rs
+++ b/moli-renderer-v8/src/native_bridge/context_host/child_documents/commit.rs
@@ -239,6 +239,7 @@ impl JsContextHost {
             parser_base_url,
             source.as_ref(),
             is_xml_document,
+            snapshot.content_type.as_deref(),
         );
         Some(ChildDocumentInstallResult {
             initial_classic_ready_work: parser_start.initial_classic_ready_work,
diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_documents/live_parser.rs b/moli-renderer-v8/src/native_bridge/context_host/child_documents/live_parser.rs
index 9244a3da0a..3c20c96316 100644
--- a/moli-renderer-v8/src/native_bridge/context_host/child_documents/live_parser.rs
+++ b/moli-renderer-v8/src/native_bridge/context_host/child_documents/live_parser.rs
@@ -1695,6 +1695,7 @@ impl JsContextHost {
         document_base_url: Url,
         markup: &str,
         is_xml_document: bool,
+        content_type: Option<&str>,
     ) -> ChildLiveDocumentParserStartResult {
         let owner = FrameDocumentOwner::new(owner_local_window_id, owner_document_id);
         self.child_document_parsers.clear(owner);
@@ -1703,6 +1704,13 @@ impl JsContextHost {
                 document_base_url,
                 document_handle,
             )
+        } else if content_type.is_some_and(moli_web_mime::is_text_document_mime) {
+            let mut parser_owner = ChildFrameLiveParserOwner::new(self, scope, document_handle);
+            DocumentParserSession::start_finite_live_text_document(
+                document_base_url,
+                document_handle,
+                &mut parser_owner,
+            )
         } else {
             DocumentParserSession::start_finite_live_document(
                 document_base_url,
diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_documents/loads.rs b/moli-renderer-v8/src/native_bridge/context_host/child_documents/loads.rs
index 1b938ab280..1fc4831ea5 100644
--- a/moli-renderer-v8/src/native_bridge/context_host/child_documents/loads.rs
+++ b/moli-renderer-v8/src/native_bridge/context_host/child_documents/loads.rs
@@ -21,7 +21,8 @@ use crate::{
         LoadedChildDocument, SubresourceResponseBody,
     },
 };
-use moli_encoding::decode_html_document_with_fallback;
+use moli_encoding::{HtmlDocumentStreamingDecoder, decode_html_document_with_fallback};
+use moli_encoding_detector::detect_legacy_html_encoding;
 
 pub(crate) struct AppliedChildDocumentLoadCompletion {
     /// Initial parser-classic work produced by the committed child document.
@@ -796,8 +797,24 @@ fn child_document_load_outcome_from_response(
         let body_bytes = response_body
             .try_bytes()
             .map_err(|error| format!("failed to read child document response body: {error}"))?;
-        let (markup, character_set) =
-            decode_html_document_with_fallback(&body_bytes, &head.headers, Some(&fallback));
+        let (markup, character_set) = if let Some(mime) = content_type
+            .as_deref()
+            .filter(|mime| moli_web_mime::is_text_document_mime(mime))
+        {
+            let mut decoder = HtmlDocumentStreamingDecoder::new_text_document(
+                &head.headers,
+                head.final_url.as_str(),
+                detect_legacy_html_encoding,
+                moli_web_mime::is_json_module_mime(mime) || mime == "text/json",
+            );
+            let mut markup = decoder.push(&body_bytes).concat();
+            if let Some(tail) = decoder.finish() {
+                markup.push_str(&tail);
+            }
+            (markup, decoder.document_encoding_name())
+        } else {
+            decode_html_document_with_fallback(&body_bytes, &head.headers, Some(&fallback))
+        };
         (markup, character_set.to_owned())
     };
     let policy_container =
diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/fetch_xhr.rs b/moli-renderer-v8/src/runtime/page_vm/tests/fetch_xhr.rs
index b64f376ca7..be73d0be4e 100644
--- a/moli-renderer-v8/src/runtime/page_vm/tests/fetch_xhr.rs
+++ b/moli-renderer-v8/src/runtime/page_vm/tests/fetch_xhr.rs
@@ -3703,10 +3703,12 @@ fn spawn_repeated_synchronous_xhr_server(
                     }
                     Err(error) => panic!("accept repeated synchronous XHR: {error}"),
                 };
-                // Accepted sockets inherit nonblocking mode on macOS.
+                // macOS can inherit O_NONBLOCK from the listening socket.
+                // This fixture reads the complete request synchronously; the
+                // socket timeout alone does not turn WouldBlock into a wait.
                 stream
                     .set_nonblocking(false)
-                    .expect("use blocking synchronous XHR request I/O");
+                    .expect("make accepted synchronous XHR stream blocking");
                 stream
                     .set_read_timeout(Some(Duration::from_secs(2)))
                     .expect("bound synchronous XHR request read");
diff --git a/moli-renderer-v8/src/runtime/phase_one/streaming.rs b/moli-renderer-v8/src/runtime/phase_one/streaming.rs
index 4ec593a54d..52c164fb39 100644
--- a/moli-renderer-v8/src/runtime/phase_one/streaming.rs
+++ b/moli-renderer-v8/src/runtime/phase_one/streaming.rs
@@ -636,13 +636,7 @@ fn response_document_parser(
     env: &PageVmEnvConfig,
 ) -> (ParseTimeDriverState, HtmlDocumentStreamingDecoder) {
     let content_type = moli_web_mime::response_document_content_type(headers);
-    let text_type = content_type.filter(|mime| {
-        !moli_web_mime::is_html_document_mime(mime)
-            && !moli_web_mime::is_dom_parser_xml_mime(mime)
-            && (moli_web_mime::is_text_mime(mime)
-                || moli_web_mime::is_json_module_mime(mime)
-                || moli_web_mime::is_javascript_mime(mime))
-    });
+    let text_type = content_type.filter(|mime| moli_web_mime::is_text_document_mime(mime));
     let mut state = ParseTimeDriverState::new_with_scripting_enabled(
         final_url,
         main_document_parser_scripting_enabled(env),
diff --git a/moli-web-mime/src/classification.rs b/moli-web-mime/src/classification.rs
index 3ff3a0dd50..35fca10b58 100644
--- a/moli-web-mime/src/classification.rs
+++ b/moli-web-mime/src/classification.rs
@@ -51,6 +51,14 @@ pub fn is_text_mime_essence(input: &str) -> bool {
     input.starts_with("text/")
 }
 
+/// Navigation responses rendered as literal text rather than HTML or XML.
+/// This also includes JSON and JavaScript resources opened as documents.
+pub fn is_text_document_mime(input: &str) -> bool {
+    !is_html_document_mime(input)
+        && !is_dom_parser_xml_mime(input)
+        && (is_text_mime(input) || is_json_module_mime(input) || is_javascript_mime(input))
+}
+
 /// Whether a `style` element's raw `type` attribute selects classic CSS.
 ///
 /// HTML's update-a-style-block algorithm compares the untrimmed attribute
diff --git a/moli-web-mime/src/lib.rs b/moli-web-mime/src/lib.rs
index 4262227c35..7204f28df5 100644
--- a/moli-web-mime/src/lib.rs
+++ b/moli-web-mime/src/lib.rs
@@ -14,9 +14,9 @@ pub use classification::{
     is_font_mime, is_font_mime_essence, is_form_urlencoded_mime, is_html_document_mime,
     is_image_mime, is_image_mime_essence, is_javascript_mime, is_javascript_mime_essence,
     is_json_module_mime, is_multipart_form_data_mime, is_png_image_mime, is_png_image_mime_essence,
-    is_supported_document_mime_type, is_svg_image_mime, is_svg_image_mime_essence, is_text_mime,
-    is_text_mime_essence, is_video_mime, is_video_mime_essence, is_webassembly_mime,
-    multipart_form_data_boundary,
+    is_supported_document_mime_type, is_svg_image_mime, is_svg_image_mime_essence,
+    is_text_document_mime, is_text_mime, is_text_mime_essence, is_video_mime,
+    is_video_mime_essence, is_webassembly_mime, multipart_form_data_boundary,
 };
 pub use data_url::{
     data_url_body_and_computed_mime_type, data_url_body_and_mime_type, data_url_mime_type,
diff --git a/moli-web-mime/src/tests.rs b/moli-web-mime/src/tests.rs
index b2c49be9d0..d486591207 100644
--- a/moli-web-mime/src/tests.rs
+++ b/moli-web-mime/src/tests.rs
@@ -829,3 +829,25 @@ fn script_like_mime_type_block_matches_fetch_response_rule() {
     ));
     assert!(!should_script_like_response_be_blocked_due_to_mime_type(&[]));
 }
+#[test]
+fn document_text_classification_excludes_html_and_xml() {
+    for mime in [
+        "text/plain",
+        "Text/Css; charset=utf-8",
+        "application/json",
+        "application/ld+json",
+        "application/javascript",
+    ] {
+        assert!(crate::is_text_document_mime(mime), "{mime}");
+    }
+    for mime in [
+        "text/html",
+        "text/xml",
+        "application/xhtml+xml",
+        "image/svg+xml",
+        "image/png",
+        "application/octet-stream",
+    ] {
+        assert!(!crate::is_text_document_mime(mime), "{mime}");
+    }
+}

From 98fcf16abe80998545f9c1af1c1a742014ce8700 Mon Sep 17 00:00:00 2001
From: kevin 
Date: Sun, 20 Sep 2026 20:19:48 +0800
Subject: [PATCH 06/23] Fix WebDriver activation boundaries and share network
 cleanup rules

---
 moli-bounded-buffer/src/buffer.rs             | 49 ++++++++++-----
 moli-bounded-buffer/src/tests.rs              | 34 ++++++++++
 moli-fetch/src/lib.rs                         |  3 +-
 moli-fetch/src/request.rs                     |  6 +-
 .../src/protocol_server/tests/classic.rs      |  2 -
 moli-protocol/src/domains/network/agent.rs    | 54 +++++-----------
 .../src/domains/network/redirect_request.rs   | 20 ++----
 moli/src/telemetry.rs                         | 62 +++++++------------
 8 files changed, 116 insertions(+), 114 deletions(-)

diff --git a/moli-bounded-buffer/src/buffer.rs b/moli-bounded-buffer/src/buffer.rs
index ed0fa4ed35..b89cb4a7fe 100644
--- a/moli-bounded-buffer/src/buffer.rs
+++ b/moli-bounded-buffer/src/buffer.rs
@@ -70,23 +70,7 @@ where
     /// longer fit. Retained entries keep their original insertion order.
     pub fn set_limits(&mut self, limits: ByteLimits) -> Vec<(K, V)> {
         self.limits = limits;
-        let mut evicted = Vec::new();
-        // Filter the order once. Calling remove for every oversized entry
-        // would rescan the whole queue on each removal when budgets shrink.
-        self.insertion_order.retain(|key| {
-            if self
-                .entries
-                .get(key)
-                .is_none_or(|entry| entry.byte_len <= limits.max_entry_bytes)
-            {
-                return true;
-            }
-            if let Some(entry) = self.entries.remove(key) {
-                self.used_bytes -= entry.byte_len;
-                evicted.push((key.clone(), entry.value));
-            }
-            false
-        });
+        let mut evicted = self.retain_entries(|_, entry| entry.byte_len <= limits.max_entry_bytes);
         while self.used_bytes > limits.max_total_bytes {
             if let Some(entry) = self.pop_oldest() {
                 evicted.push(entry);
@@ -182,12 +166,43 @@ where
     }
 
     /// Removes all entries and releases all byte charges.
+    ///
+    /// This does not return removed entries; use [`Self::retain`] to take ownership.
     pub fn clear(&mut self) {
         self.entries.clear();
         self.insertion_order.clear();
         self.used_bytes = 0;
     }
 
+    /// Visits each value once in insertion order, returning removed entries in
+    /// that order and releasing their byte charges. Mutating a value does not
+    /// change its recorded charge; use [`Self::insert`] to replace its charge.
+    pub fn retain(&mut self, mut keep: impl FnMut(&K, &mut V) -> bool) -> Vec<(K, V)> {
+        self.retain_entries(|key, entry| keep(key, &mut entry.value))
+    }
+
+    fn retain_entries(
+        &mut self,
+        mut keep: impl FnMut(&K, &mut BufferedValue) -> bool,
+    ) -> Vec<(K, V)> {
+        let mut removed = Vec::new();
+        self.insertion_order.retain(|key| {
+            if self
+                .entries
+                .get_mut(key)
+                .is_some_and(|entry| keep(key, entry))
+            {
+                return true;
+            }
+            if let Some(entry) = self.entries.remove(key) {
+                self.used_bytes -= entry.byte_len;
+                removed.push((key.clone(), entry.value));
+            }
+            false
+        });
+        removed
+    }
+
     fn pop_oldest(&mut self) -> Option<(K, V)> {
         while let Some(key) = self.insertion_order.pop_front() {
             let Some(entry) = self.entries.remove(&key) else {
diff --git a/moli-bounded-buffer/src/tests.rs b/moli-bounded-buffer/src/tests.rs
index 0f3b57d566..d002720a5f 100644
--- a/moli-bounded-buffer/src/tests.rs
+++ b/moli-bounded-buffer/src/tests.rs
@@ -154,3 +154,37 @@ fn remove_and_clear_return_all_byte_charges() {
     assert!(buffer.is_empty());
     assert_eq!(buffer.used_bytes(), 0);
 }
+
+#[test]
+fn retain_visits_once_preserves_fifo_and_releases_only_removed_charges() {
+    let mut buffer = BoundedByteBuffer::new(ByteLimits::new(10, 10));
+    for key in 0..4 {
+        let _ = buffer.insert(key, key * 10, 2);
+    }
+    let mut visited = Vec::new();
+    let removed = buffer.retain(|key, value| {
+        visited.push(*key);
+        *value += 1;
+        key % 2 == 0
+    });
+    assert_eq!(visited, vec![0, 1, 2, 3]);
+    assert_eq!(removed, vec![(1, 11), (3, 31)]);
+    assert_eq!(buffer.used_bytes(), 4);
+    assert_eq!(buffer.get(&2), Some(&21));
+    assert_eq!(
+        buffer.insert(4, 40, 8),
+        InsertOutcome::Stored {
+            evicted: vec![(0, 1)]
+        }
+    );
+    assert!(buffer.retain(|_, _| true).is_empty());
+    assert_eq!(buffer.used_bytes(), 10);
+    assert_eq!(buffer.retain(|_, _| false), vec![(2, 21), (4, 40)]);
+    assert_eq!(buffer.used_bytes(), 0);
+    assert!(buffer.is_empty());
+    assert!(
+        buffer
+            .retain(|_, _| panic!("empty buffer must not visit"))
+            .is_empty()
+    );
+}
diff --git a/moli-fetch/src/lib.rs b/moli-fetch/src/lib.rs
index 90c1e6ca89..9484127cd8 100644
--- a/moli-fetch/src/lib.rs
+++ b/moli-fetch/src/lib.rs
@@ -72,7 +72,8 @@ pub use request::{
     RequestAuthScheme, RequestAuthTarget, RequestCacheMode, RequestCredentialsMode,
     RequestHeaderOverride, RequestMode, RequestPriorityHints, RequestRedirectMode,
     RequestResourceType, ResourceLoadPriority, ScriptFetchRequestMetadata,
-    ScriptFetchSchedulerPriority, SubresourceRequestMetadata,
+    ScriptFetchSchedulerPriority, SubresourceRequestMetadata, is_request_body_header_name,
+    redirect_status_rewrites_to_get,
 };
 pub use request_policy::{is_bad_port, should_request_be_blocked_due_to_bad_port};
 pub use response::{
diff --git a/moli-fetch/src/request.rs b/moli-fetch/src/request.rs
index 16cc65e9ec..b2cf21adc5 100644
--- a/moli-fetch/src/request.rs
+++ b/moli-fetch/src/request.rs
@@ -1122,12 +1122,14 @@ impl Request {
     }
 }
 
-fn redirect_status_rewrites_to_get(status: u16, method: &str) -> bool {
+/// Whether following this HTTP redirect replaces the request method with GET.
+pub fn redirect_status_rewrites_to_get(status: u16, method: &str) -> bool {
     status == 303 && !method.eq_ignore_ascii_case("GET") && !method.eq_ignore_ascii_case("HEAD")
         || matches!(status, 301 | 302) && method.eq_ignore_ascii_case("POST")
 }
 
-fn is_request_body_header_name(name: &str) -> bool {
+/// Whether a header must be removed when a redirect discards the request body.
+pub fn is_request_body_header_name(name: &str) -> bool {
     matches!(
         name.to_ascii_lowercase().as_str(),
         "content-encoding"
diff --git a/moli-protocol-server/src/protocol_server/tests/classic.rs b/moli-protocol-server/src/protocol_server/tests/classic.rs
index 6f40732dd3..0a52f5e99a 100644
--- a/moli-protocol-server/src/protocol_server/tests/classic.rs
+++ b/moli-protocol-server/src/protocol_server/tests/classic.rs
@@ -11623,7 +11623,6 @@ async fn webdriver_classic_click_uses_top_level_pointer_coordinates_inside_offse
 }
 
 #[tokio::test]
-#[ignore = "Pending iframe overlay click fix; expectations verified against Chromium"]
 async fn webdriver_classic_frame_target_click_is_invariant_under_neutral_frame_wrapping() {
     for depth in 0..=2 {
         for selector in ["#target", "#container"] {
@@ -11710,7 +11709,6 @@ async fn webdriver_classic_frame_target_click_is_invariant_under_neutral_frame_w
 }
 
 #[tokio::test]
-#[ignore = "Pending ancestor overlay click fix; expectations verified against Chromium"]
 async fn webdriver_classic_frame_click_rejects_ancestor_overlays_without_dispatch() {
     for (depth, blocked_level) in [(1, 0), (2, 0), (2, 1)] {
         let app = build_router(test_state());
diff --git a/moli-protocol/src/domains/network/agent.rs b/moli-protocol/src/domains/network/agent.rs
index eea278c026..aa8aead026 100644
--- a/moli-protocol/src/domains/network/agent.rs
+++ b/moli-protocol/src/domains/network/agent.rs
@@ -1402,18 +1402,12 @@ impl CapturedResponseBodyStore {
     fn revoke_retained_visibility(&mut self, session_id: Option<&str>) {
         // Revocation affects only old-document claims, not ordinary access to
         // responses from the current document.
-        for request_id in &self.retained_request_ids {
-            if let Some(body) = self.bodies.get_mut(request_id)
-                && !body.remove_session_visibility(session_id)
-            {
-                self.bodies.remove(request_id);
-            }
-            if let Some(body) = self.buffered_bodies.get_mut(request_id)
-                && !body.remove_session_visibility(session_id)
-            {
-                self.buffered_bodies.remove(request_id);
-            }
-        }
+        let keep = |request_id: &String, body: &mut CapturedResponseBody| {
+            !self.retained_request_ids.contains(request_id)
+                || body.remove_session_visibility(session_id)
+        };
+        self.bodies.retain(keep);
+        self.buffered_bodies.retain(keep);
     }
 
     fn effective_durable_limits(&self) -> ByteLimits {
@@ -1462,13 +1456,20 @@ impl CapturedResponseBodyStore {
     }
 
     fn trim_durable_entries(&mut self) {
+        let mut expired_payloads = HashSet::new();
         while self.durable_entry_order.len() > DURABLE_RESPONSE_BODY_MAX_ENTRIES {
             if let Some(id) = self.durable_entry_order.pop_front() {
                 self.bodies.remove(&id);
-                self.buffered_bodies.remove(&id);
                 self.retained_request_ids.remove(&id);
+                if self.buffered_bodies.contains_key(&id) {
+                    expired_payloads.insert(id);
+                }
             }
         }
+        if !expired_payloads.is_empty() {
+            self.buffered_bodies
+                .retain(|id, _| !expired_payloads.contains(id));
+        }
     }
 
     fn prepare_navigation(&mut self) {
@@ -1485,16 +1486,7 @@ impl CapturedResponseBodyStore {
                 && !matches!(body.state, CapturedResponseBodyState::Pending)
         };
         self.bodies.retain(|_, body| retain(body));
-        let ids = self
-            .buffered_bodies
-            .iter()
-            .map(|(id, _)| id.clone())
-            .collect::>();
-        for id in ids {
-            if !self.buffered_bodies.get_mut(&id).is_some_and(retain) {
-                self.buffered_bodies.remove(&id);
-            }
-        }
+        self.buffered_bodies.retain(|_, body| retain(body));
         self.retained_request_ids = self
             .bodies
             .keys()
@@ -1638,20 +1630,8 @@ impl CapturedResponseBodyStore {
         self.bodies
             .retain(|_, body| body.remove_session_visibility(session_id));
 
-        let buffered_request_ids = self
-            .buffered_bodies
-            .iter()
-            .map(|(request_id, _)| request_id.clone())
-            .collect::>();
-        for request_id in buffered_request_ids {
-            let retain = self
-                .buffered_bodies
-                .get_mut(request_id.as_str())
-                .is_some_and(|body| body.remove_session_visibility(session_id));
-            if !retain {
-                self.buffered_bodies.remove(request_id.as_str());
-            }
-        }
+        self.buffered_bodies
+            .retain(|_, body| body.remove_session_visibility(session_id));
     }
 
     #[cfg(test)]
diff --git a/moli-protocol/src/domains/network/redirect_request.rs b/moli-protocol/src/domains/network/redirect_request.rs
index 01dbebf0d2..459caf9430 100644
--- a/moli-protocol/src/domains/network/redirect_request.rs
+++ b/moli-protocol/src/domains/network/redirect_request.rs
@@ -1,6 +1,6 @@
 /// Project the request metadata for successive followed redirect hops.
 ///
-/// This mirrors FetchRequest::apply_redirect_status: the emitted CDP request
+/// This shares FetchRequest::apply_redirect_status rules: the emitted CDP request
 /// must describe the new hop, not repeat the original POST after it became GET.
 pub(super) struct RedirectRequest<'a> {
     pub(super) method: &'a str,
@@ -22,23 +22,11 @@ impl<'a> RedirectRequest<'a> {
     }
 
     pub(super) fn follow(&mut self, status: u16) {
-        let becomes_get = matches!(status, 301 | 302) && self.method.eq_ignore_ascii_case("POST")
-            || status == 303
-                && !self.method.eq_ignore_ascii_case("GET")
-                && !self.method.eq_ignore_ascii_case("HEAD");
-        if becomes_get {
+        if moli_fetch::redirect_status_rewrites_to_get(status, self.method) {
             self.method = "GET";
             self.body = None;
-            self.headers.retain(|(name, _)| {
-                !matches!(
-                    name.to_ascii_lowercase().as_str(),
-                    "content-encoding"
-                        | "content-language"
-                        | "content-length"
-                        | "content-location"
-                        | "content-type"
-                )
-            });
+            self.headers
+                .retain(|(name, _)| !moli_fetch::is_request_body_header_name(name));
         }
     }
 }
diff --git a/moli/src/telemetry.rs b/moli/src/telemetry.rs
index 97a30d277e..626b89d53a 100644
--- a/moli/src/telemetry.rs
+++ b/moli/src/telemetry.rs
@@ -40,9 +40,6 @@ mod tests {
 
     struct SharedWriterGuard(Arc>>);
 
-    #[derive(Clone, Copy)]
-    struct BrokenWriter;
-
     impl Write for SharedWriterGuard {
         fn write(&mut self, buffer: &[u8]) -> io::Result {
             self.0.lock().extend_from_slice(buffer);
@@ -54,30 +51,6 @@ mod tests {
         }
     }
 
-    impl Write for BrokenWriter {
-        fn write(&mut self, _buffer: &[u8]) -> io::Result {
-            Err(io::Error::new(
-                io::ErrorKind::BrokenPipe,
-                "diagnostic sink is gone",
-            ))
-        }
-
-        fn flush(&mut self) -> io::Result<()> {
-            Err(io::Error::new(
-                io::ErrorKind::BrokenPipe,
-                "diagnostic sink is gone",
-            ))
-        }
-    }
-
-    impl<'writer> MakeWriter<'writer> for BrokenWriter {
-        type Writer = Self;
-
-        fn make_writer(&'writer self) -> Self::Writer {
-            *self
-        }
-    }
-
     impl<'writer> MakeWriter<'writer> for SharedWriter {
         type Writer = SharedWriterGuard;
 
@@ -138,17 +111,28 @@ mod tests {
 
     #[test]
     fn broken_diagnostic_writer_does_not_panic() {
-        let subscriber = tracing_subscriber::fmt()
-            .without_time()
-            .with_ansi(false)
-            .with_target(false)
-            .with_writer(BrokenWriter)
-            .with_env_filter(first_party_filter("error"))
-            .log_internal_errors(false)
-            .finish();
-
-        tracing::subscriber::with_default(subscriber, || {
-            tracing::error!("broken diagnostic writer regression");
-        });
+        const CHILD: &str = "MOLI_TEST_BROKEN_DIAGNOSTIC_SINK";
+        if std::env::var_os(CHILD).is_some() {
+            super::init("error");
+            tracing::error!("the diagnostic receiver has already exited");
+            return;
+        }
+        let (reader, writer) = std::io::pipe().unwrap();
+        drop(reader);
+        let status = std::process::Command::new(std::env::current_exe().unwrap())
+            .args([
+                "--exact",
+                "telemetry::tests::broken_diagnostic_writer_does_not_panic",
+                "--nocapture",
+            ])
+            .env(CHILD, "1")
+            .stdout(std::process::Stdio::null())
+            .stderr(writer)
+            .status()
+            .unwrap();
+        assert!(
+            status.success(),
+            "logging to a closed diagnostic pipe terminated the process: {status}"
+        );
     }
 }

From 4d33077c7516e875b50b0a4114da1bfdca3d3c6a Mon Sep 17 00:00:00 2001
From: kevin 
Date: Sun, 20 Sep 2026 21:07:26 +0800
Subject: [PATCH 07/23] fix(layout): invalidate adopted stylesheet geometry

---
 .../context_host/host_environment.rs          |  1 +
 .../src/script_vm/tests/dom_xhr/misc.rs       | 77 +++++++++++++++++++
 2 files changed, 78 insertions(+)

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 1fd11f3e2a..89c2e6a692 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
@@ -876,6 +876,7 @@ impl JsContextHost {
         document: DomHandle,
         installations: Vec,
     ) {
+        self.mark_layout_input_dirty();
         self.style_engine
             .set_document_adopted_style_sheet_installations(document, installations);
     }
diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs
index 25128bf4d2..84efab80e6 100644
--- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs
+++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs
@@ -1463,6 +1463,83 @@ return [
     assert_eq!(result, "38|8|88");
 }
 
+#[test]
+fn adopted_stylesheet_geometry_refreshes_across_script_turns_without_rendering() {
+    for shadow in [false, true] {
+        let mut vm = new_parsed_test_vm(
+            "https://adopted-sheet-layout.test/",
+            "
", + ); + vm.eval(&format!(r#" + globalThis.styleRoot = {root}; + styleRoot.innerHTML = '
'; + globalThis.target = styleRoot.querySelector('#target'); + globalThis.sheet = new CSSStyleSheet(); + sheet.replaceSync('#target {{width:40px;height:10px}}'); + "#, root = if shadow {"document.getElementById('host').attachShadow({mode:'open'})"} else {"document.body"})) + .expect("create the same baseline rules in document and shadow scopes"); + // Document adoption is on Document, while its test markup lives in body. + vm.eval(if shadow { + "globalThis.adopter=styleRoot;" + } else { + "globalThis.adopter=document;" + }) + .unwrap(); + let read = "[target.getBoundingClientRect().width,parseFloat(getComputedStyle(target).width),target.offsetWidth].join('|')"; + assert_eq!(vm.eval(read).unwrap(), "20|20|20"); + for (mutation, width) in [ + ("adopter.adoptedStyleSheets=[sheet];", 40), + ("sheet.replaceSync('#target {width:80px;height:10px}');", 80), + ("sheet.deleteRule(0);", 20), + ( + "sheet.insertRule('#target {width:120px;height:10px}',0);", + 120, + ), + ("adopter.adoptedStyleSheets=[];", 20), + ("adopter.adoptedStyleSheets.push(sheet);", 120), + ("adopter.adoptedStyleSheets.pop();", 20), + ] { + let before = vm + ._context_host + .borrow() + .layout_pass_observability_for_test() + .1; + vm.eval(mutation).unwrap(); + assert_eq!( + vm._context_host + .borrow() + .layout_pass_observability_for_test() + .1, + before, + "stylesheet changes must not eagerly build layout: shadow={shadow} {mutation}" + ); + let expected = format!("{width}|{width}|{width}"); + assert_eq!( + vm.eval(read).unwrap(), + expected, + "shadow={shadow} {mutation}" + ); + assert_eq!( + vm._context_host + .borrow() + .layout_pass_observability_for_test() + .1, + before + 1, + "first geometry read must refresh exactly once: shadow={shadow} {mutation}" + ); + assert_eq!(vm.eval(read).unwrap(), expected); + assert_eq!( + vm._context_host + .borrow() + .layout_pass_observability_for_test() + .1, + before + 1, + "a later clean script turn must reuse layout" + ); + } + } +} + #[test] fn geometry_getters_refresh_after_mutation_and_reuse_clean_layout() { let mut vm = new_storage_test_vm("https://oneshot-layout-demand.test/"); From a92847de3972dc5d22afdb6a5d0f7042e713a671 Mon Sep 17 00:00:00 2001 From: kevin Date: Sun, 20 Sep 2026 21:07:26 +0800 Subject: [PATCH 08/23] test(cdp): declare redirect expectations explicitly --- .../src/domains/network/redirect_request.rs | 66 ++++++++++++++----- 1 file changed, 50 insertions(+), 16 deletions(-) diff --git a/moli-protocol/src/domains/network/redirect_request.rs b/moli-protocol/src/domains/network/redirect_request.rs index 459caf9430..132cbe72e3 100644 --- a/moli-protocol/src/domains/network/redirect_request.rs +++ b/moli-protocol/src/domains/network/redirect_request.rs @@ -37,22 +37,56 @@ mod tests { #[test] fn redirect_status_method_matrix_matches_fetch_semantics() { - for status in [301, 302, 303, 307, 308] { - for method in ["GET", "HEAD", "POST", "PUT", "DELETE", "post"] { - let headers = vec![ - ("Content-Type".into(), "text/plain".into()), - ("CONTENT-LENGTH".into(), "5".into()), - ("Accept".into(), "*/*".into()), - ]; - let mut request = RedirectRequest::new(method, Some("value"), &headers); - request.follow(status); - let changed = matches!(status, 301 | 302) && method.eq_ignore_ascii_case("POST") - || status == 303 && !matches!(method, "GET" | "HEAD"); - assert_eq!(request.method, if changed { "GET" } else { method }); - assert_eq!(request.body, if changed { None } else { Some("value") }); - assert_eq!(request.headers.len(), if changed { 1 } else { 3 }); - assert_eq!(headers.len(), 3, "original metadata stays unchanged"); - } + // Explicit HTTP expectations, independent of the implementation's + // classification formula. None means the redirect discards the body. + for (status, method, expected_method, expected_body) in [ + (301, "GET", "GET", Some("value")), + (301, "HEAD", "HEAD", Some("value")), + (301, "POST", "GET", None), + (301, "PUT", "PUT", Some("value")), + (301, "DELETE", "DELETE", Some("value")), + (301, "post", "GET", None), + (302, "GET", "GET", Some("value")), + (302, "HEAD", "HEAD", Some("value")), + (302, "POST", "GET", None), + (302, "PUT", "PUT", Some("value")), + (302, "DELETE", "DELETE", Some("value")), + (302, "post", "GET", None), + (303, "GET", "GET", Some("value")), + (303, "HEAD", "HEAD", Some("value")), + (303, "POST", "GET", None), + (303, "PUT", "GET", None), + (303, "DELETE", "GET", None), + (303, "post", "GET", None), + (307, "GET", "GET", Some("value")), + (307, "HEAD", "HEAD", Some("value")), + (307, "POST", "POST", Some("value")), + (307, "PUT", "PUT", Some("value")), + (307, "DELETE", "DELETE", Some("value")), + (307, "post", "post", Some("value")), + (308, "GET", "GET", Some("value")), + (308, "HEAD", "HEAD", Some("value")), + (308, "POST", "POST", Some("value")), + (308, "PUT", "PUT", Some("value")), + (308, "DELETE", "DELETE", Some("value")), + (308, "post", "post", Some("value")), + ] { + let headers = vec![ + ("Content-Type".into(), "text/plain".into()), + ("CONTENT-LENGTH".into(), "5".into()), + ("Accept".into(), "*/*".into()), + ]; + let mut request = RedirectRequest::new(method, Some("value"), &headers); + request.follow(status); + assert_eq!(request.method, expected_method, "{status} {method}"); + assert_eq!(request.body, expected_body, "{status} {method}"); + let expected_headers = if expected_body.is_none() { + vec![("Accept".into(), "*/*".into())] + } else { + headers.clone() + }; + assert_eq!(request.headers, expected_headers, "{status} {method}"); + assert_eq!(headers.len(), 3, "original metadata stays unchanged"); } } From 2715f79056ae5e01c8ae07fcc0d04a3dd0f7cf9e Mon Sep 17 00:00:00 2001 From: kevin Date: Sun, 20 Sep 2026 21:07:27 +0800 Subject: [PATCH 09/23] test(curl): wait for proxy fixture connections --- moli-curl/src/proxy.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/moli-curl/src/proxy.rs b/moli-curl/src/proxy.rs index 0288698d27..3341a02ace 100644 --- a/moli-curl/src/proxy.rs +++ b/moli-curl/src/proxy.rs @@ -579,21 +579,21 @@ mod tests { assert!(connected_port == origin_port || connected_port == proxy_port); let curl_uses_proxy = connected_port == proxy_port; let listener = if curl_uses_proxy { &proxy } else { &origin }; - // A completed client connect can precede the listener's - // accept readiness on macOS. Bound that handoff explicitly. + // Client connect completion does not guarantee the server's + // nonblocking accept queue is observable in the same turn. let deadline = Instant::now() + Duration::from_secs(2); let _connection = loop { match listener.accept() { Ok(connection) => break connection, Err(error) - if error.kind() == std::io::ErrorKind::WouldBlock - && Instant::now() < deadline => + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted + ) && Instant::now() < deadline => { - std::thread::sleep(Duration::from_millis(1)); - } - Err(error) => { - panic!("accept {host} with NO_PROXY={no_proxy:?}: {error}") + std::thread::sleep(Duration::from_millis(5)); } + Err(error) => panic!("libcurl's selected endpoint did not accept: {error}"), } }; From df44de533a7d371c1319684f351de0485b45dce4 Mon Sep 17 00:00:00 2001 From: kevin Date: Sun, 20 Sep 2026 22:03:30 +0800 Subject: [PATCH 10/23] fix(forms): preserve detached document property semantics --- .../detached_objects/builders/elements.rs | 13 +- moli-renderer-v8/src/native_bridge/element.rs | 3 +- .../src/native_bridge/element/forms.rs | 3 +- .../element/forms/form_element.rs | 136 +++++++++--------- .../native_bridge/element/template_install.rs | 1 + .../template_install/accessors_forms.rs | 1 + .../accessors_forms/controls.rs | 9 +- .../tests/dom_xhr/forms/named_lookup.rs | 54 ++++++- 8 files changed, 141 insertions(+), 79 deletions(-) diff --git a/moli-renderer-v8/src/native_bridge/document/detached_objects/builders/elements.rs b/moli-renderer-v8/src/native_bridge/document/detached_objects/builders/elements.rs index b88da42ac5..c6d3981c92 100644 --- a/moli-renderer-v8/src/native_bridge/document/detached_objects/builders/elements.rs +++ b/moli-renderer-v8/src/native_bridge/document/detached_objects/builders/elements.rs @@ -487,9 +487,16 @@ pub(in crate::native_bridge::document) fn build_detached_element_object<'s>( let mut object = if let Some(proto) = element_interface_prototype(scope, owner_document, prototype_name, html_interface_like) { - DetachedElementObjectDeclaration::new(proto, to_string_tag) - .bind(scope) - .ok()? + let declaration = DetachedElementObjectDeclaration::new(proto, to_string_tag); + if html_interface_like && local_name == "form" { + let template = v8::ObjectTemplate::new(scope); + crate::native_bridge::element::install_form_property_handlers(template); + let object = template.new_instance(scope)?; + declaration.bind_into(scope, object).ok()?; + object + } else { + declaration.bind(scope).ok()? + } } else { new_detached_object_with_prototype(scope, "__detachedElementPrototype", to_string_tag)? }; diff --git a/moli-renderer-v8/src/native_bridge/element.rs b/moli-renderer-v8/src/native_bridge/element.rs index c3f184a220..e66535788c 100644 --- a/moli-renderer-v8/src/native_bridge/element.rs +++ b/moli-renderer-v8/src/native_bridge/element.rs @@ -676,7 +676,8 @@ use stylesheets::{ style_blocking_setter_function, style_disabled_getter_function, style_disabled_setter_function, }; pub(super) use template_install::{ - install_specialized_instance_properties, install_specialized_template, + install_form_property_handlers, install_specialized_instance_properties, + install_specialized_template, }; pub(super) use tree_mutation::{ node_insert_adjacent_element_callback, node_insert_adjacent_html_callback, diff --git a/moli-renderer-v8/src/native_bridge/element/forms.rs b/moli-renderer-v8/src/native_bridge/element/forms.rs index 88cf9cd664..a1a0111b61 100644 --- a/moli-renderer-v8/src/native_bridge/element/forms.rs +++ b/moli-renderer-v8/src/native_bridge/element/forms.rs @@ -18,7 +18,7 @@ use super::super::{ current_or_live_delegate_node_arg_handle, insert_before_in_reaction_scope, insert_before_to_current_reaction_queue, node_is_element, node_or_foreign_arg_handle, node_or_foreign_arg_handle_allow_detached, node_runtime_and_handle_from_args, - node_runtime_and_handle_from_args_or_detached, node_runtime_and_handle_from_object, + node_runtime_and_handle_from_args_or_detached, node_runtime_and_handle_from_object_or_detached, remove_child_in_reaction_scope, remove_child_to_current_reaction_queue, set_text_content_in_reaction_scope, set_wrapped_node_or_null, @@ -224,7 +224,6 @@ pub(in crate::native_bridge) use self::simple_controls::{ progress_max_setter_function, progress_position_getter_function, progress_value_getter_function, progress_value_setter_function, }; -pub(crate) use self::submission::submit_form_with_submit_event; pub(in crate::native_bridge) use self::submission::{ FormAssociatedResetCallbackTiming, form_request_submit_callback, form_reset_callback, form_submit_callback, reset_form_default_action, diff --git a/moli-renderer-v8/src/native_bridge/element/forms/form_element.rs b/moli-renderer-v8/src/native_bridge/element/forms/form_element.rs index a112c714be..4bd57e7827 100644 --- a/moli-renderer-v8/src/native_bridge/element/forms/form_element.rs +++ b/moli-renderer-v8/src/native_bridge/element/forms/form_element.rs @@ -544,13 +544,14 @@ pub(in crate::native_bridge) fn form_target_setter_function<'s>( rv.set_undefined(); } -pub(in crate::native_bridge) fn form_named_getter( - scope: &mut v8::PinScope<'_, '_>, +pub(in crate::native_bridge) fn form_named_getter<'s>( + scope: &mut v8::PinScope<'s, '_>, key: v8::Local<'_, v8::Name>, - args: v8::PropertyCallbackArguments<'_>, + args: v8::PropertyCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) -> v8::Intercepted { - let Ok((runtime_ptr, handle)) = node_runtime_and_handle_from_object(scope, args.holder()) + let Ok((runtime_ptr, handle)) = + node_runtime_and_handle_from_object_or_detached(scope, args.holder()) else { return v8::Intercepted::kNo; }; @@ -587,33 +588,32 @@ pub(in crate::native_bridge) fn form_named_getter( let Some(past_handle) = runtime.form_past_named_item(handle, &key) else { return v8::Intercepted::kNo; }; - let Some(node) = runtime - .native_bridge_mut() - .wrap_handle(scope, runtime_ptr, past_handle) + let Some(node) = + wrapped_handle_value_for_receiver(scope, runtime_ptr, args.holder(), past_handle) else { return v8::Intercepted::kNo; }; - rv.set(node.into()); + rv.set(node); return v8::Intercepted::kYes; }; runtime.remember_form_past_named_item(handle, key, match_handle); - let Some(node) = runtime - .native_bridge_mut() - .wrap_handle(scope, runtime_ptr, match_handle) + let Some(node) = + wrapped_handle_value_for_receiver(scope, runtime_ptr, args.holder(), match_handle) else { return v8::Intercepted::kNo; }; - rv.set(node.into()); + rv.set(node); v8::Intercepted::kYes } -pub(in crate::native_bridge) fn form_named_descriptor( - scope: &mut v8::PinScope<'_, '_>, +pub(in crate::native_bridge) fn form_named_descriptor<'s>( + scope: &mut v8::PinScope<'s, '_>, key: v8::Local<'_, v8::Name>, - args: v8::PropertyCallbackArguments<'_>, + args: v8::PropertyCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) -> v8::Intercepted { - let Ok((runtime_ptr, handle)) = node_runtime_and_handle_from_object(scope, args.holder()) + let Ok((runtime_ptr, handle)) = + node_runtime_and_handle_from_object_or_detached(scope, args.holder()) else { return v8::Intercepted::kNo; }; @@ -646,24 +646,22 @@ pub(in crate::native_bridge) fn form_named_descriptor( list.into() } else if let Some(match_handle) = matches.first().copied() { runtime.remember_form_past_named_item(handle, key, match_handle); - let Some(node) = runtime - .native_bridge_mut() - .wrap_handle(scope, runtime_ptr, match_handle) + let Some(node) = + wrapped_handle_value_for_receiver(scope, runtime_ptr, args.holder(), match_handle) else { return v8::Intercepted::kNo; }; - node.into() + node } else { let Some(past_handle) = runtime.form_past_named_item(handle, &key) else { return v8::Intercepted::kNo; }; - let Some(node) = runtime - .native_bridge_mut() - .wrap_handle(scope, runtime_ptr, past_handle) + let Some(node) = + wrapped_handle_value_for_receiver(scope, runtime_ptr, args.holder(), past_handle) else { return v8::Intercepted::kNo; }; - node.into() + node }; let Ok(descriptor) = DataPropertyDescriptorDeclaration::new(value, false, false).bind(scope) else { @@ -673,13 +671,14 @@ pub(in crate::native_bridge) fn form_named_descriptor( v8::Intercepted::kYes } -pub(in crate::native_bridge) fn form_named_deleter( - scope: &mut v8::PinScope<'_, '_>, +pub(in crate::native_bridge) fn form_named_deleter<'s>( + scope: &mut v8::PinScope<'s, '_>, key: v8::Local<'_, v8::Name>, - args: v8::PropertyCallbackArguments<'_>, + args: v8::PropertyCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Boolean>, ) -> v8::Intercepted { - let Ok((runtime_ptr, handle)) = node_runtime_and_handle_from_object(scope, args.holder()) + let Ok((runtime_ptr, handle)) = + node_runtime_and_handle_from_object_or_detached(scope, args.holder()) else { return v8::Intercepted::kNo; }; @@ -699,14 +698,15 @@ pub(in crate::native_bridge) fn form_named_deleter( v8::Intercepted::kYes } -pub(in crate::native_bridge) fn form_named_definer( - scope: &mut v8::PinScope<'_, '_>, +pub(in crate::native_bridge) fn form_named_definer<'s>( + scope: &mut v8::PinScope<'s, '_>, key: v8::Local<'_, v8::Name>, _desc: &v8::PropertyDescriptor, - args: v8::PropertyCallbackArguments<'_>, + args: v8::PropertyCallbackArguments<'s>, _rv: v8::ReturnValue<'_, v8::Boolean>, ) -> v8::Intercepted { - let Ok((runtime_ptr, handle)) = node_runtime_and_handle_from_object(scope, args.holder()) + let Ok((runtime_ptr, handle)) = + node_runtime_and_handle_from_object_or_detached(scope, args.holder()) else { return v8::Intercepted::kNo; }; @@ -788,13 +788,14 @@ fn form_has_named_item_or_past_name( || runtime.form_past_named_item(form_handle, key).is_some() } -pub(in crate::native_bridge) fn form_indexed_getter( - scope: &mut v8::PinScope<'_, '_>, +pub(in crate::native_bridge) fn form_indexed_getter<'s>( + scope: &mut v8::PinScope<'s, '_>, index: u32, - args: v8::PropertyCallbackArguments<'_>, + args: v8::PropertyCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) -> v8::Intercepted { - let Ok((runtime_ptr, form_handle)) = node_runtime_and_handle_from_object(scope, args.holder()) + let Ok((runtime_ptr, form_handle)) = + node_runtime_and_handle_from_object_or_detached(scope, args.holder()) else { return v8::Intercepted::kNo; }; @@ -814,13 +815,14 @@ pub(in crate::native_bridge) fn form_indexed_getter( v8::Intercepted::kYes } -pub(in crate::native_bridge) fn form_indexed_query( - scope: &mut v8::PinScope<'_, '_>, +pub(in crate::native_bridge) fn form_indexed_query<'s>( + scope: &mut v8::PinScope<'s, '_>, index: u32, - args: v8::PropertyCallbackArguments<'_>, + args: v8::PropertyCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Integer>, ) -> v8::Intercepted { - let Ok((runtime_ptr, form_handle)) = node_runtime_and_handle_from_object(scope, args.holder()) + let Ok((runtime_ptr, form_handle)) = + node_runtime_and_handle_from_object_or_detached(scope, args.holder()) else { return v8::Intercepted::kNo; }; @@ -839,14 +841,15 @@ pub(in crate::native_bridge) fn form_indexed_query( v8::Intercepted::kYes } -pub(in crate::native_bridge) fn form_indexed_setter( - scope: &mut v8::PinScope<'_, '_>, +pub(in crate::native_bridge) fn form_indexed_setter<'s>( + scope: &mut v8::PinScope<'s, '_>, index: u32, _value: v8::Local<'_, v8::Value>, - args: v8::PropertyCallbackArguments<'_>, + args: v8::PropertyCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Boolean>, ) -> v8::Intercepted { - let Ok((runtime_ptr, form_handle)) = node_runtime_and_handle_from_object(scope, args.holder()) + let Ok((runtime_ptr, form_handle)) = + node_runtime_and_handle_from_object_or_detached(scope, args.holder()) else { return v8::Intercepted::kNo; }; @@ -857,13 +860,14 @@ pub(in crate::native_bridge) fn form_indexed_setter( v8::Intercepted::kYes } -pub(in crate::native_bridge) fn form_indexed_descriptor( - scope: &mut v8::PinScope<'_, '_>, +pub(in crate::native_bridge) fn form_indexed_descriptor<'s>( + scope: &mut v8::PinScope<'s, '_>, index: u32, - args: v8::PropertyCallbackArguments<'_>, + args: v8::PropertyCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) -> v8::Intercepted { - let Ok((runtime_ptr, form_handle)) = node_runtime_and_handle_from_object(scope, args.holder()) + let Ok((runtime_ptr, form_handle)) = + node_runtime_and_handle_from_object_or_detached(scope, args.holder()) else { return v8::Intercepted::kNo; }; @@ -887,13 +891,14 @@ pub(in crate::native_bridge) fn form_indexed_descriptor( v8::Intercepted::kYes } -pub(in crate::native_bridge) fn form_indexed_deleter( - scope: &mut v8::PinScope<'_, '_>, +pub(in crate::native_bridge) fn form_indexed_deleter<'s>( + scope: &mut v8::PinScope<'s, '_>, index: u32, - args: v8::PropertyCallbackArguments<'_>, + args: v8::PropertyCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Boolean>, ) -> v8::Intercepted { - let Ok((runtime_ptr, form_handle)) = node_runtime_and_handle_from_object(scope, args.holder()) + let Ok((runtime_ptr, form_handle)) = + node_runtime_and_handle_from_object_or_detached(scope, args.holder()) else { return v8::Intercepted::kNo; }; @@ -904,14 +909,15 @@ pub(in crate::native_bridge) fn form_indexed_deleter( v8::Intercepted::kYes } -pub(in crate::native_bridge) fn form_indexed_enumerator( - scope: &mut v8::PinScope<'_, '_>, - args: v8::PropertyCallbackArguments<'_>, +pub(in crate::native_bridge) fn form_indexed_enumerator<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::PropertyCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Array>, ) { #[cfg(test)] crate::native_bridge::element::record_form_lookup_enumeration_for_test(); - let Ok((runtime_ptr, form_handle)) = node_runtime_and_handle_from_object(scope, args.holder()) + let Ok((runtime_ptr, form_handle)) = + node_runtime_and_handle_from_object_or_detached(scope, args.holder()) else { rv.set(v8::Array::new(scope, 0)); return; @@ -922,14 +928,15 @@ pub(in crate::native_bridge) fn form_indexed_enumerator( rv.set(v8::Array::new_with_elements(scope, &keys)); } -pub(in crate::native_bridge) fn form_indexed_definer( - scope: &mut v8::PinScope<'_, '_>, +pub(in crate::native_bridge) fn form_indexed_definer<'s>( + scope: &mut v8::PinScope<'s, '_>, index: u32, _desc: &v8::PropertyDescriptor, - args: v8::PropertyCallbackArguments<'_>, + args: v8::PropertyCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Boolean>, ) -> v8::Intercepted { - let Ok((runtime_ptr, form_handle)) = node_runtime_and_handle_from_object(scope, args.holder()) + let Ok((runtime_ptr, form_handle)) = + node_runtime_and_handle_from_object_or_detached(scope, args.holder()) else { return v8::Intercepted::kNo; }; @@ -940,13 +947,14 @@ pub(in crate::native_bridge) fn form_indexed_definer( v8::Intercepted::kYes } -pub(in crate::native_bridge) fn form_named_query( - scope: &mut v8::PinScope<'_, '_>, +pub(in crate::native_bridge) fn form_named_query<'s>( + scope: &mut v8::PinScope<'s, '_>, key: v8::Local<'_, v8::Name>, - args: v8::PropertyCallbackArguments<'_>, + args: v8::PropertyCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Integer>, ) -> v8::Intercepted { - let Ok((runtime_ptr, handle)) = node_runtime_and_handle_from_object(scope, args.holder()) + let Ok((runtime_ptr, handle)) = + node_runtime_and_handle_from_object_or_detached(scope, args.holder()) else { return v8::Intercepted::kNo; }; diff --git a/moli-renderer-v8/src/native_bridge/element/template_install.rs b/moli-renderer-v8/src/native_bridge/element/template_install.rs index 412589a2bc..743f497f49 100644 --- a/moli-renderer-v8/src/native_bridge/element/template_install.rs +++ b/moli-renderer-v8/src/native_bridge/element/template_install.rs @@ -7,6 +7,7 @@ mod accessors_structural; mod instance; mod methods; +pub(in crate::native_bridge) use accessors_forms::install_form_property_handlers; use accessors_forms::install_form_template_accessors; use accessors_media::install_media_template_accessors; use accessors_resource::install_resource_template_accessors; diff --git a/moli-renderer-v8/src/native_bridge/element/template_install/accessors_forms.rs b/moli-renderer-v8/src/native_bridge/element/template_install/accessors_forms.rs index 9d48775d66..dffba5af3f 100644 --- a/moli-renderer-v8/src/native_bridge/element/template_install/accessors_forms.rs +++ b/moli-renderer-v8/src/native_bridge/element/template_install/accessors_forms.rs @@ -5,6 +5,7 @@ mod input; mod select; use self::controls::install_form_control_template_accessors; +pub(in crate::native_bridge) use self::controls::install_form_property_handlers; use self::input::install_input_template_accessors; use self::select::install_select_option_template_accessors; diff --git a/moli-renderer-v8/src/native_bridge/element/template_install/accessors_forms/controls.rs b/moli-renderer-v8/src/native_bridge/element/template_install/accessors_forms/controls.rs index 8e45434083..cce5cf6487 100644 --- a/moli-renderer-v8/src/native_bridge/element/template_install/accessors_forms/controls.rs +++ b/moli-renderer-v8/src/native_bridge/element/template_install/accessors_forms/controls.rs @@ -6,13 +6,13 @@ use crate::native_bridge::element::forms::{ }; pub(super) fn install_form_control_template_accessors<'s, 'i>( - scope: &mut v8::PinScope<'s, 'i, ()>, + _scope: &mut v8::PinScope<'s, 'i, ()>, template: v8::Local<'s, v8::ObjectTemplate>, installer: SpecializedTemplateInstaller, ) -> bool { match installer { SpecializedTemplateInstaller::HtmlFormElement => { - install_form_accessors(scope, template); + install_form_property_handlers(template); true } SpecializedTemplateInstaller::HtmlButtonElement @@ -29,9 +29,8 @@ pub(super) fn install_form_control_template_accessors<'s, 'i>( } } -fn install_form_accessors<'s, 'i>( - _scope: &mut v8::PinScope<'s, 'i, ()>, - template: v8::Local<'s, v8::ObjectTemplate>, +pub(in crate::native_bridge) fn install_form_property_handlers( + template: v8::Local<'_, v8::ObjectTemplate>, ) { template.set_indexed_property_handler( v8::IndexedPropertyHandlerConfiguration::new() diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms/named_lookup.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms/named_lookup.rs index b5177adadf..27c60ab28a 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms/named_lookup.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms/named_lookup.rs @@ -194,15 +194,11 @@ fn form_named_lookup_candidates_include_detached_shadow_and_child_documents() { } #[test] -#[ignore = "known baseline detached-document named-property limitation, including adopted wrappers"] fn form_named_lookup_detached_document_and_adopted_wrappers() { let mut vm = new_parsed_test_vm( "https://form-lookup-detached-document.test/", "", ); - // These expectations pass in Chromium. Both cases already fail on the - // pre-optimization Moli binary; keep them visible without treating that - // separate compatibility defect as a performance regression or a pass. let result = vm.eval(r#" (() => { const results=[]; @@ -253,3 +249,53 @@ fn form_named_lookup_candidates_do_not_cache_custom_element_eligibility() { "#).expect("named candidates are not a cached form eligibility decision"); assert_eq!(result, "ok"); } + +#[test] +fn form_named_lookup_preserves_identity_and_live_properties_across_document_scopes() { + let mut vm = new_parsed_test_vm( + "https://form-document-properties.test/", + "", + ); + let result = vm.eval(r#" + (() => { + for (const mode of ['active', 'detached', 'adopted']) { + const doc = mode === 'active' ? document : document.implementation.createHTMLDocument('forms'); + const f = doc.body.appendChild(doc.createElement('form')); + const input = f.appendChild(doc.createElement('input')); + input.name = 'originalKey'; + if (f.originalKey !== input) throw Error(mode + ': initial identity'); + if (mode === 'adopted') { + if (document.adoptNode(f) !== f) throw Error('adoption replaced wrapper'); + document.body.appendChild(f); + } + if (f[0] !== input || !(0 in f) || Object.getOwnPropertyDescriptor(f, '0').value !== input) + throw Error(mode + ': indexed identity'); + const d = Object.getOwnPropertyDescriptor(f, 'originalKey'); + if (d.value !== input || d.writable || d.enumerable || !d.configurable) + throw Error(mode + ': named descriptor'); + input.name = 'newKey'; + if (f.newKey !== input || f.originalKey !== input) throw Error(mode + ': past name'); + const second = f.appendChild(doc.createElement('input')); + second.name = 'newKey'; + const list = f.newKey; + if (!(list instanceof RadioNodeList) || list.length !== 2 || list[0] !== input || list[1] !== second) + throw Error(mode + ': multiple matches'); + second.remove(); + if (list.length !== 1 || f.newKey !== input) throw Error(mode + ': live list'); + input.name = 'submit'; + if (f.submit !== input || Object.getOwnPropertyDescriptor(f, 'submit').value !== input) + throw Error(mode + ': native member override'); + const symbol = Symbol('custom'); + f[symbol] = 42; + if (f[symbol] !== 42 || !delete f[symbol]) throw Error(mode + ': ordinary symbol'); + input.remove(); + if (f.originalKey !== undefined || f.newKey !== undefined || 'originalKey' in f || f[0] !== undefined) + throw Error(mode + ': removed control'); + if (typeof f.submit !== 'function') throw Error(mode + ': restored native member'); + f.remove(); + } + return 'ok'; + })() + "#).expect("form property behavior must not depend on Document attachment or adoption"); + assert_eq!(result, "ok"); +} From 7b0bb9f2778215ed1fce359ae0d34495e4aec9ce Mon Sep 17 00:00:00 2001 From: kevin Date: Mon, 21 Sep 2026 15:25:02 +0800 Subject: [PATCH 11/23] fix(review): preserve cache lifetime and validation contracts --- moli-fetch/src/tests/support.rs | 73 ++++++++++++++++++- moli-protocol/src/domains/network/agent.rs | 49 +++++++++++++ .../src/domains/network/output_queue.rs | 2 +- .../src/runtime/phase_one/state.rs | 17 +++++ .../src/runtime/phase_one/streaming.rs | 15 ++-- 5 files changed, 147 insertions(+), 9 deletions(-) diff --git a/moli-fetch/src/tests/support.rs b/moli-fetch/src/tests/support.rs index 4f7d026117..92f5c7b093 100644 --- a/moli-fetch/src/tests/support.rs +++ b/moli-fetch/src/tests/support.rs @@ -1003,14 +1003,85 @@ impl ScriptedHttpServer { } } +#[test] +fn scripted_server_waits_for_request_on_nonblocking_accepted_socket() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let mut client = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let (stream, _) = listener.accept().unwrap(); + // macOS may inherit this mode from the listening socket. + stream.set_nonblocking(true).unwrap(); + let hits = Arc::new(AtomicUsize::new(0)); + let requests = Arc::new(Mutex::new(Vec::new())); + let responses = Arc::new(Mutex::new(VecDeque::from([ScriptedResponse::ok("ready")]))); + let (done_tx, done_rx) = std_mpsc::channel(); + let handler = { + let hits = Arc::clone(&hits); + let requests = Arc::clone(&requests); + thread::spawn(move || { + handle_scripted_connection(stream, hits, requests, responses); + done_tx.send(()).unwrap(); + }) + }; + assert!( + matches!( + done_rx.recv_timeout(Duration::from_millis(100)), + Err(std_mpsc::RecvTimeoutError::Timeout) + ), + "a connection without HTTP bytes must not receive a scripted response" + ); + client + .write_all(b"GET /delayed HTTP/1.1\r\nHost: localhost\r\n\r\n") + .unwrap(); + let mut response = String::new(); + client.read_to_string(&mut response).unwrap(); + handler.join().unwrap(); + assert!(response.ends_with("ready")); + assert_eq!(hits.load(Ordering::SeqCst), 1); + assert!(requests.lock()[0].starts_with("GET /delayed HTTP/1.1\r\n")); +} + +#[test] +fn scripted_server_does_not_consume_response_for_closed_unused_connection() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let client = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (stream, _) = listener.accept().unwrap(); + drop(client); + let hits = Arc::new(AtomicUsize::new(0)); + let requests = Arc::new(Mutex::new(Vec::new())); + let responses = Arc::new(Mutex::new(VecDeque::from([ScriptedResponse::ok("ready")]))); + handle_scripted_connection( + stream, + Arc::clone(&hits), + Arc::clone(&requests), + Arc::clone(&responses), + ); + assert_eq!(hits.load(Ordering::SeqCst), 0); + assert!(requests.lock().is_empty()); + assert_eq!(responses.lock().len(), 1); +} + fn handle_scripted_connection( mut stream: std::net::TcpStream, hits: Arc, requests: Arc>>, responses: Arc>>, ) { + stream + .set_nonblocking(false) + .expect("scripted HTTP connection should use blocking reads"); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("scripted HTTP connection should have a bounded read"); let mut request = [0; 1024]; - let bytes_read = stream.read(&mut request).unwrap_or(0); + let bytes_read = stream + .read(&mut request) + .expect("read scripted HTTP request"); + if bytes_read == 0 { + return; + } let request_text = String::from_utf8_lossy(&request[..bytes_read]).into_owned(); requests.lock().push(request_text); let _ = hits.fetch_add(1, Ordering::SeqCst) + 1; diff --git a/moli-protocol/src/domains/network/agent.rs b/moli-protocol/src/domains/network/agent.rs index aa8aead026..c4ff1b7c8f 100644 --- a/moli-protocol/src/domains/network/agent.rs +++ b/moli-protocol/src/domains/network/agent.rs @@ -1408,6 +1408,14 @@ impl CapturedResponseBodyStore { }; self.bodies.retain(keep); self.buffered_bodies.retain(keep); + self.prune_removed_entry_indexes(); + } + + fn prune_removed_entry_indexes(&mut self) { + let exists = + |id: &String| self.bodies.contains_key(id) || self.buffered_bodies.contains_key(id); + self.durable_entry_order.retain(exists); + self.retained_request_ids.retain(exists); } fn effective_durable_limits(&self) -> ByteLimits { @@ -1632,6 +1640,7 @@ impl CapturedResponseBodyStore { self.buffered_bodies .retain(|_, body| body.remove_session_visibility(session_id)); + self.prune_removed_entry_indexes(); } #[cfg(test)] @@ -2144,6 +2153,46 @@ mod tests { assert!(store.durable_entry_order.is_empty()); } + #[test] + fn durable_response_body_revocation_does_not_evict_other_sessions_below_budget() { + for disable_retention in [false, true] { + let mut store = CapturedResponseBodyStore::default(); + for session in ["a", "b"] { + store.configure_durable( + Some(session), + Some(moli_bounded_buffer::ByteLimits::new(100_000, 100)), + ); + } + store.insert("keep".into(), "original".into(), [Some("a".into())]); + for n in 1..super::DURABLE_RESPONSE_BODY_MAX_ENTRIES { + store.insert(format!("b-{n}"), "other".into(), [Some("b".into())]); + } + if disable_retention { + store.prepare_navigation(); + store.configure_durable(Some("b"), None); + } else { + store.remove_session_visibility(Some("b")); + } + assert!(store.get("b-1").is_none()); + store.insert("new".into(), "next".into(), [Some("a".into())]); + assert_eq!( + store + .get("keep") + .expect("unrelated response must remain readable") + .body_bytes_limited(100) + .unwrap(), + b"original" + ); + assert_eq!( + store.get("new").unwrap().body_bytes_limited(100).unwrap(), + b"next" + ); + store.prepare_navigation(); + assert!(store.get("keep").unwrap().is_visible_to_session(Some("a"))); + assert!(!store.get("keep").unwrap().is_visible_to_session(Some("b"))); + } + } + #[test] fn durable_response_body_store_navigation_preserves_only_existing_opted_claims() { let mut store = CapturedResponseBodyStore::default(); diff --git a/moli-protocol/src/domains/network/output_queue.rs b/moli-protocol/src/domains/network/output_queue.rs index 5f8cd11179..a89f2019c3 100644 --- a/moli-protocol/src/domains/network/output_queue.rs +++ b/moli-protocol/src/domains/network/output_queue.rs @@ -3698,7 +3698,7 @@ mod tests { document_url, request_url.clone(), "POST".to_owned(), - vec![("Content-Type".to_owned(), "text/plain".to_owned())], + vec![("Content-Type".to_owned(), "text/plain".to_owned())].into(), Some("body".to_owned()), SubresourceResourceType::Fetch, SubresourceRequestInitiatorType::Script, diff --git a/moli-renderer-v8/src/runtime/phase_one/state.rs b/moli-renderer-v8/src/runtime/phase_one/state.rs index 1367df0c19..b5f1dc232b 100644 --- a/moli-renderer-v8/src/runtime/phase_one/state.rs +++ b/moli-renderer-v8/src/runtime/phase_one/state.rs @@ -34,6 +34,23 @@ impl ParseTimeDriverState { } } + pub(super) fn new_text(final_url: Url, mime: &str) -> Self { + Self { + parser_session: DocumentParserSession::start_main_text_document( + final_url.clone(), + mime, + ), + final_url, + document_character_set: "UTF-8".to_owned(), + scheduler: DocumentScriptScheduler::new(), + pending_parsing_blocking_script: PendingParsingBlockingClassicScriptRunner::empty(), + buffered_document_preloads: Box::default(), + service_worker_preload_context: None, + input_closed: false, + is_text_document: true, + } + } + pub(super) fn new_xml(final_url: Url) -> Self { Self { parser_session: DocumentParserSession::start_main_xml_document(final_url.clone()), diff --git a/moli-renderer-v8/src/runtime/phase_one/streaming.rs b/moli-renderer-v8/src/runtime/phase_one/streaming.rs index 52c164fb39..742ddb222b 100644 --- a/moli-renderer-v8/src/runtime/phase_one/streaming.rs +++ b/moli-renderer-v8/src/runtime/phase_one/streaming.rs @@ -637,14 +637,15 @@ fn response_document_parser( ) -> (ParseTimeDriverState, HtmlDocumentStreamingDecoder) { let content_type = moli_web_mime::response_document_content_type(headers); let text_type = content_type.filter(|mime| moli_web_mime::is_text_document_mime(mime)); - let mut state = ParseTimeDriverState::new_with_scripting_enabled( - final_url, - main_document_parser_scripting_enabled(env), - ); + let mut state = if let Some(mime) = &text_type { + ParseTimeDriverState::new_text(final_url, mime) + } else { + ParseTimeDriverState::new_with_scripting_enabled( + final_url, + main_document_parser_scripting_enabled(env), + ) + }; let decoder = if let Some(mime) = text_type { - state.parser_session = - DocumentParserSession::start_main_text_document(state.final_url.clone(), &mime); - state.is_text_document = true; HtmlDocumentStreamingDecoder::new_text_document( headers, state.final_url.as_str(), From 569b6ebaaa7441595c0e21ac63c62c1dd5247ad4 Mon Sep 17 00:00:00 2001 From: lanyue-llk <270302213+lanyue-llk@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:25:23 +0800 Subject: [PATCH 12/23] fix(browser): preserve layout and navigation invariants --- moli-layout/src/taffy_tree.rs | 5 +- .../src/actions.rs | 9 ---- moli-protocol/src/conn/runtime_load.rs | 49 ++++++++++++++----- .../context_host/child_documents/commit.rs | 8 --- .../context_host/host_environment.rs | 17 ++++++- .../src/script_vm/tests/element_click.rs | 2 +- 6 files changed, 56 insertions(+), 34 deletions(-) diff --git a/moli-layout/src/taffy_tree.rs b/moli-layout/src/taffy_tree.rs index e2327896a9..2fc147a9e2 100644 --- a/moli-layout/src/taffy_tree.rs +++ b/moli-layout/src/taffy_tree.rs @@ -2868,7 +2868,10 @@ where parent_width: child_inputs.parent_size.width, }); let line_y = state.line_y() as f32; - slot = inline_float_slot(block_context, line_y, None, 0.0); + // Preserve the edge slot for an empty inline boundary even + // when the float consumes all available inline space. The + // next nonempty line will search for a fitting slot below. + slot = block_context.find_content_slot(line_y, Clear::None, None); if slot.y > line_y { clearance = clearance.max(slot.y); } diff --git a/moli-protocol-webdriver-classic/src/actions.rs b/moli-protocol-webdriver-classic/src/actions.rs index 79a48a60e4..955cfec117 100644 --- a/moli-protocol-webdriver-classic/src/actions.rs +++ b/moli-protocol-webdriver-classic/src/actions.rs @@ -176,15 +176,6 @@ pub fn element_click_input_commands( 0, Some(0), ), - dispatch_mouse_event_command( - context, - DevToolsMouseEventType::Moved, - DevToolsPointerType::Mouse, - point.x, - point.y, - 0, - Some(0), - ), dispatch_mouse_event_command( context, DevToolsMouseEventType::Pressed, diff --git a/moli-protocol/src/conn/runtime_load.rs b/moli-protocol/src/conn/runtime_load.rs index 4ea23fe262..c3305bec82 100644 --- a/moli-protocol/src/conn/runtime_load.rs +++ b/moli-protocol/src/conn/runtime_load.rs @@ -592,16 +592,30 @@ async fn first_nonempty_response_body_chunk( Some(chunk) if chunk.is_empty() => continue, Some(chunk) => return Ok(Some(chunk)), None => { - response - .finish() - .await - .context("failed to read page body from stream")?; + response.finish().await?; return Ok(None); } } } } +fn failed_provisional_body_load( + error: anyhow::Error, + context: &str, +) -> anyhow::Result { + if error + .downcast_ref::() + .is_some_and(|failure| { + failure.network_error_text() == moli_fetch::NET_ERR_ABORTED_ERROR_TEXT + }) + { + return Ok(NavigationLoadOutcome::network_failure( + moli_fetch::NET_ERR_ABORTED_ERROR_TEXT.to_owned(), + )); + } + Err(error.context(context.to_owned())) +} + fn spawn_streaming_body_capture( mut response: StreamingRawResponse, initial_chunk: Option>, @@ -959,6 +973,11 @@ impl BackgroundNavigationLoadJob { network_error_text = failure.network_error_text(), "main document transport failed before response metadata" ); + if failure.network_error_text() == moli_fetch::NET_ERR_ABORTED_ERROR_TEXT { + return Ok(NavigationLoadOutcome::network_failure( + failure.network_error_text().to_owned(), + )); + } return prepare_network_error_page_navigation_with_engine_async( &mut engine, self.page_reservation, @@ -1363,9 +1382,12 @@ async fn build_navigation_from_streaming_raw_response_with_engine_async( body_progress_source.body_network_progress_for_completed_events(network_events); let body_progress_source_for_body_finish = body_progress_source.clone(); let mut initial_body_chunk = if response_status_may_use_http_error_page(response_status) { - match first_nonempty_response_body_chunk(&mut response).await? { - Some(chunk) => Some(chunk), - None => { + match first_nonempty_response_body_chunk(&mut response).await { + Err(error) => { + return failed_provisional_body_load(error, "failed to read page body from stream"); + } + Ok(Some(chunk)) => Some(chunk), + Ok(None) => { let body = CapturedBody::from_string(http_error_page_html(&final_url, response_status)); return prepare_browser_owned_error_page_navigation_with_engine_async( @@ -1400,10 +1422,9 @@ async fn build_navigation_from_streaming_raw_response_with_engine_async( .append(&chunk) .context("failed to capture XML page body")?; } - response - .finish() - .await - .context("failed to read XML page body from stream")?; + if let Err(error) = response.finish().await { + return failed_provisional_body_load(error, "failed to read XML page body from stream"); + } let captured_body = body_writer .finish() .context("failed to finish captured XML page body")?; @@ -1479,7 +1500,8 @@ async fn build_navigation_from_streaming_raw_response_with_engine_async( let (body_tx, body_rx) = mpsc::channel(EXTERNAL_RAW_BODY_CHANNEL_CAPACITY); let (completion_tx, completion_rx) = oneshot::channel(); - let raw_body = moli_core::runtime::ExternalRawDocumentBodyStream::new(body_rx, completion_rx); + let raw_body = moli_core::runtime::ExternalRawDocumentBodyStream::new(body_rx, completion_rx) + .with_stop_loading_cancellation(response.cancellation_handle()); let page_storage = load_inputs.page_storage_handles(); let main_document_commit = load_inputs .main_document_commit_for_final_url(&final_url, None) @@ -1702,7 +1724,8 @@ impl CdpConnection { let raw_body = moli_core::runtime::ExternalRawDocumentBodyStream::new( renderer_body_rx, renderer_completion_rx, - ); + ) + .with_stop_loading_cancellation(response.cancellation_handle()); let shared_resource_runtime = self.shared_resource_runtime_for_navigation_load_inputs(&load_inputs); let mut engine = self.navigation_engine_handle_for_load_inputs(&load_inputs); diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_documents/commit.rs b/moli-renderer-v8/src/native_bridge/context_host/child_documents/commit.rs index a1d27afdb6..1d4e2d9bcc 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_documents/commit.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_documents/commit.rs @@ -170,16 +170,8 @@ impl JsContextHost { navigation_loader: Option, is_xml_document: bool, ) -> Option { - let is_plain_text_document = snapshot - .content_type - .as_deref() - .is_some_and(|mime| mime.eq_ignore_ascii_case("text/plain")); let source = if is_xml_document { std::borrow::Cow::Borrowed(snapshot.markup.as_str()) - } else if is_plain_text_document { - std::borrow::Cow::Owned(crate::dom_parser::plain_text_document_parser_input( - &snapshot.markup, - )) } else { crate::dom_parser::preserve_decoded_bom_only_browsing_context_body( &snapshot.markup, 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 89c2e6a692..56ef91fbfc 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 @@ -811,12 +811,25 @@ impl JsContextHost { viewport_surface: Option, ) -> bool { let changed = self.viewport_surface != viewport_surface; - if changed { + let previous_style_viewport = self.style_viewport(); + let previous_emulated_view = self + .viewport_surface + .and_then(|surface| surface.emulated_view); + self.viewport_surface = viewport_surface; + if changed && previous_style_viewport != self.style_viewport() { self.style_engine .bump_target_context_epoch_for_document(self.document_handle()); self.mark_layout_input_dirty(); } - self.viewport_surface = viewport_surface; + if previous_emulated_view + != self + .viewport_surface + .and_then(|surface| surface.emulated_view) + { + self.document_layout_state + .borrow_mut() + .mark_visual_state_dirty(); + } changed } diff --git a/moli-renderer-v8/src/script_vm/tests/element_click.rs b/moli-renderer-v8/src/script_vm/tests/element_click.rs index 3bd3af5b44..7a04af1ab0 100644 --- a/moli-renderer-v8/src/script_vm/tests/element_click.rs +++ b/moli-renderer-v8/src/script_vm/tests/element_click.rs @@ -1,5 +1,5 @@ use super::*; -use crate::runtime::{RendererElementClickTarget, RendererPointerEventProperties}; +use crate::runtime::{RendererElementClickError, RendererElementClickTarget}; #[test] fn native_element_click_requires_explicit_layout_without_building_it() { From bcff7194e1ba9a1de8bfcd9c47f262e382f4fc14 Mon Sep 17 00:00:00 2001 From: lanyue-llk <270302213+lanyue-llk@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:47:00 +0800 Subject: [PATCH 13/23] fix(parser): omit doctype from text documents --- moli-parser/src/session.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/moli-parser/src/session.rs b/moli-parser/src/session.rs index 2cf345dc00..652f3e475b 100644 --- a/moli-parser/src/session.rs +++ b/moli-parser/src/session.rs @@ -207,7 +207,7 @@ impl HtmlParserSession { // Parse only the browser-owned shell. Response bytes enter the tokenizer // after it has switched to plaintext, so tags and entities stay literal. self.process(StrTendril::from(concat!( - "", + "", "
\n"
         )));
         self.tokenizer.set_plaintext_state();

From ff5e9cdade0e2cb813f7931bfc637a9dcb652ebe Mon Sep 17 00:00:00 2001
From: lanyue-llk <270302213+lanyue-llk@users.noreply.github.com>
Date: Fri, 25 Sep 2026 23:31:36 +0800
Subject: [PATCH 14/23] fix(rebase): preserve explicit layout publication

---
 .../src/protocol_server/tests/classic.rs      |   4 +
 .../src/native_bridge/context_host/layout.rs  |   8 +-
 .../context_host/layout_state.rs              |  25 +-
 .../page_vm/tests/computed_size/sampling.rs   |  38 +--
 .../tests/grid_resolved_track_values.rs       |  68 +---
 .../runtime/page_vm/tests/rendering_update.rs | 115 +------
 .../src/runtime/phase_one/state.rs            |   1 -
 .../tests/dom_elements/dom_surface.rs         | 291 ------------------
 .../src/script_vm/tests/dom_xhr/dom.rs        |  31 +-
 .../src/script_vm/tests/dom_xhr/misc.rs       |  81 +----
 .../src/script_vm/tests/element_click.rs      |   2 +-
 11 files changed, 45 insertions(+), 619 deletions(-)

diff --git a/moli-protocol-server/src/protocol_server/tests/classic.rs b/moli-protocol-server/src/protocol_server/tests/classic.rs
index 0a52f5e99a..17aecb3089 100644
--- a/moli-protocol-server/src/protocol_server/tests/classic.rs
+++ b/moli-protocol-server/src/protocol_server/tests/classic.rs
@@ -11643,6 +11643,7 @@ async fn webdriver_classic_frame_target_click_is_invariant_under_neutral_frame_w
                 json!({"url":classic_data_url(&html)}),
             )
             .await;
+            classic_capture_layout(app.clone(), session_id).await;
             for _ in 0..depth {
                 classic_request_json_with_body(
                     app.clone(),
@@ -11682,6 +11683,7 @@ async fn webdriver_classic_frame_target_click_is_invariant_under_neutral_frame_w
                     document.body.appendChild(veil);
                 "#,"args":[]}),
             ).await;
+            classic_capture_layout(app.clone(), session_id).await;
             let (status, blocked) = classic_request_status_and_json(
                 app.clone(),
                 Method::POST,
@@ -11728,6 +11730,7 @@ async fn webdriver_classic_frame_click_rejects_ancestor_overlays_without_dispatc
             json!({"url":classic_data_url(&html)}),
         )
         .await;
+        classic_capture_layout(app.clone(), session_id).await;
         for _ in 0..depth {
             assert_eq!(
                 classic_request_json_with_body(
@@ -11754,6 +11757,7 @@ async fn webdriver_classic_frame_click_rejects_ancestor_overlays_without_dispatc
                 json!({"script":overlay_script,"args":[if blocked {"block"} else {"none"}]}),
             )
             .await;
+            classic_capture_layout(app.clone(), session_id).await;
             let (status, clicked) = classic_request_status_and_json(
                 app.clone(),
                 Method::POST,
diff --git a/moli-renderer-v8/src/native_bridge/context_host/layout.rs b/moli-renderer-v8/src/native_bridge/context_host/layout.rs
index 9ccf8c7ac2..53e3c46899 100644
--- a/moli-renderer-v8/src/native_bridge/context_host/layout.rs
+++ b/moli-renderer-v8/src/native_bridge/context_host/layout.rs
@@ -466,12 +466,10 @@ impl JsContextHost {
             .mark_visual_state_dirty();
     }
 
+    /// Invalidates derived geometry and the next explicit visual publication
+    /// while retaining the last snapshot used by input and geometry readers.
     pub(crate) fn mark_layout_input_dirty(&self) {
-        debug_assert!(!self.layout_pass_active.get());
-        self.clear_layout_rect_cache();
-        self.document_layout_state
-            .borrow_mut()
-            .mark_latest_layout_dirty();
+        self.invalidate_layout_after_interaction_state_change();
     }
 
     pub(crate) fn document_web_font_resources_are_current(
diff --git a/moli-renderer-v8/src/native_bridge/context_host/layout_state.rs b/moli-renderer-v8/src/native_bridge/context_host/layout_state.rs
index ea38d5b777..574eebcd2f 100644
--- a/moli-renderer-v8/src/native_bridge/context_host/layout_state.rs
+++ b/moli-renderer-v8/src/native_bridge/context_host/layout_state.rs
@@ -43,7 +43,6 @@ pub(super) struct DocumentLayoutState {
     web_font_resource_generation: Option,
     visual_state_generation: u64,
     latest_layout: LatestLayoutTreeCache,
-    latest_layout_is_dirty: bool,
     /// Last used content viewport published by each live iframe owner's
     /// parent layout. Blink keeps the equivalent size on LocalFrameView; it is
     /// separate from the single latest-tree slot because a later fresh layout
@@ -137,16 +136,6 @@ impl DocumentLayoutState {
         self.latest_layout.publish(document, tree);
     }
 
-    pub(super) const fn latest_layout_is_dirty(&self) -> bool {
-        self.latest_layout_is_dirty
-    }
-
-    /// Retain the frozen tree for sampled consumers without permitting exact
-    /// geometry queries to reuse answers from before a layout input changed.
-    pub(super) fn mark_latest_layout_dirty(&mut self) {
-        self.latest_layout_is_dirty = true;
-    }
-
     pub(super) fn clear_latest_layout(&mut self) {
         self.latest_layout.clear();
         self.mark_visual_state_dirty();
@@ -239,24 +228,15 @@ impl DocumentLayoutState {
         &mut self,
         resources: impl IntoIterator,
     ) {
-        let registered_before = self.services.web_font_count();
         self.web_fonts
             .retain_active_slots(resources, &mut self.services);
-        if self.services.web_font_count() != registered_before {
-            self.mark_latest_layout_dirty();
-        }
     }
 
     pub(super) fn admit(
         &mut self,
         resource: StylesheetLoadBlockingResource,
     ) -> Option {
-        let registered_before = self.services.web_font_count();
-        let admitted = self.web_fonts.admit(resource, &mut self.services);
-        if self.services.web_font_count() != registered_before {
-            self.mark_latest_layout_dirty();
-        }
-        admitted
+        self.web_fonts.admit(resource, &mut self.services)
     }
 
     pub(super) fn complete(
@@ -264,9 +244,6 @@ impl DocumentLayoutState {
         terminal: CompletedStylesheetWebFont,
     ) -> DocumentWebFontCompletion {
         let completion = self.web_fonts.complete(terminal, &mut self.services);
-        if matches!(&completion, DocumentWebFontCompletion::Registered(_)) {
-            self.mark_latest_layout_dirty();
-        }
         if !matches!(&completion, DocumentWebFontCompletion::Stale) {
             self.mark_visual_state_dirty();
         }
diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/computed_size/sampling.rs b/moli-renderer-v8/src/runtime/page_vm/tests/computed_size/sampling.rs
index 46fa1825d1..8e80743fd1 100644
--- a/moli-renderer-v8/src/runtime/page_vm/tests/computed_size/sampling.rs
+++ b/moli-renderer-v8/src/runtime/page_vm/tests/computed_size/sampling.rs
@@ -77,7 +77,7 @@ async fn computed_size_held_getters_wait_for_explicit_publication() {
 }
 
 #[tokio::test(flavor = "current_thread")]
-async fn computed_size_grid_sampling_refreshes_dirty_geometry_on_first_demand() {
+async fn computed_size_grid_sampling_reuses_old_geometry_until_explicit_refresh() {
     run_page_vm_async_test(async move {
         let mut page = page_with_size_fixture(GRID)?;
         page.set_viewport_surface(Some(crate::protocol_types::ViewportSurface {
@@ -116,12 +116,6 @@ async fn computed_size_grid_sampling_refreshes_dirty_geometry_on_first_demand()
             'mutated'
         "#,
         )?;
-        assert_eq!(page.vm().layout_pass_observability_for_test().1, passes + 1);
-        assert_eq!(
-            page.vm().layout_snapshot_cache_observability_for_test(),
-            sampled,
-            "style mutation must only mark the retained snapshot dirty"
-        );
         assert_eq!(
             read_without_layout(&mut page, "held.color")?,
             json!("rgb(255, 0, 0)")
@@ -132,33 +126,13 @@ async fn computed_size_grid_sampling_refreshes_dirty_geometry_on_first_demand()
         );
         assert_eq!(
             page.vm_mut().eval("held.gridTemplateColumns")?,
-            "100px 100px"
-        );
-        assert_eq!(page.vm().layout_pass_observability_for_test().1, passes + 2);
-        assert_eq!(
-            held_sizes(&mut page)?,
-            json!(["200px", "40px", "200px", "40px"])
-        );
-        let refreshed = page.vm().layout_snapshot_cache_observability_for_test();
-        assert_eq!(
-            refreshed.2,
-            sampled.2 + 1,
-            "the first exact demand must publish one replacement tree"
+            "40px 120px"
         );
-
-        page.vm_mut().eval("'clean turn'")?;
-        assert_eq!(page.vm().layout_pass_observability_for_test().1, passes + 2);
+        assert_eq!(page.vm().layout_pass_observability_for_test().1, passes + 1);
         assert_eq!(
-            page.vm().layout_snapshot_cache_observability_for_test(),
-            refreshed,
-            "a clean turn must keep the replacement tree reusable"
+            page.vm().layout_snapshot_cache_observability_for_test().2,
+            sampled.2
         );
-        assert_eq!(
-            page.vm_mut().eval("held.gridTemplateColumns")?,
-            "100px 100px"
-        );
-        assert_eq!(page.vm().layout_pass_observability_for_test().1, passes + 2);
-
         publish_size_layout(&mut page)?;
         assert_eq!(
             held_sizes(&mut page)?,
@@ -168,7 +142,7 @@ async fn computed_size_grid_sampling_refreshes_dirty_geometry_on_first_demand()
             page.vm_mut().eval("held.gridTemplateColumns")?,
             "100px 100px"
         );
-        assert_eq!(page.vm().layout_pass_observability_for_test().1, passes + 3);
+        assert_eq!(page.vm().layout_pass_observability_for_test().1, passes + 2);
         Ok::<_, anyhow::Error>(())
     })
     .await
diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/grid_resolved_track_values.rs b/moli-renderer-v8/src/runtime/page_vm/tests/grid_resolved_track_values.rs
index 83485f053a..4804f8a3b1 100644
--- a/moli-renderer-v8/src/runtime/page_vm/tests/grid_resolved_track_values.rs
+++ b/moli-renderer-v8/src/runtime/page_vm/tests/grid_resolved_track_values.rs
@@ -1,7 +1,7 @@
 use super::*;
 
 #[tokio::test(flavor = "current_thread")]
-async fn computed_style_refreshes_dirty_used_grid_tracks_on_exact_demand() {
+async fn computed_style_serializes_used_grid_tracks_from_the_frozen_layout_tree() {
     run_page_vm_async_test(async move {
         let loader =
             crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader");
@@ -89,82 +89,20 @@ vertical:getComputedStyle(document.getElementById('vertical')).gridTemplateColum
             "resolved horizontal Grid longhands must expose used tracks while preserving expanded line names, without publishing physical-axis values for vertical Grid",
         );
 
-        let passes_before_mutation = page_vm.vm().layout_pass_observability_for_test().1;
-        let cache_before_mutation = page_vm
-            .vm()
-            .layout_snapshot_cache_observability_for_test();
-
         page_vm
             .vm_mut()
             .eval("document.getElementById('named').style.cssText='width:400px;grid-template-columns:[new] 1fr 1fr';'mutated'")?;
-        assert_eq!(
-            page_vm.vm().layout_pass_observability_for_test().1,
-            passes_before_mutation,
-            "a style mutation must mark layout dirty without eagerly rebuilding it",
-        );
-        assert_eq!(
-            page_vm
-                .vm()
-                .layout_snapshot_cache_observability_for_test(),
-            cache_before_mutation,
-            "marking the current frozen tree dirty must not discard or replace it eagerly",
-        );
-
         assert_eq!(
             page_vm.vm_mut().eval(
                 "getComputedStyle(document.getElementById('named')).gridTemplateColumns",
             )?,
-            "[new] 200px 200px",
-            "an exact used-track read must refresh a dirty layout before answering",
+            "[a] 21px [b c] 22px [d] 23px [e c] 22px [d] 23px [e f] 189px [g]",
+            "a synchronous style read must stay on the last published layout epoch",
         );
-        let passes_after_demand = page_vm.vm().layout_pass_observability_for_test().1;
-        let cache_after_demand = page_vm
-            .vm()
-            .layout_snapshot_cache_observability_for_test();
-        assert_eq!(passes_after_demand, passes_before_mutation + 1);
-        assert_eq!(cache_after_demand.0, cache_before_mutation.0);
-        assert_eq!(cache_after_demand.1, cache_before_mutation.1 + 1);
-        assert_eq!(cache_after_demand.2, cache_before_mutation.2 + 1);
-
-        page_vm.vm_mut().eval("'clean turn'")?;
-        assert_eq!(
-            page_vm.vm().layout_pass_observability_for_test().1,
-            passes_after_demand,
-            "a clean script turn must retain the freshly published layout",
-        );
-        assert_eq!(
-            page_vm.vm_mut().eval(
-                "getComputedStyle(document.getElementById('named')).gridTemplateColumns",
-            )?,
-            "[new] 200px 200px",
-        );
-        let cache_after_reuse = page_vm
-            .vm()
-            .layout_snapshot_cache_observability_for_test();
-        assert_eq!(
-            page_vm.vm().layout_pass_observability_for_test().1,
-            passes_after_demand,
-            "a repeated exact read across a clean turn must reuse the frozen tree",
-        );
-        assert_eq!(cache_after_reuse.0, cache_after_demand.0 + 1);
-        assert_eq!(cache_after_reuse.1, cache_after_demand.1);
-        assert_eq!(cache_after_reuse.2, cache_after_demand.2);
-
         page_vm
             .vm_mut()
             .screenshot_layout_snapshot(moli_layout::PaintViewport::new(400, 300, 1.0))?
             .expect("updated used Grid track CSSOM screenshot layout");
-        assert_eq!(
-            page_vm.vm().layout_pass_observability_for_test().1,
-            passes_after_demand + 1,
-            "a screenshot remains a force-fresh paint boundary even when the tree is clean",
-        );
-        let cache_after_screenshot = page_vm
-            .vm()
-            .layout_snapshot_cache_observability_for_test();
-        assert_eq!(cache_after_screenshot.0, cache_after_reuse.0);
-        assert_eq!(cache_after_screenshot.1, cache_after_reuse.1);
-        assert_eq!(cache_after_screenshot.2, cache_after_reuse.2 + 1);
         assert_eq!(
             page_vm.vm_mut().eval(
                 "getComputedStyle(document.getElementById('named')).gridTemplateColumns",
diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs b/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs
index e2a9aa778c..750ef75bf6 100644
--- a/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs
+++ b/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs
@@ -234,14 +234,6 @@ document.body.innerHTML = '
< page_vm .vm_mut() .eval("document.getElementById('target').style.width='180px'; 'mutated'")?; - let after_mutation = page_vm.vm().layout_pass_observability_for_test(); - let cache_after_mutation = page_vm - .vm() - .layout_snapshot_cache_observability_for_test(); - assert_eq!(after_mutation.1, after_first.1); - assert_eq!(cache_after_mutation, cache_after_first); - - let second_viewport = moli_layout::LayoutViewport::new(480, 300, 2.0); let second = moli_layout::GeometryProvider::answer( page_vm.vm_mut(), &batch, @@ -261,18 +253,19 @@ document.body.innerHTML = '
< } answer => panic!("unexpected box-model answer: {answer:?}"), }; - assert!((second_width - 180.0).abs() <= 0.05, "{second_width}"); - assert_eq!(second.metrics.reason, moli_layout::LayoutFlushReason::SynchronousGeometry); - assert_eq!(second.metrics.paint_operation_count, 0); + assert!((second_width - 120.0).abs() <= 0.05, "{second_width}"); + assert_eq!(after_second.2, after_first.2); + assert_eq!(after_second.3, after_first.3); + assert_eq!(second.metrics, first.metrics); assert!(matches!( second.answers[0], moli_layout::LayoutQueryAnswer::DocumentMetrics(metrics) - if metrics.viewport == second_viewport + if metrics.viewport == viewport )); let snapshot = page_vm .vm_mut() - .screenshot_layout_snapshot(moli_layout::LayoutViewport::new(320, 200, 1.0))? + .screenshot_layout_snapshot(viewport)? .expect("current document screenshot layout"); let after_screenshot = page_vm.vm().layout_pass_observability_for_test(); let cache_after_screenshot = page_vm @@ -303,20 +296,6 @@ document.body.innerHTML = '
< && diagnostic.code != "scroll-paint-deferred" })); - page_vm.vm_mut().eval("'clean turn'")?; - assert_eq!( - page_vm.vm().layout_pass_observability_for_test().1, - after_screenshot.1, - "a clean script turn must not eagerly rebuild layout" - ); - assert_eq!( - page_vm - .vm() - .layout_snapshot_cache_observability_for_test(), - cache_after_screenshot, - "turn-exit side-cache cleanup must retain the clean frozen tree" - ); - let third = moli_layout::GeometryProvider::answer( page_vm.vm_mut(), &batch, @@ -393,81 +372,21 @@ document.body.innerHTML = '
'; page_vm .vm_mut() .eval("document.getElementById('target').style.width='80px'; 'mutated'")?; - assert_eq!( - page_vm.vm().layout_pass_observability_for_test().1, - passes_before + 1, - "the style mutation must not eagerly rebuild layout", - ); - assert_eq!( - page_vm - .vm() - .layout_snapshot_cache_observability_for_test(), - cache_after_first, - "marking the first frame dirty must retain its frozen tree until demand", - ); - - let refreshed_on_demand = moli_layout::GeometryProvider::answer( - page_vm.vm_mut(), - &batch, - )?; - let refreshed_on_demand_width = match &refreshed_on_demand.answers[0] { - moli_layout::LayoutQueryAnswer::BoxModel(Some(model)) => { - model.border.bounding_rect().width - } - answer => panic!("unexpected refreshed box-model answer: {answer:?}"), - }; - assert!( - (refreshed_on_demand_width - 80.0).abs() <= 0.05, - "{refreshed_on_demand_width}" - ); - assert_eq!( - page_vm.vm().layout_pass_observability_for_test().1, - passes_before + 2 - ); - let cache_after_demand = page_vm - .vm() - .layout_snapshot_cache_observability_for_test(); - assert_eq!(cache_after_demand.0, cache_before.0); - assert_eq!(cache_after_demand.1, cache_before.1 + 1); - assert_eq!(cache_after_demand.2, cache_before.2 + 2); - - page_vm.vm_mut().eval("'clean turn'")?; - assert_eq!( - page_vm.vm().layout_pass_observability_for_test().1, - passes_before + 2, - "a clean script turn must not rebuild the demanded layout", - ); - assert_eq!( - page_vm - .vm() - .layout_snapshot_cache_observability_for_test(), - cache_after_demand, - "turn-exit side-cache cleanup must retain the clean frozen tree", - ); - let reused = moli_layout::GeometryProvider::answer( + let stale = moli_layout::GeometryProvider::answer( page_vm.vm_mut(), - moli_layout::LayoutFlushReason::SynchronousGeometry, - moli_layout::LayoutViewport::new(320, 200, 1.0), &batch, )?; - let reused_width = match &reused.answers[0] { + let stale_width = match &stale.answers[0] { moli_layout::LayoutQueryAnswer::BoxModel(Some(model)) => { model.border.bounding_rect().width } - answer => panic!("unexpected reused box-model answer: {answer:?}"), + answer => panic!("unexpected stale box-model answer: {answer:?}"), }; - assert!((reused_width - 80.0).abs() <= 0.05, "{reused_width}"); + assert!((stale_width - 40.0).abs() <= 0.05, "{stale_width}"); assert_eq!( page_vm.vm().layout_pass_observability_for_test().1, - passes_before + 2, - "a repeated exact read across a clean turn must reuse the frozen tree", + passes_before + 1 ); - let cache_after_reuse = page_vm - .vm() - .layout_snapshot_cache_observability_for_test(); - assert_eq!(cache_after_reuse.0, cache_before.0 + 1); - assert_eq!(cache_after_reuse.1, cache_before.1 + 1); - assert_eq!(cache_after_reuse.2, cache_before.2 + 2); page_vm .vm_mut() @@ -478,14 +397,14 @@ document.body.innerHTML = '
'; .expect("second screencast frame layout"); assert_eq!( page_vm.vm().layout_pass_observability_for_test().1, - passes_before + 3 + passes_before + 2 ); let cache_after_second = page_vm .vm() .layout_snapshot_cache_observability_for_test(); assert_eq!(cache_after_second.0, cache_before.0 + 1); - assert_eq!(cache_after_second.1, cache_before.1 + 1); - assert_eq!(cache_after_second.2, cache_before.2 + 3); + assert_eq!(cache_after_second.1, cache_before.1); + assert_eq!(cache_after_second.2, cache_before.2 + 2); assert!(cache_after_second.3.is_some()); let refreshed = moli_layout::GeometryProvider::answer( @@ -501,14 +420,14 @@ document.body.innerHTML = '
'; assert!((refreshed_width - 80.0).abs() <= 0.05, "{refreshed_width}"); assert_eq!( page_vm.vm().layout_pass_observability_for_test().1, - passes_before + 3 + passes_before + 2 ); let cache_after_query = page_vm .vm() .layout_snapshot_cache_observability_for_test(); assert_eq!(cache_after_query.0, cache_before.0 + 2); - assert_eq!(cache_after_query.1, cache_before.1 + 1); - assert_eq!(cache_after_query.2, cache_before.2 + 3); + assert_eq!(cache_after_query.1, cache_before.1); + assert_eq!(cache_after_query.2, cache_before.2 + 2); Ok::<_, anyhow::Error>(()) }) .await diff --git a/moli-renderer-v8/src/runtime/phase_one/state.rs b/moli-renderer-v8/src/runtime/phase_one/state.rs index b5f1dc232b..1ded75f43a 100644 --- a/moli-renderer-v8/src/runtime/phase_one/state.rs +++ b/moli-renderer-v8/src/runtime/phase_one/state.rs @@ -43,7 +43,6 @@ impl ParseTimeDriverState { final_url, document_character_set: "UTF-8".to_owned(), scheduler: DocumentScriptScheduler::new(), - pending_parsing_blocking_script: PendingParsingBlockingClassicScriptRunner::empty(), buffered_document_preloads: Box::default(), service_worker_preload_context: None, input_closed: false, diff --git a/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface.rs b/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface.rs index c55703c5ad..8de1c77bbb 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface.rs @@ -1,296 +1,5 @@ use super::*; -#[test] -fn offset_parent_refreshes_a_connected_insertion_before_any_other_geometry_read() { - let mut vm = new_storage_test_vm("https://offset-parent-layout.test/"); - vm.eval( - r#" - if (!document.documentElement) document.appendChild(document.createElement('html')); - if (!document.head) document.documentElement.appendChild(document.createElement('head')); - if (!document.body) document.documentElement.appendChild(document.createElement('body')); - document.head.innerHTML = ``; - document.body.innerHTML = '
'; - document.getElementById('existing').getBoundingClientRect().width; - globalThis.menu = document.createElement('div'); - menu.id = 'menu'; - globalThis.candidate = document.createElement('button'); - candidate.id = 'candidate'; - candidate.textContent = 'Abishek S abisubramanya27'; - menu.appendChild(candidate); - 'installed'; - "#, - ) - .expect("fixture and initial layout should evaluate"); - - let passes_after_initial_layout = vm.layout_pass_observability_for_test().1; - assert_eq!( - vm.eval("candidate.offsetParent === null && candidate.offsetWidth === 0") - .unwrap(), - "true", - "geometry for a detached candidate must remain empty" - ); - assert_eq!( - vm.layout_pass_observability_for_test().1, - passes_after_initial_layout, - "building and querying a detached subtree must not invalidate live layout" - ); - - vm.eval("document.body.appendChild(menu); 'connected'") - .expect("connected insertion should evaluate"); - assert_eq!( - vm.layout_pass_observability_for_test().1, - passes_after_initial_layout, - "the connected insertion must mark layout dirty without rebuilding it eagerly" - ); - - let result = vm - .eval( - r#" - (() => { - const parent = candidate.offsetParent; - const width = candidate.offsetWidth; - return JSON.stringify({ parentIsMenu: parent === menu, width }); - })() - "#, - ) - .expect("offsetParent-first geometry read should evaluate"); - assert_eq!( - result, r#"{"parentIsMenu":true,"width":240}"#, - "offsetParent must refresh a dirty tree before answering for the inserted element" - ); - assert_eq!( - vm.layout_pass_observability_for_test().1, - passes_after_initial_layout + 1, - "offsetParent and the following width read should share one fresh pass" - ); - - assert_eq!( - vm.eval("candidate.offsetParent === menu && candidate.offsetWidth === 240") - .unwrap(), - "true" - ); - assert_eq!( - vm.layout_pass_observability_for_test().1, - passes_after_initial_layout + 1, - "clean geometry reads must reuse the refreshed tree" - ); -} - -#[test] -fn geometry_reuses_clean_layout_after_detached_subtree_mutations() { - let mut vm = new_storage_test_vm("https://layout-detached.test/"); - vm.eval( - r#" - if (!document.documentElement) document.appendChild(document.createElement('html')); - if (!document.body) document.documentElement.appendChild(document.createElement('body')); - document.body.innerHTML = '
'; - globalThis.target = document.getElementById('target'); - globalThis.detached = document.createElement('div'); - target.getBoundingClientRect().width; - "#, - ) - .expect("fixture should evaluate"); - let passes = vm.layout_pass_observability_for_test().1; - for mutation in [ - "detached.textContent = 'not on the page'", - "detached.appendChild(document.createElement('span'))", - "detached.style.width = '300px'", - "detached.setAttribute('data-wide', '')", - "detached.firstChild.data = 'changed text'", - ] { - vm.eval(mutation) - .expect("detached mutation should evaluate"); - assert_eq!( - vm.eval("target.getBoundingClientRect().width").unwrap(), - "40" - ); - assert_eq!( - vm.layout_pass_observability_for_test().1, - passes, - "detached mutation must not invalidate page layout: {mutation}" - ); - } -} - -#[test] -fn geometry_refreshes_pseudo_class_inputs_on_demand() { - let mut vm = new_storage_test_vm("https://layout-state.test/"); - vm.eval(r#" - if (!document.documentElement) document.appendChild(document.createElement('html')); - if (!document.body) document.documentElement.appendChild(document.createElement('body')); - document.body.innerHTML = ''; - globalThis.target = document.getElementById('target'); - target.getBoundingClientRect().width; - "#).expect("fixture should evaluate"); - let mut passes = vm.layout_pass_observability_for_test().1; - for (mutation, expected) in [ - ("target.focus({preventScroll: true})", "80"), - ("target.checked = true", "120"), - ("target.checked = false", "80"), - ("target.blur()", "40"), - ] { - vm.eval(mutation).expect("element state should change"); - assert_eq!(vm.layout_pass_observability_for_test().1, passes); - assert_eq!( - vm.eval("target.getBoundingClientRect().width").unwrap(), - expected, - "geometry must reflect changed pseudo class: {mutation}" - ); - passes += 1; - assert_eq!(vm.layout_pass_observability_for_test().1, passes); - assert_eq!( - vm.eval("target.getBoundingClientRect().width").unwrap(), - expected - ); - assert_eq!(vm.layout_pass_observability_for_test().1, passes); - } -} - -#[test] -fn geometry_refreshes_attribute_selectors_and_cssom_on_demand() { - let mut vm = new_storage_test_vm("https://layout-inputs.test/"); - vm.eval(r#" - if (!document.documentElement) document.appendChild(document.createElement('html')); - if (!document.body) document.documentElement.appendChild(document.createElement('body')); - document.body.innerHTML = '
'; - globalThis.target = document.getElementById('target'); - target.getBoundingClientRect().width; - "#).expect("fixture should evaluate"); - let mut passes = vm.layout_pass_observability_for_test().1; - for (mutation, expected) in [ - ("target.setAttribute('data-wide', '')", "80"), - ( - "document.getElementById('rules').sheet.insertRule('#target[data-wide] { width: 120px }', 2)", - "120", - ), - ("target.removeAttribute('data-wide')", "40"), - ] { - vm.eval(mutation).expect("layout input should change"); - assert_eq!( - vm.layout_pass_observability_for_test().1, - passes, - "input changes must not eagerly lay out: {mutation}" - ); - assert_eq!( - vm.eval("target.getBoundingClientRect().width").unwrap(), - expected - ); - passes += 1; - assert_eq!(vm.layout_pass_observability_for_test().1, passes); - assert_eq!( - vm.eval("target.getBoundingClientRect().width").unwrap(), - expected - ); - assert_eq!( - vm.layout_pass_observability_for_test().1, - passes, - "clean geometry must be reusable across script turns" - ); - } -} - -#[test] -fn geometry_refreshes_inserted_and_existing_nodes_on_demand() { - let mut vm = new_storage_test_vm("https://layout-mutation.test/"); - - vm.eval( - r#" - (() => { - if (!document.documentElement) { - document.appendChild(document.createElement("html")); - } - if (!document.body) { - document.documentElement.appendChild(document.createElement("body")); - } - document.body.innerHTML = '
Marker
'; - return document.getElementById("marker").getBoundingClientRect().top; - })() - "#, - ) - .expect("initial layout read should evaluate"); - - let before_top = vm - .eval("document.getElementById('marker').getBoundingClientRect().top") - .expect("initial marker geometry should evaluate") - .parse::() - .expect("initial marker top should be numeric"); - - let passes_before = vm.layout_pass_observability_for_test().1; - vm.eval( - r#"document.getElementById("content").insertAdjacentHTML( - "afterbegin", - '
' - )"#, - ) - .expect("connected insertion should evaluate"); - assert_eq!( - vm.layout_pass_observability_for_test().1, - passes_before, - "a DOM mutation alone must not run layout" - ); - assert!( - vm.layout_snapshot_cache_observability_for_test() - .3 - .is_some(), - "mutation should retain the sampled frozen tree" - ); - - let result = vm - .eval( - r#" - (() => { - const rect = document.getElementById("target").getBoundingClientRect(); - const marker = document.getElementById("marker").getBoundingClientRect(); - return JSON.stringify({ target: [rect.width, rect.height], markerTop: marker.top }); - })() - "#, - ) - .expect("geometry after a connected insertion should evaluate"); - - assert_eq!( - vm.layout_pass_observability_for_test().1, - passes_before + 1, - "all post-mutation geometry reads should share one fresh pass" - ); - - let result: serde_json::Value = - serde_json::from_str(&result).expect("geometry result should be JSON"); - assert!( - result["target"][0] - .as_f64() - .is_some_and(|width| width > 0.0) - && result["target"][1] - .as_f64() - .is_some_and(|height| height > 0.0), - "inserted content should receive real geometry: {result}" - ); - assert!( - result["markerTop"] - .as_f64() - .is_some_and(|top| top > before_top), - "existing geometry must not be reused after a preceding sibling is inserted: {result}" - ); - vm.eval("const content = document.getElementById('content'); content.firstElementChild.remove(); content.firstElementChild.remove()") - .expect("connected removals should evaluate"); - assert_eq!( - vm.layout_pass_observability_for_test().1, - passes_before + 1, - "removal itself must not run layout" - ); - assert_eq!( - vm.eval("document.getElementById('marker').getBoundingClientRect().top") - .unwrap() - .parse::() - .unwrap(), - before_top, - "removing previously connected siblings must invalidate old geometry" - ); - assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 2); -} - #[test] fn svg_create_rect_supports_capability_detection_and_detached_float_values() { let mut vm = new_storage_test_vm("https://svg-rect.test/"); diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom.rs index 2b67228d69..08d2bdb924 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom.rs @@ -8697,11 +8697,8 @@ for (let index = 0; index < 128; index++) { ); assert_eq!(layout_after_first.1, layout_before.1 + 1); assert_eq!(layout_after_repeated.1, layout_after_first.1); - assert_eq!(layout_after_second.1, layout_after_first.1 + 1); - assert_eq!( - layout_after_stylesheet_mutation.1, - layout_after_second.1 + 1 - ); + assert_eq!(layout_after_second.1, layout_after_first.1); + assert_eq!(layout_after_stylesheet_mutation.1, layout_after_first.1); assert_eq!( update_materializations_after_first.saturating_sub(update_materializations_before), 1, @@ -8768,25 +8765,13 @@ fn inner_text_new_sources_wait_for_a_fresh_paint_layout() { ); assert_eq!(vm.layout_pass_observability_for_test().1, passes_before); - vm.eval("const added = document.createElement('span'); added.textContent = 'b'; target.append(added)") - .expect("text insertion should evaluate"); - assert_eq!( - vm.layout_pass_observability_for_test().1, - passes_before + 1, - "text insertion alone must not run layout" - ); assert_eq!( - vm.eval("target.innerText") - .expect("the warm innerText read should evaluate"), - "ab", - "innerText must sample newly connected text without requiring a screenshot" - ); - assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 2); - assert_eq!(vm.eval("target.innerText").unwrap(), "ab"); - assert_eq!( - vm.layout_pass_observability_for_test().1, - passes_before + 2, - "repeated innerText should reuse clean geometry across turns" + vm.eval( + "const added = document.createElement('span'); added.textContent = 'b'; target.append(added); target.innerText", + ) + .expect("the warm innerText read should evaluate"), + "a", + "a text source absent from the latest frozen layout tree remains unrendered until refresh" ); assert_eq!(vm.layout_pass_observability_for_test().1, passes_before); diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs index 84efab80e6..3a2ea2cd98 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs @@ -1464,84 +1464,7 @@ return [ } #[test] -fn adopted_stylesheet_geometry_refreshes_across_script_turns_without_rendering() { - for shadow in [false, true] { - let mut vm = new_parsed_test_vm( - "https://adopted-sheet-layout.test/", - "
", - ); - vm.eval(&format!(r#" - globalThis.styleRoot = {root}; - styleRoot.innerHTML = '
'; - globalThis.target = styleRoot.querySelector('#target'); - globalThis.sheet = new CSSStyleSheet(); - sheet.replaceSync('#target {{width:40px;height:10px}}'); - "#, root = if shadow {"document.getElementById('host').attachShadow({mode:'open'})"} else {"document.body"})) - .expect("create the same baseline rules in document and shadow scopes"); - // Document adoption is on Document, while its test markup lives in body. - vm.eval(if shadow { - "globalThis.adopter=styleRoot;" - } else { - "globalThis.adopter=document;" - }) - .unwrap(); - let read = "[target.getBoundingClientRect().width,parseFloat(getComputedStyle(target).width),target.offsetWidth].join('|')"; - assert_eq!(vm.eval(read).unwrap(), "20|20|20"); - for (mutation, width) in [ - ("adopter.adoptedStyleSheets=[sheet];", 40), - ("sheet.replaceSync('#target {width:80px;height:10px}');", 80), - ("sheet.deleteRule(0);", 20), - ( - "sheet.insertRule('#target {width:120px;height:10px}',0);", - 120, - ), - ("adopter.adoptedStyleSheets=[];", 20), - ("adopter.adoptedStyleSheets.push(sheet);", 120), - ("adopter.adoptedStyleSheets.pop();", 20), - ] { - let before = vm - ._context_host - .borrow() - .layout_pass_observability_for_test() - .1; - vm.eval(mutation).unwrap(); - assert_eq!( - vm._context_host - .borrow() - .layout_pass_observability_for_test() - .1, - before, - "stylesheet changes must not eagerly build layout: shadow={shadow} {mutation}" - ); - let expected = format!("{width}|{width}|{width}"); - assert_eq!( - vm.eval(read).unwrap(), - expected, - "shadow={shadow} {mutation}" - ); - assert_eq!( - vm._context_host - .borrow() - .layout_pass_observability_for_test() - .1, - before + 1, - "first geometry read must refresh exactly once: shadow={shadow} {mutation}" - ); - assert_eq!(vm.eval(read).unwrap(), expected); - assert_eq!( - vm._context_host - .borrow() - .layout_pass_observability_for_test() - .1, - before + 1, - "a later clean script turn must reuse layout" - ); - } - } -} - -#[test] -fn geometry_getters_refresh_after_mutation_and_reuse_clean_layout() { +fn geometry_getters_reuse_latest_layout_across_nodes_and_mutation() { let mut vm = new_storage_test_vm("https://oneshot-layout-demand.test/"); let passes_before = vm ._context_host @@ -1591,7 +1514,7 @@ yield; // Publish this scene before reading its geometry. ) .expect("latest layout snapshot reads should evaluate"); - assert_eq!(result, "38|8|38|8|38|43|8"); + assert_eq!(result, "38|8|38|8|38|38|0"); let passes = vm ._context_host .borrow() diff --git a/moli-renderer-v8/src/script_vm/tests/element_click.rs b/moli-renderer-v8/src/script_vm/tests/element_click.rs index 7a04af1ab0..3bd3af5b44 100644 --- a/moli-renderer-v8/src/script_vm/tests/element_click.rs +++ b/moli-renderer-v8/src/script_vm/tests/element_click.rs @@ -1,5 +1,5 @@ use super::*; -use crate::runtime::{RendererElementClickError, RendererElementClickTarget}; +use crate::runtime::{RendererElementClickTarget, RendererPointerEventProperties}; #[test] fn native_element_click_requires_explicit_layout_without_building_it() { From ad6dee53a2c7e0610fdd389f929ced1b59356f90 Mon Sep 17 00:00:00 2001 From: kevin Date: Tue, 22 Sep 2026 12:03:16 +0800 Subject: [PATCH 15/23] fix(protocol): complete inspector rendering and navigation contracts --- Cargo.lock | 1 + moli-core/src/page/mod.rs | 6 +- moli-core/src/page/settings_support.rs | 16 + moli-page-types/src/lib.rs | 4 +- moli-paint/Cargo.toml | 1 + moli-paint/src/lib.rs | 2 + moli-paint/src/vision.rs | 214 +++++++++++++ .../conn/browser_context/emulation_owner.rs | 31 +- moli-protocol/src/conn/dispatch.rs | 108 +++++-- moli-protocol/src/conn/dispatch_tests.rs | 58 ++++ .../src/conn/state/devtools_session.rs | 1 + moli-protocol/src/conn/state/emulation.rs | 8 +- moli-protocol/src/domains/actions.rs | 7 + moli-protocol/src/domains/browser.rs | 39 ++- moli-protocol/src/domains/css.rs | 22 +- moli-protocol/src/domains/emulation.rs | 74 ++++- moli-protocol/src/domains/emulation/tests.rs | 1 + .../emulation/tests/inspector_surfaces.rs | 208 ++++++++++++ moli-protocol/src/domains/input.rs | 78 ++++- moli-protocol/src/domains/mod.rs | 3 + .../network/main_document_progress/mod.rs | 71 ++--- .../network/main_document_progress/tests.rs | 132 +++++++- moli-protocol/src/domains/overlay.rs | 184 +++++++++++ moli-protocol/src/domains/page.rs | 3 + moli-protocol/src/domains/schema.rs | 59 ++++ moli-renderer-v8/src/lib.rs | 6 +- moli-renderer-v8/src/runtime/mod.rs | 5 +- moli-renderer-v8/src/runtime/page_commands.rs | 4 + moli-renderer-v8/src/runtime/page_dom.rs | 3 +- moli-renderer-v8/src/runtime/page_overlay.rs | 299 ++++++++++++++++++ .../src/runtime/page_screenshot.rs | 64 +++- moli-renderer-v8/src/runtime/page_surface.rs | 4 + moli-renderer-v8/src/runtime/page_vm/mod.rs | 2 + .../page_vm/tests/computed_size/lifecycle.rs | 1 + .../runtime/page_vm/tests/rendering_update.rs | 1 + moli-renderer-v8/src/runtime/tests.rs | 10 + moli-renderer-v8/src/style_engine/retained.rs | 18 +- .../src/style_engine/stylesheet.rs | 1 + .../src/style_engine/world_environment.rs | 8 + .../src/style_engine/world_lifecycle.rs | 4 +- moli/src/cli.rs | 4 + moli/src/fetch_dump/raster.rs | 1 + moli/tests/cli.rs | 2 + 43 files changed, 1633 insertions(+), 135 deletions(-) create mode 100644 moli-paint/src/vision.rs create mode 100644 moli-protocol/src/domains/emulation/tests/inspector_surfaces.rs create mode 100644 moli-protocol/src/domains/overlay.rs create mode 100644 moli-protocol/src/domains/schema.rs create mode 100644 moli-renderer-v8/src/runtime/page_overlay.rs diff --git a/Cargo.lock b/Cargo.lock index 48764e4dba..e583dd4e22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2904,6 +2904,7 @@ dependencies = [ "anyrender", "anyrender_svg", "anyrender_vello_cpu", + "image", "moli-image", "moli-layout", "peniko", diff --git a/moli-core/src/page/mod.rs b/moli-core/src/page/mod.rs index c0ef0acd6c..e8bafb7e67 100644 --- a/moli-core/src/page/mod.rs +++ b/moli-core/src/page/mod.rs @@ -104,7 +104,7 @@ pub use moli_renderer_v8::{ RendererDomFocusOutcome, RendererDomMutationEvent, RendererDomMutationEventBatch, RendererDomSnapshotCaptureOptions, RendererDomSnapshotCapturePayload, RendererDragData, RendererDragDataItem, RendererDraggedDirectory, RendererDraggedFile, RendererFrameToken, - RendererInputDispatchOutcome, RendererInspectorCommandRoute, + RendererInputDispatchOutcome, RendererInspectorCommandRoute, RendererInspectorOverlayCommand, RendererJavaScriptDialogCompletion, RendererJavaScriptDialogId, RendererJavaScriptDialogResult, RendererJavaScriptDialogSource, RendererLayoutMetrics, RendererLifecycleEpoch, RendererLifecycleEventStamp, RendererLifecycleStartReason, RendererLifecycleTerminationStamp, @@ -131,8 +131,8 @@ pub use moli_renderer_v8::{ RendererServiceWorkerTargetInfo, RendererServiceWorkerVersionStatus, RendererSetDocumentContentResult, RendererSharedWorkerConsoleMessage, RendererSharedWorkerTargetEvent, RendererSharedWorkerTargetInfo, RendererSyntheticResponseBody, - RendererTextSearchMatch, RendererTouchPoint, RendererVisualStateToken, - RendererWindowDocumentSource, RuntimeConsoleMessageSnapshot, + RendererTextSearchMatch, RendererTouchPoint, RendererVisionDeficiency, + RendererVisualStateToken, RendererWindowDocumentSource, RuntimeConsoleMessageSnapshot, }; pub use moli_renderer_v8::{ RendererAppManifest, RendererAppManifestDisplayMode, RendererAppManifestError, diff --git a/moli-core/src/page/settings_support.rs b/moli-core/src/page/settings_support.rs index 6a2845f4fd..a42d546fe4 100644 --- a/moli-core/src/page/settings_support.rs +++ b/moli-core/src/page/settings_support.rs @@ -331,6 +331,22 @@ impl Page { .await } + pub fn start_set_inspector_overlay( + &self, + inspector_session_id: Option, + command: super::RendererInspectorOverlayCommand, + ) -> Result { + self.start_page_command(RendererPageCommand::SetInspectorOverlay { + inspector_session_id, + command, + }) + } + + pub fn finish_set_inspector_overlay(&mut self, completion: CompletedPageCommand) -> Result<()> { + let reply = self.finish_page_command(completion); + expect_page_reply!(reply, "set inspector overlay", "a unit reply", RendererPageReply::Unit => Ok(()),) + } + pub fn start_set_emulated_media( &self, overrides: &EmulatedMediaOverrides, diff --git a/moli-page-types/src/lib.rs b/moli-page-types/src/lib.rs index e7664253cd..e838c73aaa 100644 --- a/moli-page-types/src/lib.rs +++ b/moli-page-types/src/lib.rs @@ -3111,8 +3111,10 @@ pub struct RuntimeIsolatedWorldDefinition { pub grant_universal_access: bool, } -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq)] pub struct EmulatedMediaOverrides { + /// CSS environment preference, independent of media query feature overrides. + pub preferred_text_scale: Option, pub media: Option, pub color_scheme: Option, pub reduced_motion: Option, diff --git a/moli-paint/Cargo.toml b/moli-paint/Cargo.toml index 858d4044a8..45ae6bceec 100644 --- a/moli-paint/Cargo.toml +++ b/moli-paint/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" license = "MIT OR Apache-2.0" [dependencies] +image = { version = "0.25.10", default-features = false } anyrender = { git = "https://github.com/ldm0/anyrender", rev = "18fd67d7a5622a9821d86434857fd69856912a43" } anyrender_svg = { git = "https://github.com/ldm0/anyrender", rev = "18fd67d7a5622a9821d86434857fd69856912a43", default-features = false, features = ["text"] } anyrender_vello_cpu = { git = "https://github.com/ldm0/anyrender", rev = "18fd67d7a5622a9821d86434857fd69856912a43", default-features = false, features = ["bitmap-glyphs", "filters"] } diff --git a/moli-paint/src/lib.rs b/moli-paint/src/lib.rs index 4587192289..73c4c94833 100644 --- a/moli-paint/src/lib.rs +++ b/moli-paint/src/lib.rs @@ -7,6 +7,8 @@ //! layout state is copied into this crate. mod raster; +mod vision; +pub use vision::VisionDeficiency; pub use moli_image::RgbaImage as RasterImage; pub use raster::{ diff --git a/moli-paint/src/vision.rs b/moli-paint/src/vision.rs new file mode 100644 index 0000000000..336931ed6a --- /dev/null +++ b/moli-paint/src/vision.rs @@ -0,0 +1,214 @@ +//! Display-wide vision emulation in linear RGB, matching SVG filter color space. + +use crate::{MAX_TRANSIENT_RASTER_BYTES, PaintError, RasterImage}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum VisionDeficiency { + #[default] + None, + BlurredVision, + ReducedContrast, + Achromatopsia, + Deuteranopia, + Protanopia, + Tritanopia, +} + +impl VisionDeficiency { + pub fn apply(self, raster: &mut RasterImage, device_scale: f32) -> Result<(), PaintError> { + if self == Self::None { + return Ok(()); + } + let expected = (raster.width as usize) + .checked_mul(raster.height as usize) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or(PaintError::BufferLengthOverflow { + width: raster.width, + height: raster.height, + })?; + if raster.rgba.len() != expected { + return Err(PaintError::UnexpectedBufferLength { + expected, + actual: raster.rgba.len(), + }); + } + if self == Self::BlurredVision { + return blur(raster, device_scale); + } + // Machado et al. (2009), full-severity matrices rounded as in Blink's + // core/css/vision_deficiency.cc. Alpha is not affected by color emulation. + let matrix = match self { + Self::Achromatopsia => [[0.213, 0.715, 0.072]; 3], + Self::Deuteranopia => [ + [0.367, 0.861, -0.228], + [0.280, 0.673, 0.047], + [-0.012, 0.043, 0.969], + ], + Self::Protanopia => [ + [0.152, 1.053, -0.205], + [0.115, 0.786, 0.099], + [-0.004, -0.048, 1.052], + ], + Self::Tritanopia => [ + [1.256, -0.077, -0.179], + [-0.078, 0.931, 0.148], + [0.005, 0.691, 0.304], + ], + Self::ReducedContrast => [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + Self::None | Self::BlurredVision => unreachable!(), + }; + let offset = if self == Self::ReducedContrast { + 0.5 + } else { + 0.0 + }; + for pixel in raster.rgba.chunks_exact_mut(4) { + let rgb = [pixel[0], pixel[1], pixel[2]].map(srgb_to_linear); + for (channel, row) in pixel[..3].iter_mut().zip(matrix) { + let value = row + .into_iter() + .zip(rgb) + .map(|(weight, value)| weight * value) + .sum::() + + offset; + *channel = linear_to_srgb(value); + } + } + Ok(()) + } +} + +fn srgb_to_linear(value: u8) -> f32 { + let value = f32::from(value) / 255.0; + if value <= 0.04045 { + value / 12.92 + } else { + ((value + 0.055) / 1.055).powf(2.4) + } +} + +fn linear_to_srgb(value: f32) -> u8 { + let value = value.clamp(0.0, 1.0); + let value = if value <= 0.0031308 { + value * 12.92 + } else { + 1.055 * value.powf(1.0 / 2.4) - 0.055 + }; + (value * 255.0).round() as u8 +} + +// Filter in premultiplied linear RGB so transparent pixels cannot bleed their +// hidden color into visible neighbors. Charge the source, intermediate and +// destination float images against the same budget as paint filters. +fn blur(raster: &mut RasterImage, device_scale: f32) -> Result<(), PaintError> { + if !(device_scale * 2.0).is_normal() || device_scale <= 0.0 { + return Err(PaintError::InvalidCaptureDeviceScale { device_scale }); + } + let padding = (6.0 * device_scale).ceil() as u32; + let (width, height) = raster + .width + .checked_add(padding.saturating_mul(2)) + .zip(raster.height.checked_add(padding.saturating_mul(2))) + .ok_or(PaintError::BufferLengthOverflow { + width: raster.width, + height: raster.height, + })?; + let required_bytes = (width as usize) + .saturating_mul(height as usize) + .saturating_mul(48) + .saturating_add(raster.rgba.len()); + if required_bytes > MAX_TRANSIENT_RASTER_BYTES { + return Err(PaintError::TransientRasterBudgetExceeded { + required_bytes, + max_bytes: MAX_TRANSIENT_RASTER_BYTES, + }); + } + if raster.width == 0 || raster.height == 0 { + return Ok(()); + } + let image = image::Rgba32FImage::from_fn(width, height, |x, y| { + if x < padding || y < padding || x >= padding + raster.width || y >= padding + raster.height + { + return image::Rgba([0.0; 4]); + } + let (x, y) = (x - padding, y - padding); + let offset = ((y as usize) * raster.width as usize + x as usize) * 4; + let pixel = &raster.rgba[offset..offset + 4]; + let alpha = f32::from(pixel[3]) / 255.0; + image::Rgba([ + srgb_to_linear(pixel[0]) * alpha, + srgb_to_linear(pixel[1]) * alpha, + srgb_to_linear(pixel[2]) * alpha, + alpha, + ]) + }); + let blurred = image::imageops::blur(&image, 2.0 * device_scale); + for (index, output) in raster.rgba.chunks_exact_mut(4).enumerate() { + let input = blurred.get_pixel( + index as u32 % raster.width + padding, + index as u32 / raster.width + padding, + ); + let alpha = input[3]; + for (out, value) in output[..3].iter_mut().zip(input.0) { + *out = linear_to_srgb(if alpha > 0.0 { value / alpha } else { 0.0 }); + } + output[3] = (alpha * 255.0).round() as u8; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn color_filters_preserve_alpha_and_neutral_colors() { + for filter in [ + VisionDeficiency::Achromatopsia, + VisionDeficiency::Deuteranopia, + VisionDeficiency::Protanopia, + VisionDeficiency::Tritanopia, + ] { + let mut raster = RasterImage { + width: 3, + height: 1, + rgba: vec![0, 0, 0, 255, 128, 128, 128, 64, 255, 255, 255, 0], + }; + filter.apply(&mut raster, 1.0).unwrap(); + assert_eq!( + raster.rgba, + [0, 0, 0, 255, 128, 128, 128, 64, 255, 255, 255, 0] + ); + } + } + + #[test] + fn blur_does_not_expose_transparent_pixel_colors() { + let mut raster = RasterImage { + width: 2, + height: 1, + rgba: vec![255, 0, 0, 0, 0, 0, 255, 255], + }; + VisionDeficiency::BlurredVision + .apply(&mut raster, 1.0) + .unwrap(); + for pixel in raster.rgba.chunks_exact(4) { + assert_eq!(&pixel[..3], &[0, 0, 255]); + assert!(pixel[3] > 0 && pixel[3] < 255); + } + } + + #[test] + fn invalid_buffer_is_rejected_without_mutation() { + let mut raster = RasterImage { + width: 2, + height: 1, + rgba: vec![1, 2, 3, 4], + }; + assert!(matches!( + VisionDeficiency::BlurredVision.apply(&mut raster, 1.0), + Err(PaintError::UnexpectedBufferLength { .. }) + )); + assert_eq!(raster.rgba, [1, 2, 3, 4]); + } +} diff --git a/moli-protocol/src/conn/browser_context/emulation_owner.rs b/moli-protocol/src/conn/browser_context/emulation_owner.rs index e3600769c0..66551ad705 100644 --- a/moli-protocol/src/conn/browser_context/emulation_owner.rs +++ b/moli-protocol/src/conn/browser_context/emulation_owner.rs @@ -12,6 +12,14 @@ pub(crate) struct TargetEmulationStateUpdate<'a> { } impl TargetEmulationStateUpdate<'_> { + pub(crate) fn set_vision_deficiency( + &mut self, + vision: moli_core::page::RendererVisionDeficiency, + ) { + self.raw.vision_deficiency = vision; + self.effective.vision_deficiency = vision; + } + pub(crate) fn set_default_background_color(&mut self, color: Option<[u8; 4]>) { self.raw.default_background_color = color; self.effective.default_background_color = color; @@ -33,8 +41,15 @@ impl TargetEmulationStateUpdate<'_> { self.effective.geolocation_override = geolocation_override; } - pub(crate) fn set_emulated_media(&mut self, emulated_media: EmulatedMediaOverrides) { + pub(crate) fn set_preferred_text_scale(&mut self, scale: Option) { + self.raw.emulated_media.preferred_text_scale = scale; + self.effective.emulated_media.preferred_text_scale = scale; + } + + pub(crate) fn set_emulated_media(&mut self, mut emulated_media: EmulatedMediaOverrides) { + emulated_media.preferred_text_scale = self.raw.emulated_media.preferred_text_scale; self.raw.emulated_media = emulated_media.clone(); + emulated_media.preferred_text_scale = self.effective.emulated_media.preferred_text_scale; self.effective.emulated_media = emulated_media; } @@ -145,6 +160,20 @@ impl TargetSessionOwnerRef<'_> { } impl CdpConnection { + pub(crate) fn vision_deficiency_for_owner( + &self, + owner: &crate::conn::CommandOwnerScope, + ) -> moli_core::page::RendererVisionDeficiency { + self.target_session_owner_ref_for_owner(owner) + .and_then(|owner| { + owner + .browser_context + .page_target(&owner.target_id) + .map(|target| target.effective_emulation_state.vision_deficiency) + }) + .unwrap_or_default() + } + pub(crate) fn default_background_color_for_owner( &self, owner: &crate::conn::CommandOwnerScope, diff --git a/moli-protocol/src/conn/dispatch.rs b/moli-protocol/src/conn/dispatch.rs index 1dcc40e817..ac7742d122 100644 --- a/moli-protocol/src/conn/dispatch.rs +++ b/moli-protocol/src/conn/dispatch.rs @@ -5,6 +5,7 @@ use serde_json::json; use crate::domains::command_output::CommandOutputPlan; use super::*; +use crate::domains::schema::CdpDomain; pub struct PendingCdpCommandDispatch { inner: PendingCdpCommandDispatchKind, @@ -49,6 +50,7 @@ enum PendingCdpCommandDispatchKind { Network(crate::domains::network::PendingNetworkCommandDispatch), Fetch(crate::domains::fetch::PendingFetchCommandDispatch), Io(Box), + Overlay(crate::domains::overlay::PendingOverlayCommandDispatch), Security(crate::domains::security::PendingSecurityCommandDispatch), Browser(crate::domains::browser::PendingBrowserCommandDispatch), Target(crate::domains::target::PendingTargetCommandDispatch), @@ -72,6 +74,7 @@ enum CompletedCdpCommandDispatchKind { Network(crate::domains::network::CompletedNetworkCommandDispatch), Fetch(crate::domains::fetch::CompletedFetchCommandDispatch), Io(Box), + Overlay(crate::domains::overlay::CompletedOverlayCommandDispatch), Security(crate::domains::security::CompletedSecurityCommandDispatch), Browser(crate::domains::browser::CompletedBrowserCommandDispatch), Target(crate::domains::target::CompletedTargetCommandDispatch), @@ -97,6 +100,7 @@ impl PendingCdpCommandDispatchKind { Self::Network(_) => "Network", Self::Fetch(_) => "Fetch", Self::Io(_) => "IO", + Self::Overlay(_) => "Overlay", Self::Security(_) => "Security", Self::Browser(_) => "Browser", Self::Target(_) => "Target", @@ -124,6 +128,7 @@ impl CompletedCdpCommandDispatchKind { Self::Network(_) => "Network", Self::Fetch(_) => "Fetch", Self::Io(_) => "IO", + Self::Overlay(_) => "Overlay", Self::Security(_) => "Security", Self::Browser(_) => "Browser", Self::Target(_) => "Target", @@ -316,6 +321,9 @@ impl PendingCdpCommandDispatch { PendingCdpCommandDispatchKind::Io(pending) => { CompletedCdpCommandDispatchKind::Io(Box::new(Box::pin(pending.wait()).await)) } + PendingCdpCommandDispatchKind::Overlay(pending) => { + CompletedCdpCommandDispatchKind::Overlay(Box::pin(pending.wait()).await) + } PendingCdpCommandDispatchKind::Security(pending) => { CompletedCdpCommandDispatchKind::Security(Box::pin(pending.wait()).await) } @@ -521,8 +529,8 @@ impl CdpConnection { command_context.terminal_response_delivery_override(), ); self.record_tracing_command(cmd.method, cmd.session_id); - let step = match domain { - "Browser" => Some( + let step = match domain.parse::().ok() { + Some(CdpDomain::Browser) => Some( match crate::domains::browser::try_start_browser_command_dispatch(self, &cmd) { crate::domains::browser::BrowserCommandTaskStep::Pending(pending) => { self.pending_step(PendingCdpCommandDispatchKind::Browser(pending)) @@ -532,7 +540,7 @@ impl CdpConnection { } }, ), - "Runtime" => crate::domains::runtime::try_start_runtime_command_dispatch(self, &cmd) + Some(CdpDomain::Runtime) => crate::domains::runtime::try_start_runtime_command_dispatch(self, &cmd) .map(|step| match step { crate::domains::runtime::RuntimeCommandTaskStep::Pending(pending) => { self.pending_step(PendingCdpCommandDispatchKind::Runtime(pending)) @@ -541,7 +549,7 @@ impl CdpConnection { self.complete_with_output_plan(command_context, plan, cmd.id, cmd.session_id) } }), - "HeapProfiler" => { + Some(CdpDomain::HeapProfiler) => { crate::domains::heap_profiler::try_start_heap_profiler_command_dispatch( self, &cmd, @@ -555,7 +563,7 @@ impl CdpConnection { } }) } - "Profiler" => crate::domains::profiler::try_start_profiler_command_dispatch(self, &cmd) + Some(CdpDomain::Profiler) => crate::domains::profiler::try_start_profiler_command_dispatch(self, &cmd) .map(|step| match step { crate::domains::runtime::RuntimeCommandTaskStep::Pending(pending) => { self.pending_step(PendingCdpCommandDispatchKind::Runtime(pending)) @@ -564,7 +572,7 @@ impl CdpConnection { self.complete_with_output_plan(command_context, plan, cmd.id, cmd.session_id) } }), - "Debugger" => { + Some(CdpDomain::Debugger) => { crate::domains::debugger::try_start_debugger_command_dispatch( self, &cmd, @@ -583,7 +591,7 @@ impl CdpConnection { } }) } - "Accessibility" => { + Some(CdpDomain::Accessibility) => { crate::domains::accessibility::try_start_accessibility_command_dispatch(self, &cmd) .map(|step| { match step { @@ -596,7 +604,7 @@ impl CdpConnection { } }) } - "Input" => Some( + Some(CdpDomain::Input) => Some( match crate::domains::input::try_start_input_command_dispatch(self, &cmd) { crate::domains::input::InputCommandDispatchStep::Pending(pending) => { self.pending_step(PendingCdpCommandDispatchKind::Input(pending)) @@ -606,7 +614,7 @@ impl CdpConnection { } }, ), - "CSS" => crate::domains::css::try_start_css_command_dispatch(self, &cmd).map(|step| { + Some(CdpDomain::Css) => crate::domains::css::try_start_css_command_dispatch(self, &cmd).map(|step| { match step { crate::domains::css::CssCommandDispatchStep::Pending(pending) => { self.pending_step(PendingCdpCommandDispatchKind::Css(pending)) @@ -616,7 +624,13 @@ impl CdpConnection { } } }), - "DOM" => crate::domains::dom::try_start_dom_command_dispatch(self, &cmd).map(|step| { + Some(CdpDomain::Dom) if matches!(cmd.action, "highlightNode" | "highlightRect" | "hideHighlight") => Some( + match crate::domains::overlay::try_start_overlay_command_dispatch(self, &cmd) { + crate::domains::overlay::OverlayCommandTaskStep::Pending(pending) => self.pending_step(PendingCdpCommandDispatchKind::Overlay(pending)), + crate::domains::overlay::OverlayCommandTaskStep::Complete(plan) => self.complete_with_output_plan(command_context, plan, cmd.id, cmd.session_id), + } + ), + Some(CdpDomain::Dom) => crate::domains::dom::try_start_dom_command_dispatch(self, &cmd).map(|step| { match step { crate::domains::dom::DomCommandDispatchStep::Pending(pending) => { self.pending_step(PendingCdpCommandDispatchKind::Dom(*pending)) @@ -626,7 +640,7 @@ impl CdpConnection { } } }), - "DOMStorage" => Some( + Some(CdpDomain::DomStorage) => Some( match crate::domains::dom_storage::try_start_dom_storage_command_dispatch( self, &cmd, ) { @@ -638,7 +652,7 @@ impl CdpConnection { } }, ), - "Console" => { + Some(CdpDomain::Console) => { crate::domains::console::try_start_console_command_dispatch( self, &cmd, @@ -653,13 +667,13 @@ impl CdpConnection { }, ) } - "Emulation" | "Network" if let Some(step) = crate::domains::runtime::try_start_worker_emulation_command_dispatch(self, &cmd) => { + Some(CdpDomain::Emulation | CdpDomain::Network) if let Some(step) = crate::domains::runtime::try_start_worker_emulation_command_dispatch(self, &cmd) => { Some(match step { crate::domains::runtime::RuntimeCommandTaskStep::Pending(pending) => self.pending_step(PendingCdpCommandDispatchKind::Runtime(pending)), crate::domains::runtime::RuntimeCommandTaskStep::Complete(plan) => self.complete_with_output_plan(command_context, plan, cmd.id, cmd.session_id), }) } - "Network" => Some( + Some(CdpDomain::Network) => Some( match crate::domains::network::start_network_domain_command_dispatch(self, &cmd) { crate::domains::network::NetworkDomainCommandTaskStep::Network( crate::domains::network::NetworkCommandTaskStep::Pending(pending), @@ -678,7 +692,7 @@ impl CdpConnection { } }, ), - "Target" => { + Some(CdpDomain::Target) => { crate::domains::target::try_start_target_command_dispatch(self, &cmd).map(|step| { match step { crate::domains::target::TargetCommandTaskStep::Pending(pending) => { @@ -690,7 +704,7 @@ impl CdpConnection { } }) } - "Tracing" => Some( + Some(CdpDomain::Tracing) => Some( match crate::domains::tracing::try_start_tracing_command_dispatch(self, &cmd) { crate::domains::tracing::TracingCommandTaskStep::Pending(pending) => { self.pending_step(PendingCdpCommandDispatchKind::Tracing(*pending)) @@ -704,7 +718,7 @@ impl CdpConnection { ), }, ), - "Fetch" => { + Some(CdpDomain::Fetch) => { crate::domains::fetch::try_start_fetch_command_dispatch(self, &cmd).map(|step| { match step { crate::domains::fetch::FetchCommandTaskStep::Pending(pending) => { @@ -716,7 +730,7 @@ impl CdpConnection { } }) } - "Page" => { + Some(CdpDomain::Page) => { crate::domains::page::try_start_page_command_dispatch(self, &cmd).map(|step| { match step { crate::domains::page::PageCommandTaskStep::Pending(pending) => { @@ -728,15 +742,15 @@ impl CdpConnection { } }) } - "Inspector" => { + Some(CdpDomain::Inspector) => { let plan = crate::domains::inspector::command_output_plan(self, &cmd); Some(self.complete_with_output_plan(command_context, plan, cmd.id, cmd.session_id)) } - "Log" => { + Some(CdpDomain::Log) => { let plan = crate::domains::log::command_output_plan(self, &cmd); Some(self.complete_with_output_plan(command_context, plan, cmd.id, cmd.session_id)) } - "Storage" => Some( + Some(CdpDomain::Storage) => Some( match crate::domains::storage::try_start_storage_command_dispatch(self, &cmd) { crate::domains::storage::StorageCommandTaskStep::Pending(pending) => { self.pending_step(PendingCdpCommandDispatchKind::Storage(pending)) @@ -746,7 +760,7 @@ impl CdpConnection { } }, ), - "DOMSnapshot" => { + Some(CdpDomain::DomSnapshot) => { Some( match crate::domains::dom_snapshot::try_start_dom_snapshot_command_dispatch( self, &cmd, @@ -765,7 +779,16 @@ impl CdpConnection { }, ) } - "Security" => Some( + Some(CdpDomain::Overlay) => Some( + match crate::domains::overlay::try_start_overlay_command_dispatch(self, &cmd) { + crate::domains::overlay::OverlayCommandTaskStep::Pending(pending) => { + self.pending_step(PendingCdpCommandDispatchKind::Overlay(pending)) + } + crate::domains::overlay::OverlayCommandTaskStep::Complete(plan) => { + self.complete_with_output_plan(command_context, plan, cmd.id, cmd.session_id) + } + }, + ), Some(CdpDomain::Security) => Some( match crate::domains::security::try_start_security_command_dispatch(self, &cmd) { crate::domains::security::SecurityCommandTaskStep::Pending(pending) => { self.pending_step(PendingCdpCommandDispatchKind::Security(pending)) @@ -775,11 +798,11 @@ impl CdpConnection { } }, ), - "ServiceWorker" => { + Some(CdpDomain::ServiceWorker) => { let plan = crate::domains::service_worker::command_output_plan(self, &cmd); Some(self.complete_with_output_plan(command_context, plan, cmd.id, cmd.session_id)) } - "IO" => Some( + Some(CdpDomain::Io) => Some( match crate::domains::io::try_start_io_command_dispatch(self, &cmd) { crate::domains::io::IoCommandTaskStep::Pending(pending) => { self.pending_step(PendingCdpCommandDispatchKind::Io(pending)) @@ -789,7 +812,7 @@ impl CdpConnection { } }, ), - "Autofill" => Some( + Some(CdpDomain::Autofill) => Some( match crate::domains::autofill::try_start_autofill_command_dispatch(self, &cmd) { crate::domains::autofill::AutofillCommandTaskStep::Pending(pending) => { self.pending_step(PendingCdpCommandDispatchKind::Autofill(pending)) @@ -804,23 +827,23 @@ impl CdpConnection { } }, ), - "Audits" => { + Some(CdpDomain::Audits) => { let plan = crate::domains::audits::command_output_plan(self, &cmd); Some(self.complete_with_output_plan(command_context, plan, cmd.id, cmd.session_id)) } - "SystemInfo" => { + Some(CdpDomain::SystemInfo) => { let plan = crate::domains::system_info::command_output_plan(&cmd); Some(self.complete_with_output_plan(command_context, plan, cmd.id, cmd.session_id)) } - "WebAuthn" => { + Some(CdpDomain::WebAuthn) => { let plan = crate::domains::webauthn::command_output_plan(&cmd); Some(self.complete_with_output_plan(command_context, plan, cmd.id, cmd.session_id)) } - "WebMCP" => { + Some(CdpDomain::WebMCP) => { let plan = crate::domains::web_mcp::command_output_plan(&cmd); Some(self.complete_with_output_plan(command_context, plan, cmd.id, cmd.session_id)) } - "Performance" => Some( + Some(CdpDomain::Performance) => Some( match crate::domains::performance::try_start_performance_command_dispatch( self, &cmd, @@ -834,7 +857,7 @@ impl CdpConnection { } }, ), - "DOMDebugger" => Some( + Some(CdpDomain::DomDebugger) => Some( match crate::domains::dom_debugger::try_start_dom_debugger_command_dispatch( self, &cmd, ) { @@ -846,7 +869,7 @@ impl CdpConnection { } }, ), - "Emulation" => crate::domains::emulation::try_start_emulation_command_dispatch( + Some(CdpDomain::Emulation) => crate::domains::emulation::try_start_emulation_command_dispatch( self, &cmd, ) .map(|step| match step { @@ -857,7 +880,11 @@ impl CdpConnection { self.complete_with_output_plan(command_context, plan, cmd.id, cmd.session_id) } }), - _ => { + Some(CdpDomain::Schema) => { + let plan = crate::domains::schema::command_output_plan(&cmd); + Some(self.complete_with_output_plan(command_context, plan, cmd.id, cmd.session_id)) + } + None => { Some(self.complete_with_output_plan( command_context, CommandOutputPlan::error(-32601, "Unknown domain"), @@ -1180,6 +1207,19 @@ impl CdpConnection { session_id.as_deref(), ); } + CompletedCdpCommandDispatchKind::Overlay(completed) => { + let command_id = completed.command_id(); + let session_id = completed.session_id().map(str::to_owned); + let plan = + crate::domains::overlay::complete_pending_overlay_command(self, completed); + append_command_output_plan( + &mut out, + command_context, + plan, + command_id, + session_id.as_deref(), + ); + } CompletedCdpCommandDispatchKind::Security(completed) => { let command_id = completed.command_id(); let session_id = completed.session_id().map(str::to_owned); diff --git a/moli-protocol/src/conn/dispatch_tests.rs b/moli-protocol/src/conn/dispatch_tests.rs index 437e5f45c2..24bcbf8b76 100644 --- a/moli-protocol/src/conn/dispatch_tests.rs +++ b/moli-protocol/src/conn/dispatch_tests.rs @@ -6926,6 +6926,64 @@ fn command_dispatch_completes_browser_sync_commands() { } } +#[test] +fn browser_window_bounds_readback_preserves_unspecified_dimensions() { + let mut conn = CdpConnection::new(); + let request = |conn: &mut CdpConnection, method: &str, params: Value| { + let raw = json!({"id": 1, "method": method, "params": params}).to_string(); + complete_messages(conn.start_command_dispatch(&raw)).remove(0) + }; + let window = request(&mut conn, "Browser.getWindowForTarget", json!({})); + let id = &window["result"]["windowId"]; + for bounds in [json!({"width": 900, "height": 640}), json!({"left": 25})] { + let response = request( + &mut conn, + "Browser.setWindowBounds", + json!({ + "windowId": id, "bounds": bounds + }), + ); + assert!(response.get("error").is_none(), "{response}"); + } + let response = request( + &mut conn, + "Browser.getWindowBounds", + json!({"windowId": id}), + ); + let bounds = &response["result"]["bounds"]; + assert_eq!(bounds["width"], 900, "{response}"); + assert_eq!(bounds["height"], 640); + assert_eq!(bounds["left"], 25); + let response = request( + &mut conn, + "Browser.getWindowBounds", + json!({"windowId": -1}), + ); + assert!(response.get("error").is_some(), "{response}"); +} + +#[test] +fn schema_discovery_lists_unique_dispatchable_domains() { + let mut conn = CdpConnection::new(); + let messages = + complete_messages(conn.start_command_dispatch(r#"{"id":1,"method":"Schema.getDomains"}"#)); + let domains = messages[0]["result"]["domains"] + .as_array() + .expect("domains"); + let mut names = std::collections::HashSet::new(); + for domain in domains { + let name = domain["name"].as_str().expect("domain name"); + assert!(names.insert(name)); + assert_eq!(domain["version"], "1.3"); + let raw = json!({"id":2,"method":format!("{name}.nonexistent")}).to_string(); + let response = complete_messages(conn.start_command_dispatch(&raw)); + assert_ne!(response[0]["error"]["message"], "Unknown domain", "{name}"); + } + for required in ["Page", "DOM", "CSS", "Network", "Runtime", "Schema"] { + assert!(names.contains(required)); + } +} + #[test] fn command_dispatch_completes_browser_owner_commands_without_legacy_fallback() { let mut conn = CdpConnection::new(); diff --git a/moli-protocol/src/conn/state/devtools_session.rs b/moli-protocol/src/conn/state/devtools_session.rs index 5cf0535dfe..e6abc999f0 100644 --- a/moli-protocol/src/conn/state/devtools_session.rs +++ b/moli-protocol/src/conn/state/devtools_session.rs @@ -677,6 +677,7 @@ pub(crate) struct DevToolsNetworkSessionState { #[derive(Clone, Debug, PartialEq, Default)] pub(crate) struct DevToolsEmulationSessionState { pub(crate) default_background_color: Option<[u8; 4]>, + pub(crate) vision_deficiency: moli_core::page::RendererVisionDeficiency, // UA, Accept-Language, and platform are independent handler contributions. pub(crate) browser_identity_override: Option, pub(crate) network_conditions: Option, diff --git a/moli-protocol/src/conn/state/emulation.rs b/moli-protocol/src/conn/state/emulation.rs index 1c6cf993d5..c43ff9bdb4 100644 --- a/moli-protocol/src/conn/state/emulation.rs +++ b/moli-protocol/src/conn/state/emulation.rs @@ -89,8 +89,10 @@ impl EmulatedGeolocationOverrideState { } } -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq)] pub struct EmulatedMediaOverrides { + /// CSS environment preference, independent of media query feature overrides. + pub preferred_text_scale: Option, pub media: Option, pub color_scheme: Option, pub reduced_motion: Option, @@ -102,6 +104,7 @@ pub struct EmulatedMediaOverrides { impl From for moli_core::page::EmulatedMediaOverrides { fn from(value: EmulatedMediaOverrides) -> Self { Self { + preferred_text_scale: value.preferred_text_scale, media: value.media, color_scheme: value.color_scheme, reduced_motion: value.reduced_motion, @@ -115,6 +118,7 @@ impl From for moli_core::page::EmulatedMediaOverrides { impl From<&EmulatedMediaOverrides> for moli_core::page::EmulatedMediaOverrides { fn from(value: &EmulatedMediaOverrides) -> Self { Self { + preferred_text_scale: value.preferred_text_scale, media: value.media.clone(), color_scheme: value.color_scheme.clone(), reduced_motion: value.reduced_motion.clone(), @@ -133,6 +137,7 @@ impl From<&EmulatedMediaOverrides> for moli_core::page::EmulatedMediaOverrides { #[derive(Debug, Clone, PartialEq, Default)] pub(crate) struct EffectiveTargetEmulationState { pub(crate) default_background_color: Option<[u8; 4]>, + pub(crate) vision_deficiency: moli_core::page::RendererVisionDeficiency, pub(crate) network_conditions: Option, pub(crate) geolocation_override: Option, pub(crate) emulated_media: EmulatedMediaOverrides, @@ -185,6 +190,7 @@ impl EffectiveTargetEmulationState { // target-wide side effect while retaining each handler's raw copy. self.emulated_media = EmulatedMediaOverrides::default(); self.default_background_color = None; + self.vision_deficiency = Default::default(); if raw.emulated_device_metrics.is_some() { self.emulated_device_metrics = None; } diff --git a/moli-protocol/src/domains/actions.rs b/moli-protocol/src/domains/actions.rs index 82f6f7b1fe..1b8b6830d6 100644 --- a/moli-protocol/src/domains/actions.rs +++ b/moli-protocol/src/domains/actions.rs @@ -48,7 +48,9 @@ pub(crate) enum AutofillAction { #[strum(serialize_all = "camelCase")] pub(crate) enum BrowserAction { GetVersion, + GetBrowserCommandLine, GetWindowForTarget, + GetWindowBounds, SetWindowBounds, SetDownloadBehavior, CancelDownload, @@ -190,6 +192,9 @@ pub(crate) enum EmulationAction { SetUserAgentOverride, SetEmulatedMedia, SetDefaultBackgroundColorOverride, + SetEmulatedVisionDeficiency, + #[strum(serialize = "setEmulatedOSTextScale")] + SetEmulatedOSTextScale, } #[derive(Clone, Copy, Debug, Eq, PartialEq, strum::EnumString)] @@ -240,6 +245,7 @@ pub(crate) enum InputAction { DispatchTouchEvent, EmulateTouchFromMouseEvent, SynthesizeTapGesture, + SynthesizeScrollGesture, } impl InputAction { @@ -252,6 +258,7 @@ impl InputAction { | Self::InsertText | Self::DispatchTouchEvent | Self::EmulateTouchFromMouseEvent + | Self::SynthesizeScrollGesture | Self::SynthesizeTapGesture ) } diff --git a/moli-protocol/src/domains/browser.rs b/moli-protocol/src/domains/browser.rs index 2825e8caf3..3c2c678855 100644 --- a/moli-protocol/src/domains/browser.rs +++ b/moli-protocol/src/domains/browser.rs @@ -1,5 +1,5 @@ use chromiumoxide_cdp::cdp::browser_protocol::browser::{ - CancelDownloadParams, SetWindowBoundsParams, + CancelDownloadParams, GetWindowBoundsParams, SetWindowBoundsParams, }; use serde::Deserialize; use serde_json::{Value, json}; @@ -157,9 +157,15 @@ pub(crate) fn try_start_browser_command_dispatch( }; match action { BrowserAction::GetVersion => BrowserCommandTaskStep::Complete(get_version(conn)), + BrowserAction::GetBrowserCommandLine => { + BrowserCommandTaskStep::Complete(get_browser_command_line()) + } BrowserAction::GetWindowForTarget => { BrowserCommandTaskStep::Complete(get_window_for_target(conn)) } + BrowserAction::GetWindowBounds => { + BrowserCommandTaskStep::Complete(get_window_bounds(conn, cmd)) + } BrowserAction::SetWindowBounds => { BrowserCommandTaskStep::Complete(set_window_bounds(conn, cmd)) } @@ -186,6 +192,19 @@ fn get_version(conn: &CdpConnection) -> CommandOutputPlan { })) } +fn get_browser_command_line() -> CommandOutputPlan { + let arguments: Vec<_> = std::env::args_os().collect(); + if !arguments.iter().any(|arg| arg == "--enable-automation") { + return CommandOutputPlan::error( + -32000, + "Command line not returned because --enable-automation not set.", + ); + } + CommandOutputPlan::result(json!({ + "arguments": arguments.iter().map(|arg| arg.to_string_lossy()).collect::>() + })) +} + fn bounds_json(bounds: &BrowserWindowBounds) -> Value { let mut value = json!({ "windowState": bounds.window_state, @@ -215,6 +234,16 @@ fn get_window_for_target(conn: &CdpConnection) -> CommandOutputPlan { })) } +fn get_window_bounds(conn: &CdpConnection, cmd: &Cmd<'_>) -> CommandOutputPlan { + let Ok(Some(params)) = cmd.get_params::() else { + return CommandOutputPlan::error(-32602, "InvalidParams"); + }; + if *params.window_id.inner() != i64::from(DEV_TOOLS_WINDOW_ID) { + return CommandOutputPlan::error(-32602, "InvalidParams"); + } + CommandOutputPlan::result(json!({"bounds": bounds_json(&conn.window_bounds)})) +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct SetDownloadBehaviorParams { @@ -301,10 +330,10 @@ fn set_window_bounds(conn: &mut CdpConnection, cmd: &Cmd<'_>) -> CommandOutputPl return CommandOutputPlan::error(-32602, "InvalidParams"); } }; - conn.window_bounds.left = left; - conn.window_bounds.top = top; - conn.window_bounds.width = width; - conn.window_bounds.height = height; + conn.window_bounds.left = left.or(conn.window_bounds.left); + conn.window_bounds.top = top.or(conn.window_bounds.top); + conn.window_bounds.width = width.or(conn.window_bounds.width); + conn.window_bounds.height = height.or(conn.window_bounds.height); if let Some(window_state) = params.bounds.window_state { conn.window_bounds.window_state = window_state.as_ref().to_owned(); } diff --git a/moli-protocol/src/domains/css.rs b/moli-protocol/src/domains/css.rs index b022d26563..07551740ae 100644 --- a/moli-protocol/src/domains/css.rs +++ b/moli-protocol/src/domains/css.rs @@ -626,12 +626,18 @@ pub(crate) fn complete_pending_css_command( } fn computed_style_command_output_plan(properties: Vec<(String, String)>) -> CommandOutputPlan { - CommandOutputPlan::result(json!({ - "computedStyle": properties - .into_iter() - .map(|(name, value)| json!({ "name": name, "value": value })) - .collect::>() - })) + use chromiumoxide_cdp::cdp::browser_protocol::css::{ + ComputedStyleExtraFields, CssComputedStyleProperty, GetComputedStyleForNodeReturns, + }; + let result = GetComputedStyleForNodeReturns::new( + properties + .into_iter() + .map(|(name, value)| CssComputedStyleProperty::new(name, value)) + .collect(), + // Stylo does not implement Blink's base appearance rendering mode. + ComputedStyleExtraFields::new(false), + ); + CommandOutputPlan::result(json!(result)) } fn inline_style_result_from_attributes_resolution( @@ -1088,6 +1094,10 @@ mod tests { .await; let computed = take_response_by_id(&mut ctx, 204); assert_eq!(computed["sessionId"], "SID-background"); + let typed: chromiumoxide_cdp::cdp::browser_protocol::css::GetComputedStyleForNodeReturns = + serde_json::from_value(computed["result"].clone()) + .expect("computed style must be consumable by typed CDP clients"); + assert!(!typed.extra_fields.is_appearance_base); let computed_properties = computed["result"]["computedStyle"] .as_array() .expect("computedStyle array"); diff --git a/moli-protocol/src/domains/emulation.rs b/moli-protocol/src/domains/emulation.rs index 8f80897bfb..5e011287d2 100644 --- a/moli-protocol/src/domains/emulation.rs +++ b/moli-protocol/src/domains/emulation.rs @@ -236,6 +236,12 @@ pub(crate) fn try_start_emulation_command_dispatch( Some(start_user_agent_override_command(conn, cmd)) } Some(EmulationAction::SetEmulatedMedia) => Some(start_emulated_media_command(conn, cmd)), + Some(EmulationAction::SetEmulatedOSTextScale) => { + Some(start_os_text_scale_command(conn, cmd)) + } + Some(EmulationAction::SetEmulatedVisionDeficiency) => Some( + EmulationCommandTaskStep::Complete(vision_deficiency_command(conn, cmd)), + ), Some(EmulationAction::SetDefaultBackgroundColorOverride) => Some( EmulationCommandTaskStep::Complete(default_background_color_command(conn, cmd)), ), @@ -832,7 +838,48 @@ fn start_emulated_media_command( "BrowserContextNotLoaded", )); } - let page_overrides: moli_core::page::EmulatedMediaOverrides = (&overrides).into(); + start_style_environment_update(conn, cmd) +} + +fn start_os_text_scale_command( + conn: &mut CdpConnection, + cmd: &Cmd<'_>, +) -> EmulationCommandTaskStep { + #[derive(serde::Deserialize, Default)] + struct TextScaleParams { + scale: Option, + } + let params = match cmd.get_params::() { + Ok(params) => params.unwrap_or_default(), + Err(error) => { + return EmulationCommandTaskStep::Complete(CommandOutputPlan::error(-32602, error)); + } + }; + if params + .scale + .is_some_and(|scale| !scale.is_finite() || scale <= 0.0) + { + return EmulationCommandTaskStep::Complete(CommandOutputPlan::error( + -32602, + "Text scale must be finite and positive", + )); + } + if let Err(error) = + page_session::update_page_emulation_state(conn, cmd.session_id, |mut state| { + state.set_preferred_text_scale(params.scale); + }) + { + return EmulationCommandTaskStep::Complete(CommandOutputPlan::error(-31998, error)); + } + start_style_environment_update(conn, cmd) +} + +fn start_style_environment_update( + conn: &mut CdpConnection, + cmd: &Cmd<'_>, +) -> EmulationCommandTaskStep { + let owner = CommandOwnerScope::capture(conn, cmd.session_id); + let page_overrides = conn.navigation_load_inputs_for_owner(&owner).emulated_media; 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, @@ -2856,3 +2903,28 @@ fn finish_emulation_page_operation( } } } + +fn vision_deficiency_command(conn: &mut CdpConnection, cmd: &Cmd<'_>) -> CommandOutputPlan { + use chromiumoxide_cdp::cdp::browser_protocol::emulation::{ + SetEmulatedVisionDeficiencyParams, SetEmulatedVisionDeficiencyType, + }; + use moli_core::page::RendererVisionDeficiency as Vision; + let Ok(Some(params)) = cmd.get_params::() else { + return CommandOutputPlan::error(-32602, "Invalid vision deficiency"); + }; + let vision = match params.r#type { + SetEmulatedVisionDeficiencyType::None => Vision::None, + SetEmulatedVisionDeficiencyType::BlurredVision => Vision::BlurredVision, + SetEmulatedVisionDeficiencyType::ReducedContrast => Vision::ReducedContrast, + SetEmulatedVisionDeficiencyType::Achromatopsia => Vision::Achromatopsia, + SetEmulatedVisionDeficiencyType::Deuteranopia => Vision::Deuteranopia, + SetEmulatedVisionDeficiencyType::Protanopia => Vision::Protanopia, + SetEmulatedVisionDeficiencyType::Tritanopia => Vision::Tritanopia, + }; + match page_session::update_page_emulation_state(conn, cmd.session_id, |mut state| { + state.set_vision_deficiency(vision) + }) { + Ok(()) => CommandOutputPlan::success(), + Err(error) => CommandOutputPlan::error(-31998, error), + } +} diff --git a/moli-protocol/src/domains/emulation/tests.rs b/moli-protocol/src/domains/emulation/tests.rs index 76ac1936a8..1ff4bd3850 100644 --- a/moli-protocol/src/domains/emulation/tests.rs +++ b/moli-protocol/src/domains/emulation/tests.rs @@ -20,6 +20,7 @@ use tokio::{ }; mod device_geometry; +mod inspector_surfaces; mod native_navigator; async fn evaluate(ctx: &mut TestContext, expression: &str) -> serde_json::Value { diff --git a/moli-protocol/src/domains/emulation/tests/inspector_surfaces.rs b/moli-protocol/src/domains/emulation/tests/inspector_surfaces.rs new file mode 100644 index 0000000000..467edf7c56 --- /dev/null +++ b/moli-protocol/src/domains/emulation/tests/inspector_surfaces.rs @@ -0,0 +1,208 @@ +use super::*; +use base64::Engine as _; + +async fn command( + ctx: &mut TestContext, + method: &str, + params: serde_json::Value, +) -> serde_json::Value { + ctx.process_async( + json!({"id": 88801, "sessionId": "SID-1", "method": method, "params": params}), + ) + .await; + let response = ctx.take_response_by_id(88801); + assert!(response["error"].is_null(), "{method}: {response}"); + response["result"].clone() +} + +async fn screenshot(ctx: &mut TestContext) -> moli_image::RgbaImage { + let result = command(ctx, "Page.captureScreenshot", json!({"format":"png"})).await; + let bytes = base64::engine::general_purpose::STANDARD + .decode(result["data"].as_str().unwrap()) + .unwrap(); + moli_image::decode_png(&bytes).unwrap() +} +fn pixel(image: &moli_image::RgbaImage, x: usize, y: usize) -> &[u8] { + let offset = (y * image.width as usize + x) * 4; + &image.rgba[offset..offset + 4] +} +async fn page() -> TestContext { + let mut ctx = TestContext::new(); + load_session_page_for_pending_emulation_test(&mut ctx).await; + command( + &mut ctx, + "Emulation.setDeviceMetricsOverride", + json!({"width":100,"height":100,"deviceScaleFactor":1,"mobile":false}), + ) + .await; + evaluate( + &mut ctx, + "document.body.style='margin:0;background:white';undefined", + ) + .await; + ctx +} + +#[tokio::test(flavor = "multi_thread")] +async fn os_text_scale_updates_css_environment_and_preserves_media_preferences() { + let mut ctx = page().await; + evaluate(&mut ctx,r#"document.head.innerHTML='';document.body.innerHTML='
A
A
';undefined"#).await; + for (scale, expected) in [ + (json!({"scale":1.5}), json!(["24px", "30px", "16px"])), + (json!({"scale":2}), json!(["32px", "40px", "16px"])), + (json!({}), json!(["16px", "20px", "16px"])), + ] { + command(&mut ctx, "Emulation.setEmulatedOSTextScale", scale).await; + command( + &mut ctx, + "Emulation.setEmulatedMedia", + json!({"features":[{"name":"prefers-color-scheme","value":"dark"}]}), + ) + .await; + assert_eq!(evaluate(&mut ctx,"[getComputedStyle(scaled).fontSize,getComputedStyle(scaled).width,getComputedStyle(plain).fontSize]").await,expected); + assert_eq!( + evaluate( + &mut ctx, + "matchMedia('(prefers-color-scheme:dark)').matches" + ) + .await, + json!(true) + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn vision_deficiency_changes_capture_pixels_without_mutating_dom() { + let mut ctx = page().await; + evaluate(&mut ctx, "document.body.style.background='red';undefined").await; + let original = screenshot(&mut ctx).await; + assert_eq!(pixel(&original, 50, 50), [255, 0, 0, 255]); + command( + &mut ctx, + "Emulation.setEmulatedVisionDeficiency", + json!({"type":"achromatopsia"}), + ) + .await; + let gray = screenshot(&mut ctx).await; + let channels = pixel(&gray, 50, 50); + assert_eq!(channels[0], channels[1]); + assert_eq!(channels[1], channels[2]); + assert!((126..=128).contains(&channels[0])); + assert_eq!( + evaluate(&mut ctx, "getComputedStyle(document.body).backgroundColor").await, + json!("rgb(255, 0, 0)") + ); + command( + &mut ctx, + "Emulation.setEmulatedVisionDeficiency", + json!({"type":"none"}), + ) + .await; + assert_eq!(screenshot(&mut ctx).await.rgba, original.rgba); +} + +#[tokio::test(flavor = "multi_thread")] +async fn inspector_highlight_rect_is_visible_and_hide_restores_original_pixels() { + let mut ctx = page().await; + let original = screenshot(&mut ctx).await; + command(&mut ctx, "DOM.enable", json!({})).await; + command(&mut ctx, "Overlay.enable", json!({})).await; + command( + &mut ctx, + "DOM.highlightRect", + json!({"x":10,"y":10,"width":30,"height":30,"color":{"r":255,"g":0,"b":0,"a":1}}), + ) + .await; + command( + &mut ctx, + "Emulation.setEmulatedVisionDeficiency", + json!({"type":"achromatopsia"}), + ) + .await; + let highlighted = screenshot(&mut ctx).await; + assert_eq!(pixel(&highlighted, 20, 20), [255, 0, 0, 255]); + assert_eq!(pixel(&highlighted, 60, 60), pixel(&original, 60, 60)); + assert_eq!( + evaluate(&mut ctx, "document.body.children.length").await, + json!(0) + ); + command(&mut ctx, "DOM.hideHighlight", json!({})).await; + assert_eq!(screenshot(&mut ctx).await.rgba, original.rgba); +} + +#[tokio::test(flavor = "multi_thread")] +async fn inspector_highlight_tracks_live_node_geometry_and_rejects_stale_ids() { + let mut ctx = page().await; + evaluate(&mut ctx,"document.body.innerHTML='
';undefined").await; + let doc = command(&mut ctx, "DOM.getDocument", json!({})).await; + let node = command( + &mut ctx, + "DOM.querySelector", + json!({"nodeId":doc["root"]["nodeId"],"selector":"#target"}), + ) + .await; + command(&mut ctx,"Overlay.highlightNode",json!({"nodeId":node["nodeId"],"highlightConfig":{"contentColor":{"r":0,"g":255,"b":0,"a":1}}})).await; + assert_eq!(pixel(&screenshot(&mut ctx).await, 20, 20), [0, 255, 0, 255]); + evaluate(&mut ctx, "target.style.left='60px';undefined").await; + let moved = screenshot(&mut ctx).await; + assert_eq!(pixel(&moved, 70, 20), [0, 255, 0, 255]); + assert_eq!(pixel(&moved, 20, 20), [255, 255, 255, 255]); + command(&mut ctx, "Overlay.disable", json!({})).await; + assert_eq!( + pixel(&screenshot(&mut ctx).await, 70, 20), + [255, 255, 255, 255] + ); + ctx.process_async(json!({"id":88802,"sessionId":"SID-1","method":"DOM.highlightNode","params":{"nodeId":2147483647,"highlightConfig":{}}})).await; + assert!(ctx.take_response_by_id(88802)["error"].is_object()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn scroll_gesture_uses_hit_target_and_honors_wheel_cancellation() { + let mut ctx = page().await; + evaluate(&mut ctx,r#"document.body.innerHTML='
';window.events=[];scroller.addEventListener('wheel',e=>events.push([e.deltaX,e.deltaY,e.isTrusted]));undefined"#).await; + command( + &mut ctx, + "Input.synthesizeScrollGesture", + json!({"x":20,"y":20,"xDistance":-30,"yDistance":-60,"gestureSourceType":"mouse"}), + ) + .await; + assert_eq!( + evaluate( + &mut ctx, + "[scroller.scrollLeft,scroller.scrollTop,scrollY,events.reduce((sum,e)=>sum+e[0],0),events.reduce((sum,e)=>sum+e[1],0),events.every(e=>e[2])]" + ) + .await, + json!([30, 60, 0, 30, 60, true]) + ); + evaluate( + &mut ctx, + "scroller.addEventListener('wheel',e=>e.preventDefault(),{passive:false});undefined", + ) + .await; + command( + &mut ctx, + "Input.synthesizeScrollGesture", + json!({"x":20,"y":20,"yDistance":-60}), + ) + .await; + assert_eq!(evaluate(&mut ctx, "scroller.scrollTop").await, json!(60)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn blurred_vision_composites_transparent_filter_edges_over_browser_background() { + let mut ctx = page().await; + evaluate(&mut ctx, "document.body.style.background='red';undefined").await; + command( + &mut ctx, + "Emulation.setEmulatedVisionDeficiency", + json!({"type":"blurredVision"}), + ) + .await; + let blurred = screenshot(&mut ctx).await; + assert_eq!(pixel(&blurred, 50, 50), [255, 0, 0, 255]); + let edge = pixel(&blurred, 0, 50); + assert_eq!(edge[0], 255); + assert_eq!(edge[3], 255); + assert!((90..=115).contains(&edge[1]), "{edge:?}"); + assert_eq!(edge[1], edge[2]); +} diff --git a/moli-protocol/src/domains/input.rs b/moli-protocol/src/domains/input.rs index fd3ba4df06..4333d6ac45 100644 --- a/moli-protocol/src/domains/input.rs +++ b/moli-protocol/src/domains/input.rs @@ -35,7 +35,9 @@ use chromiumoxide_cdp::cdp::browser_protocol::input::{ DispatchTouchEventType as CdpDispatchTouchEventType, EmulateTouchFromMouseEventParams as CdpEmulateTouchFromMouseEventParams, EmulateTouchFromMouseEventType as CdpEmulateTouchFromMouseEventType, - MouseButton as CdpMouseButton, SynthesizeTapGestureParams as CdpSynthesizeTapGestureParams, + MouseButton as CdpMouseButton, + SynthesizeScrollGestureParams as CdpSynthesizeScrollGestureParams, + SynthesizeTapGestureParams as CdpSynthesizeTapGestureParams, }; mod drag; @@ -309,6 +311,7 @@ pub(crate) fn try_start_input_command_dispatch( InputAction::DispatchMouseEvent | InputAction::DispatchTouchEvent | InputAction::EmulateTouchFromMouseEvent + | InputAction::SynthesizeScrollGesture | InputAction::SynthesizeTapGesture | InputAction::DispatchDragEvent => match validate_coordinate_input_params(cmd, action) { Ok(()) if conn.layout_policy() == moli_core::LayoutPolicy::Mock => { @@ -492,6 +495,46 @@ fn validate_coordinate_input_params( .then_some(()) .ok_or_else(PendingInputCommandStartError::invalid_params) } + InputAction::SynthesizeScrollGesture => { + let params = scroll_gesture_params(cmd)?; + if !optional_enum_param_matches( + cmd, + "gestureSourceType", + &["default", "mouse", "touch"], + ) || ![ + params.x, + params.y, + params.x_distance.unwrap_or_default(), + params.y_distance.unwrap_or_default(), + ] + .into_iter() + .all(f64::is_finite) + || params.speed.is_some_and(|speed| speed <= 0) + || params.repeat_count.is_some_and(|count| count < 0) + || params.repeat_delay_ms.is_some_and(|delay| delay < 0) + { + return Err(PendingInputCommandStartError::invalid_params()); + } + // Wheel gestures share the hit testing, cancellation and scroll-chain + // machinery of dispatched wheel input. Touch/fling and traced repeated + // gestures need a timed gesture source and are not silently approximated. + if matches!( + params.gesture_source_type, + Some(chromiumoxide_cdp::cdp::browser_protocol::input::GestureSourceType::Touch) + ) || params.repeat_count.unwrap_or_default() != 0 + || params.x_overscroll.unwrap_or_default() != 0.0 + || params.y_overscroll.unwrap_or_default() != 0.0 + || params.interaction_marker_name.is_some() + || params.speed.is_some() + || params.repeat_delay_ms.is_some() + || params.prevent_fling == Some(false) + { + return Err(PendingInputCommandStartError::unsupported( + "Timed, touch, fling, overscroll and repeated scroll gestures are not supported", + )); + } + Ok(()) + } InputAction::SynthesizeTapGesture => { if !optional_enum_param_matches( cmd, @@ -538,6 +581,14 @@ fn validate_coordinate_input_params( } } +fn scroll_gesture_params( + cmd: &Cmd<'_>, +) -> Result { + cmd.get_params::() + .map_err(|_| PendingInputCommandStartError::invalid_params())? + .ok_or_else(PendingInputCommandStartError::invalid_params) +} + fn coordinate_input_unsupported_message(action: InputAction) -> &'static str { match action { InputAction::DispatchMouseEvent => DISPATCH_MOUSE_EVENT_UNSUPPORTED_MESSAGE, @@ -546,6 +597,7 @@ fn coordinate_input_unsupported_message(action: InputAction) -> &'static str { EMULATE_TOUCH_FROM_MOUSE_EVENT_UNSUPPORTED_MESSAGE } InputAction::SynthesizeTapGesture => SYNTHESIZE_TAP_GESTURE_UNSUPPORTED_MESSAGE, + InputAction::SynthesizeScrollGesture => DISPATCH_MOUSE_EVENT_UNSUPPORTED_MESSAGE, InputAction::DispatchDragEvent => DISPATCH_DRAG_EVENT_UNSUPPORTED_MESSAGE, InputAction::CancelDragging | InputAction::DispatchKeyEvent @@ -627,6 +679,7 @@ fn start_pending_input_command( InputAction::DispatchMouseEvent | InputAction::DispatchTouchEvent | InputAction::EmulateTouchFromMouseEvent + | InputAction::SynthesizeScrollGesture | InputAction::SynthesizeTapGesture | InputAction::DispatchDragEvent => { if conn.layout_policy() == moli_core::LayoutPolicy::Mock { @@ -854,6 +907,29 @@ fn build_cdp_coordinate_input_command( }, )) } + InputAction::SynthesizeScrollGesture => { + let params = scroll_gesture_params(cmd)?; + Ok(DevToolsCommand::DispatchMouseEvent( + DevToolsDispatchMouseEventCommand { + context, + event_type: DevToolsMouseEventType::Wheel, + pointer_type: DevToolsPointerType::Mouse, + x: params.x, + y: params.y, + button: -1, + buttons: None, + click_count: 0, + delta_x: -params.x_distance.unwrap_or_default(), + delta_y: -params.y_distance.unwrap_or_default(), + force: 0.0, + tangential_pressure: 0.0, + tilt_x: 0.0, + tilt_y: 0.0, + twist: 0.0, + modifiers: 0, + }, + )) + } InputAction::SynthesizeTapGesture => { let params = cmd .get_params::() diff --git a/moli-protocol/src/domains/mod.rs b/moli-protocol/src/domains/mod.rs index f923cc5477..eddf9ffe52 100644 --- a/moli-protocol/src/domains/mod.rs +++ b/moli-protocol/src/domains/mod.rs @@ -29,6 +29,7 @@ pub mod performance; pub mod profiler; pub mod runtime; pub(crate) mod runtime_context_events; +pub(crate) mod schema; pub mod security; pub mod service_worker; pub(crate) mod session; @@ -38,3 +39,5 @@ pub mod target; pub mod tracing; pub mod web_mcp; pub mod webauthn; + +pub(crate) mod overlay; diff --git a/moli-protocol/src/domains/network/main_document_progress/mod.rs b/moli-protocol/src/domains/network/main_document_progress/mod.rs index 4cd2a16416..61ae856a9b 100644 --- a/moli-protocol/src/domains/network/main_document_progress/mod.rs +++ b/moli-protocol/src/domains/network/main_document_progress/mod.rs @@ -203,7 +203,6 @@ impl CompletedDownloadProgressTransfer { ) -> (MainDocumentProgressGate, CompletedDownloadBodyArtifact) { let response_headers = self.network_events.response_headers.clone(); let progress = CompletedDownloadBodyNetworkProgress { - encoded_data_length: completed_download_body_len_hint(&self.body, &response_headers), network_events: self.network_events, }; let queue = completed_download_body_progress_queue(progress, conn, state, final_url); @@ -222,31 +221,12 @@ impl CompletedDownloadProgressTransfer { } } -fn completed_download_body_len_hint( - body: &CompletedDownloadProgressBody, - response_headers: &[(String, Vec)], -) -> usize { - match body { - CompletedDownloadProgressBody::Buffered(body) => body.len(), - CompletedDownloadProgressBody::Streaming(_) => response_headers - .iter() - .find(|(name, _)| name.eq_ignore_ascii_case("content-length")) - .and_then(|(_, value)| moli_fetch::decode_header_value(value).trim().parse().ok()) - .unwrap_or_default(), - } -} - #[derive(Debug)] struct CompletedDownloadBodyNetworkProgress { - encoded_data_length: usize, network_events: CompletedMainDocumentNetworkEvents, } impl CompletedDownloadBodyNetworkProgress { - fn len(&self) -> usize { - self.encoded_data_length - } - fn into_network_events(self) -> CompletedMainDocumentNetworkEvents { self.network_events } @@ -494,7 +474,7 @@ fn completed_document_body_progress_queue( event_request_id, body_progress_source.into_completed_body_events(), final_url, - encoded_data_length, + MainDocumentNetworkCompletion::Loaded(encoded_data_length), ) } @@ -505,7 +485,6 @@ fn completed_download_body_progress_queue( final_url: &Url, ) -> MainDocumentProgressQueueHandle { let network_observed = main_document_network_observed(conn, state.session_id.as_deref()); - let encoded_data_length = progress.len(); completed_or_streaming_document_progress_queue( conn, state, @@ -513,7 +492,7 @@ fn completed_download_body_progress_queue( state.request_id.clone(), Some(progress.into_network_events()), final_url, - encoded_data_length, + MainDocumentNetworkCompletion::Download, ) } @@ -524,7 +503,7 @@ fn completed_or_streaming_document_progress_queue( request_id: Option, events: Option, final_url: &Url, - encoded_data_length: usize, + completion: MainDocumentNetworkCompletion, ) -> MainDocumentProgressQueueHandle { let Some(events) = events else { return MainDocumentProgressQueueHandle::from_source( @@ -544,7 +523,7 @@ fn completed_or_streaming_document_progress_queue( state.timestamp, ); MainDocumentProgressQueueHandle::from_source(MainDocumentProgressSource::completed_body( - context.event_batches(&events, final_url, encoded_data_length), + context.event_batches(&events, final_url, completion), )) } @@ -1632,6 +1611,13 @@ fn main_document_network_event_session_ids( conn.network_event_session_ids_for_session_owner(trigger_session_id) } +#[derive(Clone, Copy)] +enum MainDocumentNetworkCompletion { + Loaded(usize), + // The response body belongs to the download manager; document loading is aborted. + Download, +} + struct CompletedMainDocumentProgressContext { session_ids: Vec>, request_id: Option, @@ -1676,12 +1662,30 @@ impl CompletedMainDocumentProgressContext { &self, events: &CompletedMainDocumentNetworkEvents, final_url: &Url, - encoded_data_length: usize, + completion: MainDocumentNetworkCompletion, ) -> MainDocumentNavigationProgressEventBatches { + let encoded_data_length = match completion { + MainDocumentNetworkCompletion::Loaded(length) => length, + MainDocumentNetworkCompletion::Download => 0, + }; + let terminal = self.progress_target().map(|target| match completion { + MainDocumentNetworkCompletion::Loaded(encoded_data_length) => { + MainDocumentNavigationProgressEvent::LoadingFinished { + target, + encoded_data_length, + } + } + MainDocumentNetworkCompletion::Download => { + MainDocumentNavigationProgressEvent::LoadingFailed { + target, + error_text: moli_fetch::NET_ERR_ABORTED_ERROR_TEXT.to_owned(), + } + } + }); MainDocumentNavigationProgressEventBatches::new( self.request_and_redirect_progress_events(events), self.response_received_progress_events(events, final_url, encoded_data_length), - self.loading_finished_progress_events(encoded_data_length), + terminal.into_iter().collect(), ) } @@ -1885,19 +1889,6 @@ impl CompletedMainDocumentProgressContext { }] } - fn loading_finished_progress_events( - &self, - encoded_data_length: usize, - ) -> Vec { - let Some(target) = self.progress_target() else { - return Vec::new(); - }; - vec![MainDocumentNavigationProgressEvent::LoadingFinished { - target, - encoded_data_length, - }] - } - fn progress_target(&self) -> Option { Some(MainDocumentProgressEventTarget { session_ids: self.session_ids.clone(), diff --git a/moli-protocol/src/domains/network/main_document_progress/tests.rs b/moli-protocol/src/domains/network/main_document_progress/tests.rs index 9241cf0d9b..4bddf851a6 100644 --- a/moli-protocol/src/domains/network/main_document_progress/tests.rs +++ b/moli-protocol/src/domains/network/main_document_progress/tests.rs @@ -51,6 +51,36 @@ fn completed_events() -> CompletedMainDocumentNetworkEvents { ) } +#[test] +fn download_response_terminates_document_request_as_aborted_once() { + let events = completed_events(); + let url = Url::parse("http://example.test/attachment").unwrap(); + let batches = completed_progress_context().event_batches( + &events, + &url, + MainDocumentNetworkCompletion::Download, + ); + let mut drain = + MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); + drain.mark_output_visible_until(MainDocumentProgressOutputBoundary::BodyFinishedVisible); + let messages = drain_into_protocol_messages(&mut drain); + let terminal: Vec<_> = messages + .iter() + .filter(|m| { + matches!( + m["method"].as_str(), + Some("Network.loadingFailed" | "Network.loadingFinished") + ) + }) + .collect(); + assert_eq!(terminal.len(), 1); + assert_eq!(terminal[0]["method"], "Network.loadingFailed"); + assert_eq!(terminal[0]["params"]["requestId"], "REQ-1"); + assert_eq!(terminal[0]["params"]["errorText"], "net::ERR_ABORTED"); + assert_eq!(terminal[0]["params"]["canceled"], true); + assert!(drain_into_protocol_messages(&mut drain).is_empty()); +} + fn observation_journal( exchanges: Vec<(Vec<(String, String)>, u16, Vec<(String, String)>)>, ) -> NetworkObservationJournal { @@ -743,7 +773,11 @@ fn completed_body_http_response_emits_correlated_empty_cookie_extra_info() { let final_url = Url::parse("http://example.test/final").unwrap(); let mut events = completed_events(); events.network_extra_info_available = true; - let batches = completed_progress_context().event_batches(&events, &final_url, 17); + let batches = completed_progress_context().event_batches( + &events, + &final_url, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut drain = MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); @@ -797,7 +831,11 @@ fn completed_body_extra_info_uses_transport_observed_headers() { 200, vec![("X-Raw-Response".to_owned(), "observed".to_owned())], )])); - let batches = completed_progress_context().event_batches(&events, &final_url, 17); + let batches = completed_progress_context().event_batches( + &events, + &final_url, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut drain = MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); @@ -852,7 +890,11 @@ fn completed_body_auth_retry_uses_initial_request_and_final_response_observation vec![("X-Final-Response".to_owned(), "yes".to_owned())], ), ])); - let batches = completed_progress_context().event_batches(&events, &final_url, 17); + let batches = completed_progress_context().event_batches( + &events, + &final_url, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut drain = MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); @@ -894,7 +936,11 @@ fn completed_body_revalidation_keeps_raw_304_extra_info() { 304, vec![("ETag".to_owned(), "\"v1\"".to_owned())], )])); - let batches = completed_progress_context().event_batches(&events, &final_url, 17); + let batches = completed_progress_context().event_batches( + &events, + &final_url, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut drain = MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); @@ -922,7 +968,11 @@ fn completed_body_revalidation_keeps_raw_304_extra_info() { fn completed_body_http_service_worker_response_does_not_infer_extra_info_from_url() { let final_url = Url::parse("http://example.test/sw-controlled").unwrap(); let events = completed_events(); - let batches = completed_progress_context().event_batches(&events, &final_url, 17); + let batches = completed_progress_context().event_batches( + &events, + &final_url, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut drain = MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); @@ -1007,7 +1057,11 @@ fn completed_body_http_redirect_emits_correlated_no_cookie_extra_info() { "FRAME-1".to_owned(), 12.5, ); - let batches = context.event_batches(&events, &final_url, 17); + let batches = context.event_batches( + &events, + &final_url, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut drain = MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); @@ -1099,7 +1153,11 @@ fn completed_body_redirect_without_transport_extra_info_keeps_flag_false() { from_cache: false, negotiated_http_version: None, }]; - let batches = completed_progress_context().event_batches(&events, &final_url, 17); + let batches = completed_progress_context().event_batches( + &events, + &final_url, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut drain = MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); @@ -1179,7 +1237,11 @@ fn critical_client_hint_restart_keeps_discarded_response_extra_info_separate_fro negotiated_http_version: Some(NegotiatedHttpVersion::Http11), }]; - let batches = completed_progress_context().event_batches(&events, &navigation_url, 17); + let batches = completed_progress_context().event_batches( + &events, + &navigation_url, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut drain = MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); drain.mark_output_visible_until(MainDocumentProgressOutputBoundary::BodyFinishedVisible); @@ -1267,7 +1329,11 @@ fn transportless_https_upgrade_attributes_wire_headers_to_upgraded_request() { negotiated_http_version: Some(NegotiatedHttpVersion::Http11), }]; - let batches = completed_progress_context().event_batches(&events, &final_url, 17); + let batches = completed_progress_context().event_batches( + &events, + &final_url, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut drain = MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); drain.mark_output_visible_until(MainDocumentProgressOutputBoundary::BodyFinishedVisible); @@ -1321,7 +1387,11 @@ fn completed_body_uses_negotiated_protocol_for_redirect_and_final_response() { from_cache: false, negotiated_http_version: Some(NegotiatedHttpVersion::Http10), }]; - let batches = completed_progress_context().event_batches(&events, &final_url, 17); + let batches = completed_progress_context().event_batches( + &events, + &final_url, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut drain = MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); @@ -1347,7 +1417,11 @@ fn cached_completed_body_emits_served_from_cache_before_response() { let final_url = Url::parse("http://example.test/final").unwrap(); let mut events = completed_events(); events.response_from_cache = true; - let batches = completed_progress_context().event_batches(&events, &final_url, 17); + let batches = completed_progress_context().event_batches( + &events, + &final_url, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut drain = MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); @@ -1402,7 +1476,11 @@ fn cached_completed_body_redirect_emits_cache_event_before_next_request() { from_cache: true, negotiated_http_version: None, }]; - let batches = completed_progress_context().event_batches(&events, &final_url, 17); + let batches = completed_progress_context().event_batches( + &events, + &final_url, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut drain = MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); @@ -1543,7 +1621,11 @@ fn progress_output_queue_buffers_body_finished_until_response_received() { fn completed_body_progress_queue_drains_network_events_by_milestone() { let final_url = Url::parse("http://example.test/final").unwrap(); let events = completed_events(); - let batches = completed_progress_context().event_batches(&events, &final_url, 17); + let batches = completed_progress_context().event_batches( + &events, + &final_url, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut drain = MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); @@ -1582,7 +1664,11 @@ fn completed_body_progress_queue_drains_network_events_by_milestone() { fn completed_body_progress_queue_can_release_all_materialized_events_at_body_finished() { let final_url = Url::parse("http://example.test/final").unwrap(); let events = completed_events(); - let batches = completed_progress_context().event_batches(&events, &final_url, 17); + let batches = completed_progress_context().event_batches( + &events, + &final_url, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut drain = MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); @@ -1602,7 +1688,11 @@ fn completed_body_progress_queue_can_release_all_materialized_events_at_body_fin fn completed_body_progress_drain_keeps_mark_ready_separate_from_output_drain() { let final_url = Url::parse("http://example.test/final").unwrap(); let events = completed_events(); - let batches = completed_progress_context().event_batches(&events, &final_url, 17); + let batches = completed_progress_context().event_batches( + &events, + &final_url, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut drain = MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); @@ -1636,7 +1726,11 @@ fn completed_body_progress_drain_keeps_mark_ready_separate_from_output_drain() { fn progress_output_barrier_drains_source_generated_progress_before_cdp_output() { let final_url = Url::parse("http://example.test/final").unwrap(); let events = completed_events(); - let batches = completed_progress_context().event_batches(&events, &final_url, 17); + let batches = completed_progress_context().event_batches( + &events, + &final_url, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut gate = MainDocumentProgressGate::new(MainDocumentProgressDrain::from_source( MainDocumentProgressSource::completed_body(batches), )); @@ -1660,7 +1754,11 @@ fn progress_output_barrier_drains_source_generated_progress_before_cdp_output() ); let events = completed_events(); - let batches = completed_progress_context().event_batches(&events, &final_url, 17); + let batches = completed_progress_context().event_batches( + &events, + &final_url, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut gate = MainDocumentProgressGate::new(MainDocumentProgressDrain::from_source( MainDocumentProgressSource::completed_body(batches), )); diff --git a/moli-protocol/src/domains/overlay.rs b/moli-protocol/src/domains/overlay.rs new file mode 100644 index 0000000000..2b1505140b --- /dev/null +++ b/moli-protocol/src/domains/overlay.rs @@ -0,0 +1,184 @@ +use super::command_output::CommandOutputPlan; +use crate::conn::{CdpConnection, Cmd, CommandOwnerScope}; +use moli_core::page::{CompletedPageCommand, PendingPageCommand, RendererInspectorOverlayCommand}; +use serde::Deserialize; + +#[derive(Clone, Copy, Deserialize)] +struct Color { + r: i32, + g: i32, + b: i32, + a: Option, +} +impl Color { + fn components(self) -> [f32; 4] { + [ + self.r.clamp(0, 255) as f32 / 255.0, + self.g.clamp(0, 255) as f32 / 255.0, + self.b.clamp(0, 255) as f32 / 255.0, + self.a.unwrap_or(1.0).clamp(0.0, 1.0), + ] + } +} +fn color(value: Option) -> [f32; 4] { + value.map_or([0.0; 4], Color::components) +} +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct HighlightConfig { + content_color: Option, + padding_color: Option, + border_color: Option, + margin_color: Option, +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct NodeParams { + node_id: Option, + backend_node_id: Option, + object_id: Option, + highlight_config: HighlightConfig, +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RectParams { + x: i32, + y: i32, + width: i32, + height: i32, + color: Option, + outline_color: Option, +} + +pub(crate) struct PendingOverlayCommandDispatch { + command_id: Option, + owner: CommandOwnerScope, + pending: PendingPageCommand, +} +pub(crate) struct CompletedOverlayCommandDispatch { + command_id: Option, + owner: CommandOwnerScope, + completed: Result, +} +pub(crate) enum OverlayCommandTaskStep { + Pending(PendingOverlayCommandDispatch), + Complete(CommandOutputPlan), +} +impl PendingOverlayCommandDispatch { + pub(crate) async fn wait(self) -> CompletedOverlayCommandDispatch { + CompletedOverlayCommandDispatch { + command_id: self.command_id, + owner: self.owner, + completed: self.pending.wait().await.map_err(|error| error.to_string()), + } + } +} +impl CompletedOverlayCommandDispatch { + pub(crate) fn command_id(&self) -> Option { + self.command_id + } + pub(crate) fn session_id(&self) -> Option<&str> { + self.owner.session_id() + } +} + +fn parse_command(cmd: &Cmd<'_>) -> Result { + let invalid = |error| CommandOutputPlan::error(-32602, error); + match cmd.action { + "enable" => Ok(RendererInspectorOverlayCommand::Enable), + "disable" => Ok(RendererInspectorOverlayCommand::Disable), + "hideHighlight" => Ok(RendererInspectorOverlayCommand::Hide), + "highlightRect" => { + let p = cmd + .get_params::() + .map_err(invalid)? + .ok_or_else(|| invalid("Missing rectangle"))?; + if p.width < 0 || p.height < 0 { + return Err(invalid("Rectangle dimensions must be nonnegative")); + } + Ok(RendererInspectorOverlayCommand::Rect { + rect: [p.x as f32, p.y as f32, p.width as f32, p.height as f32], + color: color(p.color), + outline: color(p.outline_color), + }) + } + "highlightNode" => { + let p = cmd + .get_params::() + .map_err(invalid)? + .ok_or_else(|| invalid("Missing node"))?; + if p.node_id.is_none() && p.backend_node_id.is_none() && p.object_id.is_none() { + return Err(invalid("A node reference is required")); + } + let config = p.highlight_config; + Ok(RendererInspectorOverlayCommand::Node { + node_id: p.node_id, + backend_node_id: p.backend_node_id, + object_id: p.object_id, + colors: [ + config.content_color, + config.padding_color, + config.border_color, + config.margin_color, + ] + .map(color), + }) + } + _ => Err(CommandOutputPlan::error(-32601, "UnknownMethod")), + } +} + +pub(crate) fn try_start_overlay_command_dispatch( + conn: &mut CdpConnection, + cmd: &Cmd<'_>, +) -> OverlayCommandTaskStep { + let command = match parse_command(cmd) { + Ok(command) => command, + Err(plan) => return OverlayCommandTaskStep::Complete(plan), + }; + if conn.layout_policy() == moli_core::LayoutPolicy::Mock + && matches!( + command, + RendererInspectorOverlayCommand::Rect { .. } + | RendererInspectorOverlayCommand::Node { .. } + ) + { + return OverlayCommandTaskStep::Complete(CommandOutputPlan::error( + -32000, + "Inspector highlighting requires layout", + )); + } + let owner = CommandOwnerScope::capture(conn, cmd.session_id); + let session = conn.target_renderer_runtime_inspector_session_id_for_session(cmd.session_id); + let result = conn + .loaded_page_mut_for_protocol_access_for_owner(&owner) + .and_then(|page| { + page.start_set_inspector_overlay(session, command) + .map_err(|error| error.to_string()) + }); + match result { + Ok(pending) => OverlayCommandTaskStep::Pending(PendingOverlayCommandDispatch { + command_id: cmd.id, + owner, + pending, + }), + Err(error) => OverlayCommandTaskStep::Complete(CommandOutputPlan::error(-32000, error)), + } +} + +pub(crate) fn complete_pending_overlay_command( + conn: &mut CdpConnection, + completed: CompletedOverlayCommandDispatch, +) -> CommandOutputPlan { + let result = completed.completed.and_then(|completion| { + conn.loaded_page_mut_for_protocol_access_for_owner(&completed.owner) + .and_then(|page| { + page.finish_set_inspector_overlay(completion) + .map_err(|error| error.to_string()) + }) + }); + match result { + Ok(()) => CommandOutputPlan::success(), + Err(error) => CommandOutputPlan::error(-32000, error), + } +} diff --git a/moli-protocol/src/domains/page.rs b/moli-protocol/src/domains/page.rs index 12ee4b28ce..a8495412e2 100644 --- a/moli-protocol/src/domains/page.rs +++ b/moli-protocol/src/domains/page.rs @@ -1988,6 +1988,7 @@ impl CdpConnection { }; let request = RendererCaptureScreencastFrameRequest { base_background_color: self.default_background_color_for_owner(&owner_scope), + vision_deficiency: self.vision_deficiency_for_owner(&owner_scope), format: match config.format() { PageScreencastFormat::Png => RendererScreenshotFormat::Png, PageScreencastFormat::Jpeg => RendererScreenshotFormat::Jpeg, @@ -6972,6 +6973,7 @@ fn start_devtools_capture_screenshot_command( let session_id = command.context.session_id.as_ref().map(|id| id.as_str()); let owner_scope = CommandOwnerScope::capture(conn, session_id); let base_background_color = conn.default_background_color_for_owner(&owner_scope); + let vision_deficiency = conn.vision_deficiency_for_owner(&owner_scope); let page = match conn.loaded_page_mut_for_protocol_access(session_id) { Ok(page) => page, Err(message) => { @@ -7038,6 +7040,7 @@ fn start_devtools_print_to_pdf_command( }; let request = RendererCaptureScreenshotRequest { base_background_color: [255; 4], + vision_deficiency: Default::default(), purpose: RendererScreenshotPurpose::Print { print_background: command.print_background.unwrap_or(false), }, diff --git a/moli-protocol/src/domains/schema.rs b/moli-protocol/src/domains/schema.rs new file mode 100644 index 0000000000..23025bc09e --- /dev/null +++ b/moli-protocol/src/domains/schema.rs @@ -0,0 +1,59 @@ +use serde_json::json; +use strum::IntoEnumIterator; + +use crate::conn::Cmd; +use crate::domains::command_output::CommandOutputPlan; + +/// Domains installed by the connection dispatcher. Keep discovery and dispatch exhaustive. +#[derive(Clone, Copy, strum::EnumString, strum::EnumIter, strum::AsRefStr)] +pub(crate) enum CdpDomain { + Browser, + Runtime, + HeapProfiler, + Profiler, + Debugger, + Accessibility, + Input, + #[strum(serialize = "CSS")] + Css, + #[strum(serialize = "DOM")] + Dom, + #[strum(serialize = "DOMStorage")] + DomStorage, + Console, + Network, + Target, + Tracing, + Fetch, + Page, + Inspector, + Log, + Storage, + #[strum(serialize = "DOMSnapshot")] + DomSnapshot, + Overlay, + Security, + ServiceWorker, + #[strum(serialize = "IO")] + Io, + Autofill, + Audits, + SystemInfo, + WebAuthn, + WebMCP, + Performance, + #[strum(serialize = "DOMDebugger")] + DomDebugger, + Emulation, + Schema, +} + +pub(crate) fn command_output_plan(cmd: &Cmd<'_>) -> CommandOutputPlan { + if cmd.method != "Schema.getDomains" { + return CommandOutputPlan::error(-32601, "UnknownMethod"); + } + let domains: Vec<_> = CdpDomain::iter() + .map(|domain| json!({"name": domain.as_ref(), "version": crate::version::PROTOCOL_VERSION})) + .collect(); + CommandOutputPlan::result(json!({"domains": domains})) +} diff --git a/moli-renderer-v8/src/lib.rs b/moli-renderer-v8/src/lib.rs index f52835290e..ca918c2cb4 100644 --- a/moli-renderer-v8/src/lib.rs +++ b/moli-renderer-v8/src/lib.rs @@ -261,7 +261,7 @@ pub use runtime::{ RendererDomSnapshotCaptureOptions, RendererDomSnapshotCapturePayload, RendererDragData, RendererDragDataItem, RendererDraggedDirectory, RendererDraggedFile, RendererFrameToken, RendererGeometryQuad, RendererInputDispatchOutcome, RendererInspectorCommandEnvelope, - RendererInspectorCommandRoute, RendererInspectorIngressTicket, + RendererInspectorCommandRoute, RendererInspectorIngressTicket, RendererInspectorOverlayCommand, RendererInspectorProtocolConfiguration, RendererInspectorProtocolConfigurationCommand, RendererInspectorSessionRestoreSnapshot, RendererJavaScriptDialogCompletion, RendererJavaScriptDialogId, RendererJavaScriptDialogResult, RendererJavaScriptDialogSource, @@ -312,8 +312,8 @@ pub use runtime::{ RendererSharedWorkerConsoleMessage, RendererSharedWorkerTargetEvent, RendererSharedWorkerTargetInfo, RendererStyleSheetHeader, RendererStyleSheetInventoryUpdate, RendererStyleSheetPayload, RendererSyntheticResponseBody, RendererTextSearchMatch, - RendererTouchPoint, RendererVisualStateToken, RendererWindowDocumentSource, - RuntimeConsoleMessageSnapshot, renderer_output_transport_channel, + RendererTouchPoint, RendererVisionDeficiency, RendererVisualStateToken, + RendererWindowDocumentSource, RuntimeConsoleMessageSnapshot, renderer_output_transport_channel, }; pub use runtime::{ RendererElementClickError, RendererElementClickTarget, RendererPreparedPointerClick, diff --git a/moli-renderer-v8/src/runtime/mod.rs b/moli-renderer-v8/src/runtime/mod.rs index 0880947fa3..9e6b9ded81 100644 --- a/moli-renderer-v8/src/runtime/mod.rs +++ b/moli-renderer-v8/src/runtime/mod.rs @@ -304,7 +304,7 @@ pub(crate) use self::page_context_cancel::{ pub use self::page_screenshot::{ RendererCaptureScreencastFrameRequest, RendererCaptureScreenshotRequest, RendererScreenshotClip, RendererScreenshotFormat, RendererScreenshotPurpose, - RendererScreenshotRegion, RendererVisualStateToken, + RendererScreenshotRegion, RendererVisionDeficiency, RendererVisualStateToken, }; pub(super) use self::page_state::RendererPageEntry; pub use self::page_state::RendererPageRecord; @@ -800,3 +800,6 @@ pub(super) enum PageVmRuntimeExpressionAwaitAdvance { #[cfg(test)] mod tests; + +mod page_overlay; +pub use page_overlay::RendererInspectorOverlayCommand; diff --git a/moli-renderer-v8/src/runtime/page_commands.rs b/moli-renderer-v8/src/runtime/page_commands.rs index 255205b4c3..9d4b1c8938 100644 --- a/moli-renderer-v8/src/runtime/page_commands.rs +++ b/moli-renderer-v8/src/runtime/page_commands.rs @@ -1123,6 +1123,10 @@ impl PageVm { self.set_bypass_content_security_policy(bypass); Ok(RendererPageReply::Unit) } + RendererPageCommand::SetInspectorOverlay { inspector_session_id, command } => { + self.set_inspector_overlay(inspector_session_id.as_deref(), command)?; + Ok(RendererPageReply::Unit) + } RendererPageCommand::SetEmulatedMedia(overrides) => { self.set_emulated_media(&overrides); Ok(RendererPageReply::Unit) diff --git a/moli-renderer-v8/src/runtime/page_dom.rs b/moli-renderer-v8/src/runtime/page_dom.rs index 2300ea04db..edd775abfa 100644 --- a/moli-renderer-v8/src/runtime/page_dom.rs +++ b/moli-renderer-v8/src/runtime/page_dom.rs @@ -3919,6 +3919,7 @@ impl PageVm { &mut self, inspector_session_id: Option<&str>, ) -> bool { + self.inspector_overlay.detach_session(inspector_session_id); let session_key = DevToolsSessionKey::from_wire_session_id( inspector_session_id.filter(|session_id| !session_id.is_empty()), ); @@ -3994,7 +3995,7 @@ impl PageVm { .mutate_document_node_attribute(handle, mutation) } - fn live_handle_for_dom_frontend_node_id( + pub(super) fn live_handle_for_dom_frontend_node_id( &mut self, inspector_session_id: Option<&str>, frontend_node_id: u32, diff --git a/moli-renderer-v8/src/runtime/page_overlay.rs b/moli-renderer-v8/src/runtime/page_overlay.rs new file mode 100644 index 0000000000..a2d6aed48e --- /dev/null +++ b/moli-renderer-v8/src/runtime/page_overlay.rs @@ -0,0 +1,299 @@ +//! Inspector-only paint, kept outside the inspected DOM and its style cascade. +use super::{PageVm, RendererDocumentLifecycleIdentity, RendererDocumentNodeGeometry}; +use anyhow::{Result, anyhow}; +use moli_layout::{ + PaintBrush, PaintColor, PaintFragment, PaintPath, PaintPathElement, PaintPoint, PaintRect, + PaintShape, PaintSnapshot, +}; + +#[derive(Clone, Debug)] +pub enum RendererInspectorOverlayCommand { + Enable, + Disable, + Hide, + Rect { + rect: [f32; 4], + color: [f32; 4], + outline: [f32; 4], + }, + Node { + node_id: Option, + backend_node_id: Option, + object_id: Option, + colors: [[f32; 4]; 4], + }, +} + +#[derive(Clone, Debug)] +enum Highlight { + Rect { + rect: [f32; 4], + color: PaintColor, + outline: PaintColor, + }, + Node { + backend_node_id: u32, + colors: [PaintColor; 4], + }, +} + +#[derive(Default)] +pub(super) struct InspectorOverlayState { + highlight: Option<(RendererDocumentLifecycleIdentity, Option, Highlight)>, + pub(super) revision: u64, +} + +impl InspectorOverlayState { + pub(super) fn detach_session(&mut self, session: Option<&str>) { + if self + .highlight + .as_ref() + .is_some_and(|(_, owner, _)| owner.as_deref() == session) + { + self.highlight = None; + self.revision = self.revision.wrapping_add(1); + } + } +} + +impl PageVm { + pub(super) fn set_inspector_overlay( + &mut self, + session: Option<&str>, + command: RendererInspectorOverlayCommand, + ) -> Result<()> { + let highlight = match command { + RendererInspectorOverlayCommand::Enable => return Ok(()), + RendererInspectorOverlayCommand::Disable | RendererInspectorOverlayCommand::Hide => { + None + } + RendererInspectorOverlayCommand::Rect { + rect, + color, + outline, + } => Some(Highlight::Rect { + rect, + color: color_from_components(color), + outline: color_from_components(outline), + }), + RendererInspectorOverlayCommand::Node { + node_id, + backend_node_id, + object_id, + colors, + } => { + let handle = if let Some(id) = node_id { + self.live_handle_for_dom_frontend_node_id(session, id) + } else if let Some(id) = backend_node_id { + self.live_handle_for_backend_node_id(id) + } else if let Some(id) = object_id { + self.vm_mut() + .live_node_handle_for_runtime_object_id(session, &id)? + } else { + None + }; + let backend_node_id = handle + .and_then(|handle| self.renderer_backend_node_id_for_live_handle(handle)) + .ok_or_else(|| anyhow!("Could not find node with given id"))?; + Some(Highlight::Node { + backend_node_id, + colors: colors.map(color_from_components), + }) + } + }; + self.inspector_overlay.highlight = highlight.map(|value| { + ( + self.document_lifecycle.identity(), + session.map(str::to_owned), + value, + ) + }); + self.inspector_overlay.revision = self.inspector_overlay.revision.wrapping_add(1); + Ok(()) + } + + pub(super) fn composite_inspector_overlay( + &mut self, + page: &PaintSnapshot, + raster: &mut moli_paint::RasterImage, + ) -> Result<()> { + let mut snapshot = PaintSnapshot::new(page.viewport, PaintColor::TRANSPARENT); + snapshot.surface = page.surface; + snapshot.viewport_to_surface = page.viewport_to_surface; + self.append_inspector_overlay(&mut snapshot)?; + if snapshot.fragments.is_empty() { + return Ok(()); + } + let required_bytes = raster.rgba.len().saturating_mul(2); + if required_bytes > moli_paint::MAX_TRANSIENT_RASTER_BYTES { + return Err(moli_paint::PaintError::TransientRasterBudgetExceeded { + required_bytes, + max_bytes: moli_paint::MAX_TRANSIENT_RASTER_BYTES, + } + .into()); + } + let overlay = moli_paint::raster_snapshot(&snapshot)?; + // Inspector UI is composited after page effects and never enters print. + for (dst, src) in raster + .rgba + .chunks_exact_mut(4) + .zip(overlay.rgba.chunks_exact(4)) + { + let source = [src[0], src[1], src[2], src[3]]; + let destination = [dst[0], dst[1], dst[2], dst[3]]; + dst.copy_from_slice(&source_over(source, destination)); + } + Ok(()) + } + + pub(super) fn append_inspector_overlay(&mut self, snapshot: &mut PaintSnapshot) -> Result<()> { + let Some((document, _, highlight)) = self.inspector_overlay.highlight.clone() else { + return Ok(()); + }; + if document != self.document_lifecycle.identity() { + return Ok(()); + } + match highlight { + Highlight::Rect { + rect: [x, y, width, height], + color, + outline, + } => { + let outer = [x, y, x + width, y, x + width, y + height, x, y + height]; + fill_quad(snapshot, outer, None, color); + // Inspector rectangle outlines are one CSS pixel wide. + let inner = [ + x + 1.0, + y + 1.0, + x + width - 1.0, + y + 1.0, + x + width - 1.0, + y + height - 1.0, + x + 1.0, + y + height - 1.0, + ]; + fill_quad(snapshot, outer, Some(inner), outline); + } + Highlight::Node { + backend_node_id, + colors, + } => { + if let Some(RendererDocumentNodeGeometry::FoundElement { box_model, .. }) = + self.document_geometry_for_backend_node_id(backend_node_id)? + { + let quads = [ + box_model.content, + box_model.padding, + box_model.border, + box_model.margin, + ] + .map(|quad| quad.points.map(|value| value as f32)); + for (index, (quad, color)) in quads.into_iter().zip(colors).enumerate() { + fill_quad( + snapshot, + quad, + index.checked_sub(1).map(|previous| quads[previous]), + color, + ); + } + } + } + } + Ok(()) + } +} + +fn fill_quad( + snapshot: &mut PaintSnapshot, + outer: [f32; 8], + inner: Option<[f32; 8]>, + color: PaintColor, +) { + if color.alpha <= 0.0 { + return; + } + let mut elements = Vec::with_capacity(10); + let points = |quad: [f32; 8]| { + [ + PaintPoint::new(quad[0], quad[1]), + PaintPoint::new(quad[2], quad[3]), + PaintPoint::new(quad[4], quad[5]), + PaintPoint::new(quad[6], quad[7]), + ] + }; + let outer_points = points(outer); + for (index, point) in outer_points.into_iter().enumerate() { + elements.push(if index == 0 { + PaintPathElement::MoveTo(point) + } else { + PaintPathElement::LineTo(point) + }); + } + elements.push(PaintPathElement::Close); + if let Some(inner) = inner { + for (index, point) in points(inner).into_iter().rev().enumerate() { + elements.push(if index == 0 { + PaintPathElement::MoveTo(point) + } else { + PaintPathElement::LineTo(point) + }); + } + elements.push(PaintPathElement::Close); + } + let min_x = outer_points + .iter() + .map(|p| p.x) + .fold(f32::INFINITY, f32::min); + let min_y = outer_points + .iter() + .map(|p| p.y) + .fold(f32::INFINITY, f32::min); + let max_x = outer_points + .iter() + .map(|p| p.x) + .fold(f32::NEG_INFINITY, f32::max); + let max_y = outer_points + .iter() + .map(|p| p.y) + .fold(f32::NEG_INFINITY, f32::max); + snapshot.fragments.push(PaintFragment::Fill { + shape: PaintShape::Path(PaintPath { + elements, + bounds: PaintRect::new(min_x, min_y, max_x - min_x, max_y - min_y), + }), + brush: PaintBrush::Solid(color), + transform: snapshot.viewport_to_surface, + }); +} + +fn color_from_components([r, g, b, a]: [f32; 4]) -> PaintColor { + PaintColor::new(r, g, b, a) +} + +pub(super) fn composite_background(raster: &mut moli_paint::RasterImage, color: PaintColor) { + let background = color + .components() + .map(|value| (value.clamp(0.0, 1.0) * 255.0).round() as u8); + for pixel in raster.rgba.chunks_exact_mut(4) { + let foreground = [pixel[0], pixel[1], pixel[2], pixel[3]]; + pixel.copy_from_slice(&source_over(foreground, background)); + } +} + +fn source_over(source: [u8; 4], destination: [u8; 4]) -> [u8; 4] { + let source_alpha = u32::from(source[3]); + let destination_alpha = u32::from(destination[3]) * (255 - source_alpha); + let alpha = source_alpha * 255 + destination_alpha; + if alpha == 0 { + return [0; 4]; + } + let mut result = [0; 4]; + for (index, out) in result[..3].iter_mut().enumerate() { + *out = ((u32::from(source[index]) * source_alpha * 255 + + u32::from(destination[index]) * destination_alpha + + alpha / 2) + / alpha) as u8; + } + result[3] = ((alpha + 127) / 255) as u8; + result +} diff --git a/moli-renderer-v8/src/runtime/page_screenshot.rs b/moli-renderer-v8/src/runtime/page_screenshot.rs index c483ca9119..ac79d8ac81 100644 --- a/moli-renderer-v8/src/runtime/page_screenshot.rs +++ b/moli-renderer-v8/src/runtime/page_screenshot.rs @@ -3,6 +3,7 @@ use moli_layout::{ PaintViewport, }; use moli_page_types::{LayoutPolicy, ViewportSurface}; +pub use moli_paint::VisionDeficiency as RendererVisionDeficiency; use std::sync::Arc; use std::time::Instant; @@ -75,6 +76,8 @@ struct RendererVisualState { viewport_height: u32, device_pixel_ratio_bits: u32, base_background_color: [u8; 4], + vision_deficiency: RendererVisionDeficiency, + inspector_overlay_revision: u64, } impl RendererVisualStateToken { @@ -102,9 +105,25 @@ impl RendererVisualStateToken { viewport_height: viewport.css_height, device_pixel_ratio_bits: viewport.device_pixel_ratio.to_bits(), base_background_color, + vision_deficiency: RendererVisionDeficiency::None, + inspector_overlay_revision: 0, })) } + fn with_capture_effects( + mut self, + vision: RendererVisionDeficiency, + overlay_revision: u64, + ) -> Self { + Arc::get_mut(&mut self.0) + .expect("new visual token") + .vision_deficiency = vision; + Arc::get_mut(&mut self.0) + .expect("new visual token") + .inspector_overlay_revision = overlay_revision; + self + } + fn has_same_resource_generation(&self, other: &Self) -> bool { self.0.resource_generation == other.0.resource_generation } @@ -152,6 +171,7 @@ pub struct RendererCaptureScreenshotRequest { pub region: RendererScreenshotRegion, /// Straight-alpha sRGB bytes in RGBA order, beneath author backgrounds. pub base_background_color: [u8; 4], + pub vision_deficiency: RendererVisionDeficiency, pub optimize_for_speed: bool, pub max_width: Option, pub max_height: Option, @@ -166,6 +186,7 @@ pub struct RendererCaptureScreencastFrameRequest { pub quality: u8, /// Straight-alpha sRGB bytes in RGBA order, beneath author backgrounds. pub base_background_color: [u8; 4], + pub vision_deficiency: RendererVisionDeficiency, pub optimize_for_speed: bool, pub max_width: Option, pub max_height: Option, @@ -180,6 +201,7 @@ impl RendererCaptureScreenshotRequest { quality: 100, region: RendererScreenshotRegion::Viewport, base_background_color: [255; 4], + vision_deficiency: RendererVisionDeficiency::None, optimize_for_speed: false, max_width: None, max_height: None, @@ -216,6 +238,8 @@ impl PageVm { request.format, request.quality, request.optimize_for_speed, + request.vision_deficiency, + request.purpose == RendererScreenshotPurpose::Screenshot, paint_capture, match request.purpose { RendererScreenshotPurpose::Screenshot => moli_layout::LayoutFlushReason::Screenshot, @@ -261,11 +285,14 @@ impl PageVm { surface.inner_height, surface.device_pixel_ratio as f32, ); - let visual_state_before = self.vm().visual_state_token( - self.document_lifecycle.identity(), - viewport, - request.base_background_color, - ); + let visual_state_before = self + .vm() + .visual_state_token( + self.document_lifecycle.identity(), + viewport, + request.base_background_color, + ) + .with_capture_effects(request.vision_deficiency, self.inspector_overlay.revision); if request.known_visual_state.as_ref() == Some(&visual_state_before) { return Ok(RendererCaptureScreencastFrameReply::Unchanged); } @@ -282,6 +309,8 @@ impl PageVm { request.format, request.quality, request.optimize_for_speed, + request.vision_deficiency, + true, paint_capture, moli_layout::LayoutFlushReason::Screencast, )? { @@ -293,11 +322,14 @@ impl PageVm { return Ok(RendererCaptureScreencastFrameReply::NoDocument); } }; - let visual_state_after = self.vm().visual_state_token( - self.document_lifecycle.identity(), - viewport, - request.base_background_color, - ); + let visual_state_after = self + .vm() + .visual_state_token( + self.document_lifecycle.identity(), + viewport, + request.base_background_color, + ) + .with_capture_effects(request.vision_deficiency, self.inspector_overlay.revision); let visual_state = visual_state_for_captured_screencast_frame(visual_state_before, visual_state_after); Ok(RendererCaptureScreencastFrameReply::Captured( @@ -314,6 +346,8 @@ impl PageVm { format: RendererScreenshotFormat, quality: u8, optimize_for_speed: bool, + vision_deficiency: RendererVisionDeficiency, + include_inspector_overlay: bool, paint_capture: PaintCaptureRequest, reason: moli_layout::LayoutFlushReason, ) -> anyhow::Result { @@ -362,6 +396,16 @@ impl PageVm { let raster_started = profile_enabled.then(Instant::now); let mut raster = moli_paint::raster_snapshot(&snapshot)?; + vision_deficiency.apply(&mut raster, snapshot.surface.device_scale)?; + if vision_deficiency == RendererVisionDeficiency::BlurredVision { + super::page_overlay::composite_background( + &mut raster, + paint_capture.base_background_color, + ); + } + if include_inspector_overlay { + self.composite_inspector_overlay(&snapshot, &mut raster)?; + } let raster_us = raster_started .map(|started| started.elapsed().as_micros()) .unwrap_or_default(); diff --git a/moli-renderer-v8/src/runtime/page_surface.rs b/moli-renderer-v8/src/runtime/page_surface.rs index 3472300cdf..0bdeb22a0f 100644 --- a/moli-renderer-v8/src/runtime/page_surface.rs +++ b/moli-renderer-v8/src/runtime/page_surface.rs @@ -5321,6 +5321,10 @@ pub enum RendererPageCommand { SetDocumentActivity(moli_page_types::DocumentActivity), SetScriptExecutionDisabled(bool), SetBypassContentSecurityPolicy(bool), + SetInspectorOverlay { + inspector_session_id: Option, + command: super::RendererInspectorOverlayCommand, + }, SetEmulatedMedia(crate::protocol_types::EmulatedMediaOverrides), SetViewportSurface(Option), SetNetworkOffline(bool), diff --git a/moli-renderer-v8/src/runtime/page_vm/mod.rs b/moli-renderer-v8/src/runtime/page_vm/mod.rs index d7f620759b..572baad675 100644 --- a/moli-renderer-v8/src/runtime/page_vm/mod.rs +++ b/moli-renderer-v8/src/runtime/page_vm/mod.rs @@ -1573,6 +1573,7 @@ pub(crate) struct PageVm { ScriptExecutionReportSnapshotSignature, Arc, )>, + pub(super) inspector_overlay: super::page_overlay::InspectorOverlayState, dom_agent_state: RendererDomAgentState, pending_dom_mutation_event_batches: Vec, last_published_document_title: String, @@ -4291,6 +4292,7 @@ impl PageVm { report, report_snapshot_cache: None, dom_agent_state, + inspector_overlay: Default::default(), pending_dom_mutation_event_batches: Vec::new(), last_published_document_title: String::new(), css_agent_sessions: HashMap::new(), diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/computed_size/lifecycle.rs b/moli-renderer-v8/src/runtime/page_vm/tests/computed_size/lifecycle.rs index 1263915d78..46210acad8 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/computed_size/lifecycle.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/computed_size/lifecycle.rs @@ -147,6 +147,7 @@ async fn computed_size_reads_do_not_invalidate_screencast_tokens() { set_viewport(&mut page, 40, 30)?; let request = |known_visual_state| RendererCaptureScreencastFrameRequest { base_background_color: [255; 4], + vision_deficiency: Default::default(), format: RendererScreenshotFormat::Png, quality: 100, optimize_for_speed: true, diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs b/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs index 750ef75bf6..72606e5d65 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs @@ -17,6 +17,7 @@ fn viewport_screencast_request( ) -> crate::runtime::RendererCaptureScreencastFrameRequest { crate::runtime::RendererCaptureScreencastFrameRequest { base_background_color: [255; 4], + vision_deficiency: Default::default(), format: crate::runtime::RendererScreenshotFormat::Png, quality: 100, optimize_for_speed: true, diff --git a/moli-renderer-v8/src/runtime/tests.rs b/moli-renderer-v8/src/runtime/tests.rs index 5393c3203d..415eee4c5c 100644 --- a/moli-renderer-v8/src/runtime/tests.rs +++ b/moli-renderer-v8/src/runtime/tests.rs @@ -574,6 +574,7 @@ async fn screenshot_and_screencast_paint_downloaded_svg_vectors() { &page, super::RendererCaptureScreencastFrameRequest { base_background_color: [255; 4], + vision_deficiency: Default::default(), format: super::RendererScreenshotFormat::Png, quality: 100, optimize_for_speed: true, @@ -687,6 +688,7 @@ async fn capture_screenshot_encodes_jpeg_and_limits_device_dimensions() { let request = super::RendererCaptureScreenshotRequest { base_background_color: [255; 4], + vision_deficiency: Default::default(), purpose: super::RendererScreenshotPurpose::Screenshot, format: super::RendererScreenshotFormat::Jpeg, quality: 80, @@ -756,6 +758,7 @@ async fn print_capture_uses_print_media_controls_backgrounds_and_restores_screen &page, super::RendererCaptureScreenshotRequest { base_background_color: [255; 4], + vision_deficiency: Default::default(), purpose: super::RendererScreenshotPurpose::Print { print_background: true, }, @@ -775,6 +778,7 @@ async fn print_capture_uses_print_media_controls_backgrounds_and_restores_screen &page, super::RendererCaptureScreenshotRequest { base_background_color: [255; 4], + vision_deficiency: Default::default(), purpose: super::RendererScreenshotPurpose::Print { print_background: false, }, @@ -842,6 +846,7 @@ async fn capture_screenshot_clip_and_full_document_keep_the_live_layout_viewport &page, super::RendererCaptureScreenshotRequest { base_background_color: [255; 4], + vision_deficiency: Default::default(), purpose: super::RendererScreenshotPurpose::Screenshot, format: super::RendererScreenshotFormat::Png, quality: 100, @@ -860,6 +865,7 @@ async fn capture_screenshot_clip_and_full_document_keep_the_live_layout_viewport &page, super::RendererCaptureScreenshotRequest { base_background_color: [255; 4], + vision_deficiency: Default::default(), purpose: super::RendererScreenshotPurpose::Screenshot, format: super::RendererScreenshotFormat::Png, quality: 100, @@ -927,6 +933,7 @@ async fn bounded_viewport_clip_keeps_root_controls_while_page_clip_omits_them() &page, super::RendererCaptureScreenshotRequest { base_background_color: [255; 4], + vision_deficiency: Default::default(), purpose: super::RendererScreenshotPurpose::Screenshot, format: super::RendererScreenshotFormat::Png, quality: 100, @@ -957,6 +964,7 @@ async fn bounded_viewport_clip_keeps_root_controls_while_page_clip_omits_them() &page, super::RendererCaptureScreenshotRequest { base_background_color: [255; 4], + vision_deficiency: Default::default(), purpose: super::RendererScreenshotPurpose::Screenshot, format: super::RendererScreenshotFormat::Png, quality: 100, @@ -989,6 +997,7 @@ async fn capture_screenshot_rejects_full_document_at_the_128k_css_boundary() { .await; let request = super::RendererCaptureScreenshotRequest { base_background_color: [255; 4], + vision_deficiency: Default::default(), purpose: super::RendererScreenshotPurpose::Screenshot, format: super::RendererScreenshotFormat::Png, quality: 100, @@ -12494,6 +12503,7 @@ addEventListener("wheel", event => { &page, super::RendererCaptureScreencastFrameRequest { base_background_color: [255; 4], + vision_deficiency: Default::default(), format: super::RendererScreenshotFormat::Png, quality: 100, optimize_for_speed: true, diff --git a/moli-renderer-v8/src/style_engine/retained.rs b/moli-renderer-v8/src/style_engine/retained.rs index f860090395..5bd83c5948 100644 --- a/moli-renderer-v8/src/style_engine/retained.rs +++ b/moli-renderer-v8/src/style_engine/retained.rs @@ -41,7 +41,7 @@ pub(super) struct RetainedStyleInvalidations { pub(super) shadow_scope_fallbacks: Vec, pub(super) removed_shadow_scopes: Vec, pub(super) viewport_size_changed: bool, - pub(super) used_color_scheme_changed: bool, + pub(super) computed_environment_changed: bool, } /// Compares an explicit full-world snapshot with the canonical retained @@ -78,7 +78,7 @@ impl RetainedStyleInvalidations { shadow_scope_fallbacks: Vec::new(), removed_shadow_scopes: Vec::new(), viewport_size_changed: false, - used_color_scheme_changed: false, + computed_environment_changed: false, } } } @@ -208,7 +208,7 @@ pub(super) fn update_retained_style_system( invalidations.document = document_update.invalidations; invalidations.document_scope_fallback = document_update.scope_fallback; invalidations.viewport_size_changed = document_update.viewport_size_changed; - invalidations.used_color_scheme_changed = document_update.used_color_scheme_changed; + invalidations.computed_environment_changed = document_update.computed_environment_changed; let shadow_reconciliation = reconcile_shadow_scopes( retained, @@ -269,7 +269,7 @@ pub(super) fn update_retained_style_system_incrementally( invalidations.document = document_update.invalidations; invalidations.document_scope_fallback = document_update.scope_fallback; invalidations.viewport_size_changed = document_update.viewport_size_changed; - invalidations.used_color_scheme_changed = document_update.used_color_scheme_changed; + invalidations.computed_environment_changed = document_update.computed_environment_changed; let shadow_reconciliation = reconcile_dirty_shadow_scopes( retained, @@ -327,7 +327,7 @@ struct DocumentScopeUpdate { scope_fallback: bool, device_changed: bool, viewport_size_changed: bool, - used_color_scheme_changed: bool, + computed_environment_changed: bool, device_affected_origins: OriginSet, stylesheets_changed: bool, } @@ -373,8 +373,10 @@ fn update_document_scope( let viewport_size_changed = retained.key.viewport_width_bits != key.viewport_width_bits || retained.key.viewport_height_bits != key.viewport_height_bits; - let used_color_scheme_changed = retained.key.environment.stylo_prefers_color_scheme() - != key.environment.stylo_prefers_color_scheme() + let computed_environment_changed = retained.key.environment.preferred_text_scale() + != key.environment.preferred_text_scale() + || retained.key.environment.stylo_prefers_color_scheme() + != key.environment.stylo_prefers_color_scheme() || retained.key.environment.stylo_page_color_schemes() != key.environment.stylo_page_color_schemes(); let device_changed = viewport_size_changed @@ -437,7 +439,7 @@ fn update_document_scope( scope_fallback, device_changed, viewport_size_changed, - used_color_scheme_changed, + computed_environment_changed, device_affected_origins, stylesheets_changed, } diff --git a/moli-renderer-v8/src/style_engine/stylesheet.rs b/moli-renderer-v8/src/style_engine/stylesheet.rs index aa403d1887..2427f28af3 100644 --- a/moli-renderer-v8/src/style_engine/stylesheet.rs +++ b/moli-renderer-v8/src/style_engine/stylesheet.rs @@ -398,6 +398,7 @@ pub(super) fn new_style_device_with_viewport( PointerCapabilities::default(), PointerCapabilities::default(), ); + device.set_preferred_text_scale(environment.preferred_text_scale()); device.set_media_feature_preferences(environment.stylo_media_feature_preferences()); device.set_page_color_schemes(environment.stylo_page_color_schemes()); device diff --git a/moli-renderer-v8/src/style_engine/world_environment.rs b/moli-renderer-v8/src/style_engine/world_environment.rs index 58069dc8ba..9d39f2ed84 100644 --- a/moli-renderer-v8/src/style_engine/world_environment.rs +++ b/moli-renderer-v8/src/style_engine/world_environment.rs @@ -103,6 +103,7 @@ impl From> for StyleViewport { #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] pub(crate) struct StyloStyleEnvironment { + preferred_text_scale_bits: Option, media_type: StyloStyleMediaType, color_scheme: StyloStyleColorScheme, page_color_scheme_bits: u8, @@ -155,6 +156,7 @@ impl StyloStyleEnvironment { overrides: &crate::protocol_types::EmulatedMediaOverrides, ) -> Self { Self { + preferred_text_scale_bits: overrides.preferred_text_scale.map(f32::to_bits), media_type: if overrides.media.as_deref() == Some("print") { StyloStyleMediaType::Print } else { @@ -197,6 +199,12 @@ impl StyloStyleEnvironment { self } + pub(super) fn preferred_text_scale(self) -> f32 { + self.preferred_text_scale_bits + .map(f32::from_bits) + .unwrap_or(1.0) + } + pub(super) fn stylo_media_type(self) -> MediaType { match self.media_type { StyloStyleMediaType::Screen => MediaType::screen(), diff --git a/moli-renderer-v8/src/style_engine/world_lifecycle.rs b/moli-renderer-v8/src/style_engine/world_lifecycle.rs index d0c7becfd0..595e77cd42 100644 --- a/moli-renderer-v8/src/style_engine/world_lifecycle.rs +++ b/moli-renderer-v8/src/style_engine/world_lifecycle.rs @@ -303,7 +303,7 @@ fn apply_retained_stylesheet_invalidations( shadow_scope_fallbacks, removed_shadow_scopes, viewport_size_changed, - used_color_scheme_changed, + computed_environment_changed, } = invalidations; let mut stylesheet_invalidation_roots = IndexSet::new(); if let Some(invalidations) = document_invalidations { @@ -353,7 +353,7 @@ fn apply_retained_stylesheet_invalidations( document, )); } - if used_color_scheme_changed { + if computed_environment_changed { invalidation_cleanup.invalidate_subtrees(host, [document]); } invalidation_cleanup.retain_stylesheet_invalidation_roots(host, stylesheet_invalidation_roots); diff --git a/moli/src/cli.rs b/moli/src/cli.rs index e8af1cfaa1..79f6732cd4 100644 --- a/moli/src/cli.rs +++ b/moli/src/cli.rs @@ -429,6 +429,10 @@ fn parse_request_header_arg(raw: &str) -> Result { #[derive(Debug, Clone, PartialEq, Eq, Args)] pub struct ServeArgs { + /// Allow CDP Browser.getBrowserCommandLine to expose the process arguments. + #[arg(long)] + pub enable_automation: bool, + #[arg(long, default_value = "127.0.0.1")] pub host: String, diff --git a/moli/src/fetch_dump/raster.rs b/moli/src/fetch_dump/raster.rs index 41429f5b4c..683f35b1ac 100644 --- a/moli/src/fetch_dump/raster.rs +++ b/moli/src/fetch_dump/raster.rs @@ -33,6 +33,7 @@ pub(super) async fn render_pdf(page: &mut Page) -> Result> { page, RendererCaptureScreenshotRequest { base_background_color: [255; 4], + vision_deficiency: Default::default(), purpose: RendererScreenshotPurpose::Print { print_background: false, }, diff --git a/moli/tests/cli.rs b/moli/tests/cli.rs index a8bc9166d1..1ffd65ea28 100644 --- a/moli/tests/cli.rs +++ b/moli/tests/cli.rs @@ -611,6 +611,7 @@ fn infers_serve_mode_when_called_without_args() { assert_eq!( cli.command, Commands::Serve(Box::new(ServeArgs { + enable_automation: false, host: "127.0.0.1".to_owned(), port: 9222, timeout: 10, @@ -637,6 +638,7 @@ fn parses_serve_flags_with_explicit_command() { assert_eq!( cli.command, Commands::Serve(Box::new(ServeArgs { + enable_automation: false, host: "0.0.0.0".to_owned(), port: 9333, timeout: 42, From be2afd53a9ff033e5a146d0543ec3ddea6eefefd Mon Sep 17 00:00:00 2001 From: lanyue-llk <270302213+lanyue-llk@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:30:55 +0800 Subject: [PATCH 16/23] test(worker): use renderer stack budget for child message test --- moli-renderer-v8/src/runtime/page_vm/tests/worker.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/worker.rs b/moli-renderer-v8/src/runtime/page_vm/tests/worker.rs index 6ba6ca0808..8bf1affd63 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/worker.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/worker.rs @@ -2528,9 +2528,10 @@ async fn blob_worker_created_in_data_iframe_inherits_opaque_broadcast_channel_ow .await; } -#[tokio::test] -async fn child_message_handler_external_dedicated_worker_binds_child_client_event_owner() { - run_page_vm_async_test(async move { +#[test] +fn child_message_handler_external_dedicated_worker_binds_child_client_event_owner() { + // Match the render runtime's stack budget for the nested child/Worker turns. + run_page_vm_large_stack_async_test("child-message-worker-owner", || async move { let (base_url, server) = spawn_path_response_http_server(vec![( "/worker.js", "HTTP/1.1 200 OK", @@ -2655,8 +2656,7 @@ document server .await .expect("child message-created Worker server should finish"); - }) - .await; + }); } #[tokio::test] From 0511ee0c2e129830abf7d327817635aabef22ee3 Mon Sep 17 00:00:00 2001 From: lanyue-llk <270302213+lanyue-llk@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:14:04 +0800 Subject: [PATCH 17/23] fix(protocol): honor overlay payload and session lifecycle --- .../src/conn/state/devtools_session.rs | 1 + .../emulation/tests/inspector_surfaces.rs | 128 ++++++++++++++- moli-protocol/src/domains/overlay.rs | 155 ++++++++++++------ 3 files changed, 232 insertions(+), 52 deletions(-) diff --git a/moli-protocol/src/conn/state/devtools_session.rs b/moli-protocol/src/conn/state/devtools_session.rs index e6abc999f0..4bd313e573 100644 --- a/moli-protocol/src/conn/state/devtools_session.rs +++ b/moli-protocol/src/conn/state/devtools_session.rs @@ -32,6 +32,7 @@ use moli_page_types::{ #[derive(Clone, Debug, Default, PartialEq)] pub(crate) struct DevToolsSessionState { pub(crate) dom_session_state: DevToolsDomSessionState, + pub(crate) overlay_enabled: bool, pub(crate) dom_debugger_event_listener_breakpoints: BTreeSet, pub(crate) dom_debugger_xhr_breakpoints: BTreeSet, diff --git a/moli-protocol/src/domains/emulation/tests/inspector_surfaces.rs b/moli-protocol/src/domains/emulation/tests/inspector_surfaces.rs index 467edf7c56..e3bebec04f 100644 --- a/moli-protocol/src/domains/emulation/tests/inspector_surfaces.rs +++ b/moli-protocol/src/domains/emulation/tests/inspector_surfaces.rs @@ -141,7 +141,7 @@ async fn inspector_highlight_tracks_live_node_geometry_and_rejects_stale_ids() { json!({"nodeId":doc["root"]["nodeId"],"selector":"#target"}), ) .await; - command(&mut ctx,"Overlay.highlightNode",json!({"nodeId":node["nodeId"],"highlightConfig":{"contentColor":{"r":0,"g":255,"b":0,"a":1}}})).await; + command(&mut ctx,"Overlay.highlightNode",json!({"nodeId":node["nodeId"],"highlightConfig":{"showInfo":false,"showStyles":false,"showRulers":false,"contentColor":{"r":0,"g":255,"b":0,"a":1}}})).await; assert_eq!(pixel(&screenshot(&mut ctx).await, 20, 20), [0, 255, 0, 255]); evaluate(&mut ctx, "target.style.left='60px';undefined").await; let moved = screenshot(&mut ctx).await; @@ -156,6 +156,132 @@ async fn inspector_highlight_tracks_live_node_geometry_and_rejects_stale_ids() { assert!(ctx.take_response_by_id(88802)["error"].is_object()); } +#[tokio::test(flavor = "multi_thread")] +async fn inspector_highlight_rejects_unsupported_visual_options_explicitly() { + let mut ctx = page().await; + let doc = command(&mut ctx, "DOM.getDocument", json!({})).await; + ctx.process_async(json!({ + "id": 88803, "sessionId": "SID-1", "method": "Overlay.highlightNode", + "params": {"nodeId":doc["root"]["nodeId"],"highlightConfig":{ + "showInfo":true,"contentColor":{"r":255,"g":0,"b":0} + }} + })) + .await; + let response = ctx.take_response_by_id(88803); + assert_eq!(response["error"]["code"], json!(-32602)); + assert!( + response["error"]["message"] + .as_str() + .unwrap() + .contains("showInfo") + ); + + ctx.process_async(json!({ + "id": 88804, "sessionId": "SID-1", "method": "Overlay.highlightNode", + "params": {"nodeId":doc["root"]["nodeId"],"selector":"div", "highlightConfig":{}} + })) + .await; + let response = ctx.take_response_by_id(88804); + assert_eq!(response["error"]["code"], json!(-32602)); + assert!( + response["error"]["message"] + .as_str() + .unwrap() + .contains("selector") + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn inspector_domain_state_survives_navigation_and_cleanup_without_document() { + let mut ctx = TestContext::new(); + let mut browser_context = BrowserContext::new("BID-1".into()); + browser_context.set_active_target_id("TID-1"); + browser_context.attach_active_session("SID-1"); + ctx.conn + .install_browser_context_fixture_for_test(browser_context); + + command(&mut ctx, "Overlay.enable", json!({})).await; + command(&mut ctx, "Overlay.hideHighlight", json!({})).await; + let owner = crate::conn::CommandOwnerScope::capture(&ctx.conn, Some("SID-1")); + assert!( + ctx.conn + .target_devtools_session_state_for_owner(&owner) + .unwrap() + .overlay_enabled + ); + + ctx.install_navigation_fixture_for_session_owner( + "data:text/html,first", + Some("SID-1"), + ) + .await; + assert!( + ctx.conn + .target_devtools_session_state_for_owner(&owner) + .unwrap() + .overlay_enabled + ); + command( + &mut ctx, + "Overlay.highlightRect", + json!({ + "x":10,"y":10,"width":30,"height":30,"color":{"r":255,"g":0,"b":0} + }), + ) + .await; + assert_eq!(pixel(&screenshot(&mut ctx).await, 20, 20), [255, 0, 0, 255]); + + command( + &mut ctx, + "Page.navigate", + json!({"url":"data:text/html,second"}), + ) + .await; + assert!( + ctx.conn + .target_devtools_session_state_for_owner(&owner) + .unwrap() + .overlay_enabled + ); + assert_eq!( + pixel(&screenshot(&mut ctx).await, 20, 20), + [255, 255, 255, 255] + ); + command( + &mut ctx, + "Overlay.highlightRect", + json!({"x":10,"y":10,"width":30,"height":30,"color":{"r":0,"g":0,"b":255}}), + ) + .await; + assert_eq!(pixel(&screenshot(&mut ctx).await, 20, 20), [0, 0, 255, 255]); + ctx.conn + .browser_context + .as_mut() + .unwrap() + .start_document_navigation_for_active_target("PENDING-OVERLAY".to_owned()) + .unwrap(); + command(&mut ctx, "Overlay.hideHighlight", json!({})).await; + command(&mut ctx, "Overlay.disable", json!({})).await; + ctx.conn + .browser_context + .as_mut() + .unwrap() + .clear_pending_document_navigation_for_target_if_loader_matches( + Some("TID-1"), + "PENDING-OVERLAY", + ); + assert_eq!( + pixel(&screenshot(&mut ctx).await, 20, 20), + [255, 255, 255, 255] + ); + assert!( + !ctx.conn + .target_devtools_session_state_for_owner(&owner) + .unwrap() + .overlay_enabled + ); +} + #[tokio::test(flavor = "multi_thread")] async fn scroll_gesture_uses_hit_target_and_honors_wheel_cancellation() { let mut ctx = page().await; diff --git a/moli-protocol/src/domains/overlay.rs b/moli-protocol/src/domains/overlay.rs index 2b1505140b..2d665f04dc 100644 --- a/moli-protocol/src/domains/overlay.rs +++ b/moli-protocol/src/domains/overlay.rs @@ -1,43 +1,18 @@ use super::command_output::CommandOutputPlan; use crate::conn::{CdpConnection, Cmd, CommandOwnerScope}; +use chromiumoxide_cdp::cdp::browser_protocol::{dom::Rgba, overlay::HighlightNodeParams}; use moli_core::page::{CompletedPageCommand, PendingPageCommand, RendererInspectorOverlayCommand}; use serde::Deserialize; -#[derive(Clone, Copy, Deserialize)] -struct Color { - r: i32, - g: i32, - b: i32, - a: Option, -} -impl Color { - fn components(self) -> [f32; 4] { +fn color(value: Option) -> [f32; 4] { + value.map_or([0.0; 4], |value| { [ - self.r.clamp(0, 255) as f32 / 255.0, - self.g.clamp(0, 255) as f32 / 255.0, - self.b.clamp(0, 255) as f32 / 255.0, - self.a.unwrap_or(1.0).clamp(0.0, 1.0), + value.r.clamp(0, 255) as f32 / 255.0, + value.g.clamp(0, 255) as f32 / 255.0, + value.b.clamp(0, 255) as f32 / 255.0, + value.a.unwrap_or(1.0).clamp(0.0, 1.0) as f32, ] - } -} -fn color(value: Option) -> [f32; 4] { - value.map_or([0.0; 4], Color::components) -} -#[derive(Deserialize, Default)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct HighlightConfig { - content_color: Option, - padding_color: Option, - border_color: Option, - margin_color: Option, -} -#[derive(Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct NodeParams { - node_id: Option, - backend_node_id: Option, - object_id: Option, - highlight_config: HighlightConfig, + }) } #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -46,18 +21,39 @@ struct RectParams { y: i32, width: i32, height: i32, - color: Option, - outline_color: Option, + color: Option, + outline_color: Option, +} + +fn unsupported_highlight_option( + config: &chromiumoxide_cdp::cdp::browser_protocol::overlay::HighlightConfig, +) -> Option { + let mut fields = serde_json::to_value(config).ok()?.as_object()?.clone(); + for color in ["contentColor", "paddingColor", "borderColor", "marginColor"] { + fields.remove(color); + } + // These values request exactly the protocol defaults and need no painting. + for flag in ["showInfo", "showStyles", "showRulers", "showExtensionLines"] { + if fields.get(flag) == Some(&serde_json::Value::Bool(false)) { + fields.remove(flag); + } + } + if fields.get("showAccessibilityInfo") == Some(&serde_json::Value::Bool(true)) { + fields.remove("showAccessibilityInfo"); + } + fields.into_iter().next().map(|(name, _)| name) } pub(crate) struct PendingOverlayCommandDispatch { command_id: Option, owner: CommandOwnerScope, + cleanup: bool, pending: PendingPageCommand, } pub(crate) struct CompletedOverlayCommandDispatch { command_id: Option, owner: CommandOwnerScope, + cleanup: bool, completed: Result, } pub(crate) enum OverlayCommandTaskStep { @@ -69,6 +65,7 @@ impl PendingOverlayCommandDispatch { CompletedOverlayCommandDispatch { command_id: self.command_id, owner: self.owner, + cleanup: self.cleanup, completed: self.pending.wait().await.map_err(|error| error.to_string()), } } @@ -104,17 +101,34 @@ fn parse_command(cmd: &Cmd<'_>) -> Result { let p = cmd - .get_params::() + .get_params::() .map_err(invalid)? .ok_or_else(|| invalid("Missing node"))?; + if p.selector.is_some() { + return Err(invalid("selector highlighting is not supported")); + } if p.node_id.is_none() && p.backend_node_id.is_none() && p.object_id.is_none() { return Err(invalid("A node reference is required")); } + if let Some(option) = unsupported_highlight_option(&p.highlight_config) { + return Err(CommandOutputPlan::error( + -32602, + format!("Highlight option {option} is not supported"), + )); + } + let node_id = p + .node_id + .map(|id| u32::try_from(*id.inner()).map_err(|_| invalid("Invalid nodeId"))) + .transpose()?; + let backend_node_id = p + .backend_node_id + .map(|id| u32::try_from(*id.inner()).map_err(|_| invalid("Invalid backendNodeId"))) + .transpose()?; let config = p.highlight_config; Ok(RendererInspectorOverlayCommand::Node { - node_id: p.node_id, - backend_node_id: p.backend_node_id, - object_id: p.object_id, + node_id, + backend_node_id, + object_id: p.object_id.map(String::from), colors: [ config.content_color, config.padding_color, @@ -136,6 +150,32 @@ pub(crate) fn try_start_overlay_command_dispatch( Ok(command) => command, Err(plan) => return OverlayCommandTaskStep::Complete(plan), }; + let owner = CommandOwnerScope::capture(conn, cmd.session_id); + if conn + .target_devtools_session_state_for_owner(&owner) + .is_none() + { + return OverlayCommandTaskStep::Complete(CommandOutputPlan::error( + -32000, + "No target for Overlay command", + )); + } + if let Some(enabled) = match command { + RendererInspectorOverlayCommand::Enable => Some(true), + RendererInspectorOverlayCommand::Disable => Some(false), + _ => None, + } { + conn.with_target_devtools_session_state_for_owner_mut(&owner, |state| { + state.overlay_enabled = enabled; + }); + if enabled { + return OverlayCommandTaskStep::Complete(CommandOutputPlan::success()); + } + } + let cleanup = matches!( + command, + RendererInspectorOverlayCommand::Disable | RendererInspectorOverlayCommand::Hide + ); if conn.layout_policy() == moli_core::LayoutPolicy::Mock && matches!( command, @@ -148,18 +188,24 @@ pub(crate) fn try_start_overlay_command_dispatch( "Inspector highlighting requires layout", )); } - let owner = CommandOwnerScope::capture(conn, cmd.session_id); let session = conn.target_renderer_runtime_inspector_session_id_for_session(cmd.session_id); - let result = conn - .loaded_page_mut_for_protocol_access_for_owner(&owner) - .and_then(|page| { - page.start_set_inspector_overlay(session, command) - .map_err(|error| error.to_string()) - }); + let page = if cleanup { + conn.loaded_page_mut_for_target_configuration_for_owner(&owner) + } else { + conn.loaded_page_mut_for_protocol_access_for_owner(&owner) + }; + if cleanup && page.is_err() { + return OverlayCommandTaskStep::Complete(CommandOutputPlan::success()); + } + let result = page.and_then(|page| { + page.start_set_inspector_overlay(session, command) + .map_err(|error| error.to_string()) + }); match result { Ok(pending) => OverlayCommandTaskStep::Pending(PendingOverlayCommandDispatch { command_id: cmd.id, owner, + cleanup, pending, }), Err(error) => OverlayCommandTaskStep::Complete(CommandOutputPlan::error(-32000, error)), @@ -171,11 +217,18 @@ pub(crate) fn complete_pending_overlay_command( completed: CompletedOverlayCommandDispatch, ) -> CommandOutputPlan { let result = completed.completed.and_then(|completion| { - conn.loaded_page_mut_for_protocol_access_for_owner(&completed.owner) - .and_then(|page| { - page.finish_set_inspector_overlay(completion) - .map_err(|error| error.to_string()) - }) + let page = if completed.cleanup { + conn.loaded_page_mut_for_target_configuration_for_owner(&completed.owner) + } else { + conn.loaded_page_mut_for_protocol_access_for_owner(&completed.owner) + }; + match page { + Ok(page) => page + .finish_set_inspector_overlay(completion) + .map_err(|error| error.to_string()), + Err(_) if completed.cleanup => Ok(()), + Err(error) => Err(error), + } }); match result { Ok(()) => CommandOutputPlan::success(), From 8ebed1f5417807a87e07dc7fd77cf89db5da39f5 Mon Sep 17 00:00:00 2001 From: lanyue-llk <270302213+lanyue-llk@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:07:35 +0800 Subject: [PATCH 18/23] fix: preserve per-target style state and bound vision blur memory --- Cargo.lock | 22 ++-- Cargo.toml | 14 +-- moli-paint/src/vision.rs | 115 +++++++++++------- .../src/conn/state/devtools_session.rs | 1 - moli-protocol/src/domains/emulation.rs | 26 ++-- .../src/domains/emulation/page_session.rs | 42 ++++++- moli-protocol/src/domains/emulation/tests.rs | 47 +++++++ .../emulation/tests/inspector_surfaces.rs | 26 ---- moli-protocol/src/domains/overlay.rs | 13 +- 9 files changed, 191 insertions(+), 115 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e583dd4e22..c5257d9150 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4615,7 +4615,7 @@ dependencies = [ [[package]] name = "selectors" version = "0.40.0" -source = "git+https://github.com/lexmount/stylo?branch=moli#3c8bbb450a52f9a4dcb2de51061216a1d2727def" +source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" dependencies = [ "bitflags", "cssparser", @@ -4747,7 +4747,7 @@ dependencies = [ [[package]] name = "servo_arc" version = "0.4.3" -source = "git+https://github.com/lexmount/stylo?branch=moli#3c8bbb450a52f9a4dcb2de51061216a1d2727def" +source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" dependencies = [ "serde", "stable_deref_trait", @@ -5000,7 +5000,7 @@ dependencies = [ [[package]] name = "stylo" version = "0.20.0" -source = "git+https://github.com/lexmount/stylo?branch=moli#3c8bbb450a52f9a4dcb2de51061216a1d2727def" +source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" dependencies = [ "app_units", "arrayvec", @@ -5056,7 +5056,7 @@ dependencies = [ [[package]] name = "stylo_atoms" version = "0.20.0" -source = "git+https://github.com/lexmount/stylo?branch=moli#3c8bbb450a52f9a4dcb2de51061216a1d2727def" +source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" dependencies = [ "string_cache", "string_cache_codegen", @@ -5065,7 +5065,7 @@ dependencies = [ [[package]] name = "stylo_derive" version = "0.20.0" -source = "git+https://github.com/lexmount/stylo?branch=moli#3c8bbb450a52f9a4dcb2de51061216a1d2727def" +source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" dependencies = [ "darling", "proc-macro2", @@ -5077,7 +5077,7 @@ dependencies = [ [[package]] name = "stylo_dom" version = "0.20.0" -source = "git+https://github.com/lexmount/stylo?branch=moli#3c8bbb450a52f9a4dcb2de51061216a1d2727def" +source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" dependencies = [ "bitflags", "stylo_malloc_size_of", @@ -5086,7 +5086,7 @@ dependencies = [ [[package]] name = "stylo_malloc_size_of" version = "0.20.0" -source = "git+https://github.com/lexmount/stylo?branch=moli#3c8bbb450a52f9a4dcb2de51061216a1d2727def" +source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" dependencies = [ "app_units", "cssparser", @@ -5103,7 +5103,7 @@ dependencies = [ [[package]] name = "stylo_static_prefs" version = "0.20.0" -source = "git+https://github.com/lexmount/stylo?branch=moli#3c8bbb450a52f9a4dcb2de51061216a1d2727def" +source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" dependencies = [ "toml", ] @@ -5121,7 +5121,7 @@ dependencies = [ [[package]] name = "stylo_traits" version = "0.20.0" -source = "git+https://github.com/lexmount/stylo?branch=moli#3c8bbb450a52f9a4dcb2de51061216a1d2727def" +source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" dependencies = [ "app_units", "bitflags", @@ -5421,7 +5421,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "to_shmem" version = "0.5.0" -source = "git+https://github.com/lexmount/stylo?branch=moli#3c8bbb450a52f9a4dcb2de51061216a1d2727def" +source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" dependencies = [ "cssparser", "servo_arc", @@ -5434,7 +5434,7 @@ dependencies = [ [[package]] name = "to_shmem_derive" version = "0.1.0" -source = "git+https://github.com/lexmount/stylo?branch=moli#3c8bbb450a52f9a4dcb2de51061216a1d2727def" +source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" dependencies = [ "darling", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 2cabafaad5..beb8041ded 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -102,13 +102,13 @@ curl-sys = { git = "https://github.com/lexmount/curl-rust", rev = "a0ea59f5ca1b1 cookie = { git = "https://github.com/ldm0/cookie-rs", branch = "priority" } v8 = { path = "vendor/v8-152.2.0" } deno_v8 = { path = "vendor/deno_v8-0.3.0" } -selectors = { git = "https://github.com/lexmount/stylo", branch = "moli" } -servo_arc = { git = "https://github.com/lexmount/stylo", branch = "moli" } -stylo = { git = "https://github.com/lexmount/stylo", branch = "moli" } -stylo_atoms = { git = "https://github.com/lexmount/stylo", branch = "moli" } -stylo_dom = { git = "https://github.com/lexmount/stylo", branch = "moli" } -stylo_static_prefs = { git = "https://github.com/lexmount/stylo", branch = "moli" } -stylo_traits = { git = "https://github.com/lexmount/stylo", branch = "moli" } +selectors = { git = "https://github.com/lanyue-llk/stylo", rev = "402a73dba5624bbfb0a16639f03bd48db63af1b0" } +servo_arc = { git = "https://github.com/lanyue-llk/stylo", rev = "402a73dba5624bbfb0a16639f03bd48db63af1b0" } +stylo = { git = "https://github.com/lanyue-llk/stylo", rev = "402a73dba5624bbfb0a16639f03bd48db63af1b0" } +stylo_atoms = { git = "https://github.com/lanyue-llk/stylo", rev = "402a73dba5624bbfb0a16639f03bd48db63af1b0" } +stylo_dom = { git = "https://github.com/lanyue-llk/stylo", rev = "402a73dba5624bbfb0a16639f03bd48db63af1b0" } +stylo_static_prefs = { git = "https://github.com/lanyue-llk/stylo", rev = "402a73dba5624bbfb0a16639f03bd48db63af1b0" } +stylo_traits = { git = "https://github.com/lanyue-llk/stylo", rev = "402a73dba5624bbfb0a16639f03bd48db63af1b0" } taffy = { git = "https://github.com/ldm0/taffy", rev = "645bf5a49df607a8bff34ec90c5343901d04f10e" } [workspace.lints.clippy] diff --git a/moli-paint/src/vision.rs b/moli-paint/src/vision.rs index 336931ed6a..8d0f1f8d44 100644 --- a/moli-paint/src/vision.rs +++ b/moli-paint/src/vision.rs @@ -98,62 +98,74 @@ fn linear_to_srgb(value: f32) -> u8 { } // Filter in premultiplied linear RGB so transparent pixels cannot bleed their -// hidden color into visible neighbors. Charge the source, intermediate and -// destination float images against the same budget as paint filters. +// hidden color into visible neighbors. Process horizontal bands with enough +// transparent overlap for the Gaussian kernel instead of retaining full-size +// float images for the entire capture. fn blur(raster: &mut RasterImage, device_scale: f32) -> Result<(), PaintError> { if !(device_scale * 2.0).is_normal() || device_scale <= 0.0 { return Err(PaintError::InvalidCaptureDeviceScale { device_scale }); } - let padding = (6.0 * device_scale).ceil() as u32; - let (width, height) = raster - .width - .checked_add(padding.saturating_mul(2)) - .zip(raster.height.checked_add(padding.saturating_mul(2))) - .ok_or(PaintError::BufferLengthOverflow { + // Four sigma covers image::blur's finite kernel on both sides of a band. + let padding = (8.0 * device_scale).ceil() as u32; + let width = raster.width.checked_add(padding.saturating_mul(2)).ok_or( + PaintError::BufferLengthOverflow { width: raster.width, height: raster.height, - })?; - let required_bytes = (width as usize) - .saturating_mul(height as usize) - .saturating_mul(48) - .saturating_add(raster.rgba.len()); - if required_bytes > MAX_TRANSIENT_RASTER_BYTES { + }, + )?; + if raster.width == 0 || raster.height == 0 { + return Ok(()); + } + // The filter holds its input, two work images, and output simultaneously. + let float_row_bytes = (width as usize).saturating_mul(64); + let raster_bytes = raster.rgba.len().saturating_mul(2); + let max_rows = (MAX_TRANSIENT_RASTER_BYTES.saturating_sub(raster_bytes) / float_row_bytes) + .saturating_sub(padding as usize * 2); + if max_rows == 0 { return Err(PaintError::TransientRasterBudgetExceeded { - required_bytes, + required_bytes: float_row_bytes + .saturating_mul(padding as usize * 2 + 1) + .saturating_add(raster_bytes), max_bytes: MAX_TRANSIENT_RASTER_BYTES, }); } - if raster.width == 0 || raster.height == 0 { - return Ok(()); - } - let image = image::Rgba32FImage::from_fn(width, height, |x, y| { - if x < padding || y < padding || x >= padding + raster.width || y >= padding + raster.height - { - return image::Rgba([0.0; 4]); - } - let (x, y) = (x - padding, y - padding); - let offset = ((y as usize) * raster.width as usize + x as usize) * 4; - let pixel = &raster.rgba[offset..offset + 4]; - let alpha = f32::from(pixel[3]) / 255.0; - image::Rgba([ - srgb_to_linear(pixel[0]) * alpha, - srgb_to_linear(pixel[1]) * alpha, - srgb_to_linear(pixel[2]) * alpha, - alpha, - ]) - }); - let blurred = image::imageops::blur(&image, 2.0 * device_scale); - for (index, output) in raster.rgba.chunks_exact_mut(4).enumerate() { - let input = blurred.get_pixel( - index as u32 % raster.width + padding, - index as u32 / raster.width + padding, - ); - let alpha = input[3]; - for (out, value) in output[..3].iter_mut().zip(input.0) { - *out = linear_to_srgb(if alpha > 0.0 { value / alpha } else { 0.0 }); + let band_rows = max_rows.min(256).min(raster.height as usize) as u32; + let mut result = vec![0; raster.rgba.len()]; + for start_y in (0..raster.height).step_by(band_rows as usize) { + let rows = band_rows.min(raster.height - start_y); + let image = image::Rgba32FImage::from_fn(width, rows + padding * 2, |x, y| { + let source_y = start_y.checked_add(y).and_then(|y| y.checked_sub(padding)); + let Some(source_y) = source_y.filter(|&y| y < raster.height) else { + return image::Rgba([0.0; 4]); + }; + let Some(source_x) = x.checked_sub(padding).filter(|&x| x < raster.width) else { + return image::Rgba([0.0; 4]); + }; + let offset = ((source_y as usize) * raster.width as usize + source_x as usize) * 4; + let pixel = &raster.rgba[offset..offset + 4]; + let alpha = f32::from(pixel[3]) / 255.0; + image::Rgba([ + srgb_to_linear(pixel[0]) * alpha, + srgb_to_linear(pixel[1]) * alpha, + srgb_to_linear(pixel[2]) * alpha, + alpha, + ]) + }); + let blurred = image::imageops::blur(&image, 2.0 * device_scale); + for y in 0..rows { + let offset = ((start_y + y) as usize * raster.width as usize) * 4; + let row = &mut result[offset..offset + raster.width as usize * 4]; + for (x, output) in row.chunks_exact_mut(4).enumerate() { + let input = blurred.get_pixel(x as u32 + padding, y + padding); + let alpha = input[3]; + for (out, value) in output[..3].iter_mut().zip(input.0) { + *out = linear_to_srgb(if alpha > 0.0 { value / alpha } else { 0.0 }); + } + output[3] = (alpha * 255.0).round() as u8; + } } - output[3] = (alpha * 255.0).round() as u8; } + raster.rgba = result; Ok(()) } @@ -211,4 +223,19 @@ mod tests { )); assert_eq!(raster.rgba, [1, 2, 3, 4]); } + + #[test] + fn blurred_vision_accepts_4k_capture() { + let mut raster = RasterImage { + width: 3840, + height: 2160, + rgba: vec![255; 3840 * 2160 * 4], + }; + VisionDeficiency::BlurredVision + .apply(&mut raster, 1.0) + .unwrap(); + for y in [255, 256, 511, 512, 1080] { + assert_eq!(&raster.rgba[(y * 3840 + 1920) * 4..][..4], &[255; 4]); + } + } } diff --git a/moli-protocol/src/conn/state/devtools_session.rs b/moli-protocol/src/conn/state/devtools_session.rs index 4bd313e573..e6abc999f0 100644 --- a/moli-protocol/src/conn/state/devtools_session.rs +++ b/moli-protocol/src/conn/state/devtools_session.rs @@ -32,7 +32,6 @@ use moli_page_types::{ #[derive(Clone, Debug, Default, PartialEq)] pub(crate) struct DevToolsSessionState { pub(crate) dom_session_state: DevToolsDomSessionState, - pub(crate) overlay_enabled: bool, pub(crate) dom_debugger_event_listener_breakpoints: BTreeSet, pub(crate) dom_debugger_xhr_breakpoints: BTreeSet, diff --git a/moli-protocol/src/domains/emulation.rs b/moli-protocol/src/domains/emulation.rs index 5e011287d2..7011422883 100644 --- a/moli-protocol/src/domains/emulation.rs +++ b/moli-protocol/src/domains/emulation.rs @@ -828,15 +828,12 @@ fn start_emulated_media_command( return EmulationCommandTaskStep::Complete(CommandOutputPlan::result(json!({}))); } let overrides = media::emulated_media_overrides_from_params(params); - if !conn.update_emulation_state_for_session_owner(cmd.session_id, |state| { - if let Some(mut state) = state { + if let Err(error) = + page_session::update_style_environment_state(conn, cmd.session_id, |mut state| { state.set_emulated_media(overrides.clone()); - } - }) { - return EmulationCommandTaskStep::Complete(CommandOutputPlan::error( - -31998, - "BrowserContextNotLoaded", - )); + }) + { + return EmulationCommandTaskStep::Complete(CommandOutputPlan::error(-31998, error)); } start_style_environment_update(conn, cmd) } @@ -865,7 +862,7 @@ fn start_os_text_scale_command( )); } if let Err(error) = - page_session::update_page_emulation_state(conn, cmd.session_id, |mut state| { + page_session::update_style_environment_state(conn, cmd.session_id, |mut state| { state.set_preferred_text_scale(params.scale); }) { @@ -878,10 +875,8 @@ fn start_style_environment_update( conn: &mut CdpConnection, cmd: &Cmd<'_>, ) -> EmulationCommandTaskStep { - let owner = CommandOwnerScope::capture(conn, cmd.session_id); - let page_overrides = conn.navigation_load_inputs_for_owner(&owner).emulated_media; let pending = if emulation_command_is_context_wide(conn, cmd.session_id) { - match start_context_emulated_media_page_commands(conn, &page_overrides) { + match start_context_emulated_media_page_commands(conn) { Ok(pending) => pending, Err(error) => { return EmulationCommandTaskStep::Complete(CommandOutputPlan::error(-32000, error)); @@ -889,6 +884,9 @@ fn start_style_environment_update( } } else { let owner_scope = CommandOwnerScope::capture(conn, cmd.session_id); + let page_overrides = conn + .navigation_load_inputs_for_owner(&owner_scope) + .emulated_media; let Some(page) = loaded_page_mut_for_target_configuration(conn, cmd.session_id) else { return EmulationCommandTaskStep::Complete(CommandOutputPlan::result(json!({}))); }; @@ -2636,7 +2634,6 @@ fn emulation_command_is_context_wide(conn: &CdpConnection, session_id: Option<&s fn start_context_emulated_media_page_commands( conn: &mut CdpConnection, - overrides: &moli_core::page::EmulatedMediaOverrides, ) -> Result, String> { let Some(browser_context) = conn.browser_context.as_mut() else { return Ok(Vec::new()); @@ -2645,6 +2642,7 @@ fn start_context_emulated_media_page_commands( let mut pending = Vec::new(); for target in browser_context.page_targets.iter_mut() { let target_id = target.target_id().to_owned(); + let overrides = (&target.effective_emulation_state.emulated_media).into(); let Some(page) = target.loaded_page_mut() else { continue; }; @@ -2655,7 +2653,7 @@ fn start_context_emulated_media_page_commands( }, operation: PendingEmulationPageOperation::SetEmulatedMedia, pending: page - .start_set_emulated_media(overrides) + .start_set_emulated_media(&overrides) .map_err(|error| error.to_string())?, runtime_response_rx: None, }); diff --git a/moli-protocol/src/domains/emulation/page_session.rs b/moli-protocol/src/domains/emulation/page_session.rs index 9a17dc3d7b..0c8624a27f 100644 --- a/moli-protocol/src/domains/emulation/page_session.rs +++ b/moli-protocol/src/domains/emulation/page_session.rs @@ -1,4 +1,4 @@ -use crate::conn::{CdpConnection, TargetEmulationStateUpdate}; +use crate::conn::{CdpConnection, CdpSessionRoute, CommandOwnerScope, TargetEmulationStateUpdate}; pub(super) fn update_page_emulation_state( conn: &mut CdpConnection, @@ -14,3 +14,43 @@ pub(super) fn update_page_emulation_state( } Err("BrowserContextNotLoaded".to_owned()) } + +pub(super) fn update_style_environment_state( + conn: &mut CdpConnection, + session_id: Option<&str>, + mut update: impl for<'a> FnMut(TargetEmulationStateUpdate<'a>), +) -> Result<(), String> { + let owners = if super::emulation_command_is_context_wide(conn, session_id) { + conn.browser_context + .as_ref() + .map(|context| { + context + .page_targets + .iter() + .map(|target| { + CommandOwnerScope::for_route(CdpSessionRoute::PageTarget { + browser_context_id: context.id.clone(), + target_id: target.target_id().to_owned(), + session_key: moli_page_types::DevToolsSessionKey::Primary, + }) + }) + .collect::>() + }) + .unwrap_or_default() + } else { + vec![CommandOwnerScope::capture(conn, session_id)] + }; + if owners.is_empty() { + return Err("BrowserContextNotLoaded".to_owned()); + } + for owner in owners { + if !conn.update_emulation_state_for_owner(&owner, |state| { + if let Some(state) = state { + update(state); + } + }) { + return Err("BrowserContextNotLoaded".to_owned()); + } + } + Ok(()) +} diff --git a/moli-protocol/src/domains/emulation/tests.rs b/moli-protocol/src/domains/emulation/tests.rs index 1ff4bd3850..d281817ce7 100644 --- a/moli-protocol/src/domains/emulation/tests.rs +++ b/moli-protocol/src/domains/emulation/tests.rs @@ -3042,6 +3042,53 @@ async fn context_emulated_media_applies_to_loaded_background_page_without_activa ); } +#[tokio::test(flavor = "multi_thread")] +async fn context_media_keeps_each_page_text_scale_across_reload() { + let mut ctx = TestContext::new(); + let background = PageTargetHost::new( + "TID-background".to_owned(), + Some("SID-background".to_owned()), + TargetIdentityState::about_blank(), + TargetPageSlot::empty_for_test_fixture(), + ); + let mut bc = BrowserContext::new("BID-1".into()); + bc.set_active_target_id("TID-active"); + bc.attach_active_session("SID-active"); + bc.insert_page_target_host(background); + ctx.conn.install_browser_context_fixture_for_test(bc); + let html = "data:text/html,text"; + for (session, scale, id) in [("SID-background", 2.0, 1), ("SID-active", 1.5, 2)] { + ctx.install_navigation_fixture_for_session_owner(html, Some(session)) + .await; + ctx.process_async(json!({"id":id,"sessionId":session,"method":"Emulation.setEmulatedOSTextScale","params":{"scale":scale}})).await; + ctx.expect_result(id, json!({}), Some(session)); + } + ctx.process_async(json!({"id":3,"method":"Emulation.setEmulatedMedia","params":{"features":[{"name":"prefers-color-scheme","value":"dark"}]}})).await; + ctx.expect_result(3, json!({}), None); + for (session, expected, id) in [("SID-background", "32px", 4), ("SID-active", "24px", 5)] { + ctx.process_async(json!({"id":id,"sessionId":session,"method":"Runtime.evaluate","params":{"expression":"[getComputedStyle(document.body).fontSize,matchMedia('(prefers-color-scheme:dark)').matches]","returnByValue":true}})).await; + let response = ctx.take_response_by_id(id); + assert_eq!( + response["result"]["result"]["value"], + json!([expected, true]), + "{session}: {response}" + ); + } + ctx.process_async( + json!({"id":6,"sessionId":"SID-background","method":"Page.navigate","params":{"url":html}}), + ) + .await; + let navigation = ctx.take_response_by_id(6); + assert!(navigation["error"].is_null(), "{navigation}"); + ctx.process_async(json!({"id":7,"sessionId":"SID-background","method":"Runtime.evaluate","params":{"expression":"getComputedStyle(document.body).fontSize","returnByValue":true}})).await; + let response = ctx.take_response_by_id(7); + assert_eq!( + response["result"]["result"]["value"], + json!("32px"), + "{response}" + ); +} + #[tokio::test(flavor = "multi_thread")] async fn context_locale_override_applies_to_loaded_background_page_without_activation() { let mut ctx = TestContext::new(); diff --git a/moli-protocol/src/domains/emulation/tests/inspector_surfaces.rs b/moli-protocol/src/domains/emulation/tests/inspector_surfaces.rs index e3bebec04f..a2d6eb9d3b 100644 --- a/moli-protocol/src/domains/emulation/tests/inspector_surfaces.rs +++ b/moli-protocol/src/domains/emulation/tests/inspector_surfaces.rs @@ -202,25 +202,11 @@ async fn inspector_domain_state_survives_navigation_and_cleanup_without_document command(&mut ctx, "Overlay.enable", json!({})).await; command(&mut ctx, "Overlay.hideHighlight", json!({})).await; - let owner = crate::conn::CommandOwnerScope::capture(&ctx.conn, Some("SID-1")); - assert!( - ctx.conn - .target_devtools_session_state_for_owner(&owner) - .unwrap() - .overlay_enabled - ); - ctx.install_navigation_fixture_for_session_owner( "data:text/html,first", Some("SID-1"), ) .await; - assert!( - ctx.conn - .target_devtools_session_state_for_owner(&owner) - .unwrap() - .overlay_enabled - ); command( &mut ctx, "Overlay.highlightRect", @@ -237,12 +223,6 @@ async fn inspector_domain_state_survives_navigation_and_cleanup_without_document json!({"url":"data:text/html,second"}), ) .await; - assert!( - ctx.conn - .target_devtools_session_state_for_owner(&owner) - .unwrap() - .overlay_enabled - ); assert_eq!( pixel(&screenshot(&mut ctx).await, 20, 20), [255, 255, 255, 255] @@ -274,12 +254,6 @@ async fn inspector_domain_state_survives_navigation_and_cleanup_without_document pixel(&screenshot(&mut ctx).await, 20, 20), [255, 255, 255, 255] ); - assert!( - !ctx.conn - .target_devtools_session_state_for_owner(&owner) - .unwrap() - .overlay_enabled - ); } #[tokio::test(flavor = "multi_thread")] diff --git a/moli-protocol/src/domains/overlay.rs b/moli-protocol/src/domains/overlay.rs index 2d665f04dc..a201c6af0d 100644 --- a/moli-protocol/src/domains/overlay.rs +++ b/moli-protocol/src/domains/overlay.rs @@ -160,17 +160,8 @@ pub(crate) fn try_start_overlay_command_dispatch( "No target for Overlay command", )); } - if let Some(enabled) = match command { - RendererInspectorOverlayCommand::Enable => Some(true), - RendererInspectorOverlayCommand::Disable => Some(false), - _ => None, - } { - conn.with_target_devtools_session_state_for_owner_mut(&owner, |state| { - state.overlay_enabled = enabled; - }); - if enabled { - return OverlayCommandTaskStep::Complete(CommandOutputPlan::success()); - } + if matches!(command, RendererInspectorOverlayCommand::Enable) { + return OverlayCommandTaskStep::Complete(CommandOutputPlan::success()); } let cleanup = matches!( command, From 9201a570030e1a2673cf16cd785082c0ec1d89c3 Mon Sep 17 00:00:00 2001 From: lanyue-llk <270302213+lanyue-llk@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:31:16 +0800 Subject: [PATCH 19/23] fix(emulation): compose context style defaults with target overrides --- .../protocol_server/tests/cdp_dynamic_page.rs | 111 ++++++++++++++++++ .../conn/browser_context/emulation_owner.rs | 48 +++++++- .../browser_context/target_session_owner.rs | 1 + .../src/conn/page_state/fetch_state.rs | 1 + .../src/conn/state/browser_context.rs | 2 + moli-protocol/src/conn/state/emulation.rs | 26 ++++ .../src/conn/state/page_target_host.rs | 10 ++ moli-protocol/src/domains/emulation.rs | 16 ++- .../src/domains/emulation/page_session.rs | 60 ++++------ 9 files changed, 232 insertions(+), 43 deletions(-) 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 e3fb687f08..d93a2eebbb 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 @@ -2855,6 +2855,117 @@ async fn websocket_cdp_browser_reconnect_clears_detached_session_emulated_media( abort_test_cdp_server(server).await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn websocket_cdp_browser_style_environment_survives_target_creation_and_session_detach() { + let (addr, server) = spawn_test_protocol_server().await; + let (mut browser, _) = + connect_async(format!("ws://{addr}/devtools/browser/{DEFAULT_BROWSER_ID}")) + .await + .expect("connect browser websocket"); + + let media = send_cdp_command( + &mut browser, + 1, + "Emulation.setEmulatedMedia", + None, + json!({ + "features": [ + { "name": "prefers-color-scheme", "value": "dark" } + ] + }), + ) + .await; + assert_eq!(response_by_id(&media, 1)["result"], json!({})); + let text_scale = send_cdp_command( + &mut browser, + 2, + "Emulation.setEmulatedOSTextScale", + None, + json!({ "scale": 1.5 }), + ) + .await; + assert_eq!(response_by_id(&text_scale, 2)["result"], json!({})); + + let target_id = create_dynamic_target(&mut browser, 3).await; + let attach = send_cdp_command( + &mut browser, + 4, + "Target.attachToTarget", + None, + json!({ "targetId": target_id, "flatten": true }), + ) + .await; + let session_id = response_by_id(&attach, 4)["result"]["sessionId"] + .as_str() + .expect("new target session") + .to_owned(); + let url = "data:text/html,text"; + let navigation = send_cdp_command( + &mut browser, + 5, + "Page.navigate", + Some(&session_id), + json!({ "url": url }), + ) + .await; + assert!(response_by_id(&navigation, 5).get("result").is_some()); + + let inherited = send_cdp_command( + &mut browser, + 6, + "Runtime.evaluate", + Some(&session_id), + json!({ + "expression": "[matchMedia('(prefers-color-scheme: dark)').matches,getComputedStyle(document.body).fontSize]", + "returnByValue": true + }), + ) + .await; + assert_eq!( + response_by_id(&inherited, 6)["result"]["result"]["value"], + json!([true, "24px"]) + ); + + let detach = send_cdp_command( + &mut browser, + 7, + "Target.detachFromTarget", + None, + json!({ "sessionId": session_id }), + ) + .await; + assert_eq!(response_by_id(&detach, 7)["result"], json!({})); + let reattach = send_cdp_command( + &mut browser, + 8, + "Target.attachToTarget", + None, + json!({ "targetId": target_id, "flatten": true }), + ) + .await; + let replacement_session_id = response_by_id(&reattach, 8)["result"]["sessionId"] + .as_str() + .expect("replacement target session") + .to_owned(); + let retained = send_cdp_command( + &mut browser, + 9, + "Runtime.evaluate", + Some(&replacement_session_id), + json!({ + "expression": "[matchMedia('(prefers-color-scheme: dark)').matches,getComputedStyle(document.body).fontSize]", + "returnByValue": true + }), + ) + .await; + assert_eq!( + response_by_id(&retained, 9)["result"]["result"]["value"], + json!([true, "24px"]) + ); + + abort_test_cdp_server(server).await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn websocket_cdp_owner_registry_shutdown_joins_shared_default_page_owner() { let (addr, server, owner_registry) = spawn_test_protocol_server_with_owner_registry().await; diff --git a/moli-protocol/src/conn/browser_context/emulation_owner.rs b/moli-protocol/src/conn/browser_context/emulation_owner.rs index 66551ad705..52a9996aa6 100644 --- a/moli-protocol/src/conn/browser_context/emulation_owner.rs +++ b/moli-protocol/src/conn/browser_context/emulation_owner.rs @@ -9,6 +9,8 @@ use crate::conn::{ pub(crate) struct TargetEmulationStateUpdate<'a> { raw: &'a mut DevToolsEmulationSessionState, effective: &'a mut EffectiveTargetEmulationState, + emulated_media_override: &'a mut EmulatedMediaOverrides, + default_emulated_media: EmulatedMediaOverrides, } impl TargetEmulationStateUpdate<'_> { @@ -43,14 +45,20 @@ impl TargetEmulationStateUpdate<'_> { pub(crate) fn set_preferred_text_scale(&mut self, scale: Option) { self.raw.emulated_media.preferred_text_scale = scale; - self.effective.emulated_media.preferred_text_scale = scale; + self.emulated_media_override.preferred_text_scale = scale; + self.effective.emulated_media = self + .emulated_media_override + .overlaid_on(&self.default_emulated_media); } pub(crate) fn set_emulated_media(&mut self, mut emulated_media: EmulatedMediaOverrides) { emulated_media.preferred_text_scale = self.raw.emulated_media.preferred_text_scale; self.raw.emulated_media = emulated_media.clone(); - emulated_media.preferred_text_scale = self.effective.emulated_media.preferred_text_scale; - self.effective.emulated_media = emulated_media; + emulated_media.preferred_text_scale = self.emulated_media_override.preferred_text_scale; + *self.emulated_media_override = emulated_media; + self.effective.emulated_media = self + .emulated_media_override + .overlaid_on(&self.default_emulated_media); } pub(crate) fn set_emulated_device_metrics( @@ -87,6 +95,7 @@ impl TargetSessionOwnerMut<'_> { self, f: impl FnOnce(Option>), ) -> bool { + let default_emulated_media = self.browser_context.default_emulated_media.clone(); let Some(state) = self.browser_context.page_target_mut(&self.target_id) else { f(None); return false; @@ -96,7 +105,13 @@ impl TargetSessionOwnerMut<'_> { .ensure_session(&self.session_key) .emulation_session_state; let effective = &mut state.effective_emulation_state; - f(Some(TargetEmulationStateUpdate { raw, effective })); + let emulated_media_override = &mut state.emulated_media_override; + f(Some(TargetEmulationStateUpdate { + raw, + effective, + emulated_media_override, + default_emulated_media, + })); true } @@ -287,6 +302,11 @@ impl CdpConnection { &mut self, session_id: &str, ) -> Option { + let default_emulated_media = self + .browser_context + .as_ref()? + .default_emulated_media + .clone(); let mut owner = self.target_session_owner_mut(Some(session_id))?; Some(owner.mutate_page_state(|target, session_key| { let raw = std::mem::take( @@ -300,9 +320,14 @@ impl CdpConnection { .devtools_sessions .navigator_emulation .remove(session_key); + let previous_emulated_media = target.effective_emulation_state.emulated_media.clone(); let mut delta = target .effective_emulation_state .disable_session_handler(&raw); + target.emulated_media_override = Default::default(); + target.refresh_emulated_media(&default_emulated_media); + delta.emulated_media = + previous_emulated_media != target.effective_emulation_state.emulated_media; delta.navigator_queries = previous_queries != target.devtools_sessions.navigator_emulation.effective(); delta @@ -311,6 +336,21 @@ impl CdpConnection { } impl BrowserContext { + pub(crate) fn set_default_emulated_media(&mut self, mut media: EmulatedMediaOverrides) { + media.preferred_text_scale = self.default_emulated_media.preferred_text_scale; + self.default_emulated_media = media; + for target in self.page_targets.iter_mut() { + target.refresh_emulated_media(&self.default_emulated_media); + } + } + + pub(crate) fn set_default_preferred_text_scale(&mut self, scale: Option) { + self.default_emulated_media.preferred_text_scale = scale; + for target in self.page_targets.iter_mut() { + target.refresh_emulated_media(&self.default_emulated_media); + } + } + pub(crate) fn effective_active_emulated_device_metrics(&self) -> Option { self.active_page_target() .effective_emulation_state diff --git a/moli-protocol/src/conn/browser_context/target_session_owner.rs b/moli-protocol/src/conn/browser_context/target_session_owner.rs index 1a6dfa1d7a..83ca908341 100644 --- a/moli-protocol/src/conn/browser_context/target_session_owner.rs +++ b/moli-protocol/src/conn/browser_context/target_session_owner.rs @@ -370,6 +370,7 @@ impl TargetNavigationLoadInputs { .default_emulated_device_metrics .as_ref() .map(|metrics| metrics.viewport_surface()); + inputs.emulated_media = (&browser_context.default_emulated_media).into(); inputs.document_activity = browser_context.active_document_activity(); inputs.network_offline = browser_context.effective_active_network_offline(); inputs diff --git a/moli-protocol/src/conn/page_state/fetch_state.rs b/moli-protocol/src/conn/page_state/fetch_state.rs index e34aeb519b..b342d2205a 100644 --- a/moli-protocol/src/conn/page_state/fetch_state.rs +++ b/moli-protocol/src/conn/page_state/fetch_state.rs @@ -65,6 +65,7 @@ impl BrowserContext { self.default_document_cookie_manager_surface.clone(); } host.set_base_cache_disabled(self.global_cache_disabled); + host.refresh_emulated_media(&self.default_emulated_media); if let Some(config) = self.page_navigation_runtime_config.clone() { let engine = self.new_page_navigation_engine(config); host.install_navigation_engine(engine); diff --git a/moli-protocol/src/conn/state/browser_context.rs b/moli-protocol/src/conn/state/browser_context.rs index 952bc8520c..6c9f5110c3 100644 --- a/moli-protocol/src/conn/state/browser_context.rs +++ b/moli-protocol/src/conn/state/browser_context.rs @@ -76,6 +76,7 @@ pub struct BrowserContext { pub proxy_autoconfig_url: Option, pub proxy_socks_version: Option, pub(crate) default_emulated_device_metrics: Option, + pub(crate) default_emulated_media: super::EmulatedMediaOverrides, pub(crate) next_default_document_start_script_id: u32, pub(crate) default_document_start_scripts: Vec<(String, DocumentStartScript)>, pub(crate) storage_quota_overrides: HashMap, @@ -527,6 +528,7 @@ impl BrowserContext { proxy_autoconfig_url: None, proxy_socks_version: None, default_emulated_device_metrics: None, + default_emulated_media: Default::default(), next_default_document_start_script_id: 0, default_document_start_scripts: Vec::new(), storage_quota_overrides: HashMap::new(), diff --git a/moli-protocol/src/conn/state/emulation.rs b/moli-protocol/src/conn/state/emulation.rs index c43ff9bdb4..ed57528643 100644 --- a/moli-protocol/src/conn/state/emulation.rs +++ b/moli-protocol/src/conn/state/emulation.rs @@ -101,6 +101,32 @@ pub struct EmulatedMediaOverrides { pub contrast: Option, } +impl EmulatedMediaOverrides { + pub(crate) fn overlaid_on(&self, defaults: &Self) -> Self { + Self { + preferred_text_scale: self.preferred_text_scale.or(defaults.preferred_text_scale), + media: self.media.clone().or_else(|| defaults.media.clone()), + color_scheme: self + .color_scheme + .clone() + .or_else(|| defaults.color_scheme.clone()), + reduced_motion: self + .reduced_motion + .clone() + .or_else(|| defaults.reduced_motion.clone()), + reduced_transparency: self + .reduced_transparency + .clone() + .or_else(|| defaults.reduced_transparency.clone()), + forced_colors: self + .forced_colors + .clone() + .or_else(|| defaults.forced_colors.clone()), + contrast: self.contrast.clone().or_else(|| defaults.contrast.clone()), + } + } +} + impl From for moli_core::page::EmulatedMediaOverrides { fn from(value: EmulatedMediaOverrides) -> Self { Self { diff --git a/moli-protocol/src/conn/state/page_target_host.rs b/moli-protocol/src/conn/state/page_target_host.rs index d7810335ab..9c62c2df04 100644 --- a/moli-protocol/src/conn/state/page_target_host.rs +++ b/moli-protocol/src/conn/state/page_target_host.rs @@ -30,6 +30,7 @@ pub struct PageTargetHost { pub(crate) tls_verify_host_override: Option, pub(crate) environment_owner: moli_core::ProcessEnvironmentOwner, pub(crate) effective_emulation_state: EffectiveTargetEmulationState, + pub(crate) emulated_media_override: super::emulation::EmulatedMediaOverrides, pub(crate) input_intercept_drags_enabled: bool, pub(crate) input_drag_intercepted: bool, pub(crate) css_enabled: bool, @@ -54,6 +55,7 @@ impl PageTargetHost { tls_verify_host_override: None, environment_owner: Default::default(), effective_emulation_state: EffectiveTargetEmulationState::default(), + emulated_media_override: Default::default(), input_intercept_drags_enabled: false, input_drag_intercepted: false, css_enabled: false, @@ -112,6 +114,14 @@ impl PageTargetHost { &self.target_id } + pub(crate) fn refresh_emulated_media( + &mut self, + defaults: &super::emulation::EmulatedMediaOverrides, + ) { + self.effective_emulation_state.emulated_media = + self.emulated_media_override.overlaid_on(defaults); + } + fn replace_target_id(&mut self, target_id: String) { self.target_id = target_id; } diff --git a/moli-protocol/src/domains/emulation.rs b/moli-protocol/src/domains/emulation.rs index 7011422883..fe7b4c3819 100644 --- a/moli-protocol/src/domains/emulation.rs +++ b/moli-protocol/src/domains/emulation.rs @@ -828,11 +828,14 @@ fn start_emulated_media_command( return EmulationCommandTaskStep::Complete(CommandOutputPlan::result(json!({}))); } let overrides = media::emulated_media_overrides_from_params(params); - if let Err(error) = + let update = if emulation_command_is_context_wide(conn, cmd.session_id) { + page_session::set_context_emulated_media(conn, overrides) + } else { page_session::update_style_environment_state(conn, cmd.session_id, |mut state| { - state.set_emulated_media(overrides.clone()); + state.set_emulated_media(overrides); }) - { + }; + if let Err(error) = update { return EmulationCommandTaskStep::Complete(CommandOutputPlan::error(-31998, error)); } start_style_environment_update(conn, cmd) @@ -861,11 +864,14 @@ fn start_os_text_scale_command( "Text scale must be finite and positive", )); } - if let Err(error) = + let update = if emulation_command_is_context_wide(conn, cmd.session_id) { + page_session::set_context_preferred_text_scale(conn, params.scale) + } else { page_session::update_style_environment_state(conn, cmd.session_id, |mut state| { state.set_preferred_text_scale(params.scale); }) - { + }; + if let Err(error) = update { return EmulationCommandTaskStep::Complete(CommandOutputPlan::error(-31998, error)); } start_style_environment_update(conn, cmd) diff --git a/moli-protocol/src/domains/emulation/page_session.rs b/moli-protocol/src/domains/emulation/page_session.rs index 0c8624a27f..be04cf109a 100644 --- a/moli-protocol/src/domains/emulation/page_session.rs +++ b/moli-protocol/src/domains/emulation/page_session.rs @@ -1,4 +1,4 @@ -use crate::conn::{CdpConnection, CdpSessionRoute, CommandOwnerScope, TargetEmulationStateUpdate}; +use crate::conn::{CdpConnection, TargetEmulationStateUpdate}; pub(super) fn update_page_emulation_state( conn: &mut CdpConnection, @@ -18,39 +18,31 @@ pub(super) fn update_page_emulation_state( pub(super) fn update_style_environment_state( conn: &mut CdpConnection, session_id: Option<&str>, - mut update: impl for<'a> FnMut(TargetEmulationStateUpdate<'a>), + update: impl FnOnce(TargetEmulationStateUpdate<'_>), ) -> Result<(), String> { - let owners = if super::emulation_command_is_context_wide(conn, session_id) { - conn.browser_context - .as_ref() - .map(|context| { - context - .page_targets - .iter() - .map(|target| { - CommandOwnerScope::for_route(CdpSessionRoute::PageTarget { - browser_context_id: context.id.clone(), - target_id: target.target_id().to_owned(), - session_key: moli_page_types::DevToolsSessionKey::Primary, - }) - }) - .collect::>() - }) - .unwrap_or_default() - } else { - vec![CommandOwnerScope::capture(conn, session_id)] - }; - if owners.is_empty() { - return Err("BrowserContextNotLoaded".to_owned()); - } - for owner in owners { - if !conn.update_emulation_state_for_owner(&owner, |state| { - if let Some(state) = state { - update(state); - } - }) { - return Err("BrowserContextNotLoaded".to_owned()); - } - } + update_page_emulation_state(conn, session_id, update) +} + +pub(super) fn set_context_emulated_media( + conn: &mut CdpConnection, + media: crate::conn::EmulatedMediaOverrides, +) -> Result<(), String> { + let context = conn + .browser_context + .as_mut() + .ok_or_else(|| "BrowserContextNotLoaded".to_owned())?; + context.set_default_emulated_media(media); + Ok(()) +} + +pub(super) fn set_context_preferred_text_scale( + conn: &mut CdpConnection, + scale: Option, +) -> Result<(), String> { + let context = conn + .browser_context + .as_mut() + .ok_or_else(|| "BrowserContextNotLoaded".to_owned())?; + context.set_default_preferred_text_scale(scale); Ok(()) } From 4db9be858a7385e428e704920e4063de0ce3e18d Mon Sep 17 00:00:00 2001 From: lanyue-llk <270302213+lanyue-llk@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:52:12 +0800 Subject: [PATCH 20/23] fix(ci): retain Stylo baseline and use page-root emulation --- Cargo.lock | 22 +++++++++---------- Cargo.toml | 14 ++++++------ .../protocol_server/tests/cdp_dynamic_page.rs | 5 +++-- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c5257d9150..9dfa1bcc2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4615,7 +4615,7 @@ dependencies = [ [[package]] name = "selectors" version = "0.40.0" -source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" +source = "git+https://github.com/lanyue-llk/stylo?rev=3d44ed30808997861946a3f4b84a20318c4d451c#3d44ed30808997861946a3f4b84a20318c4d451c" dependencies = [ "bitflags", "cssparser", @@ -4747,7 +4747,7 @@ dependencies = [ [[package]] name = "servo_arc" version = "0.4.3" -source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" +source = "git+https://github.com/lanyue-llk/stylo?rev=3d44ed30808997861946a3f4b84a20318c4d451c#3d44ed30808997861946a3f4b84a20318c4d451c" dependencies = [ "serde", "stable_deref_trait", @@ -5000,7 +5000,7 @@ dependencies = [ [[package]] name = "stylo" version = "0.20.0" -source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" +source = "git+https://github.com/lanyue-llk/stylo?rev=3d44ed30808997861946a3f4b84a20318c4d451c#3d44ed30808997861946a3f4b84a20318c4d451c" dependencies = [ "app_units", "arrayvec", @@ -5056,7 +5056,7 @@ dependencies = [ [[package]] name = "stylo_atoms" version = "0.20.0" -source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" +source = "git+https://github.com/lanyue-llk/stylo?rev=3d44ed30808997861946a3f4b84a20318c4d451c#3d44ed30808997861946a3f4b84a20318c4d451c" dependencies = [ "string_cache", "string_cache_codegen", @@ -5065,7 +5065,7 @@ dependencies = [ [[package]] name = "stylo_derive" version = "0.20.0" -source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" +source = "git+https://github.com/lanyue-llk/stylo?rev=3d44ed30808997861946a3f4b84a20318c4d451c#3d44ed30808997861946a3f4b84a20318c4d451c" dependencies = [ "darling", "proc-macro2", @@ -5077,7 +5077,7 @@ dependencies = [ [[package]] name = "stylo_dom" version = "0.20.0" -source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" +source = "git+https://github.com/lanyue-llk/stylo?rev=3d44ed30808997861946a3f4b84a20318c4d451c#3d44ed30808997861946a3f4b84a20318c4d451c" dependencies = [ "bitflags", "stylo_malloc_size_of", @@ -5086,7 +5086,7 @@ dependencies = [ [[package]] name = "stylo_malloc_size_of" version = "0.20.0" -source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" +source = "git+https://github.com/lanyue-llk/stylo?rev=3d44ed30808997861946a3f4b84a20318c4d451c#3d44ed30808997861946a3f4b84a20318c4d451c" dependencies = [ "app_units", "cssparser", @@ -5103,7 +5103,7 @@ dependencies = [ [[package]] name = "stylo_static_prefs" version = "0.20.0" -source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" +source = "git+https://github.com/lanyue-llk/stylo?rev=3d44ed30808997861946a3f4b84a20318c4d451c#3d44ed30808997861946a3f4b84a20318c4d451c" dependencies = [ "toml", ] @@ -5121,7 +5121,7 @@ dependencies = [ [[package]] name = "stylo_traits" version = "0.20.0" -source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" +source = "git+https://github.com/lanyue-llk/stylo?rev=3d44ed30808997861946a3f4b84a20318c4d451c#3d44ed30808997861946a3f4b84a20318c4d451c" dependencies = [ "app_units", "bitflags", @@ -5421,7 +5421,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "to_shmem" version = "0.5.0" -source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" +source = "git+https://github.com/lanyue-llk/stylo?rev=3d44ed30808997861946a3f4b84a20318c4d451c#3d44ed30808997861946a3f4b84a20318c4d451c" dependencies = [ "cssparser", "servo_arc", @@ -5434,7 +5434,7 @@ dependencies = [ [[package]] name = "to_shmem_derive" version = "0.1.0" -source = "git+https://github.com/lanyue-llk/stylo?rev=402a73dba5624bbfb0a16639f03bd48db63af1b0#402a73dba5624bbfb0a16639f03bd48db63af1b0" +source = "git+https://github.com/lanyue-llk/stylo?rev=3d44ed30808997861946a3f4b84a20318c4d451c#3d44ed30808997861946a3f4b84a20318c4d451c" dependencies = [ "darling", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index beb8041ded..eab945c591 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -102,13 +102,13 @@ curl-sys = { git = "https://github.com/lexmount/curl-rust", rev = "a0ea59f5ca1b1 cookie = { git = "https://github.com/ldm0/cookie-rs", branch = "priority" } v8 = { path = "vendor/v8-152.2.0" } deno_v8 = { path = "vendor/deno_v8-0.3.0" } -selectors = { git = "https://github.com/lanyue-llk/stylo", rev = "402a73dba5624bbfb0a16639f03bd48db63af1b0" } -servo_arc = { git = "https://github.com/lanyue-llk/stylo", rev = "402a73dba5624bbfb0a16639f03bd48db63af1b0" } -stylo = { git = "https://github.com/lanyue-llk/stylo", rev = "402a73dba5624bbfb0a16639f03bd48db63af1b0" } -stylo_atoms = { git = "https://github.com/lanyue-llk/stylo", rev = "402a73dba5624bbfb0a16639f03bd48db63af1b0" } -stylo_dom = { git = "https://github.com/lanyue-llk/stylo", rev = "402a73dba5624bbfb0a16639f03bd48db63af1b0" } -stylo_static_prefs = { git = "https://github.com/lanyue-llk/stylo", rev = "402a73dba5624bbfb0a16639f03bd48db63af1b0" } -stylo_traits = { git = "https://github.com/lanyue-llk/stylo", rev = "402a73dba5624bbfb0a16639f03bd48db63af1b0" } +selectors = { git = "https://github.com/lanyue-llk/stylo", rev = "3d44ed30808997861946a3f4b84a20318c4d451c" } +servo_arc = { git = "https://github.com/lanyue-llk/stylo", rev = "3d44ed30808997861946a3f4b84a20318c4d451c" } +stylo = { git = "https://github.com/lanyue-llk/stylo", rev = "3d44ed30808997861946a3f4b84a20318c4d451c" } +stylo_atoms = { git = "https://github.com/lanyue-llk/stylo", rev = "3d44ed30808997861946a3f4b84a20318c4d451c" } +stylo_dom = { git = "https://github.com/lanyue-llk/stylo", rev = "3d44ed30808997861946a3f4b84a20318c4d451c" } +stylo_static_prefs = { git = "https://github.com/lanyue-llk/stylo", rev = "3d44ed30808997861946a3f4b84a20318c4d451c" } +stylo_traits = { git = "https://github.com/lanyue-llk/stylo", rev = "3d44ed30808997861946a3f4b84a20318c4d451c" } taffy = { git = "https://github.com/ldm0/taffy", rev = "645bf5a49df607a8bff34ec90c5343901d04f10e" } [workspace.lints.clippy] 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 d93a2eebbb..ba2db38047 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 @@ -2862,9 +2862,10 @@ async fn websocket_cdp_browser_style_environment_survives_target_creation_and_se connect_async(format!("ws://{addr}/devtools/browser/{DEFAULT_BROWSER_ID}")) .await .expect("connect browser websocket"); + let mut root_page = connect_dynamic_page(addr, DEFAULT_TARGET_ID).await; let media = send_cdp_command( - &mut browser, + &mut root_page, 1, "Emulation.setEmulatedMedia", None, @@ -2877,7 +2878,7 @@ async fn websocket_cdp_browser_style_environment_survives_target_creation_and_se .await; assert_eq!(response_by_id(&media, 1)["result"], json!({})); let text_scale = send_cdp_command( - &mut browser, + &mut root_page, 2, "Emulation.setEmulatedOSTextScale", None, From afe8782aba0b468740b3e20bd5ed20963434d2aa Mon Sep 17 00:00:00 2001 From: lanyue-llk <270302213+lanyue-llk@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:08:04 +0800 Subject: [PATCH 21/23] test(cdp): keep style defaults within one frontend owner --- .../protocol_server/tests/cdp_dynamic_page.rs | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) 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 ba2db38047..ab1a878cf8 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 @@ -2858,10 +2858,6 @@ async fn websocket_cdp_browser_reconnect_clears_detached_session_emulated_media( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn websocket_cdp_browser_style_environment_survives_target_creation_and_session_detach() { let (addr, server) = spawn_test_protocol_server().await; - let (mut browser, _) = - connect_async(format!("ws://{addr}/devtools/browser/{DEFAULT_BROWSER_ID}")) - .await - .expect("connect browser websocket"); let mut root_page = connect_dynamic_page(addr, DEFAULT_TARGET_ID).await; let media = send_cdp_command( @@ -2887,9 +2883,9 @@ async fn websocket_cdp_browser_style_environment_survives_target_creation_and_se .await; assert_eq!(response_by_id(&text_scale, 2)["result"], json!({})); - let target_id = create_dynamic_target(&mut browser, 3).await; + let target_id = create_dynamic_target(&mut root_page, 3).await; let attach = send_cdp_command( - &mut browser, + &mut root_page, 4, "Target.attachToTarget", None, @@ -2902,7 +2898,7 @@ async fn websocket_cdp_browser_style_environment_survives_target_creation_and_se .to_owned(); let url = "data:text/html,text"; let navigation = send_cdp_command( - &mut browser, + &mut root_page, 5, "Page.navigate", Some(&session_id), @@ -2912,7 +2908,7 @@ async fn websocket_cdp_browser_style_environment_survives_target_creation_and_se assert!(response_by_id(&navigation, 5).get("result").is_some()); let inherited = send_cdp_command( - &mut browser, + &mut root_page, 6, "Runtime.evaluate", Some(&session_id), @@ -2928,7 +2924,7 @@ async fn websocket_cdp_browser_style_environment_survives_target_creation_and_se ); let detach = send_cdp_command( - &mut browser, + &mut root_page, 7, "Target.detachFromTarget", None, @@ -2937,7 +2933,7 @@ async fn websocket_cdp_browser_style_environment_survives_target_creation_and_se .await; assert_eq!(response_by_id(&detach, 7)["result"], json!({})); let reattach = send_cdp_command( - &mut browser, + &mut root_page, 8, "Target.attachToTarget", None, @@ -2949,7 +2945,7 @@ async fn websocket_cdp_browser_style_environment_survives_target_creation_and_se .expect("replacement target session") .to_owned(); let retained = send_cdp_command( - &mut browser, + &mut root_page, 9, "Runtime.evaluate", Some(&replacement_session_id), From d3f9a66da4ac62e8f8f0e8c28ca17b4a1c4181bc Mon Sep 17 00:00:00 2001 From: lanyue-llk <270302213+lanyue-llk@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:17:05 +0800 Subject: [PATCH 22/23] Revert "fix(emulation): compose context style defaults with target overrides" This reverts commit 9b92c4f8b8d34caf7af8a4515ed6b552761be22a. --- .../protocol_server/tests/cdp_dynamic_page.rs | 108 ------------------ .../conn/browser_context/emulation_owner.rs | 48 +------- .../browser_context/target_session_owner.rs | 1 - .../src/conn/page_state/fetch_state.rs | 1 - .../src/conn/state/browser_context.rs | 2 - moli-protocol/src/conn/state/emulation.rs | 26 ----- .../src/conn/state/page_target_host.rs | 10 -- moli-protocol/src/domains/emulation.rs | 16 +-- .../src/domains/emulation/page_session.rs | 60 +++++----- 9 files changed, 43 insertions(+), 229 deletions(-) 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 ab1a878cf8..e3fb687f08 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 @@ -2855,114 +2855,6 @@ async fn websocket_cdp_browser_reconnect_clears_detached_session_emulated_media( abort_test_cdp_server(server).await; } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn websocket_cdp_browser_style_environment_survives_target_creation_and_session_detach() { - let (addr, server) = spawn_test_protocol_server().await; - let mut root_page = connect_dynamic_page(addr, DEFAULT_TARGET_ID).await; - - let media = send_cdp_command( - &mut root_page, - 1, - "Emulation.setEmulatedMedia", - None, - json!({ - "features": [ - { "name": "prefers-color-scheme", "value": "dark" } - ] - }), - ) - .await; - assert_eq!(response_by_id(&media, 1)["result"], json!({})); - let text_scale = send_cdp_command( - &mut root_page, - 2, - "Emulation.setEmulatedOSTextScale", - None, - json!({ "scale": 1.5 }), - ) - .await; - assert_eq!(response_by_id(&text_scale, 2)["result"], json!({})); - - let target_id = create_dynamic_target(&mut root_page, 3).await; - let attach = send_cdp_command( - &mut root_page, - 4, - "Target.attachToTarget", - None, - json!({ "targetId": target_id, "flatten": true }), - ) - .await; - let session_id = response_by_id(&attach, 4)["result"]["sessionId"] - .as_str() - .expect("new target session") - .to_owned(); - let url = "data:text/html,text"; - let navigation = send_cdp_command( - &mut root_page, - 5, - "Page.navigate", - Some(&session_id), - json!({ "url": url }), - ) - .await; - assert!(response_by_id(&navigation, 5).get("result").is_some()); - - let inherited = send_cdp_command( - &mut root_page, - 6, - "Runtime.evaluate", - Some(&session_id), - json!({ - "expression": "[matchMedia('(prefers-color-scheme: dark)').matches,getComputedStyle(document.body).fontSize]", - "returnByValue": true - }), - ) - .await; - assert_eq!( - response_by_id(&inherited, 6)["result"]["result"]["value"], - json!([true, "24px"]) - ); - - let detach = send_cdp_command( - &mut root_page, - 7, - "Target.detachFromTarget", - None, - json!({ "sessionId": session_id }), - ) - .await; - assert_eq!(response_by_id(&detach, 7)["result"], json!({})); - let reattach = send_cdp_command( - &mut root_page, - 8, - "Target.attachToTarget", - None, - json!({ "targetId": target_id, "flatten": true }), - ) - .await; - let replacement_session_id = response_by_id(&reattach, 8)["result"]["sessionId"] - .as_str() - .expect("replacement target session") - .to_owned(); - let retained = send_cdp_command( - &mut root_page, - 9, - "Runtime.evaluate", - Some(&replacement_session_id), - json!({ - "expression": "[matchMedia('(prefers-color-scheme: dark)').matches,getComputedStyle(document.body).fontSize]", - "returnByValue": true - }), - ) - .await; - assert_eq!( - response_by_id(&retained, 9)["result"]["result"]["value"], - json!([true, "24px"]) - ); - - abort_test_cdp_server(server).await; -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn websocket_cdp_owner_registry_shutdown_joins_shared_default_page_owner() { let (addr, server, owner_registry) = spawn_test_protocol_server_with_owner_registry().await; diff --git a/moli-protocol/src/conn/browser_context/emulation_owner.rs b/moli-protocol/src/conn/browser_context/emulation_owner.rs index 52a9996aa6..66551ad705 100644 --- a/moli-protocol/src/conn/browser_context/emulation_owner.rs +++ b/moli-protocol/src/conn/browser_context/emulation_owner.rs @@ -9,8 +9,6 @@ use crate::conn::{ pub(crate) struct TargetEmulationStateUpdate<'a> { raw: &'a mut DevToolsEmulationSessionState, effective: &'a mut EffectiveTargetEmulationState, - emulated_media_override: &'a mut EmulatedMediaOverrides, - default_emulated_media: EmulatedMediaOverrides, } impl TargetEmulationStateUpdate<'_> { @@ -45,20 +43,14 @@ impl TargetEmulationStateUpdate<'_> { pub(crate) fn set_preferred_text_scale(&mut self, scale: Option) { self.raw.emulated_media.preferred_text_scale = scale; - self.emulated_media_override.preferred_text_scale = scale; - self.effective.emulated_media = self - .emulated_media_override - .overlaid_on(&self.default_emulated_media); + self.effective.emulated_media.preferred_text_scale = scale; } pub(crate) fn set_emulated_media(&mut self, mut emulated_media: EmulatedMediaOverrides) { emulated_media.preferred_text_scale = self.raw.emulated_media.preferred_text_scale; self.raw.emulated_media = emulated_media.clone(); - emulated_media.preferred_text_scale = self.emulated_media_override.preferred_text_scale; - *self.emulated_media_override = emulated_media; - self.effective.emulated_media = self - .emulated_media_override - .overlaid_on(&self.default_emulated_media); + emulated_media.preferred_text_scale = self.effective.emulated_media.preferred_text_scale; + self.effective.emulated_media = emulated_media; } pub(crate) fn set_emulated_device_metrics( @@ -95,7 +87,6 @@ impl TargetSessionOwnerMut<'_> { self, f: impl FnOnce(Option>), ) -> bool { - let default_emulated_media = self.browser_context.default_emulated_media.clone(); let Some(state) = self.browser_context.page_target_mut(&self.target_id) else { f(None); return false; @@ -105,13 +96,7 @@ impl TargetSessionOwnerMut<'_> { .ensure_session(&self.session_key) .emulation_session_state; let effective = &mut state.effective_emulation_state; - let emulated_media_override = &mut state.emulated_media_override; - f(Some(TargetEmulationStateUpdate { - raw, - effective, - emulated_media_override, - default_emulated_media, - })); + f(Some(TargetEmulationStateUpdate { raw, effective })); true } @@ -302,11 +287,6 @@ impl CdpConnection { &mut self, session_id: &str, ) -> Option { - let default_emulated_media = self - .browser_context - .as_ref()? - .default_emulated_media - .clone(); let mut owner = self.target_session_owner_mut(Some(session_id))?; Some(owner.mutate_page_state(|target, session_key| { let raw = std::mem::take( @@ -320,14 +300,9 @@ impl CdpConnection { .devtools_sessions .navigator_emulation .remove(session_key); - let previous_emulated_media = target.effective_emulation_state.emulated_media.clone(); let mut delta = target .effective_emulation_state .disable_session_handler(&raw); - target.emulated_media_override = Default::default(); - target.refresh_emulated_media(&default_emulated_media); - delta.emulated_media = - previous_emulated_media != target.effective_emulation_state.emulated_media; delta.navigator_queries = previous_queries != target.devtools_sessions.navigator_emulation.effective(); delta @@ -336,21 +311,6 @@ impl CdpConnection { } impl BrowserContext { - pub(crate) fn set_default_emulated_media(&mut self, mut media: EmulatedMediaOverrides) { - media.preferred_text_scale = self.default_emulated_media.preferred_text_scale; - self.default_emulated_media = media; - for target in self.page_targets.iter_mut() { - target.refresh_emulated_media(&self.default_emulated_media); - } - } - - pub(crate) fn set_default_preferred_text_scale(&mut self, scale: Option) { - self.default_emulated_media.preferred_text_scale = scale; - for target in self.page_targets.iter_mut() { - target.refresh_emulated_media(&self.default_emulated_media); - } - } - pub(crate) fn effective_active_emulated_device_metrics(&self) -> Option { self.active_page_target() .effective_emulation_state diff --git a/moli-protocol/src/conn/browser_context/target_session_owner.rs b/moli-protocol/src/conn/browser_context/target_session_owner.rs index 83ca908341..1a6dfa1d7a 100644 --- a/moli-protocol/src/conn/browser_context/target_session_owner.rs +++ b/moli-protocol/src/conn/browser_context/target_session_owner.rs @@ -370,7 +370,6 @@ impl TargetNavigationLoadInputs { .default_emulated_device_metrics .as_ref() .map(|metrics| metrics.viewport_surface()); - inputs.emulated_media = (&browser_context.default_emulated_media).into(); inputs.document_activity = browser_context.active_document_activity(); inputs.network_offline = browser_context.effective_active_network_offline(); inputs diff --git a/moli-protocol/src/conn/page_state/fetch_state.rs b/moli-protocol/src/conn/page_state/fetch_state.rs index b342d2205a..e34aeb519b 100644 --- a/moli-protocol/src/conn/page_state/fetch_state.rs +++ b/moli-protocol/src/conn/page_state/fetch_state.rs @@ -65,7 +65,6 @@ impl BrowserContext { self.default_document_cookie_manager_surface.clone(); } host.set_base_cache_disabled(self.global_cache_disabled); - host.refresh_emulated_media(&self.default_emulated_media); if let Some(config) = self.page_navigation_runtime_config.clone() { let engine = self.new_page_navigation_engine(config); host.install_navigation_engine(engine); diff --git a/moli-protocol/src/conn/state/browser_context.rs b/moli-protocol/src/conn/state/browser_context.rs index 6c9f5110c3..952bc8520c 100644 --- a/moli-protocol/src/conn/state/browser_context.rs +++ b/moli-protocol/src/conn/state/browser_context.rs @@ -76,7 +76,6 @@ pub struct BrowserContext { pub proxy_autoconfig_url: Option, pub proxy_socks_version: Option, pub(crate) default_emulated_device_metrics: Option, - pub(crate) default_emulated_media: super::EmulatedMediaOverrides, pub(crate) next_default_document_start_script_id: u32, pub(crate) default_document_start_scripts: Vec<(String, DocumentStartScript)>, pub(crate) storage_quota_overrides: HashMap, @@ -528,7 +527,6 @@ impl BrowserContext { proxy_autoconfig_url: None, proxy_socks_version: None, default_emulated_device_metrics: None, - default_emulated_media: Default::default(), next_default_document_start_script_id: 0, default_document_start_scripts: Vec::new(), storage_quota_overrides: HashMap::new(), diff --git a/moli-protocol/src/conn/state/emulation.rs b/moli-protocol/src/conn/state/emulation.rs index ed57528643..c43ff9bdb4 100644 --- a/moli-protocol/src/conn/state/emulation.rs +++ b/moli-protocol/src/conn/state/emulation.rs @@ -101,32 +101,6 @@ pub struct EmulatedMediaOverrides { pub contrast: Option, } -impl EmulatedMediaOverrides { - pub(crate) fn overlaid_on(&self, defaults: &Self) -> Self { - Self { - preferred_text_scale: self.preferred_text_scale.or(defaults.preferred_text_scale), - media: self.media.clone().or_else(|| defaults.media.clone()), - color_scheme: self - .color_scheme - .clone() - .or_else(|| defaults.color_scheme.clone()), - reduced_motion: self - .reduced_motion - .clone() - .or_else(|| defaults.reduced_motion.clone()), - reduced_transparency: self - .reduced_transparency - .clone() - .or_else(|| defaults.reduced_transparency.clone()), - forced_colors: self - .forced_colors - .clone() - .or_else(|| defaults.forced_colors.clone()), - contrast: self.contrast.clone().or_else(|| defaults.contrast.clone()), - } - } -} - impl From for moli_core::page::EmulatedMediaOverrides { fn from(value: EmulatedMediaOverrides) -> Self { Self { diff --git a/moli-protocol/src/conn/state/page_target_host.rs b/moli-protocol/src/conn/state/page_target_host.rs index 9c62c2df04..d7810335ab 100644 --- a/moli-protocol/src/conn/state/page_target_host.rs +++ b/moli-protocol/src/conn/state/page_target_host.rs @@ -30,7 +30,6 @@ pub struct PageTargetHost { pub(crate) tls_verify_host_override: Option, pub(crate) environment_owner: moli_core::ProcessEnvironmentOwner, pub(crate) effective_emulation_state: EffectiveTargetEmulationState, - pub(crate) emulated_media_override: super::emulation::EmulatedMediaOverrides, pub(crate) input_intercept_drags_enabled: bool, pub(crate) input_drag_intercepted: bool, pub(crate) css_enabled: bool, @@ -55,7 +54,6 @@ impl PageTargetHost { tls_verify_host_override: None, environment_owner: Default::default(), effective_emulation_state: EffectiveTargetEmulationState::default(), - emulated_media_override: Default::default(), input_intercept_drags_enabled: false, input_drag_intercepted: false, css_enabled: false, @@ -114,14 +112,6 @@ impl PageTargetHost { &self.target_id } - pub(crate) fn refresh_emulated_media( - &mut self, - defaults: &super::emulation::EmulatedMediaOverrides, - ) { - self.effective_emulation_state.emulated_media = - self.emulated_media_override.overlaid_on(defaults); - } - fn replace_target_id(&mut self, target_id: String) { self.target_id = target_id; } diff --git a/moli-protocol/src/domains/emulation.rs b/moli-protocol/src/domains/emulation.rs index fe7b4c3819..7011422883 100644 --- a/moli-protocol/src/domains/emulation.rs +++ b/moli-protocol/src/domains/emulation.rs @@ -828,14 +828,11 @@ fn start_emulated_media_command( return EmulationCommandTaskStep::Complete(CommandOutputPlan::result(json!({}))); } let overrides = media::emulated_media_overrides_from_params(params); - let update = if emulation_command_is_context_wide(conn, cmd.session_id) { - page_session::set_context_emulated_media(conn, overrides) - } else { + if let Err(error) = page_session::update_style_environment_state(conn, cmd.session_id, |mut state| { - state.set_emulated_media(overrides); + state.set_emulated_media(overrides.clone()); }) - }; - if let Err(error) = update { + { return EmulationCommandTaskStep::Complete(CommandOutputPlan::error(-31998, error)); } start_style_environment_update(conn, cmd) @@ -864,14 +861,11 @@ fn start_os_text_scale_command( "Text scale must be finite and positive", )); } - let update = if emulation_command_is_context_wide(conn, cmd.session_id) { - page_session::set_context_preferred_text_scale(conn, params.scale) - } else { + if let Err(error) = page_session::update_style_environment_state(conn, cmd.session_id, |mut state| { state.set_preferred_text_scale(params.scale); }) - }; - if let Err(error) = update { + { return EmulationCommandTaskStep::Complete(CommandOutputPlan::error(-31998, error)); } start_style_environment_update(conn, cmd) diff --git a/moli-protocol/src/domains/emulation/page_session.rs b/moli-protocol/src/domains/emulation/page_session.rs index be04cf109a..0c8624a27f 100644 --- a/moli-protocol/src/domains/emulation/page_session.rs +++ b/moli-protocol/src/domains/emulation/page_session.rs @@ -1,4 +1,4 @@ -use crate::conn::{CdpConnection, TargetEmulationStateUpdate}; +use crate::conn::{CdpConnection, CdpSessionRoute, CommandOwnerScope, TargetEmulationStateUpdate}; pub(super) fn update_page_emulation_state( conn: &mut CdpConnection, @@ -18,31 +18,39 @@ pub(super) fn update_page_emulation_state( pub(super) fn update_style_environment_state( conn: &mut CdpConnection, session_id: Option<&str>, - update: impl FnOnce(TargetEmulationStateUpdate<'_>), + mut update: impl for<'a> FnMut(TargetEmulationStateUpdate<'a>), ) -> Result<(), String> { - update_page_emulation_state(conn, session_id, update) -} - -pub(super) fn set_context_emulated_media( - conn: &mut CdpConnection, - media: crate::conn::EmulatedMediaOverrides, -) -> Result<(), String> { - let context = conn - .browser_context - .as_mut() - .ok_or_else(|| "BrowserContextNotLoaded".to_owned())?; - context.set_default_emulated_media(media); - Ok(()) -} - -pub(super) fn set_context_preferred_text_scale( - conn: &mut CdpConnection, - scale: Option, -) -> Result<(), String> { - let context = conn - .browser_context - .as_mut() - .ok_or_else(|| "BrowserContextNotLoaded".to_owned())?; - context.set_default_preferred_text_scale(scale); + let owners = if super::emulation_command_is_context_wide(conn, session_id) { + conn.browser_context + .as_ref() + .map(|context| { + context + .page_targets + .iter() + .map(|target| { + CommandOwnerScope::for_route(CdpSessionRoute::PageTarget { + browser_context_id: context.id.clone(), + target_id: target.target_id().to_owned(), + session_key: moli_page_types::DevToolsSessionKey::Primary, + }) + }) + .collect::>() + }) + .unwrap_or_default() + } else { + vec![CommandOwnerScope::capture(conn, session_id)] + }; + if owners.is_empty() { + return Err("BrowserContextNotLoaded".to_owned()); + } + for owner in owners { + if !conn.update_emulation_state_for_owner(&owner, |state| { + if let Some(state) = state { + update(state); + } + }) { + return Err("BrowserContextNotLoaded".to_owned()); + } + } Ok(()) } From c03c49343fb9e80797de5ad303f2d7282ea8851d Mon Sep 17 00:00:00 2001 From: lanyue-llk <270302213+lanyue-llk@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:21:22 +0800 Subject: [PATCH 23/23] Adapt screenshot emulation to rebased layout contract --- .../emulation/tests/inspector_surfaces.rs | 1 + moli-protocol/src/domains/page.rs | 27 ++++++++++++------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/moli-protocol/src/domains/emulation/tests/inspector_surfaces.rs b/moli-protocol/src/domains/emulation/tests/inspector_surfaces.rs index a2d6eb9d3b..69b241f9db 100644 --- a/moli-protocol/src/domains/emulation/tests/inspector_surfaces.rs +++ b/moli-protocol/src/domains/emulation/tests/inspector_surfaces.rs @@ -260,6 +260,7 @@ async fn inspector_domain_state_survives_navigation_and_cleanup_without_document async fn scroll_gesture_uses_hit_target_and_honors_wheel_cancellation() { let mut ctx = page().await; evaluate(&mut ctx,r#"document.body.innerHTML='
';window.events=[];scroller.addEventListener('wheel',e=>events.push([e.deltaX,e.deltaY,e.isTrusted]));undefined"#).await; + let _ = screenshot(&mut ctx).await; command( &mut ctx, "Input.synthesizeScrollGesture", diff --git a/moli-protocol/src/domains/page.rs b/moli-protocol/src/domains/page.rs index a8495412e2..ab9ba0a307 100644 --- a/moli-protocol/src/domains/page.rs +++ b/moli-protocol/src/domains/page.rs @@ -27,7 +27,8 @@ use moli_core::page::{ RendererDocumentSourcedTopLevelLocationNavigation, RendererLayoutMetrics, RendererPendingTopLevelHistoryTraversal, RendererPendingWindowOpenEvent, RendererScreenshotClip, RendererScreenshotFormat, RendererScreenshotPurpose, - RendererScreenshotRegion, RendererSetDocumentContentResult, RendererVisualStateToken, + RendererScreenshotRegion, RendererSetDocumentContentResult, RendererVisionDeficiency, + RendererVisualStateToken, }; use moli_core::{RendererDocumentTitleChanged, RendererRuntimeCommandCausalIdentity}; use serde::Deserialize; @@ -6538,6 +6539,7 @@ fn devtools_target_info_for_target_id( fn renderer_screenshot_request( command: &DevToolsCaptureScreenshotCommand, base_background_color: [u8; 4], + vision_deficiency: RendererVisionDeficiency, ) -> Result { let format = match command.format.as_deref() { None | Some("png") => RendererScreenshotFormat::Png, @@ -6573,6 +6575,7 @@ fn renderer_screenshot_request( Ok(RendererCaptureScreenshotRequest { purpose: RendererScreenshotPurpose::Screenshot, base_background_color, + vision_deficiency, format, quality: command.quality.unwrap_or(80), region, @@ -6601,8 +6604,11 @@ async fn execute_devtools_capture_screenshot_command( return Err(devtools_capture_screenshot_error(&command)); } let owner = page_command_owner(conn, &command.context)?; - let request = - renderer_screenshot_request(&command, conn.default_background_color_for_owner(&owner))?; + let request = renderer_screenshot_request( + &command, + conn.default_background_color_for_owner(&owner), + conn.vision_deficiency_for_owner(&owner), + )?; let capture_error = |message| DevToolsError::new(DevToolsErrorKind::UnableToCaptureScreen, message); let page = conn @@ -6980,12 +6986,15 @@ fn start_devtools_capture_screenshot_command( return PageCommandTaskStep::Complete(CommandOutputPlan::error(-32000, message)); } }; - let request = match renderer_screenshot_request(&command, base_background_color) { - Ok(request) => request, - Err(error) => { - return PageCommandTaskStep::Complete(CommandOutputPlan::from_devtools_error(error)); - } - }; + let request = + match renderer_screenshot_request(&command, base_background_color, vision_deficiency) { + Ok(request) => request, + Err(error) => { + return PageCommandTaskStep::Complete(CommandOutputPlan::from_devtools_error( + error, + )); + } + }; match page.start_capture_screenshot_with_request(request) { Ok(pending) => PageCommandTaskStep::Pending(PendingPageCommandDispatch { command_id,