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/Cargo.lock b/Cargo.lock index 48764e4dba..9dfa1bcc2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2904,6 +2904,7 @@ dependencies = [ "anyrender", "anyrender_svg", "anyrender_vello_cpu", + "image", "moli-image", "moli-layout", "peniko", @@ -4614,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=3d44ed30808997861946a3f4b84a20318c4d451c#3d44ed30808997861946a3f4b84a20318c4d451c" dependencies = [ "bitflags", "cssparser", @@ -4746,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=3d44ed30808997861946a3f4b84a20318c4d451c#3d44ed30808997861946a3f4b84a20318c4d451c" dependencies = [ "serde", "stable_deref_trait", @@ -4999,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=3d44ed30808997861946a3f4b84a20318c4d451c#3d44ed30808997861946a3f4b84a20318c4d451c" dependencies = [ "app_units", "arrayvec", @@ -5055,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=3d44ed30808997861946a3f4b84a20318c4d451c#3d44ed30808997861946a3f4b84a20318c4d451c" dependencies = [ "string_cache", "string_cache_codegen", @@ -5064,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=3d44ed30808997861946a3f4b84a20318c4d451c#3d44ed30808997861946a3f4b84a20318c4d451c" dependencies = [ "darling", "proc-macro2", @@ -5076,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=3d44ed30808997861946a3f4b84a20318c4d451c#3d44ed30808997861946a3f4b84a20318c4d451c" dependencies = [ "bitflags", "stylo_malloc_size_of", @@ -5085,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=3d44ed30808997861946a3f4b84a20318c4d451c#3d44ed30808997861946a3f4b84a20318c4d451c" dependencies = [ "app_units", "cssparser", @@ -5102,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=3d44ed30808997861946a3f4b84a20318c4d451c#3d44ed30808997861946a3f4b84a20318c4d451c" dependencies = [ "toml", ] @@ -5120,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=3d44ed30808997861946a3f4b84a20318c4d451c#3d44ed30808997861946a3f4b84a20318c4d451c" dependencies = [ "app_units", "bitflags", @@ -5420,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=3d44ed30808997861946a3f4b84a20318c4d451c#3d44ed30808997861946a3f4b84a20318c4d451c" dependencies = [ "cssparser", "servo_arc", @@ -5433,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=3d44ed30808997861946a3f4b84a20318c4d451c#3d44ed30808997861946a3f4b84a20318c4d451c" dependencies = [ "darling", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 2cabafaad5..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/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 = "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/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..b89cb4a7fe 100644 --- a/moli-bounded-buffer/src/buffer.rs +++ b/moli-bounded-buffer/src/buffer.rs @@ -66,6 +66,21 @@ 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 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); + } else { + break; + } + } + evicted + } + /// Returns the sum of the logical byte charges for retained entries. pub fn used_bytes(&self) -> usize { self.used_bytes @@ -151,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 dc013dd920..d002720a5f 100644 --- a/moli-bounded-buffer/src/tests.rs +++ b/moli-bounded-buffer/src/tests.rs @@ -1,5 +1,44 @@ 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)); + 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)); @@ -115,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-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-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}"), } }; 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/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/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-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-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-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..b1129d46f9 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( @@ -89,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, } @@ -436,13 +449,57 @@ 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.output_range.start >= last_atomic_offset + && (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, @@ -453,6 +510,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, @@ -477,6 +542,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() @@ -2248,6 +2316,7 @@ impl InlineNormalizer { ancestors: pending.ancestors, sources: pending.sources, control: false, + collapsed_space: true, break_spaces_opportunity: false, }, ); @@ -2349,6 +2418,7 @@ impl InlineNormalizer { ancestors: ancestors.to_vec(), sources, control, + collapsed_space: false, break_spaces_opportunity: false, }); } @@ -2737,6 +2807,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/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..cb65f5b0c6 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,150 @@ 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)); + self.reposition_empty_inline_continuation(run.preceding_inline, parent, top); + 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); + } + } + + /// 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 + .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 +1457,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 +1512,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..2fc147a9e2 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 @@ -97,15 +102,21 @@ impl PreparedWorldLayout { N: Copy + Debug + Eq + Hash, { 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); + 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(); 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; @@ -113,13 +124,22 @@ impl PreparedWorldLayout { 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); - } + self.feedback_invalidation_worklist + .extend(world.boxes[id.index()].layout_children.iter().copied()); } - if viewport_changed || !changed_boxes.is_empty() { - world.viewport_layout.cache.clear(); + + 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; + } } + world.viewport_layout.cache.clear(); for id in invalidated.iter().copied() { self.feedback_invalidation_marks[id.index()] = false; } @@ -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); } @@ -2833,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-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..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), ) }) } @@ -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,13 +766,235 @@ 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), + ); + } +} + +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"; + 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::text("text", TEXT), + Node::element("fixed-font", vec![3]), + Node::text("text", text), ]); let mut styles = Styles::default(); styles @@ -788,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, }, @@ -798,13 +1020,32 @@ 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, moli_layout::LayoutSize::new(85.0, 40.0), ); - let feedback_text = feedback.client_rects_for_source(2); + 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()); + 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" + ); // Lay out the same paragraph directly at the converged 85px content // width. Its line fragments must match the scrollbar-corrected result; @@ -822,8 +1063,89 @@ 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_eq!( + feedback_text + .iter() + .any(|quad| quad.points.iter().any(|point| point.x > 85.0)), + horizontal_overflow, + "only the unbreakable final word should retain horizontal overflow" + ); +} + +#[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] @@ -2357,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![ @@ -2632,6 +2997,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 +3602,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..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, @@ -3287,6 +3289,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-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..8d0f1f8d44 --- /dev/null +++ b/moli-paint/src/vision.rs @@ -0,0 +1,241 @@ +//! 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. 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 }); + } + // 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, + }, + )?; + 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: float_row_bytes + .saturating_mul(padding as usize * 2 + 1) + .saturating_add(raster_bytes), + max_bytes: MAX_TRANSIENT_RASTER_BYTES, + }); + } + 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; + } + } + } + raster.rgba = result; + 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]); + } + + #[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-parser/src/html.rs b/moli-parser/src/html.rs index 2eb2c8dc8c..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, @@ -438,6 +438,47 @@ 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 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 + } + pub fn start_live_document_root( &self, final_url: Url, @@ -551,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), @@ -1837,6 +1889,45 @@ 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 = + "\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(), + 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..652f3e475b 100644 --- a/moli-parser/src/session.rs +++ b/moli-parser/src/session.rs @@ -203,6 +203,16 @@ impl TokenSink for EmbedderPausingTreeBuilder { } impl HtmlParserSession { + 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!( + "", + "
\n"
+        )));
+        self.tokenizer.set_plaintext_state();
+    }
+
     fn new(sink: DocumentSink, opts: ParseOpts) -> Self {
         let tree_builder = EmbedderPausingTreeBuilder::new(sink, opts.tree_builder);
         Self {
@@ -388,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 7cee4c1469..427c451ca7 100644
--- a/moli-parser/src/stream.rs
+++ b/moli-parser/src/stream.rs
@@ -429,11 +429,25 @@ pub(crate) fn prepare_parser_script_handoff_for_static_document(
 }
 
 impl HtmlTreeSinkStream {
+    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..17aecb3089 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());
@@ -11530,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"] {
@@ -11551,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(),
@@ -11590,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,
@@ -11617,7 +11711,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());
@@ -11637,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(
@@ -11663,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-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/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/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/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..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);
@@ -4097,6 +4120,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/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/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/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/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.rs b/moli-protocol/src/domains/emulation.rs index 8f80897bfb..7011422883 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)), ), @@ -822,19 +828,55 @@ 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, error)); + } + 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( - -31998, - "BrowserContextNotLoaded", + -32602, + "Text scale must be finite and positive", )); } - let page_overrides: moli_core::page::EmulatedMediaOverrides = (&overrides).into(); + if let Err(error) = + page_session::update_style_environment_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 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)); @@ -842,6 +884,9 @@ fn start_emulated_media_command( } } 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!({}))); }; @@ -2589,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()); @@ -2598,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; }; @@ -2608,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, }); @@ -2856,3 +2901,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/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 d8a83d17c8..d281817ce7 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 { @@ -573,7 +574,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 +647,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 +2224,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 +2716,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 +2772,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(); @@ -3037,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 new file mode 100644 index 0000000000..69b241f9db --- /dev/null +++ b/moli-protocol/src/domains/emulation/tests/inspector_surfaces.rs @@ -0,0 +1,309 @@ +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":{"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; + 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 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; + ctx.install_navigation_fixture_for_session_owner( + "data:text/html,first", + Some("SID-1"), + ) + .await; + 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_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] + ); +} + +#[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; + let _ = screenshot(&mut ctx).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.rs b/moli-protocol/src/domains/network.rs index a29076e2f5..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)] @@ -504,6 +505,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 +525,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..c4ff1b7c8f 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,136 @@ 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); + 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. + 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); + 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 { + // One target owns one payload store. Multiple clients cannot multiply + // its budget; each requested budget is an upper bound, not a reserve. + 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), + ) + }, + ) + } + + fn synchronize_durable_entries(&mut self) { + if self.durable_sessions.is_empty() { + self.durable_entry_order.clear(); + self.retained_request_ids.clear(); + 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) { + 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) { + 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.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) { + 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)); + self.buffered_bodies.retain(|_, body| retain(body)); + 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 +1530,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 +1553,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 +1568,7 @@ impl CapturedResponseBodyStore { { return; } + let tracked_request_id = request_id.clone(); self.bodies.insert( request_id, CapturedResponseBody::pending_with_collector_scope( @@ -1424,6 +1577,7 @@ impl CapturedResponseBodyStore { collection_was_gated, ), ); + self.track_durable_entry(&tracked_request_id); } pub(crate) fn insert_failed_with_collector_scope( @@ -1434,6 +1588,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 +1599,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 +1619,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,23 +1634,13 @@ 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)); - 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)); + self.prune_removed_entry_indexes(); } #[cfg(test)] @@ -1959,6 +2107,145 @@ 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_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(); + 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..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, @@ -261,6 +262,7 @@ fn emit_complete_subresource_network_delivery_record( .then(|| output.request_cookie_report()) .flatten(), &[], + true, ); } } @@ -286,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, @@ -297,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(( @@ -312,6 +320,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 +462,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, ); } } @@ -502,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, @@ -513,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(( @@ -528,6 +547,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..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 } @@ -350,17 +330,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, @@ -485,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), ) } @@ -496,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, @@ -504,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, ) } @@ -515,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( @@ -535,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), )) } @@ -611,7 +599,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 +692,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 +709,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 +748,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 +933,7 @@ impl MainDocumentLiveNetworkProgressSource { redirect_chain, network_observation_journal, final_network_extra_info_available, + SubresourceRequestInitiatorType::Other, ); self.send_progress_events(MainDocumentProgressPhase::RequestStarted, events); } @@ -967,6 +957,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 +975,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 +996,7 @@ impl MainDocumentLiveNetworkProgressSource { self.progress_target(), observed_request_headers( network_observation_journal, - redirect_chain.len(), + redirect_chain, 0, &self.initial_request_headers, ), @@ -1012,15 +1005,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 +1042,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 +1068,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 +1082,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 +1099,7 @@ impl MainDocumentLiveNetworkProgressSource { target, observed_request_headers( network_observation_journal, - redirect_chain.len(), + redirect_chain, index + 1, final_request_headers, ), @@ -1111,15 +1117,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 +1360,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, @@ -1552,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, @@ -1596,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(), ) } @@ -1634,7 +1718,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 +1734,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 +1742,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 +1765,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 +1782,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 +1808,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 +1822,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 +1844,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 +1866,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, @@ -1785,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(), @@ -1907,62 +1998,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..4bddf851a6 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(), @@ -32,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 { @@ -51,6 +100,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 +496,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 +535,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 +563,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,12 +639,145 @@ 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(); 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)); @@ -614,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)); @@ -669,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)); @@ -711,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)); @@ -739,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)); @@ -785,19 +1018,50 @@ 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, + MainDocumentNetworkCompletion::Loaded(17), + ); let mut drain = MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); @@ -838,6 +1102,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")) @@ -884,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)); @@ -949,7 +1222,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(), @@ -957,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); @@ -1013,6 +1297,73 @@ 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, + MainDocumentNetworkCompletion::Loaded(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(); @@ -1036,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)); @@ -1062,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)); @@ -1117,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)); @@ -1258,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)); @@ -1297,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)); @@ -1317,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)); @@ -1351,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), )); @@ -1375,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/network/output_queue.rs b/moli-protocol/src/domains/network/output_queue.rs index 90e6199a43..a89f2019c3 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())].into(), + 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/redirect_request.rs b/moli-protocol/src/domains/network/redirect_request.rs new file mode 100644 index 0000000000..132cbe72e3 --- /dev/null +++ b/moli-protocol/src/domains/network/redirect_request.rs @@ -0,0 +1,101 @@ +/// Project the request metadata for successive followed redirect hops. +/// +/// 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, + 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) { + if moli_fetch::redirect_status_rewrites_to_get(status, self.method) { + self.method = "GET"; + self.body = None; + self.headers + .retain(|(name, _)| !moli_fetch::is_request_body_header_name(name)); + } + } +} + +#[cfg(test)] +mod tests { + use super::RedirectRequest; + + #[test] + fn redirect_status_method_matrix_matches_fetch_semantics() { + // 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"); + } + } + + #[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); + } +} 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/overlay.rs b/moli-protocol/src/domains/overlay.rs new file mode 100644 index 0000000000..a201c6af0d --- /dev/null +++ b/moli-protocol/src/domains/overlay.rs @@ -0,0 +1,228 @@ +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; + +fn color(value: Option) -> [f32; 4] { + value.map_or([0.0; 4], |value| { + [ + 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, + ] + }) +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RectParams { + x: i32, + y: i32, + width: i32, + height: i32, + 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 { + Pending(PendingOverlayCommandDispatch), + Complete(CommandOutputPlan), +} +impl PendingOverlayCommandDispatch { + pub(crate) async fn wait(self) -> CompletedOverlayCommandDispatch { + CompletedOverlayCommandDispatch { + command_id: self.command_id, + owner: self.owner, + cleanup: self.cleanup, + 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.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, + backend_node_id, + object_id: p.object_id.map(String::from), + 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), + }; + 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 matches!(command, RendererInspectorOverlayCommand::Enable) { + return OverlayCommandTaskStep::Complete(CommandOutputPlan::success()); + } + let cleanup = matches!( + command, + RendererInspectorOverlayCommand::Disable | RendererInspectorOverlayCommand::Hide + ); + 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 session = conn.target_renderer_runtime_inspector_session_id_for_session(cmd.session_id); + 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)), + } +} + +pub(crate) fn complete_pending_overlay_command( + conn: &mut CdpConnection, + completed: CompletedOverlayCommandDispatch, +) -> CommandOutputPlan { + let result = completed.completed.and_then(|completion| { + 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(), + Err(error) => CommandOutputPlan::error(-32000, error), + } +} diff --git a/moli-protocol/src/domains/page.rs b/moli-protocol/src/domains/page.rs index 68ce479c74..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; @@ -1988,6 +1989,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, @@ -4170,6 +4172,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 +4312,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 +4422,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 +4465,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(); @@ -6427,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, @@ -6462,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, @@ -6490,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 @@ -6862,18 +6979,22 @@ 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) => { 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, @@ -6928,6 +7049,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/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/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-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/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/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 `