From 974f03ecb7c162203912d2cf42c8fbbc359fd45f Mon Sep 17 00:00:00 2001 From: lanyue-llk <270302213+lanyue-llk@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:58:50 +0800 Subject: [PATCH] revert: undo split PR 640 merges (#734-#740) --- .github/actions/use-ci-release/action.yml | 15 - .github/scripts/unpack-ci-release.sh | 3 - .github/scripts/unpack-ci-release.test.cjs | 60 -- README.md | 4 +- docs/README.de.md | 2 +- docs/README.es.md | 2 +- docs/README.fr.md | 2 +- docs/README.ja.md | 2 +- docs/README.zh-CN.md | 2 +- moli-bounded-buffer/src/buffer.rs | 46 - moli-bounded-buffer/src/tests.rs | 73 -- .../moli_cdp_smoke/groups/dom_input.py | 21 +- moli-cookie-import/src/sqlite.rs | 55 +- moli-curl/src/proxy.rs | 23 +- moli-dom/src/native/host/document.rs | 26 - moli-dom/src/native/host/query_index.rs | 23 - moli-encoding/src/document.rs | 25 - moli-encoding/src/tests.rs | 96 -- moli-fetch/src/lib.rs | 3 +- moli-fetch/src/network_fetch_result.rs | 34 +- moli-fetch/src/request.rs | 6 +- moli-fetch/src/tests/support.rs | 73 +- moli-layout/src/builder.rs | 19 - moli-layout/src/inline.rs | 106 +- moli-layout/src/layout_tree/pass_result.rs | 1 - moli-layout/src/pass.rs | 8 +- moli-layout/src/projection.rs | 245 +---- moli-layout/src/taffy_tree.rs | 67 +- moli-layout/src/world.rs | 38 - moli-layout/tests/phase4_layout_contract.rs | 41 +- moli-layout/tests/phase5_output_contract.rs | 979 +----------------- moli-page-types/src/lib.rs | 8 - moli-parser/src/html.rs | 131 +-- moli-parser/src/session.rs | 17 +- moli-parser/src/stream.rs | 16 +- .../src/protocol_server/tests/classic.rs | 694 +------------ .../src/protocol_server/webdriver_bidi.rs | 24 - .../src/protocol_server/webdriver_classic.rs | 302 +----- .../webdriver_classic/helpers.rs | 3 +- .../webdriver_classic/state.rs | 18 + .../src/actions.rs | 22 +- .../src/commands/elements.rs | 69 +- .../src/commands/mod.rs | 2 +- moli-protocol-webdriver-classic/src/lib.rs | 16 +- moli-protocol-webdriver-classic/src/tests.rs | 12 +- moli-protocol-webdriver-classic/src/types.rs | 2 - .../src/conn/browser_context/network_owner.rs | 8 - moli-protocol/src/conn/devtools_command.rs | 1 - .../src/conn/page_state/fetch_state.rs | 2 +- moli-protocol/src/conn/runtime_load.rs | 123 +-- moli-protocol/src/conn/state/page_slot.rs | 33 - moli-protocol/src/conn/state/runtime_slot.rs | 17 - moli-protocol/src/domains/dom/resolve.rs | 16 +- .../src/domains/dom/tests/geometry.rs | 33 +- moli-protocol/src/domains/emulation/tests.rs | 18 +- moli-protocol/src/domains/input.rs | 50 +- moli-protocol/src/domains/input/tests.rs | 172 +-- .../domains/input/tests/keyboard_events.rs | 48 - moli-protocol/src/domains/network.rs | 13 - moli-protocol/src/domains/network/agent.rs | 317 +----- moli-protocol/src/domains/network/backlog.rs | 32 +- moli-protocol/src/domains/network/events.rs | 4 +- .../network/main_document_progress/mod.rs | 295 ++---- .../network/main_document_progress/tests.rs | 338 +----- .../src/domains/network/output_queue.rs | 71 +- .../src/domains/network/redirect_request.rs | 101 -- moli-protocol/src/domains/network/settings.rs | 29 - .../src/domains/network/tests/mod.rs | 1 - .../domains/network/tests/redirect_wire.rs | 190 ---- .../domains/network/tests/response_body.rs | 162 --- moli-protocol/src/domains/page.rs | 110 -- moli-protocol/src/domains/page/termination.rs | 12 +- .../src/domains/page/tests/lifecycle.rs | 16 +- .../src/domains/page/tests/navigation.rs | 367 ------- .../src/domains/page/tests/runtime.rs | 10 +- .../target/tests/tests_background_staging.rs | 8 +- .../src/domains/target/worker_target.rs | 2 - .../src/document_runtime/mutation_commands.rs | 2 +- moli-renderer-v8/src/dom_parser.rs | 83 +- moli-renderer-v8/src/frame_owner_model.rs | 3 +- .../frame_owner_model/lifecycle_blockers.rs | 5 - .../src/frame_owner_model/lifecycle_tasks.rs | 7 - .../src/frame_owner_model/load_event_gate.rs | 91 +- .../src/frame_owner_model/records.rs | 2 +- .../src/frame_owner_model/store.rs | 33 +- .../src/frame_owner_model/store_tests.rs | 66 +- moli-renderer-v8/src/live_document_parser.rs | 24 - .../context_host/child_documents/commit.rs | 9 +- .../child_documents/live_parser.rs | 16 +- .../context_host/child_documents/loads.rs | 68 +- .../context_host/host_environment.rs | 140 +-- .../context_host/image_resources/mod.rs | 4 - .../src/native_bridge/context_host/layout.rs | 21 +- .../context_host/layout_snapshot.rs | 6 +- .../context_host/layout_state.rs | 26 +- .../context_host/main_document_lifecycle.rs | 3 +- .../detached_objects/builders/elements.rs | 13 +- moli-renderer-v8/src/native_bridge/element.rs | 33 +- .../element/activation/default_action.rs | 65 +- .../native_bridge/element/activation/mod.rs | 1 - .../src/native_bridge/element/focus.rs | 6 +- .../src/native_bridge/element/forms.rs | 5 +- .../element/forms/form_element.rs | 175 ++-- .../native_bridge/element/forms/submission.rs | 2 +- .../native_bridge/element/geometry/metrics.rs | 42 +- .../native_bridge/element/geometry/rects.rs | 4 +- .../native_bridge/element/geometry/scroll.rs | 10 +- .../element/geometry/scroll_into_view.rs | 14 +- .../native_bridge/element/template_install.rs | 1 - .../template_install/accessors_forms.rs | 1 - .../accessors_forms/controls.rs | 9 +- .../src/network/navigation/loader.rs | 37 - .../src/page_task_queue/stylesheet_task.rs | 3 - .../src/runtime/owner_local_store/entry.rs | 19 - .../src/runtime/owner_local_store/mod.rs | 14 +- .../tests/child_document_completion.rs | 2 - .../page_vm/tests/computed_size/sampling.rs | 38 +- .../src/runtime/page_vm/tests/fetch_xhr.rs | 6 - .../tests/grid_resolved_track_values.rs | 68 +- .../runtime/page_vm/tests/rendering_update.rs | 251 +---- .../runtime/page_vm/tests/stylesheet_task.rs | 100 -- .../src/runtime/phase_one/bootstrap.rs | 2 - .../runtime/phase_one/pending_residence.rs | 8 - .../src/runtime/phase_one/state.rs | 26 - .../src/runtime/phase_one/streaming.rs | 183 +--- .../src/runtime/phase_one/streaming_input.rs | 56 +- .../runtime/phase_one/streaming_residence.rs | 6 - .../protocol_output/transport_memory.rs | 133 --- moli-renderer-v8/src/runtime/tests.rs | 278 ----- .../src/runtime/tests/open_streaming.rs | 83 -- moli-renderer-v8/src/script_vm.rs | 2 - .../src/script_vm/input_dispatch.rs | 167 +-- .../src/script_vm/stylesheet_page_tasks.rs | 7 +- .../tests/dom_elements/dom_surface.rs | 291 ------ .../script_vm/tests/dom_xhr/computed_style.rs | 185 ---- .../src/script_vm/tests/dom_xhr/dom.rs | 42 +- .../src/script_vm/tests/dom_xhr/forms.rs | 456 -------- .../tests/dom_xhr/forms/event_activation.rs | 171 --- .../tests/dom_xhr/forms/named_lookup.rs | 301 ------ .../src/script_vm/tests/dom_xhr/misc.rs | 92 +- moli-renderer-v8/src/style_engine/computed.rs | 39 +- moli-renderer-v8/src/style_engine/retained.rs | 32 - moli-renderer-v8/src/style_engine/state.rs | 6 - .../src/stylesheet_runtime/connected.rs | 4 +- moli-web-mime/src/classification.rs | 8 - moli-web-mime/src/lib.rs | 6 +- moli-web-mime/src/tests.rs | 22 - moli/src/fetch_dump/tests.rs | 4 +- moli/src/telemetry.rs | 28 - moli/tests/fetch_cli.rs | 67 -- 150 files changed, 722 insertions(+), 9546 deletions(-) delete mode 100644 .github/scripts/unpack-ci-release.test.cjs delete mode 100644 moli-protocol/src/domains/network/redirect_request.rs delete mode 100644 moli-protocol/src/domains/network/tests/redirect_wire.rs delete mode 100644 moli-renderer-v8/src/script_vm/tests/dom_xhr/forms/named_lookup.rs diff --git a/.github/actions/use-ci-release/action.yml b/.github/actions/use-ci-release/action.yml index 5c305e988..9b4582de7 100644 --- a/.github/actions/use-ci-release/action.yml +++ b/.github/actions/use-ci-release/action.yml @@ -13,21 +13,6 @@ 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 6f734de81..1311d6fc4 100755 --- a/.github/scripts/unpack-ci-release.sh +++ b/.github/scripts/unpack-ci-release.sh @@ -16,9 +16,6 @@ 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 deleted file mode 100644 index 4a18b374c..000000000 --- a/.github/scripts/unpack-ci-release.test.cjs +++ /dev/null @@ -1,60 +0,0 @@ -'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/README.md b/README.md index 688757201..74bebb3e4 100644 --- a/README.md +++ b/README.md @@ -249,9 +249,7 @@ 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 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 +geometry reads may reuse it even if the page has changed. 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 03257df5f..fc2a5635b 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 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. +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. ## Architektur diff --git a/docs/README.es.md b/docs/README.es.md index 89d758b7d..99eb389c6 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 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. +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. ## Arquitectura diff --git a/docs/README.fr.md b/docs/README.fr.md index 7d0978d96..e0174bb4f 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 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. +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. ## Architecture diff --git a/docs/README.ja.md b/docs/README.ja.md index e38cef060..a245c0b1a 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 dc4889cf0..5cfa8a7bc 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 b89cb4a7f..9a8d14fb5 100644 --- a/moli-bounded-buffer/src/buffer.rs +++ b/moli-bounded-buffer/src/buffer.rs @@ -66,21 +66,6 @@ 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 @@ -166,43 +151,12 @@ 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 d002720a5..dc013dd92 100644 --- a/moli-bounded-buffer/src/tests.rs +++ b/moli-bounded-buffer/src/tests.rs @@ -1,44 +1,5 @@ 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)); @@ -154,37 +115,3 @@ 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-cdp-smoke/moli_cdp_smoke/groups/dom_input.py b/moli-cdp-smoke/moli_cdp_smoke/groups/dom_input.py index cc8d14009..44dac5002 100644 --- a/moli-cdp-smoke/moli_cdp_smoke/groups/dom_input.py +++ b/moli-cdp-smoke/moli_cdp_smoke/groups/dom_input.py @@ -9,7 +9,6 @@ from playwright.async_api import Error as PlaywrightError, expect from . import SmokeState from ..assertions import SmokeError, assert_equal, wait_until -from ..progress import await_with_progress async def run_dom_input_group(state: SmokeState) -> None: @@ -193,11 +192,7 @@ async def run_dom_input_group(state: SmokeState) -> None: await run_playwright_expect_matcher_workflows(state) await run_locator_composition_workflows(state) await run_keyboard_editing_workflows(state) - await await_with_progress( - "scenario/playwright-and-cdp-keypress", - run_keypress_dispatch_workflow(state), - timeout_seconds=30, - ) + await run_keypress_dispatch_workflow(state) await run_cdp_control_key_name_workflow(state) await run_cdp_input_navigation_replacement_workflows(state) await run_mouse_event_workflows(state) @@ -981,10 +976,10 @@ async def run_keyboard_editing_workflows(state: SmokeState) -> None: async def run_keypress_dispatch_workflow(state: SmokeState) -> None: - page = await await_with_progress("keypress/new-page", state.context.new_page()) + page = await state.context.new_page() session = None try: - await await_with_progress("keypress/set-content", page.set_content(""" + await page.set_content(""" - """)) - session = await await_with_progress( - "keypress/new-cdp-session", state.context.new_cdp_session(page), - ) + """) + session = await state.context.new_cdp_session(page) full_sequence = ["keydown", "keypress", "beforeinput", "input", "keyup"] async def reset(cancel_at: bool = False) -> None: @@ -1052,8 +1045,8 @@ async def run_keypress_dispatch_workflow(state: SmokeState) -> None: state.record("playwright_and_cdp_keypress_dispatch") finally: if session is not None: - await await_with_progress("keypress/detach", session.detach(), timeout_seconds=5) - await await_with_progress("keypress/close-page", page.close(), timeout_seconds=5) + await session.detach() + await page.close() async def run_cdp_control_key_name_workflow(state: SmokeState) -> None: diff --git a/moli-cookie-import/src/sqlite.rs b/moli-cookie-import/src/sqlite.rs index 070726a27..ef202d31a 100644 --- a/moli-cookie-import/src/sqlite.rs +++ b/moli-cookie-import/src/sqlite.rs @@ -1,8 +1,4 @@ -use std::{ - fs, - io::ErrorKind, - path::{Path, PathBuf}, -}; +use std::{fs, io::ErrorKind, path::Path}; use anyhow::{Context, Result}; use rusqlite::{Connection, OpenFlags}; @@ -24,7 +20,8 @@ impl SqliteSnapshot { fs::copy(source, &database) .with_context(|| format!("failed to snapshot {label} `{}`", source.display()))?; for suffix in ["-wal", "-shm"] { - let companion = sqlite_companion_path(source, suffix); + let mut companion = source.as_os_str().to_owned(); + companion.push(suffix); match fs::copy( &companion, directory.path().join(format!("database.sqlite{suffix}")), @@ -50,12 +47,6 @@ impl SqliteSnapshot { } } -fn sqlite_companion_path(source: &Path, suffix: &str) -> PathBuf { - let mut companion = source.as_os_str().to_owned(); - companion.push(suffix); - companion.into() -} - #[cfg(test)] mod tests { use super::*; @@ -65,33 +56,10 @@ mod tests { check_wal_snapshot(Path::new("cookies.sqlite")) } - #[cfg(target_os = "linux")] - #[test] - fn companion_paths_preserve_non_utf8_database_names() { - use std::os::unix::ffi::OsStrExt; - - let source = Path::new(std::ffi::OsStr::from_bytes(b"cookies-\xff.sqlite")); - assert_eq!( - sqlite_companion_path(source, "-wal").as_os_str().as_bytes(), - b"cookies-\xff.sqlite-wal", - ); - assert_eq!( - sqlite_companion_path(source, "-shm").as_os_str().as_bytes(), - b"cookies-\xff.sqlite-shm", - ); - } - - #[cfg(target_os = "macos")] - #[test] - fn snapshot_preserves_non_ascii_paths_when_finding_wal_files() -> Result<()> { - check_wal_snapshot(Path::new("cookies-é.sqlite")) - } - - #[cfg(target_os = "linux")] + #[cfg(unix)] #[test] fn snapshot_preserves_non_utf8_paths_when_finding_wal_files() -> Result<()> { use std::os::unix::ffi::OsStrExt; - check_wal_snapshot(Path::new(std::ffi::OsStr::from_bytes( b"cookies-\xff.sqlite", ))) @@ -100,11 +68,7 @@ mod tests { fn check_wal_snapshot(name: &Path) -> Result<()> { let directory = tempfile::tempdir()?; let source = directory.path().join(name); - // SQLite's filename conversion differs by platform. Create the WAL - // through an ordinary path, then rename its three files so this test - // isolates the snapshot's handling of non-UTF-8 source paths. - let writer_source = directory.path().join("cookies.sqlite"); - let writer = Connection::open(&writer_source)?; + let writer = Connection::open(&source)?; writer.execute_batch( "PRAGMA journal_mode=WAL; PRAGMA wal_autocheckpoint=0; @@ -112,15 +76,6 @@ mod tests { PRAGMA wal_checkpoint(TRUNCATE); INSERT INTO entries VALUES ('only in WAL');", )?; - if source != writer_source { - for suffix in ["", "-wal", "-shm"] { - let mut original = writer_source.as_os_str().to_owned(); - original.push(suffix); - let mut renamed = source.as_os_str().to_owned(); - renamed.push(suffix); - fs::rename(Path::new(&original), Path::new(&renamed))?; - } - } let files = fs::read_dir(directory.path())? .map(|entry| { let path = entry?.path(); diff --git a/moli-curl/src/proxy.rs b/moli-curl/src/proxy.rs index 3341a02ac..8e2131dfb 100644 --- a/moli-curl/src/proxy.rs +++ b/moli-curl/src/proxy.rs @@ -519,10 +519,7 @@ mod tests { #[test] fn no_proxy_routes_match_libcurl() { - use std::{ - net::TcpListener, - time::{Duration, Instant}, - }; + use std::{net::TcpListener, time::Duration}; use curl::easy::{Easy, List}; @@ -579,23 +576,7 @@ 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 }; - // 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 matches!( - error.kind(), - std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted - ) && Instant::now() < deadline => - { - std::thread::sleep(Duration::from_millis(5)); - } - Err(error) => panic!("libcurl's selected endpoint did not accept: {error}"), - } - }; + let _connection = listener.accept().unwrap(); for (scheme, proxy_env) in [ ("http", "http_proxy"), diff --git a/moli-dom/src/native/host/document.rs b/moli-dom/src/native/host/document.rs index 230de2e62..61bafdaba 100644 --- a/moli-dom/src/native/host/document.rs +++ b/moli-dom/src/native/host/document.rs @@ -810,32 +810,6 @@ 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 a3ba184c4..ee18e3920 100644 --- a/moli-dom/src/native/host/query_index.rs +++ b/moli-dom/src/native/host/query_index.rs @@ -518,29 +518,6 @@ 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 3c14f5109..88df60445 100644 --- a/moli-encoding/src/document.rs +++ b/moli-encoding/src/document.rs @@ -47,26 +47,9 @@ 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, String)], - 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, String)]) -> Self { Self::new_with_options(headers, None, None, None) } @@ -105,7 +88,6 @@ impl HtmlDocumentStreamingDecoder { url_hint: url_hint.map(str::to_owned), decoder: None, selected_encoding: None, - sniff_html_declarations: true, } } @@ -115,7 +97,6 @@ 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()) } @@ -168,12 +149,6 @@ 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 d9717f32d..2b4863a84 100644 --- a/moli-encoding/src/tests.rs +++ b/moli-encoding/src/tests.rs @@ -24,102 +24,6 @@ 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(), - "text/plain; charset=gbk".to_owned(), - )]; - 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(), - "text/plain; charset=gbk".to_owned(), - )]; - 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![( diff --git a/moli-fetch/src/lib.rs b/moli-fetch/src/lib.rs index 20a6d8a4a..5557f1af0 100644 --- a/moli-fetch/src/lib.rs +++ b/moli-fetch/src/lib.rs @@ -68,8 +68,7 @@ pub use request::{ RequestAuthScheme, RequestAuthTarget, RequestCacheMode, RequestCredentialsMode, RequestHeaderOverride, RequestMode, RequestPriorityHints, RequestRedirectMode, RequestResourceType, ResourceLoadPriority, ScriptFetchRequestMetadata, - ScriptFetchSchedulerPriority, SubresourceRequestMetadata, is_request_body_header_name, - redirect_status_rewrites_to_get, + ScriptFetchSchedulerPriority, SubresourceRequestMetadata, }; 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 4be9401c1..eab7b76a2 100644 --- a/moli-fetch/src/network_fetch_result.rs +++ b/moli-fetch/src/network_fetch_result.rs @@ -14,7 +14,6 @@ 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, @@ -23,16 +22,6 @@ 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, @@ -42,17 +31,12 @@ 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 } @@ -354,14 +338,6 @@ 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) = @@ -672,7 +648,7 @@ mod tests { fn recorder_preserves_redirect_exchange_order_and_raw_response_status() { let recorder = NetworkObservationRecorder::default(); recorder.record_request_header_block( - b"POST /start HTTP/1.1\r\nHost: example.test\r\nAccept-Encoding: gzip\r\n\r\n", + b"GET /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"); @@ -686,7 +662,6 @@ 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(), [ @@ -701,13 +676,6 @@ 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 91c895002..4b6c4df56 100644 --- a/moli-fetch/src/request.rs +++ b/moli-fetch/src/request.rs @@ -1122,14 +1122,12 @@ impl Request { } } -/// Whether following this HTTP redirect replaces the request method with GET. -pub fn redirect_status_rewrites_to_get(status: u16, method: &str) -> bool { +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") } -/// Whether a header must be removed when a redirect discards the request body. -pub fn is_request_body_header_name(name: &str) -> bool { +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 92f5c7b09..4f7d02611 100644 --- a/moli-fetch/src/tests/support.rs +++ b/moli-fetch/src/tests/support.rs @@ -1003,85 +1003,14 @@ 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) - .expect("read scripted HTTP request"); - if bytes_read == 0 { - return; - } + let bytes_read = stream.read(&mut request).unwrap_or(0); 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 ce2091d37..2145844ef 100644 --- a/moli-layout/src/builder.rs +++ b/moli-layout/src/builder.rs @@ -622,9 +622,6 @@ 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) { @@ -635,24 +632,11 @@ 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())); @@ -660,9 +644,6 @@ 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 b1129d46f..8110acc16 100644 --- a/moli-layout/src/inline.rs +++ b/moli-layout/src/inline.rs @@ -35,16 +35,6 @@ 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( @@ -99,9 +89,6 @@ pub(crate) struct InlineTextUnit { pub(crate) ancestors: Vec, pub(crate) sources: Vec, pub(crate) control: bool, - /// A collapsed CSS space, removable again at a wrapped line boundary. - /// Preserved spaces and non-breaking spaces must retain their geometry. - pub(crate) collapsed_space: bool, pub(crate) break_spaces_opportunity: bool, } @@ -449,57 +436,13 @@ 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 mut line_rect = placement.map_or_else( + let line_rect = placement.map_or_else( || { PaintRect::new( metrics.inline_min_coord + metrics.offset, @@ -510,14 +453,6 @@ 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, @@ -542,9 +477,6 @@ 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() @@ -2316,7 +2248,6 @@ impl InlineNormalizer { ancestors: pending.ancestors, sources: pending.sources, control: false, - collapsed_space: true, break_spaces_opportunity: false, }, ); @@ -2418,7 +2349,6 @@ impl InlineNormalizer { ancestors: ancestors.to_vec(), sources, control, - collapsed_space: false, break_spaces_opportunity: false, }); } @@ -2807,40 +2737,6 @@ 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/layout_tree/pass_result.rs b/moli-layout/src/layout_tree/pass_result.rs index 19c4b83ae..0cbe9b7c2 100644 --- a/moli-layout/src/layout_tree/pass_result.rs +++ b/moli-layout/src/layout_tree/pass_result.rs @@ -25,7 +25,6 @@ pub enum LayoutFlushReason { Screenshot, Screencast, SynchronousGeometry, - DomGeometry, CdpGeometry, ObserverDelivery, HitTest, diff --git a/moli-layout/src/pass.rs b/moli-layout/src/pass.rs index 241a84f7f..ebeb6fb0a 100644 --- a/moli-layout/src/pass.rs +++ b/moli-layout/src/pass.rs @@ -314,13 +314,11 @@ 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 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 +/// `invalidate_scrollbar_feedback` clears only changed boxes and their numeric +/// ancestors. All other subtrees 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 boxes whose available space can have changed. +/// recompute the paths 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 af84a165c..ded04acbe 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::{MaybeMath, MaybeResolve, ResolveOrZero}; +use taffy::ResolveOrZero; use crate::layout_tree::{CssSizing, CssSizingBox, LayoutCoordinateSpace}; use crate::overflow::{OverflowProjection, inset_rect, offset_rect, outset_rect}; @@ -53,7 +53,6 @@ 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(); @@ -751,150 +750,6 @@ 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); } @@ -1465,32 +1320,12 @@ 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 mut border_box = LayoutRect::new( + let 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, @@ -1520,86 +1355,10 @@ 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 db08ea5a0..e6d92b304 100644 --- a/moli-layout/src/taffy_tree.rs +++ b/moli-layout/src/taffy_tree.rs @@ -78,14 +78,9 @@ impl PreparedWorldLayout { self.numeric_viewport_layout = world.viewport_layout.unrounded_layout; } - /// 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. + /// 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. /// /// Taffy's next compute still starts at the viewport root, as its public /// API requires, but siblings outside these paths retain their cached @@ -102,21 +97,15 @@ impl PreparedWorldLayout { N: Copy + Debug + Eq + Hash, { assert!(self.feedback_invalidation_worklist.is_empty()); - if viewport_changed { - for layout_box in &mut world.boxes { - layout_box.clear_layout_caches(); - } - world.viewport_layout.cache.clear(); - return world.boxes.len(); - } - if changed_boxes.is_empty() { - return 0; - } self.feedback_invalidation_marks .resize(world.boxes.len(), false); - let mut invalidated = Vec::new(); + if viewport_changed { + self.feedback_invalidation_worklist.push(world.root); + } self.feedback_invalidation_worklist .extend(changed_boxes.iter().copied()); + + let mut invalidated = Vec::new(); while let Some(id) = self.feedback_invalidation_worklist.pop() { if self.feedback_invalidation_marks[id.index()] { continue; @@ -124,22 +113,13 @@ impl PreparedWorldLayout { self.feedback_invalidation_marks[id.index()] = true; invalidated.push(id); world.boxes[id.index()].clear_layout_caches(); - self.feedback_invalidation_worklist - .extend(world.boxes[id.index()].layout_children.iter().copied()); - } - - for changed in changed_boxes.iter().copied() { - let mut ancestor = world.boxes[changed.index()].layout_parent; - while let Some(id) = ancestor { - if !self.feedback_invalidation_marks[id.index()] { - self.feedback_invalidation_marks[id.index()] = true; - invalidated.push(id); - world.boxes[id.index()].clear_layout_caches(); - } - ancestor = world.boxes[id.index()].layout_parent; + if let Some(parent) = world.boxes[id.index()].layout_parent { + self.feedback_invalidation_worklist.push(parent); } } - world.viewport_layout.cache.clear(); + if viewport_changed || !changed_boxes.is_empty() { + world.viewport_layout.cache.clear(); + } for id in invalidated.iter().copied() { self.feedback_invalidation_marks[id.index()] = false; } @@ -1641,14 +1621,6 @@ 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 @@ -1808,13 +1780,6 @@ 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); } @@ -2868,11 +2833,7 @@ where parent_width: child_inputs.parent_size.width, }); let line_y = state.line_y() as f32; - // Keep the immediate edge slot, even when it has no room - // for text. An empty inline boundary remains beside an - // oversized float; the next nonempty line break searches - // for a fitting slot below it. - slot = block_context.find_content_slot(line_y, Clear::None, None); + slot = inline_float_slot(block_context, line_y, None, 0.0); 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 d9c6a4c8a..dd294f93d 100644 --- a/moli-layout/src/world.rs +++ b/moli-layout/src/world.rs @@ -593,14 +593,6 @@ 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 @@ -610,9 +602,6 @@ 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. @@ -638,9 +627,6 @@ 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(), @@ -919,30 +905,6 @@ 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 a50f08bd4..ed1f8ac1b 100644 --- a/moli-layout/tests/phase4_layout_contract.rs +++ b/moli-layout/tests/phase4_layout_contract.rs @@ -1564,44 +1564,9 @@ 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 fallback_news = rect(&fallback, RED); - let fallback_hao = rect(&fallback, GREEN); - let fallback_more = rect(&fallback, BLUE); - let glyph_baselines = fallback - .fragments - .iter() - .filter_map(|fragment| match fragment { - moli_layout::PaintFragment::GlyphRun(run) => run.glyphs.first().map(|glyph| { - run.transform - .map_point(moli_layout::LayoutPoint::new(glyph.x, glyph.y)) - .y - }), - _ => None, - }) - .collect::>(); - assert_eq!(glyph_baselines.len(), 3); - assert_close(glyph_baselines[0], fallback_more.bottom()); - assert_close(glyph_baselines[1], fallback_more.bottom()); - 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(rect(&fallback, RED).y, 45.0); + assert_close(rect(&fallback, GREEN).y, 45.0); + assert_close(rect(&fallback, BLUE).y, 19.0); } #[test] diff --git a/moli-layout/tests/phase5_output_contract.rs b/moli-layout/tests/phase5_output_contract.rs index 0ad24b249..300a1c654 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, LayoutReplacedKind, - LayoutScrollbarColors, LayoutScrollbarGutter, LayoutScrollbarPart, LayoutScrollbarWidth, - LayoutSource, LayoutSourceKind, LayoutStyleResolver, LayoutTransform2D, LayoutViewport, - PaintBrush, PaintCaptureRequest, PaintColor, PaintFragment, PaintShape, - ResolvedLayoutElementStyles, ResolvedLayoutStyle, build_layout_pass, + LayoutQuery, LayoutQueryAnswer, LayoutQueryBatch, LayoutRect, 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, - (self.0[node].local_name == "img").then_some(LayoutReplacedKind::Image), + None, ) }) } @@ -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, 6, - "each newly revealed axis invalidates the scroller subtree and root path" + output.metrics.numeric_feedback_invalidated_node_count, 4, + "each newly revealed axis invalidates only the scroller-to-root path" ); let metrics = output.element_metrics_for_source(1).unwrap(); assert_eq!( @@ -766,235 +766,13 @@ 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 = 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) { + const TEXT: &str = "alpha beta gamma delta epsilon zeta eta theta iota kappa"; let source = Source(vec![ Node::element("root", vec![1]), Node::element("scroller", vec![2]), - Node::element("fixed-font", vec![3]), - Node::text("text", text), + Node::text("text", TEXT), ]); let mut styles = Styles::default(); styles @@ -1018,31 +796,13 @@ fn assert_scrollbar_feedback_inline_geometry(text: &'static str, horizontal_over ), ); - // 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(); + let feedback = build(&source, &mut styles); 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, if horizontal_overflow { 25.0 } else { 40.0 }), - ); - let scroller_box = feedback.source_output(1).unwrap().principal_box.unwrap(); - let extent = feedback.scroll_extent(scroller_box).unwrap(); - assert!(extent.vertical_scrollbar.is_some()); - assert_eq!(extent.horizontal_scrollbar.is_some(), horizontal_overflow); - 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" + moli_layout::LayoutSize::new(85.0, 25.0), ); + let feedback_text = feedback.client_rects_for_source(2); // Lay out the same paragraph directly at the converged 85px content // width. Its line fragments must match the scrollbar-corrected result; @@ -1060,89 +820,8 @@ fn assert_scrollbar_feedback_inline_geometry(text: &'static str, horizontal_over }, ), ); - 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() - ); - } + let direct = build(&source, &mut styles); + assert_eq!(feedback_text, direct.client_rects_for_source(2)); } #[test] @@ -2676,49 +2355,6 @@ 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![ @@ -2994,583 +2630,6 @@ 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![ @@ -3599,13 +2658,9 @@ 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(), 3, "{rects:?}"); + assert_eq!(rects.len(), 2, "{rects:?}"); let first = rects[0].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:?}"); + let second = rects[1].bounding_rect(); 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 cd0cda0c8..a94496da8 100644 --- a/moli-page-types/src/lib.rs +++ b/moli-page-types/src/lib.rs @@ -3282,14 +3282,6 @@ pub struct ChildFrameDocumentNetworkSnapshot { pub request_method: String, #[serde(default)] pub request_headers: Vec<(String, String)>, - /// Exact in-process transport observations for the actual child-navigation - /// hops. Serialized snapshots retain their historical lightweight shape. - #[serde(skip)] - pub network_observation_journal: moli_fetch::NetworkObservationJournal, - /// In-process browser-facing redirect metadata paired with the transport - /// journal. Serialized snapshots retain their historical lightweight shape. - #[serde(skip)] - pub redirect_chain: Vec, pub final_url: String, pub status: u16, #[serde(default)] diff --git a/moli-parser/src/html.rs b/moli-parser/src/html.rs index 17369a9b5..ce351cf5e 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_target, + new_parser_stream_html_tree_sink_stream, }; use super::{ ParserSourcePosition, html_chunks, @@ -438,47 +438,6 @@ 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, @@ -592,17 +551,6 @@ 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), @@ -1895,83 +1843,6 @@ 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 text_document_shell_has_no_doctype_and_preserves_normalized_literal_text() { - for mime in [ - "text/plain", - "application/json", - "application/problem+json", - "text/javascript", - ] { - let stream = HtmlParser::SCRIPTING_ENABLED - .start_text_document(Url::parse("https://example.test/data").unwrap(), mime); - for chunk in [ - "\n<&", - "\r", - "\nbeta\rgamma\0", - "", - ] { - stream.feed(chunk); - } - let document = stream.finish_dom_host(); - let root = document.document_handle(); - let children = document.child_handles(root).collect::>(); - assert_eq!(children.len(), 1, "no synthetic doctype: {mime}"); - assert!(document.is_html_element_named(children[0], "html")); - let expected_mode = HtmlParser::SCRIPTING_ENABLED.parse_dom_host( - Url::parse("https://example.test/control").unwrap(), - "

control".to_owned(), - ); - assert_eq!( - document.document_quirks_mode_for_handle(root), - expected_mode.document_quirks_mode_for_handle(expected_mode.document_handle()) - ); - assert_eq!( - document.text_content(root).as_deref(), - Some("\n<&\nbeta\ngamma\u{fffd}") - ); - } - } - - #[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 2b4dfef9a..3025906a4 100644 --- a/moli-parser/src/session.rs +++ b/moli-parser/src/session.rs @@ -167,19 +167,6 @@ 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"
-        )));
-        // Text documents have no doctype but always use no-quirks mode.
-        self.sink()
-            .set_quirks_mode(html5ever::tree_builder::QuirksMode::NoQuirks);
-        self.tokenizer.set_plaintext_state();
-    }
-
     fn new(sink: DocumentSink, opts: ParseOpts) -> Self {
         let tree_builder = EmbedderPausingTreeBuilder::new(sink, opts.tree_builder);
         Self {
@@ -365,10 +352,10 @@ fn feed_with_definitive_encoding(
 
 pub(super) fn new_html_tree_sink_session(
     target: ParserStreamHtmlTreeSinkTarget,
-    options: ParseOpts,
+    scripting_enabled: bool,
 ) -> HtmlTreeSinkSession {
     let sink = DocumentSink::new(target);
-    let parser = HtmlParserSession::new(sink, options);
+    let parser = HtmlParserSession::new(sink, html_parse_opts_with_scripting(scripting_enabled));
     let script_input = ParserInputQueue::default();
 
     HtmlTreeSinkSession {
diff --git a/moli-parser/src/stream.rs b/moli-parser/src/stream.rs
index 70d3a31d3..fa14f3b82 100644
--- a/moli-parser/src/stream.rs
+++ b/moli-parser/src/stream.rs
@@ -429,25 +429,11 @@ 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 {
-        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);
+        let session = new_html_tree_sink_session(target, scripting_enabled);
         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 68d1c92ac..84a3ea0be 100644
--- a/moli-protocol-server/src/protocol_server/tests/classic.rs
+++ b/moli-protocol-server/src/protocol_server/tests/classic.rs
@@ -3,129 +3,6 @@ use axum::http::{HeaderMap, header};
 use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
 use serde_json::{Map, Value};
 
-#[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.xml",
-            "text/plain; charset=utf-8",
-            "text/plain",
-            true,
-            "\n<&\r\nbeta\rgamma\0
", - ), - ( - "json.xml", - "application/problem+json", - "application/problem+json", - 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) - .replace("\r\n", "\n") - .replace('\r', "\n") - .replace('\0', "\u{fffd}"), - null, - 0, - content_type - ]) - } else { - json!(["literal&Gülçek", 42, 1, content_type]) - }; - assert_eq!(observed["value"], expected, "{prefix}{name}"); - if literal { - let structure = classic_request_json_with_body( - app.clone(), Method::POST, &format!("/session/{session_id}/execute/sync"), - json!({"script": "const d=(document.querySelector('iframe')?.contentWindow ?? window).document; return [d.doctype===null,d.compatMode,d.body.children.length,d.body.firstElementChild.localName,d.querySelectorAll('pre').length];", "args": []}), - ).await; - assert_eq!( - structure["value"], - json!([true, "CSS1Compat", 1, "pre", 1]), - "{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()); @@ -11252,84 +11129,6 @@ async fn webdriver_classic_execute_async_honors_script_timeout() { .await; } -#[tokio::test] -async fn webdriver_classic_click_respects_dom_first_and_real_layout_policies() { - for policy in [LayoutPolicy::Mock, LayoutPolicy::OnDemand] { - let state = AppState::new_with_storage_partition_and_runtime_config( - "127.0.0.1:9222".parse().unwrap(), - Arc::new(StoragePartitionState::open(None).unwrap()), - NavigationRuntimeConfig::new( - protocol_server_test_fetch_config(FetchConfig::default()), - OptionalResourceFetchMask::NONE, - true, - policy, - ), - crate::config::DEFAULT_SCREENCAST_INTERVAL_MS, - ) - .unwrap(); - let app = build_router(state); - let session = classic_request_json(app.clone(), Method::POST, "/session").await; - let session_id = session["value"]["sessionId"].as_str().unwrap(); - let navigated = classic_request_json_with_body( - app.clone(), Method::POST, &format!("/session/{session_id}/url"), - json!({"url": "data:text/html,"}), - ).await; - assert_eq!(navigated, json!({"value": null})); - let element_id = classic_find_css_element_id(app.clone(), session_id, "#target").await; - let clicked = classic_request_json( - app.clone(), - Method::POST, - &format!("/session/{session_id}/element/{element_id}/click"), - ) - .await; - assert_eq!(clicked, json!({"value": null}), "{policy:?}"); - let observed = classic_request_json_with_body( - app.clone(), - Method::POST, - &format!("/session/{session_id}/execute/sync"), - json!({"script": "return window.events;", "args": []}), - ) - .await; - let expected = match policy { - LayoutPolicy::Mock => json!(["click"]), - LayoutPolicy::OnDemand => json!(["pointerdown", "mousedown", "mouseup", "click"]), - }; - assert_eq!(observed["value"], expected, "{policy:?}"); - classic_request_json_with_body( - app.clone(), Method::POST, &format!("/session/{session_id}/url"), - json!({"url": "data:text/html,"}), - ).await; - let target = classic_find_css_element_id(app.clone(), session_id, "#target").await; - assert_eq!( - classic_request_json( - app.clone(), - Method::POST, - &format!("/session/{session_id}/element/{target}/click"), - ) - .await, - json!({"value":null}) - ); - assert_eq!( - classic_request_json_with_body( - app.clone(), - Method::POST, - &format!("/session/{session_id}/actions"), - json!({"actions":[{"type":"key","id":"keyboard","actions":[ - {"type":"keyDown","value":"x"},{"type":"keyUp","value":"x"} - ]}]}), - ) - .await, - json!({"value":null}) - ); - let focused = classic_request_json_with_body( - app.clone(), Method::POST, &format!("/session/{session_id}/execute/sync"), - json!({"script":"return [document.activeElement.id,document.getElementById('origin').value,document.getElementById('target').value];","args":[]}), - ).await; - assert_eq!(focused["value"], json!(["target", "", "x"]), "{policy:?}"); - classic_request_json(app, Method::DELETE, &format!("/session/{session_id}")).await; - } -} - #[tokio::test] async fn webdriver_classic_element_click_uses_shared_dom_geometry_and_input() { let app = build_router(test_state()); @@ -11338,7 +11137,7 @@ async fn webdriver_classic_element_click_uses_shared_dom_geometry_and_input() { let session_id = session["value"]["sessionId"] .as_str() .expect("classic session id"); - let url = "data:text/html,"; + let url = "data:text/html,"; let navigated = classic_request_json_with_body( app.clone(), @@ -11376,18 +11175,12 @@ async fn webdriver_classic_element_click_uses_shared_dom_geometry_and_input() { Method::POST, &format!("/session/{session_id}/execute/sync"), json!({ - "script": "return [Boolean(window.__clicked),document.activeElement.id,window.events];", + "script": "return Boolean(window.__clicked);", "args": [] }), ) .await; - assert_eq!( - clicked_state, - json!({ "value": [true, "target", [ - ["pointerdown", true], ["mousedown", true], ["focus", true], - ["mouseup", true], ["click", true] - ]] }) - ); + assert_eq!(clicked_state, json!({ "value": true })); let _ = classic_request_json( app.clone(), @@ -11397,414 +11190,6 @@ async fn webdriver_classic_element_click_uses_shared_dom_geometry_and_input() { .await; } -#[tokio::test] -async fn webdriver_classic_click_pointer_focus_and_interactability_controls() { - 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().expect("session id"); - for (name, target, setup, expected_focus, expected_clicks, expected_error) in [ - ("ordinary", "", "", "target", 1, None), - ( - "clipped fixed button", - "", - "", - "target", - 1, - None, - ), - ( - "entirely outside viewport", - "", - "", - "origin", - 0, - Some("element not interactable"), - ), - ( - "cancel mousedown", - "", - "target.onmousedown=e=>e.preventDefault();", - "origin", - 1, - None, - ), - ( - "cancel pointerdown", - "", - "target.onpointerdown=e=>e.preventDefault();", - "origin", - 1, - None, - ), - ( - "label", - "", - "", - "check", - 1, - None, - ), - ( - "disabled", - "", - "", - "origin", - 0, - None, - ), - ( - "scroll", - "", - "", - "target", - 1, - None, - ), - ( - "file", - "", - "", - "origin", - 0, - Some("invalid argument"), - ), - ( - "hidden", - "", - "", - "origin", - 0, - Some("element not interactable"), - ), - ( - "obscured", - "
", - "", - "origin", - 0, - Some("element click intercepted"), - ), - ] { - let html = format!( - "{target}" - ); - let navigated = classic_request_json_with_body( - app.clone(), - Method::POST, - &format!("/session/{session_id}/url"), - json!({"url":format!("data:text/html,{html}")}), - ) - .await; - assert_eq!(navigated, json!({"value":null}), "{name}"); - let element_id = classic_find_css_element_id(app.clone(), session_id, "#target").await; - let (status, clicked) = classic_request_status_and_json( - app.clone(), - Method::POST, - &format!("/session/{session_id}/element/{element_id}/click"), - ) - .await; - if let Some(error) = expected_error { - assert_eq!(status, StatusCode::BAD_REQUEST, "{name}: {clicked}"); - assert_eq!(clicked["value"]["error"], error, "{name}"); - } else { - assert_eq!(clicked, json!({"value":null}), "{name}"); - } - let observed = classic_request_json_with_body( - app.clone(), - Method::POST, - &format!("/session/{session_id}/execute/sync"), - json!({ - "script":"return [document.activeElement.id,window.clicks];", "args":[] - }), - ) - .await; - assert_eq!(observed["value"][1], expected_clicks, "{name}"); - // Disabled controls must not be rejected or activated. Their focus - // result is not the behavior this adapter correction changes. - if name != "disabled" { - assert_eq!(observed["value"][0], expected_focus, "{name}"); - } - } - // execute/sync remains a JavaScript operation, even with user_gesture set. - classic_request_json_with_body(app.clone(), Method::POST, - &format!("/session/{session_id}/url"), json!({ - "url":"data:text/html," - })).await; - let synthetic = classic_request_json_with_body(app.clone(), Method::POST, - &format!("/session/{session_id}/execute/sync"), json!({ - "script":"let events=[];for(const type of ['pointerdown','mousedown','focus','mouseup','click'])target.addEventListener(type,e=>events.push([e.type,e.isTrusted]));target.click();return [document.activeElement.id,events];", "args":[] - })).await; - assert_eq!(synthetic, json!({"value":["origin",[["click",false]]]})); - classic_request_json(app, Method::DELETE, &format!("/session/{session_id}")).await; -} - -#[tokio::test] -async fn webdriver_classic_click_uses_top_level_pointer_coordinates_inside_offset_frame() { - for transform in ["none", "scale(0.75)", "rotate(12deg)"] { - 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().expect("session id"); - let html = format!( - "" - ); - classic_request_json_with_body( - app.clone(), - Method::POST, - &format!("/session/{session_id}/url"), - json!({"url":format!("data:text/html,{html}")}), - ) - .await; - assert_eq!( - classic_request_json_with_body( - app.clone(), - Method::POST, - &format!("/session/{session_id}/frame"), - json!({"id":0}) - ) - .await, - json!({"value":null}) - ); - let element_id = classic_find_css_element_id(app.clone(), session_id, "#target").await; - assert_eq!( - classic_request_json( - app.clone(), - Method::POST, - &format!("/session/{session_id}/element/{element_id}/click") - ) - .await, - json!({"value":null}) - ); - let observed = classic_request_json_with_body( - app.clone(), - Method::POST, - &format!("/session/{session_id}/execute/sync"), - json!({ - "script":"return [document.activeElement.id,window.clicks,window.trusted];", "args":[] - }), - ) - .await; - assert_eq!(observed, json!({"value":["target",1,true]}), "{transform}"); - classic_request_json(app, Method::DELETE, &format!("/session/{session_id}")).await; - } -} - -#[tokio::test] -async fn webdriver_classic_frame_target_click_is_invariant_under_neutral_frame_wrapping() { - for depth in 0..=2 { - for selector in ["#target", "#container"] { - 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(); - let mut html = "
".to_owned(); - for _ in 0..depth { - let child = html.replace('&', "&").replace('"', """); - html = format!( - "" - ); - } - classic_request_json_with_body( - app.clone(), - Method::POST, - &format!("/session/{session_id}/url"), - json!({"url":classic_data_url(&html)}), - ) - .await; - for _ in 0..depth { - classic_request_json_with_body( - app.clone(), - Method::POST, - &format!("/session/{session_id}/frame"), - json!({"id":0}), - ) - .await; - } - let target = classic_find_css_element_id(app.clone(), session_id, selector).await; - let (status, clicked) = classic_request_status_and_json( - app.clone(), - Method::POST, - &format!("/session/{session_id}/element/{target}/click"), - ) - .await; - assert_eq!( - status, - StatusCode::OK, - "depth={depth} target={selector}: {clicked}" - ); - assert_eq!(clicked, json!({"value":null})); - let observed = classic_request_json_with_body( - app.clone(), Method::POST, &format!("/session/{session_id}/execute/sync"), - json!({"script":"return document.getElementById('target').contentWindow.clicks;","args":[]}), - ).await; - assert_eq!(observed["value"], 1, "depth={depth} target={selector}"); - // Another child of the same document is not a descendant of the - // requested target. It must not become an accepted deep hit. - classic_request_json_with_body( - app.clone(), Method::POST, &format!("/session/{session_id}/execute/sync"), - json!({"script":r#" - window.overlayClicks=0; - const veil=document.createElement('iframe'); - veil.style.cssText='position:fixed;inset:0;width:100%;height:100%;border:0;z-index:999'; - veil.srcdoc='overlay'; - document.body.appendChild(veil); - "#,"args":[]}), - ).await; - let (status, blocked) = classic_request_status_and_json( - app.clone(), - Method::POST, - &format!("/session/{session_id}/element/{target}/click"), - ) - .await; - assert_eq!( - status, - StatusCode::BAD_REQUEST, - "depth={depth} target={selector}: {blocked}" - ); - assert_eq!(blocked["value"]["error"], "element click intercepted"); - let observed = classic_request_json_with_body( - app.clone(), Method::POST, &format!("/session/{session_id}/execute/sync"), - json!({"script":"return [document.getElementById('target').contentWindow.clicks,window.overlayClicks];","args":[]}), - ).await; - assert_eq!( - observed["value"], - json!([1, 0]), - "rejected clicks must have no effect" - ); - classic_request_json(app, Method::DELETE, &format!("/session/{session_id}")).await; - } - } -} - -#[tokio::test] -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()); - let session = classic_request_json(app.clone(), Method::POST, "/session").await; - let session_id = session["value"]["sessionId"].as_str().unwrap(); - let mut html = "".to_owned(); - for _ in 0..depth { - let child = html.replace('&', "&").replace('"', """); - html = format!( - "" - ); - } - classic_request_json_with_body( - app.clone(), - Method::POST, - &format!("/session/{session_id}/url"), - json!({"url":classic_data_url(&html)}), - ) - .await; - for _ in 0..depth { - assert_eq!( - classic_request_json_with_body( - app.clone(), - Method::POST, - &format!("/session/{session_id}/frame"), - json!({"id":0}), - ) - .await, - json!({"value":null}) - ); - } - let target = classic_find_css_element_id(app.clone(), session_id, "#target").await; - let overlay_script = format!( - "let w=window;for(let i=0;i<{};i++)w=w.parent;w.document.getElementById('veil').style.display=arguments[0];", - depth - blocked_level - ); - let read_script = "let w=window,total=0;while(w!==w.parent){w=w.parent;total+=w.overlayClicks;}return [window.clicks,total];"; - for blocked in [true, false] { - classic_request_json_with_body( - app.clone(), - Method::POST, - &format!("/session/{session_id}/execute/sync"), - json!({"script":overlay_script,"args":[if blocked {"block"} else {"none"}]}), - ) - .await; - let (status, clicked) = classic_request_status_and_json( - app.clone(), - Method::POST, - &format!("/session/{session_id}/element/{target}/click"), - ) - .await; - if blocked { - assert_eq!(status, StatusCode::BAD_REQUEST); - assert_eq!( - clicked["value"]["error"], "element click intercepted", - "depth={depth} level={blocked_level}: {clicked}" - ); - } else { - assert_eq!(status, StatusCode::OK); - assert_eq!(clicked, json!({"value":null})); - } - let observed = classic_request_json_with_body( - app.clone(), - Method::POST, - &format!("/session/{session_id}/execute/sync"), - json!({"script":read_script,"args":[]}), - ) - .await; - assert_eq!(observed["value"], json!([if blocked { 0 } else { 1 }, 0])); - } - classic_request_json(app, Method::DELETE, &format!("/session/{session_id}")).await; - } -} - -#[tokio::test] -async fn webdriver_classic_click_mousedown_navigation_does_not_activate_successor() { - 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().expect("session id"); - let successor = classic_data_url( - "", - ); - let source = classic_data_url(&format!( - "" - )); - classic_request_json_with_body( - app.clone(), - Method::POST, - &format!("/session/{session_id}/url"), - json!({"url":source}), - ) - .await; - let element_id = classic_find_css_element_id(app.clone(), session_id, "#target").await; - let clicked = classic_request_json( - app.clone(), - Method::POST, - &format!("/session/{session_id}/element/{element_id}/click"), - ) - .await; - // A complete physical sequence may navigate during mousedown; no input - // phase is retried against the successor document. - assert_eq!(clicked, json!({"value":null})); - let observed = classic_request_json_with_body( - app.clone(), - Method::POST, - &format!("/session/{session_id}/execute/sync"), - json!({ - "script":"return window.clicks;", "args":[] - }), - ) - .await; - assert_eq!(observed, json!({"value":0})); - let successor_id = classic_find_css_element_id(app.clone(), session_id, "#target").await; - assert_eq!( - classic_request_json( - app.clone(), - Method::POST, - &format!("/session/{session_id}/element/{successor_id}/click") - ) - .await, - json!({"value":null}) - ); - let observed = classic_request_json_with_body(app.clone(), Method::POST, - &format!("/session/{session_id}/execute/sync"), json!({ - "script":"return [window.clicks,document.activeElement.id,window.buttons.every(b=>b===0)];", "args":[] - })).await; - assert_eq!(observed, json!({"value":[1,"target",true]})); - classic_request_json(app, Method::DELETE, &format!("/session/{session_id}")).await; -} - #[tokio::test] async fn webdriver_classic_anchor_target_blank_click_opens_window_handle() { // Mirrors ChromeDriver's link-click new window smoke: @@ -12147,14 +11532,6 @@ async fn webdriver_classic_option_click_updates_select_state_ported_from_seleniu ) .await; assert_eq!(clicked_brie, json!({ "value": null })); - let focused_select = classic_request_json_with_body( - app.clone(), - Method::POST, - &format!("/session/{session_id}/execute/sync"), - json!({"script":"return document.activeElement.id;", "args":[]}), - ) - .await; - assert_eq!(focused_select, json!({"value":"single"})); let clicked_ham = classic_request_json( app.clone(), Method::POST, @@ -12635,71 +12012,6 @@ async fn webdriver_classic_option_click_edges_ported_from_chromium_wpt() { .await; } -#[tokio::test] -async fn webdriver_classic_enter_inserts_newline_and_submits_through_both_key_routes() { - 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 key in ["\u{E006}", "\u{E007}"] { - for use_actions in [false, true] { - for (target, expected) in [ - ("", json!(["a\nb", 0])), - ("", json!(["ab", 1])), - ] { - let html = format!( - "
{target}
" - ); - let navigated = classic_request_json_with_body( - app.clone(), - Method::POST, - &format!("/session/{session_id}/url"), - json!({"url": format!("data:text/html,{html}")}), - ) - .await; - assert_eq!(navigated, json!({"value": null})); - let element_id = - classic_find_css_element_id(app.clone(), session_id, "#field").await; - let (route, body) = if use_actions { - ( - format!("/session/{session_id}/actions"), - json!({"actions": [{ - "type": "key", "id": "keyboard", "actions": [ - {"type":"keyDown", "value":"a"}, {"type":"keyUp", "value":"a"}, - {"type":"keyDown", "value":key}, {"type":"keyUp", "value":key}, - {"type":"keyDown", "value":"b"}, {"type":"keyUp", "value":"b"} - ] - }]}), - ) - } else { - ( - format!("/session/{session_id}/element/{element_id}/value"), - json!({"text": format!("a{key}b")}), - ) - }; - let sent = - classic_request_json_with_body(app.clone(), Method::POST, &route, body).await; - assert_eq!( - sent, - json!({"value": null}), - "{key:?}, actions={use_actions}" - ); - let observed = classic_request_json_with_body( - app.clone(), - Method::POST, - &format!("/session/{session_id}/execute/sync"), - json!({"script": "return [field.value,window.submits];", "args": []}), - ) - .await; - assert_eq!( - observed["value"], expected, - "{target}, {key:?}, actions={use_actions}" - ); - } - } - } - classic_request_json(app, Method::DELETE, &format!("/session/{session_id}")).await; -} - #[tokio::test] async fn webdriver_classic_element_send_keys_uses_shared_input() { let app = build_router(test_state()); diff --git a/moli-protocol-server/src/protocol_server/webdriver_bidi.rs b/moli-protocol-server/src/protocol_server/webdriver_bidi.rs index eb7179e52..913df2b31 100644 --- a/moli-protocol-server/src/protocol_server/webdriver_bidi.rs +++ b/moli-protocol-server/src/protocol_server/webdriver_bidi.rs @@ -2977,7 +2977,6 @@ fn bidi_response_from_classic_input_error(id: u64, error: ClassicError) -> serde ClassicErrorCode::InvalidArgument | ClassicErrorCode::MoveTargetOutOfBounds | ClassicErrorCode::ElementNotInteractable - | ClassicErrorCode::ElementClickIntercepted | ClassicErrorCode::InvalidElementState | ClassicErrorCode::StaleElementReference | ClassicErrorCode::NoSuchFrame @@ -3237,29 +3236,6 @@ mod tests { use super::*; - #[test] - fn webdriver_classic_click_errors_use_existing_bidi_input_error_mapping() { - for (classic, expected) in [ - ( - ClassicErrorCode::ElementClickIntercepted, - "invalid argument", - ), - (ClassicErrorCode::ElementNotInteractable, "invalid argument"), - (ClassicErrorCode::InvalidArgument, "invalid argument"), - (ClassicErrorCode::NoSuchElement, "no such node"), - (ClassicErrorCode::UnknownError, "unknown error"), - ] { - let response = bidi_response_from_classic_input_error( - 42, - ClassicError::new(classic, "input diagnostic"), - ); - assert_eq!(response["type"], json!("error")); - assert_eq!(response["id"], json!(42)); - assert_eq!(response["error"], json!(expected)); - assert_eq!(response["message"], json!("input diagnostic")); - } - } - #[test] fn protocol_output_hook_prefers_automation_sidecar_over_protocol_message_parse() { let mut bidi = BidiConnectionState::new(); diff --git a/moli-protocol-server/src/protocol_server/webdriver_classic.rs b/moli-protocol-server/src/protocol_server/webdriver_classic.rs index 86b8753bd..5cd733d26 100644 --- a/moli-protocol-server/src/protocol_server/webdriver_classic.rs +++ b/moli-protocol-server/src/protocol_server/webdriver_classic.rs @@ -16,18 +16,17 @@ use moli_html_input_type::InputType; use moli_protocol::{ DevToolsPageResidenceIdentity, devtools_runtime::{ - DevToolsCommand, DevToolsCommandContext, DevToolsCommandResult, DevToolsDomGeometryCommand, - DevToolsDomGeometryOperation, DevToolsDomGeometryResult, DevToolsDomNodeReference, - DevToolsDownloadBehaviorSetting, DevToolsError, DevToolsErrorKind, DevToolsFrameId, - DevToolsGetFrameOwnerCommand, DevToolsGetFrameOwnerResult, - DevToolsGetNodeForLocationCommand, DevToolsGetRealmsCommand, DevToolsGetRealmsResult, + DevToolsCommand, DevToolsCommandContext, DevToolsCommandResult, DevToolsDomGeometryResult, + DevToolsDomNodeReference, DevToolsDownloadBehaviorSetting, DevToolsError, + DevToolsErrorKind, DevToolsFrameId, DevToolsGetFrameOwnerCommand, + DevToolsGetFrameOwnerResult, DevToolsGetRealmsCommand, DevToolsGetRealmsResult, DevToolsGetServiceWorkerLogsCommand, DevToolsGetServiceWorkerLogsResult, DevToolsGetTargetsCommand, DevToolsGetTargetsResult, DevToolsLayoutMetricsResult, - DevToolsLocateNodesResult, DevToolsMouseEventType, DevToolsProtocol, - DevToolsQuerySelectorResult, DevToolsRemoteHandleId, DevToolsRemoteValue, - DevToolsScriptException, DevToolsScriptResult, DevToolsSessionId, - DevToolsSetDownloadBehaviorCommand, DevToolsSetFileInputFilesCommand, DevToolsTargetId, - DevToolsTargetInfo, DevToolsTargetKind, RuntimeConsoleEvent, RuntimeExecutionContextEvent, + DevToolsLocateNodesResult, DevToolsProtocol, DevToolsQuerySelectorResult, + DevToolsRemoteHandleId, DevToolsRemoteValue, DevToolsScriptException, DevToolsScriptResult, + DevToolsSessionId, DevToolsSetDownloadBehaviorCommand, DevToolsSetFileInputFilesCommand, + DevToolsTargetId, DevToolsTargetInfo, DevToolsTargetKind, RuntimeConsoleEvent, + RuntimeExecutionContextEvent, }, version, }; @@ -36,19 +35,18 @@ use moli_protocol_webdriver_classic::{ CLASSIC_WINDOW_REFERENCE_KEY, ClassicDevToolsCommandContext, ClassicElementOriginViewportPoints, ClassicError, ClassicErrorCode, ClassicPageLoadStrategy, ClassicPromptHandler, ClassicUnhandledPromptBehavior, ClassicViewportBounds, - ClassicViewportPoint, action_element_origin_ids, active_element_command, alert_handle_command, - alert_text_command, classic_attribute_value, classic_error_from_devtools_error, - classic_property_value, classic_rect_from_geometry, classic_shadow_root_reference, - classic_text_value, clear_element_command, create_initial_target_command, current_url_command, + action_element_origin_ids, active_element_command, alert_handle_command, alert_text_command, + classic_attribute_value, classic_error_from_devtools_error, classic_property_value, + classic_rect_from_geometry, classic_shadow_root_reference, classic_text_value, + clear_element_command, create_initial_target_command, current_url_command, delete_session_response, describe_node_command, describe_node_reference_command, element_center_from_geometry, element_click_command, element_click_input_commands, - element_click_preflight_command, element_click_prepare_reference_commands, - element_screenshot_command, element_send_keys_input_commands, - element_send_keys_prepare_text_control_command, element_send_keys_text, execute_async_command, - execute_sync_command, find_element_command, find_element_command_with_root, - get_element_attributes_reference_command, get_element_computed_label_command, - get_element_computed_role_command, get_element_css_value_command, - get_element_displayed_command, get_element_enabled_command, + element_click_prepare_reference_commands, element_screenshot_command, + element_send_keys_input_commands, element_send_keys_prepare_text_control_command, + element_send_keys_text, execute_async_command, execute_sync_command, find_element_command, + find_element_command_with_root, get_element_attributes_reference_command, + get_element_computed_label_command, get_element_computed_role_command, + get_element_css_value_command, get_element_displayed_command, get_element_enabled_command, get_element_property_reference_command, get_element_rect_reference_command, get_element_rendered_text_command, get_element_text_reference_command, history_traversal_entry, layout_metrics_command, matched_capabilities_from_new_session_params, navigate_command, @@ -4432,11 +4430,10 @@ pub(super) async fn webdriver_classic_send_keys_to_element( ClassicSendKeysPreflight::NotTextControl ) { let input_context = classic_top_level_context(&binding); - let point = match element_center_from_geometry(&geometry) { - Ok(point) => point, + let commands = match element_click_input_commands(&input_context, &geometry) { + Ok(commands) => commands, Err(error) => return classic_error_into_response(error), }; - let commands = element_click_input_commands(&input_context, point); if let Err(error) = webdriver_classic_execute_empty_commands( &binding, commands, @@ -4518,158 +4515,6 @@ async fn webdriver_classic_prepare_text_control_for_send_keys( } } -enum ClassicClickPreparation { - Dom, - Pointer { - point: ClassicViewportPoint, - viewport: ClassicViewportBounds, - }, -} - -impl ClassicClickPreparation { - fn from_value(value: Value) -> Result { - match value.get("status").and_then(Value::as_str) { - Some("option" | "dom") => Ok(Self::Dom), - Some("file") => Err(ClassicError::new( - ClassicErrorCode::InvalidArgument, - "file inputs cannot be clicked by WebDriver", - )), - Some("not interactable") => Err(ClassicError::new( - ClassicErrorCode::ElementNotInteractable, - "element has no in-view clickable rectangle", - )), - Some("intercepted") => Err(ClassicError::new( - ClassicErrorCode::ElementClickIntercepted, - "another element obscures the element's in-view center", - )), - Some("pointer") => { - let coordinate = |name: &str| { - value - .get(name) - .and_then(Value::as_f64) - .filter(|value| value.is_finite()) - .ok_or_else(|| { - ClassicError::new( - ClassicErrorCode::UnknownError, - format!("click preflight has invalid {name}"), - ) - }) - }; - let point = ClassicViewportPoint::new(coordinate("x")?, coordinate("y")?)?; - let viewport = ClassicViewportBounds { - width: coordinate("viewport_width")?, - height: coordinate("viewport_height")?, - }; - if viewport.width <= 0.0 || viewport.height <= 0.0 { - return Err(ClassicError::new( - ClassicErrorCode::ElementNotInteractable, - "frame has an empty viewport", - )); - } - Ok(Self::Pointer { point, viewport }) - } - _ => Err(ClassicError::new( - ClassicErrorCode::UnknownError, - "element click preflight returned an invalid status", - )), - } - } -} - -async fn webdriver_classic_click_point( - binding: &ClassicSessionBinding, - point: ClassicViewportPoint, - viewport: ClassicViewportBounds, -) -> Result { - let Some(frame_id) = binding.current_frame_id.as_deref() else { - return ClassicViewportPoint::new(point.x, point.y); - }; - let owner = classic_frame_owner_dom_reference(binding, frame_id).await?; - let result = binding - .runtime - .execute_on_page( - DevToolsCommand::DomGeometry(DevToolsDomGeometryCommand { - context: classic_top_level_devtools_context(binding), - reference: owner.reference, - operation: DevToolsDomGeometryOperation::GetBoxModel, - }), - owner.page_residence.clone(), - ) - .await - .map_err(classic_error_from_devtools_error)?; - let DevToolsCommandResult::DomGeometry(geometry) = result else { - return Err(ClassicError::new( - ClassicErrorCode::UnknownError, - "frame geometry returned an unexpected result", - )); - }; - let model = geometry.box_model.ok_or_else(|| { - ClassicError::new( - ClassicErrorCode::ElementNotInteractable, - "frame has no content geometry", - ) - })?; - let [x0, y0, x1, y1, _, _, x3, y3] = model.content.points.as_slice() else { - return Err(ClassicError::new( - ClassicErrorCode::UnknownError, - "frame has invalid content geometry", - )); - }; - // The content quad already includes every ancestor-frame transform. Map - // the exact locally hit-tested point, retaining rotation/scale and borders. - let u = point.x / viewport.width; - let v = point.y / viewport.height; - let point = ClassicViewportPoint::new( - x0 + u * (x1 - x0) + v * (x3 - x0), - y0 + u * (y1 - y0) + v * (y3 - y0), - )?; - // Use the renderer's deep hit path, which enters a child document only - // when each ancestor frame owner is actually visible at this root point. - let hit = binding - .runtime - .execute_on_page( - DevToolsCommand::GetNodeForLocation(DevToolsGetNodeForLocationCommand { - context: classic_top_level_devtools_context(binding), - x: point.x, - y: point.y, - include_user_agent_shadow_dom: false, - ignore_pointer_events_none: false, - }), - owner.page_residence, - ) - .await - .map_err(classic_error_from_devtools_error)?; - let DevToolsCommandResult::GetNodeForLocation(hit) = hit else { - return Err(ClassicError::new( - ClassicErrorCode::UnknownError, - "frame hit test returned an unexpected result", - )); - }; - // Local preflight already requires the hit element to be in the target's - // subtree. A target iframe (or its container) can legitimately lead the - // deep hit into another document, so require the hit path to pass through - // the current frame, not to end there. Ancestor/sibling overlays fail this. - let mut hit_frame_id = Some(hit.frame_id.as_str().to_owned()); - while let Some(hit_frame) = hit_frame_id { - if hit_frame == frame_id { - return Ok(point); - } - hit_frame_id = binding - .runtime - .parent_frame_id( - binding.session_id.clone(), - binding.target_id.clone(), - hit_frame, - ) - .await - .map_err(classic_error_from_devtools_error)?; - } - Err(ClassicError::new( - ClassicErrorCode::ElementClickIntercepted, - "an ancestor document obscures the target frame's click point", - )) -} - async fn webdriver_classic_activate_element_by_handle( state: &AppState, binding: &ClassicSessionBinding, @@ -4687,98 +4532,18 @@ async fn webdriver_classic_activate_element_by_handle( object_group, ) .await?; - let prepared = binding + let result = binding .runtime - .execute_on_page( - element_click_preflight_command( - context, - object_id.object_id.clone(), - state.layout_policy.uses_real_layout(), - ), + .execute_with_pending_navigation_wait_on_page( + element_click_command(context, object_id.object_id.clone()), + None, + binding.timeouts.page_load.map(Duration::from_millis), object_id.page_residence.clone(), ) .await; - let preparation = match prepared { - Ok(DevToolsCommandResult::Script(result)) => match *result { - DevToolsScriptResult::Value(value) => ClassicClickPreparation::from_value(value.value), - DevToolsScriptResult::Exception(exception) => { - Err(classic_webdriver_command_exception_error(exception)) - } - }, - Ok(_) => Err(ClassicError::new( - ClassicErrorCode::UnknownError, - "element click preflight returned an unexpected result", - )), - Err(error) => Err(classic_error_from_devtools_error(error)), - }; - let mut activation_dispatched = false; - let mut pointer_press_completed = false; - let operation = async { - let preparation = preparation?; - match preparation { - ClassicClickPreparation::Dom => { - activation_dispatched = true; - Ok(binding - .runtime - .execute_on_page( - element_click_command(context, object_id.object_id.clone()), - object_id.page_residence.clone(), - ) - .await) - } - ClassicClickPreparation::Pointer { point, viewport } => { - let point = webdriver_classic_click_point(binding, point, viewport).await?; - // The preflight point has been mapped into the root viewport. - let pointer_context = classic_top_level_context(binding); - let commands = element_click_input_commands(&pointer_context, point); - let mut result = Ok(DevToolsCommandResult::Empty); - for command in commands { - let event_type = match &command { - DevToolsCommand::DispatchMouseEvent(event) => Some(event.event_type), - _ => None, - }; - activation_dispatched = event_type == Some(DevToolsMouseEventType::Released); - // Do not retry an input command or wait for navigation - // between pointer phases. Page residence is not a promise - // that the active Document cannot change during input. - result = binding - .runtime - .execute_on_page(command, object_id.page_residence.clone()) - .await; - if !matches!(result, Ok(DevToolsCommandResult::Empty)) { - break; - } - if event_type == Some(DevToolsMouseEventType::Pressed) { - pointer_press_completed = true; - } - } - Ok(result) - } - } - } - .await; release_classic_page_bound_remote_object(binding, context, object_id, object_group).await; - let result = operation?; let post_click_navigation = classic_wait_for_current_document(binding).await; match result { - Ok(DevToolsCommandResult::Empty) => { - post_click_navigation?; - if binding.current_frame_id.is_some() - && let Err(error) = ensure_classic_current_browsing_context_exists(binding).await - { - if error.code != ClassicErrorCode::NoSuchWindow { - return Err(error); - } - state.classic_session_registry.lock().set_current_frame_id( - &binding.session_id, - Some(format!( - "__moli-detached-frame__:{}", - binding.browsing_context_target_id() - )), - ); - } - Ok(()) - } Ok(DevToolsCommandResult::Script(result)) => { post_click_navigation?; match *result { @@ -4823,20 +4588,7 @@ async fn webdriver_classic_activate_element_by_handle( // Chromium references: // chrome/test/chromedriver/element_commands.cc (ExecuteClickElement) // chrome/test/chromedriver/window_commands.cc (ExecuteWindowCommand) - // A completed mousedown can replace the Page before the queued - // mouseup is admitted. The page-bound guard then rejects that phase - // before dispatch, rather than losing the renderer response. Both - // outcomes must leave the successor untouched. Require a completed - // pointer press so preflight/option stale handles remain errors. - Err(error) - if activation_dispatched - && (error.message == "Renderer attachment changed" - || (pointer_press_completed - && error.kind == DevToolsErrorKind::NoSuchNode - && error.message == "DOM reference belongs to a replaced Page")) => - { - post_click_navigation - } + Err(error) if error.message == "Renderer attachment changed" => post_click_navigation, Err(error) => Err(classic_error_from_devtools_error(error)), } } diff --git a/moli-protocol-server/src/protocol_server/webdriver_classic/helpers.rs b/moli-protocol-server/src/protocol_server/webdriver_classic/helpers.rs index 9fc371d12..a76b16b48 100644 --- a/moli-protocol-server/src/protocol_server/webdriver_classic/helpers.rs +++ b/moli-protocol-server/src/protocol_server/webdriver_classic/helpers.rs @@ -59,8 +59,7 @@ fn classic_error_status(code: ClassicErrorCode) -> StatusCode { | ClassicErrorCode::InvalidCookieDomain | ClassicErrorCode::InvalidElementState | ClassicErrorCode::InvalidSelector - | ClassicErrorCode::ElementNotInteractable - | ClassicErrorCode::ElementClickIntercepted => StatusCode::BAD_REQUEST, + | ClassicErrorCode::ElementNotInteractable => StatusCode::BAD_REQUEST, ClassicErrorCode::InvalidSessionId | ClassicErrorCode::NoSuchAlert | ClassicErrorCode::NoSuchCookie diff --git a/moli-protocol-server/src/protocol_server/webdriver_classic/state.rs b/moli-protocol-server/src/protocol_server/webdriver_classic/state.rs index 02b7b9bb0..192c02efd 100644 --- a/moli-protocol-server/src/protocol_server/webdriver_classic/state.rs +++ b/moli-protocol-server/src/protocol_server/webdriver_classic/state.rs @@ -519,6 +519,24 @@ impl ClassicSessionRuntimeHandle { .await } + pub(super) async fn execute_with_pending_navigation_wait_on_page( + &self, + command: DevToolsCommand, + timeout: Option, + pending_navigation_timeout: Option, + expected_page: DevToolsPageResidenceIdentity, + ) -> Result { + self.execute_request( + command, + timeout, + pending_navigation_timeout, + false, + Some(expected_page), + ) + .await + .result + } + pub(super) async fn wait_for_document_lifecycle( &self, context: DevToolsCommandContext, diff --git a/moli-protocol-webdriver-classic/src/actions.rs b/moli-protocol-webdriver-classic/src/actions.rs index 955cfec11..95f545b63 100644 --- a/moli-protocol-webdriver-classic/src/actions.rs +++ b/moli-protocol-webdriver-classic/src/actions.rs @@ -164,18 +164,10 @@ enum ClassicActionCancel { pub fn element_click_input_commands( context: &ClassicDevToolsCommandContext, - point: ClassicViewportPoint, -) -> Vec { - vec![ - dispatch_mouse_event_command( - context, - DevToolsMouseEventType::Moved, - DevToolsPointerType::Mouse, - point.x, - point.y, - 0, - Some(0), - ), + geometry: &DevToolsDomGeometryResult, +) -> Result, ClassicError> { + let point = element_center_from_geometry(geometry)?; + Ok(vec![ dispatch_mouse_event_command( context, DevToolsMouseEventType::Pressed, @@ -194,7 +186,7 @@ pub fn element_click_input_commands( 0, Some(0), ), - ] + ]) } pub fn element_send_keys_text(params: &Value) -> Result { @@ -1641,9 +1633,7 @@ fn webdriver_key_for_action_character(character: char) -> ClassicWebDriverKey { match character { '\u{E003}' => ClassicWebDriverKey::named("Backspace", "Backspace"), '\u{E004}' => ClassicWebDriverKey::named("Tab", "Tab"), - '\u{E006}' | '\u{E007}' => { - ClassicWebDriverKey::text("Enter".to_owned(), "Enter", "\r".to_owned()) - } + '\u{E006}' | '\u{E007}' => ClassicWebDriverKey::named("Enter", "Enter"), '\u{E008}' => ClassicWebDriverKey::modifier("Shift", "ShiftLeft", CLASSIC_MODIFIER_SHIFT), '\u{E009}' => { ClassicWebDriverKey::modifier("Control", "ControlLeft", CLASSIC_MODIFIER_CONTROL) diff --git a/moli-protocol-webdriver-classic/src/commands/elements.rs b/moli-protocol-webdriver-classic/src/commands/elements.rs index fa9c0cb6c..7e81929b8 100644 --- a/moli-protocol-webdriver-classic/src/commands/elements.rs +++ b/moli-protocol-webdriver-classic/src/commands/elements.rs @@ -737,63 +737,11 @@ pub fn clear_element_command( }) } -/// Validate activation without dispatching it. Real-layout sessions use pointer -/// input; DOM-first sessions retain semantic activation without hit testing. -pub fn element_click_preflight_command( - context: &ClassicDevToolsCommandContext, - object_id: impl Into, - use_real_layout: bool, -) -> DevToolsCommand { - let mut command = element_click_call_function(context, object_id); - command.function_declaration = r#"function(useRealLayout) { - if (!this || this.nodeType !== Node.ELEMENT_NODE || !this.isConnected) - throw new Error('__moli_webdriver_classic_stale_element_reference__'); - if (this.localName === 'input' && this.type === 'file') - return {status: 'file'}; - // Options have a separate selection algorithm, not a pointer target - // in the page's rendered select popup. - if (this.localName === 'option') return {status: 'option'}; - if (!useRealLayout) return {status: 'dom'}; - this.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'}); - const rect = this.getClientRects()[0]; - if (!rect || rect.width <= 0 || rect.height <= 0) - return {status: 'not interactable'}; - // WebDriver's in-view center is the clipped first client rectangle, - // not the bounding box of every fragment. Return the tested point so - // dispatch cannot recompute a different center. - const view = this.ownerDocument.defaultView; - const width = view.innerWidth, height = view.innerHeight; - const left = Math.max(0, rect.left), right = Math.min(width, rect.right); - const top = Math.max(0, rect.top), bottom = Math.min(height, rect.bottom); - if (right <= left || bottom <= top) - return {status: 'not interactable'}; - const x = Math.floor((left + right) / 2), y = Math.floor((top + bottom) / 2); - const root = this.getRootNode(); - const hit = (typeof root.elementFromPoint === 'function' ? root : this.ownerDocument) - .elementFromPoint(x, y); - if (!hit || !(hit === this || this.contains(hit))) - return {status: 'intercepted'}; - return {status: 'pointer', x, y, viewport_width: width, viewport_height: height}; - }"# - .to_owned(); - command.arguments = vec![json!(use_real_layout)]; - command.await_promise = false; - command.user_gesture = false; - DevToolsCommand::CallFunction(command) -} - pub fn element_click_command( context: &ClassicDevToolsCommandContext, object_id: impl Into, ) -> DevToolsCommand { - DevToolsCommand::CallFunction(element_click_call_function(context, object_id)) -} - -fn element_click_call_function( - context: &ClassicDevToolsCommandContext, - object_id: impl Into, -) -> DevToolsCallFunctionCommand { - DevToolsCallFunctionCommand { + DevToolsCommand::CallFunction(DevToolsCallFunctionCommand { context: context.command_context(), realm_id: None, world_name: None, @@ -807,19 +755,6 @@ fn element_click_call_function( throw new Error('__moli_webdriver_classic_element_not_interactable__'); } const frameElementBeforeClick = window.frameElement || null; - // WebDriver's option branch focuses its select container. This - // is deliberately not part of ordinary HTMLElement.click(). - if (this.localName === 'option') { - const select = this.closest('select'); - if (select) { - select.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'}); - HTMLElement.prototype.focus.call(select); - } - } else { - // DOM-first WebDriver activation still moves keyboard focus. - // Keep this at the automation boundary, not in script click(). - HTMLElement.prototype.focus.call(this); - } HTMLElement.prototype.click.call(this); let detachedFrame = false; try { @@ -844,7 +779,7 @@ fn element_click_call_function( preserve_remote_metadata: false, materialize_bidi_script_result: false, serialization_options: None, - } + }) } pub fn element_click_prepare_commands( diff --git a/moli-protocol-webdriver-classic/src/commands/mod.rs b/moli-protocol-webdriver-classic/src/commands/mod.rs index f720f7fd1..e59ac9c0e 100644 --- a/moli-protocol-webdriver-classic/src/commands/mod.rs +++ b/moli-protocol-webdriver-classic/src/commands/mod.rs @@ -21,7 +21,7 @@ pub use elements::{ classic_elements_from_query_result, classic_property_value, classic_rect_from_geometry, classic_shadow_root_id, classic_shadow_root_reference, classic_text_value, clear_element_command, describe_node_command, describe_node_reference_command, - element_click_command, element_click_preflight_command, element_click_prepare_commands, + element_click_command, element_click_prepare_commands, element_click_prepare_reference_commands, find_element_command, find_element_command_with_root, frame_id_for_element_command, get_element_attributes_command, get_element_attributes_reference_command, get_element_computed_label_command, diff --git a/moli-protocol-webdriver-classic/src/lib.rs b/moli-protocol-webdriver-classic/src/lib.rs index 89c2880c2..90312ce0b 100644 --- a/moli-protocol-webdriver-classic/src/lib.rs +++ b/moli-protocol-webdriver-classic/src/lib.rs @@ -49,14 +49,14 @@ pub use commands::{ classic_text_value, classic_window_rect_for_state, classic_window_rect_from_metrics, clear_element_command, close_window_command, create_initial_target_command, current_url_command, delete_all_cookies_command, delete_cookie_command, describe_node_command, - describe_node_reference_command, element_click_command, element_click_preflight_command, - element_click_prepare_commands, element_click_prepare_reference_commands, - element_screenshot_command, execute_async_command, execute_sync_command, find_element_command, - find_element_command_with_root, frame_id_for_element_command, get_cookies_command, - get_element_attributes_command, get_element_attributes_reference_command, - get_element_computed_label_command, get_element_computed_role_command, - get_element_css_value_command, get_element_displayed_command, get_element_enabled_command, - get_element_property_command, get_element_property_reference_command, get_element_rect_command, + describe_node_reference_command, element_click_command, element_click_prepare_commands, + element_click_prepare_reference_commands, element_screenshot_command, execute_async_command, + execute_sync_command, find_element_command, find_element_command_with_root, + frame_id_for_element_command, get_cookies_command, get_element_attributes_command, + get_element_attributes_reference_command, get_element_computed_label_command, + get_element_computed_role_command, get_element_css_value_command, + get_element_displayed_command, get_element_enabled_command, get_element_property_command, + get_element_property_reference_command, get_element_rect_command, get_element_rect_reference_command, get_element_rendered_text_command, get_element_shadow_root_command, get_element_tag_name_command, get_element_text_command, get_element_text_reference_command, history_traversal_entry, layout_metrics_command, diff --git a/moli-protocol-webdriver-classic/src/tests.rs b/moli-protocol-webdriver-classic/src/tests.rs index 0e3427481..3786bacb5 100644 --- a/moli-protocol-webdriver-classic/src/tests.rs +++ b/moli-protocol-webdriver-classic/src/tests.rs @@ -1519,11 +1519,11 @@ fn maps_element_click_to_shared_dom_geometry_and_input_commands() { let input = element_click_input_commands( &context, - ClassicViewportPoint::new(20.0, 30.0).expect("valid point"), - ); - assert_eq!(input.len(), 3); + &box_model_geometry([10.0, 20.0, 30.0, 20.0, 30.0, 40.0, 10.0, 40.0], 20, 20), + ) + .expect("input commands"); + assert_eq!(input.len(), 2); for (command, (event_type, buttons)) in input.into_iter().zip([ - (DevToolsMouseEventType::Moved, Some(0)), (DevToolsMouseEventType::Pressed, Some(1)), (DevToolsMouseEventType::Released, Some(0)), ]) { @@ -3223,10 +3223,6 @@ fn maps_alert_commands_to_shared_page_dialog_commands() { #[test] fn serializes_element_not_interactable_error_code() { - assert_eq!( - error_response(ClassicErrorCode::ElementClickIntercepted, "obscured")["value"]["error"], - json!("element click intercepted") - ); assert_eq!( error_response( ClassicErrorCode::ElementNotInteractable, diff --git a/moli-protocol-webdriver-classic/src/types.rs b/moli-protocol-webdriver-classic/src/types.rs index f0f2456cd..c7ac5533a 100644 --- a/moli-protocol-webdriver-classic/src/types.rs +++ b/moli-protocol-webdriver-classic/src/types.rs @@ -21,7 +21,6 @@ pub enum ClassicErrorCode { InvalidElementState, InvalidSelector, ElementNotInteractable, - ElementClickIntercepted, InvalidSessionId, JavascriptError, NoSuchAlert, @@ -50,7 +49,6 @@ impl ClassicErrorCode { Self::InvalidElementState => "invalid element state", Self::InvalidSelector => "invalid selector", Self::ElementNotInteractable => "element not interactable", - Self::ElementClickIntercepted => "element click intercepted", Self::InvalidSessionId => "invalid session id", Self::JavascriptError => "javascript error", Self::NoSuchAlert => "no such alert", diff --git a/moli-protocol/src/conn/browser_context/network_owner.rs b/moli-protocol/src/conn/browser_context/network_owner.rs index cde2ea3c2..b1f421834 100644 --- a/moli-protocol/src/conn/browser_context/network_owner.rs +++ b/moli-protocol/src/conn/browser_context/network_owner.rs @@ -134,14 +134,6 @@ 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/devtools_command.rs b/moli-protocol/src/conn/devtools_command.rs index 577183c22..82fcce665 100644 --- a/moli-protocol/src/conn/devtools_command.rs +++ b/moli-protocol/src/conn/devtools_command.rs @@ -324,7 +324,6 @@ impl CdpConnection { | DevToolsCommand::GetOuterHtml(_) | DevToolsCommand::DescribeNode(_) | DevToolsCommand::GetFrameOwner(_) - | DevToolsCommand::GetNodeForLocation(_) | DevToolsCommand::ResolveNode(_) | DevToolsCommand::ScrollIntoViewIfNeeded(_) | DevToolsCommand::DomObjectReference(_) diff --git a/moli-protocol/src/conn/page_state/fetch_state.rs b/moli-protocol/src/conn/page_state/fetch_state.rs index 5184eeafb..32d41936f 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.prepare_response_bodies_for_navigation(); + self.runtime_slot.clear_captured_response_bodies(); } 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 983c29297..83f36a44e 100644 --- a/moli-protocol/src/conn/runtime_load.rs +++ b/moli-protocol/src/conn/runtime_load.rs @@ -592,30 +592,16 @@ async fn first_nonempty_response_body_chunk( Some(chunk) if chunk.is_empty() => continue, Some(chunk) => return Ok(Some(chunk)), None => { - response.finish().await?; + response + .finish() + .await + .context("failed to read page body from stream")?; 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>, @@ -973,14 +959,6 @@ impl BackgroundNavigationLoadJob { network_error_text = failure.network_error_text(), "main document transport failed before response metadata" ); - // An aborted provisional navigation never commits a replacement - // document. Retain its network failure for the command/event - // completion path, without creating a browser-owned error page. - 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, @@ -1377,12 +1355,9 @@ 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 { - Err(error) => { - return failed_provisional_body_load(error, "failed to read page body from stream"); - } - Ok(Some(chunk)) => Some(chunk), - Ok(None) => { + match first_nonempty_response_body_chunk(&mut response).await? { + Some(chunk) => Some(chunk), + None => { let body = CapturedBody::from_string(http_error_page_html(&final_url, response_status)); return prepare_browser_owned_error_page_navigation_with_engine_async( @@ -1417,9 +1392,10 @@ async fn build_navigation_from_streaming_raw_response_with_engine_async( .append(&chunk) .context("failed to capture XML page body")?; } - if let Err(error) = response.finish().await { - return failed_provisional_body_load(error, "failed to read XML page body from stream"); - } + response + .finish() + .await + .context("failed to read XML page body from stream")?; let captured_body = body_writer .finish() .context("failed to finish captured XML page body")?; @@ -1495,8 +1471,7 @@ 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) - .with_stop_loading_cancellation(response.cancellation_handle()); + let raw_body = moli_core::runtime::ExternalRawDocumentBodyStream::new(body_rx, completion_rx); let page_storage = load_inputs.page_storage_handles(); let main_document_commit = load_inputs .main_document_commit_for_final_url(&final_url, None) @@ -1719,8 +1694,7 @@ 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); @@ -4045,77 +4019,6 @@ mod tests { }; use serde_json::json; - #[test] - fn provisional_body_failure_preserves_typed_source_and_context() { - let source = anyhow::Error::new(std::io::Error::new( - std::io::ErrorKind::ConnectionReset, - "body transport reset", - )) - .context("transport completion failed"); - let error = - super::failed_provisional_body_load(source, "failed to read XML page body from stream") - .expect_err("transport failure must remain an error"); - assert_eq!( - error.downcast_ref::().unwrap().kind(), - std::io::ErrorKind::ConnectionReset - ); - let message = format!("{error:#}"); - assert!(message.contains("failed to read XML page body from stream")); - assert!(message.contains("transport completion failed")); - assert!(message.contains("body transport reset")); - } - - #[test] - fn provisional_body_failure_does_not_infer_cancellation_from_error_text() { - let error = super::failed_provisional_body_load( - anyhow::anyhow!(moli_fetch::NET_ERR_ABORTED_ERROR_TEXT), - "failed to read page body from stream", - ) - .expect_err("only a typed transport cancellation may become a network outcome"); - assert!(format!("{error:#}").contains(moli_fetch::NET_ERR_ABORTED_ERROR_TEXT)); - } - - #[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()); - } - #[tokio::test] async fn streaming_body_failure_preserves_source_for_renderer_and_navigation() { let (chunks_tx, chunks_rx) = tokio::sync::mpsc::unbounded_channel(); diff --git a/moli-protocol/src/conn/state/page_slot.rs b/moli-protocol/src/conn/state/page_slot.rs index 61f7dd494..3ee27ba80 100644 --- a/moli-protocol/src/conn/state/page_slot.rs +++ b/moli-protocol/src/conn/state/page_slot.rs @@ -722,14 +722,6 @@ 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, @@ -1588,31 +1580,6 @@ 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 c4cdf4392..665372e43 100644 --- a/moli-protocol/src/conn/state/runtime_slot.rs +++ b/moli-protocol/src/conn/state/runtime_slot.rs @@ -574,10 +574,6 @@ 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, @@ -1137,19 +1133,6 @@ impl TargetRuntimeSlot { self.network_agent.clear_captured_response_bodies(); } - pub(crate) fn configure_durable_response_bodies( - &mut self, - session_id: Option<&str>, - limits: Option, - ) { - self.network_agent - .configure_durable_response_bodies(session_id, limits); - } - - pub(crate) fn prepare_response_bodies_for_navigation(&mut self) { - self.network_agent.prepare_response_bodies_for_navigation(); - } - pub(crate) fn clear_network_body_artifacts(&mut self) { self.network_agent.clear_body_artifacts(); } diff --git a/moli-protocol/src/domains/dom/resolve.rs b/moli-protocol/src/domains/dom/resolve.rs index b63d075f1..1757a375d 100644 --- a/moli-protocol/src/domains/dom/resolve.rs +++ b/moli-protocol/src/domains/dom/resolve.rs @@ -5842,7 +5842,6 @@ fn devtools_dom_command_context( DevToolsCommand::GetOuterHtml(command) => &command.context, DevToolsCommand::DescribeNode(command) => &command.context, DevToolsCommand::GetFrameOwner(command) => &command.context, - DevToolsCommand::GetNodeForLocation(command) => &command.context, DevToolsCommand::ResolveNode(command) => &command.context, DevToolsCommand::ScrollIntoViewIfNeeded(command) => &command.context, DevToolsCommand::DomObjectReference(command) @@ -6001,12 +6000,15 @@ async fn execute_devtools_dom_command_for_owner( await_pending_devtools_dom_command_result(conn, pending).await } - command @ (DevToolsCommand::DomGeometry(_) | DevToolsCommand::GetNodeForLocation(_)) => { - let pending = start_devtools_dom_command_for_owner(conn, None, owner, command) - .map_err(DevToolsError::from)? - .ok_or_else(|| { - DevToolsError::new(DevToolsErrorKind::Internal, "MissingDomCommand") - })?; + DevToolsCommand::DomGeometry(command) => { + let pending = start_devtools_dom_command_for_owner( + conn, + None, + owner, + DevToolsCommand::DomGeometry(command), + ) + .map_err(DevToolsError::from)? + .ok_or_else(|| DevToolsError::new(DevToolsErrorKind::Internal, "MissingDomCommand"))?; await_pending_devtools_dom_command_result(conn, pending).await } diff --git a/moli-protocol/src/domains/dom/tests/geometry.rs b/moli-protocol/src/domains/dom/tests/geometry.rs index bbebbeb5e..072322f83 100644 --- a/moli-protocol/src/domains/dom/tests/geometry.rs +++ b/moli-protocol/src/domains/dom/tests/geometry.rs @@ -1389,9 +1389,9 @@ async fn scroll_into_view_if_needed_accepts_element_node() { load_bc(&mut ctx, "BID-A"); // Keep the text-row scenario while making its height independent of fonts. - let spacer = "
row
".repeat(60); + let rows = "
row
".repeat(60); let url = format!( - "data:text/html,{spacer}
" + "data:text/html,{rows}
" ); navigate_to_url_and_wait_for_load_async(&mut ctx, 1, url).await; @@ -1544,9 +1544,9 @@ async fn scroll_into_view_if_needed_supports_object_id() { load_bc(&mut ctx, "BID-A"); // Keep the text-row scenario while making its height independent of fonts. - let spacer = "
row
".repeat(60); + let rows = "
row
".repeat(60); let url = format!( - "data:text/html,{spacer}
" + "data:text/html,{rows}
" ); navigate_to_url_and_wait_for_load_async(&mut ctx, 1, url).await; @@ -1624,22 +1624,11 @@ async fn scroll_into_view_if_needed_uses_first_rendered_child_of_display_content "params": { "expression": "window.scrollY" } })) .await; - let scroll_y = take_response_by_id(&mut ctx, 33)["result"]["result"]["value"] - .as_f64() - .unwrap_or_default(); - ctx.process_async(json!({ - "id": 34, - "method": "Runtime.evaluate", - "params": { "expression": "JSON.stringify({scrollY:window.scrollY, viewport:innerHeight, scrollHeight:document.documentElement.scrollHeight, targetRect:document.querySelector('#contents').getBoundingClientRect().toJSON()})" } - })) - .await; - let diagnostics = take_response_by_id(&mut ctx, 34)["result"]["result"]["value"] - .as_str() - .unwrap_or_default() - .to_owned(); assert!( - scroll_y > 0.0, - "the display:contents element should use its rendered descendant geometry: {diagnostics}" + take_response_by_id(&mut ctx, 33)["result"]["result"]["value"] + .as_f64() + .is_some_and(|scroll_y| scroll_y > 0.0), + "the display:contents element should use its rendered descendant geometry" ); } @@ -1647,9 +1636,9 @@ 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 spacer = "
row
".repeat(60); + let rows = "
row
".repeat(60); let url = format!( - "data:text/html,{spacer}
suppressed child
" + "data:text/html,{rows}
suppressed child
" ); navigate_to_url_and_wait_for_load_async(&mut ctx, 60, url).await; @@ -1804,7 +1793,7 @@ async fn scroll_into_view_if_needed_observes_live_style_change_before_scrolling( 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.process_async(json!({ diff --git a/moli-protocol/src/domains/emulation/tests.rs b/moli-protocol/src/domains/emulation/tests.rs index 76ac1936a..d8a83d17c 100644 --- a/moli-protocol/src/domains/emulation/tests.rs +++ b/moli-protocol/src/domains/emulation/tests.rs @@ -573,9 +573,7 @@ async fn admitted_idle_override_is_visible_to_concurrent_same_site_navigation() listener, Router::new().route( "/", - get(|| async { - axum::response::Html("idle navigation") - }), + get(|| async { "idle navigation" }), ), ) .await @@ -646,9 +644,7 @@ async fn idle_override_updates_idle_detector_and_clear_restores_actual_state() { listener, Router::new().route( "/", - get(|| async { - axum::response::Html("idle detector") - }), + get(|| async { "idle detector" }), ), ) .await @@ -2223,7 +2219,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 { - axum::response::Html("ok") + "ok" } async fn xhr_handler( @@ -2715,9 +2711,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(""); - axum::response::Html(format!( + format!( "" - )) + ) } let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -2771,9 +2767,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(""); - axum::response::Html(format!( + format!( "" - )) + ) } let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/moli-protocol/src/domains/input.rs b/moli-protocol/src/domains/input.rs index 9b82692f2..a6014b392 100644 --- a/moli-protocol/src/domains/input.rs +++ b/moli-protocol/src/domains/input.rs @@ -7,8 +7,8 @@ use crate::devtools_runtime::{ DevToolsDispatchDragEventCommand, DevToolsDispatchKeyEventCommand, DevToolsDispatchMouseEventCommand, DevToolsDispatchTouchEventCommand, DevToolsDragData, DevToolsDragDataItem, DevToolsDragEventType, DevToolsError, DevToolsErrorKind, - DevToolsKeyEventType, DevToolsMouseEventType, DevToolsPointerType, - DevToolsSynthesizeTapGestureCommand, DevToolsTouchEventType, DevToolsTouchPoint, + DevToolsMouseEventType, DevToolsPointerType, DevToolsSynthesizeTapGestureCommand, + DevToolsTouchEventType, DevToolsTouchPoint, }; use crate::domains::command_output::CommandOutputPlan; use moli_core::page::{ @@ -571,23 +571,7 @@ fn start_pending_input_command( action: InputAction, ) -> Result, PendingInputCommandStartError> { let owner = CommandOwnerScope::capture(conn, cmd.session_id); - let parsed_key_event = match action { - InputAction::DispatchKeyEvent => Some( - key::parse_dispatch_key_event(cmd) - .map_err(|_| PendingInputCommandStartError::invalid_params())?, - ), - _ => None, - }; - // A key press is a CDP sequence. Its keydown can start a replacement - // navigation before Playwright sends the matching keyup. Keep that - // terminal event on the still-attached outgoing Page; every other key - // event retains the normal cross-document navigation gate. - let targets_outgoing_document = conn.has_pending_document_navigation_for_owner(&owner) - && parsed_key_event - .as_ref() - .is_some_and(|parsed| parsed.event_type == DevToolsKeyEventType::KeyUp); if action.requires_document_access() - && !targets_outgoing_document && let Err(message) = conn.ensure_document_accessible_for_owner(&owner) { return Err(PendingInputCommandStartError { @@ -598,7 +582,8 @@ fn start_pending_input_command( } match action { InputAction::DispatchKeyEvent => { - let parsed = parsed_key_event.expect("dispatchKeyEvent parameters were parsed above"); + let parsed = key::parse_dispatch_key_event(cmd) + .map_err(|_| PendingInputCommandStartError::invalid_params())?; if input_events_ignored_for_owner(conn, &owner) { return Ok(None); } @@ -992,14 +977,11 @@ fn start_devtools_dispatch_key_event_command( owner: &CommandOwnerScope, command: DevToolsDispatchKeyEventCommand, ) -> Result, PendingInputCommandStartError> { - let targets_outgoing_document = command.event_type == DevToolsKeyEventType::KeyUp - && conn.has_pending_document_navigation_for_owner(owner); - start_page_input_command_with_access( + start_page_input_command( conn, command_id, owner, PendingInputCommandKind::DispatchKeyEvent, - targets_outgoing_document, |page| { page.start_dispatch_key_event_with_outcome( key::devtools_key_event_dom_event_name(command.event_type), @@ -1020,17 +1002,6 @@ fn start_page_input_command( command_owner: &CommandOwnerScope, kind: PendingInputCommandKind, start: impl FnOnce(&Page) -> anyhow::Result, -) -> Result, PendingInputCommandStartError> { - start_page_input_command_with_access(conn, command_id, command_owner, kind, false, start) -} - -fn start_page_input_command_with_access( - conn: &mut CdpConnection, - command_id: Option, - command_owner: &CommandOwnerScope, - kind: PendingInputCommandKind, - targets_outgoing_document: bool, - start: impl FnOnce(&Page) -> anyhow::Result, ) -> Result, PendingInputCommandStartError> { let page_owner = conn .target_page_residence_identity_for_owner(command_owner) @@ -1043,13 +1014,10 @@ fn start_page_input_command_with_access( } else { None }; - let page = if targets_outgoing_document { - conn.loaded_page_mut_for_interruptible_protocol_access_for_owner(command_owner) - } else { - conn.loaded_page_mut_for_protocol_access_for_owner(command_owner) - } - .ok() - .ok_or_else(PendingInputCommandStartError::no_document_loaded)?; + let page = conn + .loaded_page_mut_for_protocol_access_for_owner(command_owner) + .ok() + .ok_or_else(PendingInputCommandStartError::no_document_loaded)?; let pending = start(page).map_err(PendingInputCommandStartError::renderer_error)?; Ok(Some(PendingInputCommandDispatch { command_id, diff --git a/moli-protocol/src/domains/input/tests.rs b/moli-protocol/src/domains/input/tests.rs index 86084b17a..a4a4d4348 100644 --- a/moli-protocol/src/domains/input/tests.rs +++ b/moli-protocol/src/domains/input/tests.rs @@ -1577,151 +1577,6 @@ async fn dispatch_key_event_can_complete_through_pending_command_dispatch() { ); } -#[tokio::test(flavor = "multi_thread")] -async fn dispatch_key_press_acknowledges_real_link_navigation() { - tokio::task::LocalSet::new() - .run_until(async { - let mut ctx = TestContext::new(); - ctx.enable_background_navigation_scheduler_for_test(); - with_loaded_document( - &mut ctx, - r#" -
- - - "#, - ) - .await; - ctx.enable_page_events_for_test(None); - - for (id, event_type, text) in [(4103, "keyDown", Some("\r")), (4104, "keyUp", None)] { - let mut command = json!({ - "id": id, - "method": "Input.dispatchKeyEvent", - "params": { - "type": event_type, - "key": "Enter", - "code": "Enter" - } - }); - if let Some(text) = text { - command["params"]["text"] = json!(text); - } - ctx.process_and_wait_for_response_async(command).await; - ctx.expect_result(id, json!({}), None); - } - - wait_until_frame_stopped_loading(&mut ctx, "TID-1").await; - ctx.expect_event("Page.frameNavigated", None); - assert_eq!( - evaluate_string(&mut ctx, "document.body.textContent").await, - "destination" - ); - }) - .await; -} - -#[tokio::test(flavor = "multi_thread")] -async fn dispatch_keyup_reaches_outgoing_document_while_navigation_is_pending() { - let mut ctx = TestContext::new(); - with_loaded_document( - &mut ctx, - r#" - - - "#, - ) - .await; - ctx.conn - .browser_context - .as_mut() - .expect("browser context should exist") - .start_document_navigation_for_active_target("PENDING-KEYUP".to_owned()) - .expect("active navigation should start"); - - ctx.process_async(json!({ - "id": 4105, - "method": "Input.dispatchKeyEvent", - "params": { - "type": "keyUp", - "key": "Enter", - "code": "Enter" - } - })) - .await; - ctx.expect_result(4105, json!({}), None); - - let owner = CommandOwnerScope::capture(&ctx.conn, None); - ctx.conn - .clear_pending_document_navigation_for_owner_if_loader_matches(&owner, "PENDING-KEYUP"); - assert!( - !ctx.conn - .has_pending_document_navigation_for_session_owner(None) - ); - assert_eq!( - evaluate_string(&mut ctx, "String(window.__keyupCount)").await, - "1" - ); -} - -#[tokio::test(flavor = "multi_thread")] -async fn dispatch_keydown_still_rejects_while_navigation_is_pending() { - let mut ctx = TestContext::new(); - with_loaded_document(&mut ctx, "").await; - ctx.conn - .browser_context - .as_mut() - .expect("browser context should exist") - .start_document_navigation_for_active_target("PENDING-KEYDOWN".to_owned()) - .expect("active navigation should start"); - - ctx.process_async(json!({ - "id": 4106, - "method": "Input.dispatchKeyEvent", - "params": { - "type": "rawKeyDown", - "key": "Enter", - "code": "Enter" - } - })) - .await; - ctx.expect_error(4106, -32000, "Navigation is changing the document"); -} - -#[tokio::test(flavor = "multi_thread")] -async fn dispatch_keyup_without_document_reports_no_document_loaded() { - let mut ctx = TestContext::new(); - let mut browser_context = BrowserContext::new("BID-I".into()); - browser_context.set_active_target_id("TID-1"); - ctx.conn - .install_browser_context_fixture_for_test(browser_context); - - ctx.process_async(json!({ - "id": 4107, - "method": "Input.dispatchKeyEvent", - "params": { - "type": "keyUp", - "key": "Enter", - "code": "Enter" - } - })) - .await; - ctx.expect_error(4107, -32000, "NoDocumentLoaded"); -} - #[tokio::test(flavor = "multi_thread")] async fn insert_text_can_complete_through_pending_command_dispatch() { let mut ctx = TestContext::new(); @@ -2274,7 +2129,7 @@ async fn dispatch_key_event_enter_inserts_newline_in_textarea() { field.focus(); field.setSelectionRange(1, 1); window.__textareaEvents = []; - ['keydown', 'keypress', 'beforeinput', 'input'].forEach((type) => { + ['keydown', 'beforeinput', 'input'].forEach((type) => { field.addEventListener(type, () => window.__textareaEvents.push(type)); }); @@ -2294,29 +2149,6 @@ async fn dispatch_key_event_enter_inserts_newline_in_textarea() { .await; ctx.expect_result(16, json!({}), None); - // CDP keyDown without text has no character phase and must not edit. - assert_eq!( - evaluate_string(&mut ctx, "document.getElementById('field').value").await, - "ab" - ); - assert_eq!( - evaluate_string(&mut ctx, "JSON.stringify(window.__textareaEvents)").await, - r#"["keydown"]"# - ); - evaluate_string(&mut ctx, "window.__textareaEvents = []; ''").await; - ctx.process_async(json!({ - "id": 17, - "method": "Input.dispatchKeyEvent", - "params": { - "type": "keyDown", - "key": "Enter", - "code": "Enter", - "text": "\r" - } - })) - .await; - ctx.expect_result(17, json!({}), None); - assert_eq!( evaluate_string(&mut ctx, "document.getElementById('field').value").await, "a\nb" @@ -2330,7 +2162,7 @@ async fn dispatch_key_event_enter_inserts_newline_in_textarea() { ); assert_eq!( evaluate_string(&mut ctx, "JSON.stringify(window.__textareaEvents)").await, - r#"["keydown","keypress","beforeinput","input"]"# + r#"["keydown","beforeinput","input"]"# ); } diff --git a/moli-protocol/src/domains/input/tests/keyboard_events.rs b/moli-protocol/src/domains/input/tests/keyboard_events.rs index 482274bc0..06033c61f 100644 --- a/moli-protocol/src/domains/input/tests/keyboard_events.rs +++ b/moli-protocol/src/domains/input/tests/keyboard_events.rs @@ -133,54 +133,6 @@ async fn cdp_combined_keydown_honors_each_cancellation_boundary() { } } -#[tokio::test(flavor = "multi_thread")] -async fn cdp_canceled_raw_keydown_suppresses_only_its_following_char_phase() { - let mut ctx = keyboard_fixture(CONTROLS[0]).await; - assert!(evaluate_bool(&mut ctx, "(__cancel = 'keydown') === 'keydown'").await); - - dispatch(&mut ctx, "rawKeyDown", "a", "KeyA", "").await; - dispatch(&mut ctx, "char", "a", "KeyA", "a").await; - assert_eq!(field_value(&mut ctx).await, ""); - assert_eq!(events(&mut ctx).await, json!(["keydown:field"])); - - assert!(evaluate_bool(&mut ctx, "(__cancel = '') === ''").await); - dispatch(&mut ctx, "rawKeyDown", "b", "KeyB", "").await; - dispatch(&mut ctx, "char", "b", "KeyB", "b").await; - assert_eq!(field_value(&mut ctx).await, "b"); - assert_eq!( - events(&mut ctx).await, - json!([ - "keydown:field", - "keydown:field", - "keypress:field", - "beforeinput:field", - "input:field" - ]) - ); - - assert!(evaluate_bool(&mut ctx, "(__cancel = 'keydown') === 'keydown'").await); - dispatch(&mut ctx, "rawKeyDown", "c", "KeyC", "").await; - dispatch(&mut ctx, "keyUp", "c", "KeyC", "").await; - assert!(evaluate_bool(&mut ctx, "(__cancel = '') === ''").await); - dispatch(&mut ctx, "char", "c", "KeyC", "c").await; - assert_eq!(field_value(&mut ctx).await, "bc"); - assert_eq!( - events(&mut ctx).await, - json!([ - "keydown:field", - "keydown:field", - "keypress:field", - "beforeinput:field", - "input:field", - "keydown:field", - "keyup:field", - "keypress:field", - "beforeinput:field", - "input:field" - ]) - ); -} - #[tokio::test(flavor = "multi_thread")] async fn cdp_combined_keydown_delivers_trusted_keypresses_for_typed_characters() { let mut ctx = keyboard_fixture(CONTROLS[0]).await; diff --git a/moli-protocol/src/domains/network.rs b/moli-protocol/src/domains/network.rs index 0f2b86242..a29076e2f 100644 --- a/moli-protocol/src/domains/network.rs +++ b/moli-protocol/src/domains/network.rs @@ -44,7 +44,6 @@ mod load_resource; mod main_document_progress; mod output; mod output_queue; -mod redirect_request; mod response_body; pub(crate) mod settings; #[cfg(test)] @@ -505,14 +504,6 @@ 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 { @@ -525,10 +516,6 @@ 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 44043daa4..3ff67a9ed 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, VecDeque}; +use std::collections::{HashMap, HashSet}; use moli_bounded_buffer::{BoundedByteBuffer, ByteLimits, InsertOutcome}; use moli_core::page::{ScriptNetworkOutputItem, SubresourceNetworkRequestHandle}; @@ -15,8 +15,6 @@ 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 { @@ -707,24 +705,6 @@ 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(); } @@ -1356,9 +1336,6 @@ 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 { @@ -1375,136 +1352,9 @@ 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, @@ -1530,7 +1380,6 @@ 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, @@ -1553,7 +1402,6 @@ impl CapturedResponseBodyStore { self.bodies.insert(request_id, rejected_body); } } - self.track_durable_entry(&tracked_request_id); } pub(crate) fn insert_pending_with_collector_scope( @@ -1568,7 +1416,6 @@ impl CapturedResponseBodyStore { { return; } - let tracked_request_id = request_id.clone(); self.bodies.insert( request_id, CapturedResponseBody::pending_with_collector_scope( @@ -1577,7 +1424,6 @@ impl CapturedResponseBodyStore { collection_was_gated, ), ); - self.track_durable_entry(&tracked_request_id); } pub(crate) fn insert_failed_with_collector_scope( @@ -1588,7 +1434,6 @@ 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, @@ -1599,7 +1444,6 @@ impl CapturedResponseBodyStore { collection_was_gated, ), ); - self.track_durable_entry(&tracked_request_id); } pub(crate) fn get(&self, request_id: &str) -> Option<&CapturedResponseBody> { @@ -1619,8 +1463,6 @@ 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)] @@ -1634,13 +1476,23 @@ 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)); - self.buffered_bodies - .retain(|_, body| body.remove_session_visibility(session_id)); - self.prune_removed_entry_indexes(); + 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()); + } + } } #[cfg(test)] @@ -2107,145 +1959,6 @@ 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 c2653ae66..908a8c3d6 100644 --- a/moli-protocol/src/domains/network/backlog.rs +++ b/moli-protocol/src/domains/network/backlog.rs @@ -24,7 +24,6 @@ use super::output_queue::{ TargetSubresourceRequestNetworkDeliveryOutput, TargetSubresourceResponseNetworkDeliveryOutput, TargetWebSocketDeliveryRecord, TargetWebSocketLifecycleDeliveryKind, }; -use super::redirect_request::RedirectRequest; pub(crate) struct NetworkBacklogProjectionContext<'a> { owner: CommandOwnerScope, @@ -262,7 +261,6 @@ fn emit_complete_subresource_network_delivery_record( .then(|| output.request_cookie_report()) .flatten(), &[], - true, ); } } @@ -288,13 +286,7 @@ 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, @@ -305,9 +297,9 @@ fn emit_complete_subresource_network_delivery_record( timestamp, record_document_url, &redirect.to_url, - redirected_request.method, - redirected_request.body, - &redirected_request.headers, + output.method(), + output.request_body(), + output.request_headers(), resource_type, output.request_initiator_type(), Some(( @@ -320,7 +312,6 @@ 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, @@ -462,10 +453,6 @@ 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, ); } } @@ -515,13 +502,7 @@ 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, @@ -532,9 +513,9 @@ fn emit_staged_subresource_response_started( timestamp, request.document_url(), &redirect.to_url, - redirected_request.method, - redirected_request.body, - &redirected_request.headers, + request.method(), + request.request_body(), + request.request_headers(), resource_type, request.request_initiator_type(), Some(( @@ -547,7 +528,6 @@ 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 7b016047e..7fb17c7d3 100644 --- a/moli-protocol/src/domains/network/events.rs +++ b/moli-protocol/src/domains/network/events.rs @@ -279,7 +279,6 @@ 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); @@ -344,7 +343,7 @@ pub(crate) fn emit_request_will_be_sent( }), session_id, ); - if emit_cookie_extra_info && let Some(cookie_access_report) = cookie_access_report { + if let Some(cookie_access_report) = cookie_access_report { emit_request_will_be_sent_extra_info( out, session_id, @@ -1537,7 +1536,6 @@ 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 c97b034ac..f2f14e723 100644 --- a/moli-protocol/src/domains/network/main_document_progress/mod.rs +++ b/moli-protocol/src/domains/network/main_document_progress/mod.rs @@ -350,26 +350,17 @@ 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, - document_policy, + FailedNavigationDocumentPolicy::InvalidateCommittedDocument, 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, @@ -620,7 +611,7 @@ impl MainDocumentBodyProgressSource { response_headers, response_cookie_reports, network_observation_journal, - redirect_chain, + redirect_chain.len(), network_extra_info_available, response_from_cache, negotiated_http_version, @@ -713,7 +704,7 @@ impl MainDocumentBodyProgressSource { }; let final_exchange = navigation_exchange_group( network_observation_journal, - redirect_chain, + redirect_chain.len(), redirect_chain.len(), ) .and_then(|group| group.last()); @@ -730,7 +721,6 @@ impl MainDocumentBodyProgressSource { redirect_chain, network_observation_journal, final_network_extra_info_available, - SubresourceRequestInitiatorType::Other, ); live_source.send_progress_events(MainDocumentProgressPhase::RequestStarted, events); } @@ -769,7 +759,7 @@ impl MainDocumentBodyProgressSource { if network_extra_info_available { let (response_status, response_headers) = observed_response_metadata( network_observation_journal, - redirect_chain, + redirect_chain.len(), redirect_chain.len(), response_status, response_headers, @@ -954,7 +944,6 @@ impl MainDocumentLiveNetworkProgressSource { redirect_chain, network_observation_journal, final_network_extra_info_available, - SubresourceRequestInitiatorType::Other, ); self.send_progress_events(MainDocumentProgressPhase::RequestStarted, events); } @@ -978,7 +967,6 @@ impl MainDocumentLiveNetworkProgressSource { redirect_chain, network_observation_journal, final_network_extra_info_available, - SubresourceRequestInitiatorType::Other, ); self.send_progress_events_into_output( MainDocumentProgressPhase::RequestStarted, @@ -996,15 +984,13 @@ 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 = - 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); + 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); if initial_network_extra_info_available { let initial_request_cookie_report = if redirect_chain.is_empty() { final_request_cookie_report @@ -1017,7 +1003,7 @@ impl MainDocumentLiveNetworkProgressSource { self.progress_target(), observed_request_headers( network_observation_journal, - redirect_chain, + redirect_chain.len(), 0, &self.initial_request_headers, ), @@ -1026,27 +1012,15 @@ 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, index) + navigation_exchange_group(network_observation_journal, redirect_chain.len(), 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, + redirect_chain.len(), index, redirect.status, &redirect.headers, @@ -1063,12 +1037,10 @@ impl MainDocumentLiveNetworkProgressSource { events.push(MainDocumentNavigationProgressEvent::RequestWillBeSent { target: target.clone(), url: redirect.to_url.clone(), - method: request_method.clone(), - request_body: (request_method == final_request_method) - .then(|| request_body.map(str::to_owned)) - .flatten(), - request_headers, - request_initiator_type, + 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, redirect_response: Box::new(Some(MainDocumentRedirectResponse { url: redirect.from_url.clone(), status: redirect.status, @@ -1089,7 +1061,7 @@ impl MainDocumentLiveNetworkProgressSource { if observed_response_available && redirect.network_extra_info_available { let (status, headers) = observed_response_metadata( network_observation_journal, - redirect_chain, + redirect_chain.len(), index, redirect.status, &redirect.headers, @@ -1103,13 +1075,14 @@ impl MainDocumentLiveNetworkProgressSource { }, ); } - 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); + 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); if request_network_extra_info_available { let cookie_report = redirect.request_cookie_report.clone().or_else(|| { (index + 1 == redirect_chain.len()) @@ -1120,7 +1093,7 @@ impl MainDocumentLiveNetworkProgressSource { target, observed_request_headers( network_observation_journal, - redirect_chain, + redirect_chain.len(), index + 1, final_request_headers, ), @@ -1138,15 +1111,15 @@ impl MainDocumentLiveNetworkProgressSource { response_headers: &[(String, String)], response_cookie_reports: &[StoredCookieSetReport], network_observation_journal: &NetworkObservationJournal, - redirect_chain: &[RedirectInfo], + redirect_count: usize, 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_chain, - redirect_chain.len(), + redirect_count, + redirect_count, response_status, response_headers, ); @@ -1381,85 +1354,32 @@ 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: request_method, + method: network.request_method.clone(), request_body: None, - request_headers, + request_headers: network.request_headers.clone(), 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, - extra_info_headers, - network_extra_info_available, - emit_extra_info: network_extra_info_available, + extra_info_status: network.status, + extra_info_headers: network.response_headers.clone(), + network_extra_info_available: false, + emit_extra_info: false, encoded_data_length: 0, from_cache: network.from_cache, negotiated_http_version: None, - has_extra_info: network_extra_info_available, + has_extra_info: false, }); record_child_document_response_body( conn, @@ -1714,12 +1634,7 @@ impl CompletedMainDocumentProgressContext { cookie_access_report: initial_request_cookie_report.clone(), }); } - let initial_network_extra_info_available = navigation_exchange_group( - &events.network_observation_journal, - &events.redirect_chain, - 0, - ) - .is_some() + let initial_network_extra_info_available = !events.network_observation_journal.is_empty() || events .redirect_chain .first() @@ -1730,7 +1645,7 @@ impl CompletedMainDocumentProgressContext { target.clone(), observed_request_headers( &events.network_observation_journal, - &events.redirect_chain, + events.redirect_chain.len(), 0, &self.request_headers, ), @@ -1738,21 +1653,9 @@ 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, + events.redirect_chain.len(), index, ) .and_then(|group| group.last()) @@ -1761,7 +1664,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, + events.redirect_chain.len(), index, redirect.status, &redirect.headers, @@ -1778,11 +1681,9 @@ impl CompletedMainDocumentProgressContext { progress_events.push(MainDocumentNavigationProgressEvent::RequestWillBeSent { target: target.clone(), url: redirect.to_url.clone(), - method: request_method.clone(), - request_body: (request_method == self.request_method) - .then(|| self.request_body.clone()) - .flatten(), - request_headers, + method: events.request_method.clone(), + request_body: self.request_body.clone(), + request_headers: events.request_headers.clone(), request_initiator_type: SubresourceRequestInitiatorType::Other, redirect_response: Box::new(Some(MainDocumentRedirectResponse { url: redirect.from_url.clone(), @@ -1804,7 +1705,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, + events.redirect_chain.len(), index, redirect.status, &redirect.headers, @@ -1818,12 +1719,11 @@ impl CompletedMainDocumentProgressContext { }, ); } - let request_network_extra_info_available = navigation_exchange_group( - &events.network_observation_journal, - &events.redirect_chain, - index + 1, - ) - .is_some() + let request_network_extra_info_available = events + .network_observation_journal + .exchanges() + .get(index + 1) + .is_some() || events .redirect_chain .get(index + 1) @@ -1840,7 +1740,7 @@ impl CompletedMainDocumentProgressContext { target.clone(), observed_request_headers( &events.network_observation_journal, - &events.redirect_chain, + events.redirect_chain.len(), index + 1, &events.request_headers, ), @@ -1862,7 +1762,7 @@ impl CompletedMainDocumentProgressContext { }; let (extra_info_status, extra_info_headers) = observed_response_metadata( &events.network_observation_journal, - &events.redirect_chain, + events.redirect_chain.len(), events.redirect_chain.len(), events.response_status, &events.response_headers, @@ -2007,107 +1907,62 @@ fn request_extra_info_event( } } -fn navigation_exchange_group<'a>( - journal: &'a NetworkObservationJournal, - redirect_chain: &[impl RedirectTransportBoundary], +fn navigation_exchange_group( + journal: &NetworkObservationJournal, + redirect_count: usize, hop_index: usize, -) -> Option<&'a [NetworkExchangeObservation]> { +) -> Option<&[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_chain.len() { + if hop_index > redirect_count { return None; } let exchanges = journal.exchanges(); let mut group_start = 0; - 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; - } + 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().is_empty() + }) + }); + if !ends_redirect_hop || current_hop >= redirect_count { 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]); } - 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) - }) + if current_hop == hop_index { + return Some(&exchanges[group_start..=index]); + } + current_hop += 1; + group_start = index.saturating_add(1); } + (current_hop == hop_index && group_start < exchanges.len()).then_some(&exchanges[group_start..]) } fn observed_request_headers( journal: &NetworkObservationJournal, - redirect_chain: &[impl RedirectTransportBoundary], + redirect_count: usize, hop_index: usize, fallback: &[(String, String)], ) -> Vec<(String, String)> { - navigation_exchange_group(journal, redirect_chain, hop_index) + navigation_exchange_group(journal, redirect_count, 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_chain: &[impl RedirectTransportBoundary], + redirect_count: usize, hop_index: usize, fallback_status: u16, fallback_headers: &[(String, String)], ) -> (u16, Vec<(String, String)>) { - navigation_exchange_group(journal, redirect_chain, hop_index) + navigation_exchange_group(journal, redirect_count, 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 662ec3ef8..0e9922e88 100644 --- a/moli-protocol/src/domains/network/main_document_progress/tests.rs +++ b/moli-protocol/src/domains/network/main_document_progress/tests.rs @@ -4,8 +4,7 @@ use moli_cookie_jar::{ }; use moli_fetch::{ NegotiatedHttpVersion, NetworkExchangeObservation, NetworkObservationJournal, - NetworkRequestExtraInfo, NetworkRequestObservation, NetworkResponseExtraInfo, - NetworkResponseObservation, RedirectInfo, + NetworkRequestObservation, NetworkResponseObservation, RedirectInfo, }; use serde_json::{Value, json}; use tokio::sync::mpsc::unbounded_channel; @@ -19,24 +18,6 @@ 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(), @@ -67,22 +48,6 @@ 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, - cookie_set_reports: Vec::new(), - } -} - fn completed_progress_context() -> CompletedMainDocumentProgressContext { CompletedMainDocumentProgressContext::new( vec![Some("SID-1".to_owned())], @@ -454,36 +419,25 @@ fn live_progress_source_serializes_through_progress_emissions() { from_cache: false, negotiated_http_version: None, }; - 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())], - )), + let journal = observation_journal(vec![ + ( + vec![("Host".to_owned(), "example.test".to_owned())], + 302, + vec![("Location".to_owned(), final_url.to_string())], ), - NetworkExchangeObservation::new( - NetworkRequestObservation::new_with_method( - "GET", - vec![("Host".to_owned(), "example.test".to_owned())], - ), - Some(NetworkResponseObservation::new( - 200, - vec![("Content-Type".to_owned(), "text/html".to_owned())], - )), + ( + vec![("Host".to_owned(), "example.test".to_owned())], + 200, + vec![("Content-Type".to_owned(), "text/html".to_owned())], ), ]); - let redirects = vec![redirect]; source.emit_redirect_requests( - "POST", - Some("account=1"), + "GET", + None, &[("Accept".to_owned(), "text/html".to_owned())], None, - &redirects, + &[redirect], &journal, true, ); @@ -493,7 +447,7 @@ fn live_progress_source_serializes_through_progress_emissions() { &[("Content-Type".to_owned(), "text/html".to_owned())], &[], &journal, - &redirects, + 1, true, false, None, @@ -521,11 +475,6 @@ 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) @@ -597,135 +546,6 @@ 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())], - 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())], - 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(); @@ -953,46 +773,19 @@ fn completed_body_http_redirect_emits_correlated_no_cookie_extra_info() { from_cache: false, negotiated_http_version: None, }]; - 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(), "redirect".to_owned())], - )), - ), - 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(), "final".to_owned())], - )), - ), - ])); - let context = CompletedMainDocumentProgressContext::new( - vec![Some("SID-1".to_owned())], - Some("REQ-1".to_owned()), - false, - Url::parse("http://example.test/start").unwrap(), - "POST".to_owned(), - Some("account=1".to_owned()), - vec![( - "Content-Type".to_owned(), - "application/x-www-form-urlencoded".to_owned(), - )], - "LOADER-1".to_owned(), - "FRAME-1".to_owned(), - 12.5, - ); - let batches = context.event_batches(&events, &final_url, 17); + 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); let mut drain = MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); @@ -1033,11 +826,6 @@ 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")) @@ -1143,14 +931,7 @@ fn critical_client_hint_restart_keeps_discarded_response_extra_info_separate_fro headers: vec![("Location".to_owned(), navigation_url.to_string())], network_extra_info_available: false, request_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()), - ], - )), + response_extra_info: None, redirect_has_extra_info: false, request_cookie_report: None, cookie_set_reports: Vec::new(), @@ -1214,69 +995,6 @@ 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(), "text/html".to_owned())], - )), - )]); - 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())], - network_extra_info_available: false, - request_extra_info: None, - response_extra_info: None, - redirect_has_extra_info: false, - request_cookie_report: None, - cookie_set_reports: Vec::new(), - from_cache: false, - negotiated_http_version: Some(NegotiatedHttpVersion::Http11), - }]; - - let batches = completed_progress_context().event_batches(&events, &final_url, 17); - let mut drain = - MainDocumentProgressDrain::from_source(MainDocumentProgressSource::completed_body(batches)); - drain.mark_output_visible_until(MainDocumentProgressOutputBoundary::BodyFinishedVisible); - let out = drain_into_protocol_messages(&mut drain); - - let redirected_request = out - .iter() - .filter(|message| message["method"] == json!("Network.requestWillBeSent")) - .nth(1) - .expect("upgraded request"); - assert_eq!( - redirected_request["params"]["request"]["headers"]["X-Wire-Upgraded"], - json!("yes"), - "the sole wire exchange belongs to the post-upgrade HTTPS request" - ); - let request_extras = out - .iter() - .filter(|message| message["method"] == json!("Network.requestWillBeSentExtraInfo")) - .collect::>(); - assert_eq!( - request_extras.len(), - 1, - "a transportless internal hop must not receive wire ExtraInfo" - ); - assert_eq!( - request_extras[0]["params"]["headers"]["X-Wire-Upgraded"], - json!("yes") - ); -} - #[test] fn completed_body_uses_negotiated_protocol_for_redirect_and_final_response() { let final_url = Url::parse("https://example.test/final").unwrap(); diff --git a/moli-protocol/src/domains/network/output_queue.rs b/moli-protocol/src/domains/network/output_queue.rs index ccb2ee618..770a585b4 100644 --- a/moli-protocol/src/domains/network/output_queue.rs +++ b/moli-protocol/src/domains/network/output_queue.rs @@ -2976,18 +2976,17 @@ 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(|| request.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(|| { + network_request_headers + .is_some() + .then(StoredCookieQueryReport::default) + }) else { return; }; self.append_subresource_request_extra_info( @@ -3687,60 +3686,6 @@ 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 deleted file mode 100644 index 132cbe72e..000000000 --- a/moli-protocol/src/domains/network/redirect_request.rs +++ /dev/null @@ -1,101 +0,0 @@ -/// 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 9ebfc7cb3..61ab1d127 100644 --- a/moli-protocol/src/domains/network/settings.rs +++ b/moli-protocol/src/domains/network/settings.rs @@ -13,35 +13,6 @@ 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/mod.rs b/moli-protocol/src/domains/network/tests/mod.rs index 0a268cf33..5de2170ab 100644 --- a/moli-protocol/src/domains/network/tests/mod.rs +++ b/moli-protocol/src/domains/network/tests/mod.rs @@ -145,7 +145,6 @@ mod cache; mod cookies; mod load_resource; mod navigation; -mod redirect_wire; mod response_body; mod runtime; mod service_worker; diff --git a/moli-protocol/src/domains/network/tests/redirect_wire.rs b/moli-protocol/src/domains/network/tests/redirect_wire.rs deleted file mode 100644 index cd65d715a..000000000 --- a/moli-protocol/src/domains/network/tests/redirect_wire.rs +++ /dev/null @@ -1,190 +0,0 @@ -use super::*; - -#[derive(Clone, Debug)] -struct WireRequest { - path: String, - method: String, - headers: HeaderMap, - body: String, -} - -fn event_header<'a>(headers: &'a serde_json::Value, name: &str) -> Option<&'a str> { - headers.as_object()?.iter().find_map(|(key, value)| { - key.eq_ignore_ascii_case(name) - .then(|| value.as_str()) - .flatten() - }) -} - -#[tokio::test(flavor = "multi_thread")] -async fn form_post_redirect_chain_cdp_metadata_matches_wire_requests() { - async fn capture( - State(seen): State>>>, - method: axum::http::Method, - uri: axum::http::Uri, - headers: HeaderMap, - body: String, - ) -> axum::response::Response { - let path = uri.path().to_owned(); - seen.lock().push(WireRequest { - path: path.clone(), - method: method.to_string(), - headers, - body, - }); - match path.as_str() { - "/start" => (StatusCode::FOUND, [(LOCATION, "/middle")]).into_response(), - "/middle" => (StatusCode::TEMPORARY_REDIRECT, [(LOCATION, "/final")]).into_response(), - "/final" => axum::response::Html("

finished

").into_response(), - _ => unreachable!("only the three redirect routes use this handler"), - } - } - - let seen = Arc::new(Mutex::new(Vec::::new())); - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let app = Router::new() - .route( - "/page", - get(|| async { - axum::response::Html( - "
", - ) - }), - ) - // Accept any method so an incorrect redirect reaches an assertion, - // rather than being hidden behind the router's 405 response. - .route("/start", axum::routing::any(capture)) - .route("/middle", axum::routing::any(capture)) - .route("/final", axum::routing::any(capture)) - .with_state(seen.clone()); - let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); - let page_url = format!("http://{addr}/page"); - let mut ctx = TestContext::new(); - let mut bc = BrowserContext::new("BID-1".into()); - bc.set_active_target_id("TID-1".to_owned()); - bc.attach_active_session("SID-1".to_owned()); - ctx.conn.install_browser_context_fixture_for_test(bc); - ctx.install_navigation_fixture_for_session_owner(&page_url, Some("SID-1")) - .await; - ctx.sent.clear(); - ctx.process_async(json!({ - "id": 79_300, "method": "Network.enable", "sessionId": "SID-1" - })) - .await; - ctx.expect_result(79_300, json!({}), Some("SID-1")); - ctx.process_async(json!({ - "id": 79_301, "method": "Runtime.evaluate", "sessionId": "SID-1", - "params": {"expression": "document.getElementById('f').submit(); 'scheduled'"} - })) - .await; - let reply = ctx.take_response_by_id(79_301); - assert!(reply.get("error").is_none(), "{reply}"); - assert!(reply["result"].get("exceptionDetails").is_none(), "{reply}"); - - let final_url = format!("http://{addr}/final"); - wait_until_messages( - &mut ctx, - "SID-1", - "completed form redirect chain", - |messages| { - let id = messages.iter().find_map(|message| { - (message["method"] == json!("Network.requestWillBeSent") - && message["params"]["request"]["url"] == json!(final_url)) - .then(|| message["params"]["requestId"].as_str()) - .flatten() - }); - id.is_some_and(|id| { - messages.iter().any(|message| { - message["method"] == json!("Network.loadingFinished") - && message["params"]["requestId"] == json!(id) - }) - }) - }, - ) - .await; - server.abort(); - - let wire = seen.lock().clone(); - assert_eq!( - wire.len(), - 3, - "exactly one request per redirect hop: {wire:?}" - ); - // HTTP expectations are explicit, independent of both the server records - // and the protocol implementation, so two equally wrong views cannot pass. - for (request, (path, method, body)) in wire.iter().zip([ - ("/start", "POST", "account=alice"), - ("/middle", "GET", ""), - ("/final", "GET", ""), - ]) { - assert_eq!(request.path, path); - assert_eq!(request.method, method); - assert_eq!(request.body, body); - assert_eq!(request.headers.get("referer").unwrap(), page_url.as_str()); - } - assert_eq!( - wire[0].headers.get("content-type").unwrap(), - "application/x-www-form-urlencoded" - ); - for request in &wire[1..] { - assert!(!request.headers.contains_key("content-type")); - assert!(!request.headers.contains_key("content-length")); - } - - let requests = ctx - .sent - .iter() - .filter(|message| { - message["method"] == json!("Network.requestWillBeSent") - && wire.iter().any(|request| { - message["params"]["request"]["url"] - == json!(format!("http://{addr}{}", request.path)) - }) - }) - .collect::>(); - assert_eq!(requests.len(), 3); - let request_id = &requests[0]["params"]["requestId"]; - for (index, (event, actual)) in requests.iter().zip(&wire).enumerate() { - assert_eq!(&event["params"]["requestId"], request_id); - assert_eq!(event["params"]["request"]["method"], actual.method); - assert_eq!( - event["params"]["request"]["url"], - format!("http://{addr}{}", actual.path) - ); - // Ordinary request events describe the request before all transport - // headers are finalized. Compare wire headers through ExtraInfo below; - // Chromium can retain Content-Type here after a redirect removed it. - if index == 0 { - assert_eq!(event["params"]["request"]["postData"], "account=alice"); - } else { - assert!(event["params"]["request"].get("postData").is_none()); - assert_eq!( - event["params"]["redirectResponse"]["status"], - [302, 307][index - 1] - ); - } - } - let extra = ctx - .sent - .iter() - .filter(|message| { - message["method"] == json!("Network.requestWillBeSentExtraInfo") - && &message["params"]["requestId"] == request_id - }) - .collect::>(); - assert_eq!(extra.len(), 3, "one wire header observation per hop"); - for (event, actual) in extra.iter().zip(&wire) { - for name in ["referer", "content-type", "content-length"] { - assert_eq!( - event_header(&event["params"]["headers"], name), - actual - .headers - .get(name) - .map(|value| value.to_str().unwrap()), - "{} {name}", - actual.path - ); - } - } -} diff --git a/moli-protocol/src/domains/network/tests/response_body.rs b/moli-protocol/src/domains/network/tests/response_body.rs index cfc7d5c5d..f31eee160 100644 --- a/moli-protocol/src/domains/network/tests/response_body.rs +++ b/moli-protocol/src/domains/network/tests/response_body.rs @@ -15,168 +15,6 @@ use crate::domains::network::{ use super::*; -#[tokio::test(flavor = "multi_thread")] -async fn durable_response_body_survives_navigation_only_for_opted_session() { - let mut ctx = TestContext::new(); - let mut bc = BrowserContext::new("BID-durable".into()); - bc.set_active_target_id("TID-durable".to_owned()); - bc.attach_active_session("SID-durable".to_owned()); - assert!(bc.assign_attached_session_to_target("TID-durable", "SID-ordinary".to_owned())); - ctx.conn.install_browser_context_fixture_for_test(bc); - for (id, session, params) in [ - ( - 79_100, - "SID-durable", - json!({"enableDurableMessages":true,"maxTotalBufferSize":1024,"maxResourceBufferSize":128}), - ), - (79_101, "SID-ordinary", json!({})), - ] { - ctx.process_async( - json!({"id":id,"method":"Network.enable","sessionId":session,"params":params}), - ) - .await; - ctx.expect_result(id, json!({}), Some(session)); - } - let bc = ctx.conn.browser_context.as_mut().unwrap(); - bc.record_captured_response_body( - "REQ-durable".into(), - "retained".into(), - [Some("SID-durable".into()), Some("SID-ordinary".into())], - ); - bc.active_page_target_mut() - .prepare_document_navigation_request_ids( - &mut crate::conn::ConnectionNetworkRequestIdAllocator::default(), - true, - true, - false, - ); - ctx.process_async(json!({"id":79_102,"method":"Network.getResponseBody","sessionId":"SID-durable","params":{"requestId":"REQ-durable"}})).await; - ctx.expect_result( - 79_102, - json!({"body":"retained","base64Encoded":false}), - Some("SID-durable"), - ); - ctx.process_async(json!({"id":79_103,"method":"Network.getResponseBody","sessionId":"SID-ordinary","params":{"requestId":"REQ-durable"}})).await; - ctx.expect_error(79_103, -32000, "No resource with given identifier found"); -} - -#[tokio::test(flavor = "multi_thread")] -async fn durable_response_body_requires_explicit_positive_total_budget() { - let mut ctx = TestContext::new(); - let mut bc = BrowserContext::new("BID-durable".into()); - bc.set_active_target_id("TID-durable".to_owned()); - bc.attach_active_session("SID-durable".to_owned()); - ctx.conn.install_browser_context_fixture_for_test(bc); - for (index, params) in [ - json!({"enableDurableMessages":true}), - json!({"enableDurableMessages":true,"maxTotalBufferSize":0}), - json!({"enableDurableMessages":true,"maxTotalBufferSize":-1}), - json!({"enableDurableMessages":true,"maxTotalBufferSize":100,"maxResourceBufferSize":0}), - json!({"enableDurableMessages":"true","maxTotalBufferSize":100}), - ] - .into_iter() - .enumerate() - { - let id = 79_104 + index as u64; - ctx.process_async( - json!({"id":id,"method":"Network.enable","sessionId":"SID-durable","params":params}), - ) - .await; - ctx.expect_error(id, -32602, "InvalidParams"); - } - assert!( - !ctx.conn - .browser_context - .as_ref() - .unwrap() - .active_page_target() - .runtime_slot - .has_network_event_listeners() - ); -} - -#[tokio::test(flavor = "multi_thread")] -async fn durable_response_body_disable_detach_close_and_other_target_do_not_leak() { - let mut ctx = TestContext::new(); - let mut bc = BrowserContext::new("BID-durable".into()); - bc.set_active_target_id("TID-durable".to_owned()); - bc.attach_active_session("SID-owner".to_owned()); - assert!(bc.assign_attached_session_to_target("TID-durable", "SID-detach".to_owned())); - bc.insert_page_target_host(PageTargetHost::new( - "TID-other".into(), - Some("SID-other".into()), - TargetIdentityState::about_blank(), - TargetPageSlot::empty_for_test_fixture(), - )); - ctx.conn.install_browser_context_fixture_for_test(bc); - let params = - json!({"enableDurableMessages":true,"maxTotalBufferSize":1024,"maxResourceBufferSize":128}); - for (index, session) in ["SID-owner", "SID-detach", "SID-other"] - .into_iter() - .enumerate() - { - let id = 79_200 + index as u64; - ctx.process_async( - json!({"id":id,"method":"Network.enable","sessionId":session,"params":params}), - ) - .await; - ctx.expect_result(id, json!({}), Some(session)); - } - let bc = ctx.conn.browser_context.as_mut().unwrap(); - bc.page_target_mut("TID-durable") - .unwrap() - .runtime_slot - .record_captured_response_body( - "REQ-old".into(), - "retained".into(), - [Some("SID-owner".into()), Some("SID-detach".into())], - ); - bc.page_target_mut("TID-durable") - .unwrap() - .prepare_document_navigation_request_ids( - &mut crate::conn::ConnectionNetworkRequestIdAllocator::default(), - true, - true, - false, - ); - ctx.process_async(json!({"id":79_203,"method":"Network.getResponseBody","sessionId":"SID-other","params":{"requestId":"REQ-old"}})).await; - ctx.expect_error(79_203, -32000, "No resource with given identifier found"); - ctx.process_async(json!({"id":79_204,"method":"Network.disable","sessionId":"SID-owner"})) - .await; - ctx.expect_result(79_204, json!({}), Some("SID-owner")); - ctx.process_async( - json!({"id":79_205,"method":"Network.enable","sessionId":"SID-owner","params":params}), - ) - .await; - ctx.expect_result(79_205, json!({}), Some("SID-owner")); - ctx.process_async(json!({"id":79_206,"method":"Network.getResponseBody","sessionId":"SID-owner","params":{"requestId":"REQ-old"}})).await; - ctx.expect_error(79_206, -32000, "No resource with given identifier found"); - ctx.process_async( - json!({"id":79_207,"method":"Target.detachFromTarget","params":{"sessionId":"SID-detach"}}), - ) - .await; - ctx.expect_result(79_207, json!({}), None); - assert!( - ctx.conn - .browser_context - .as_ref() - .unwrap() - .page_target("TID-durable") - .unwrap() - .runtime_slot - .captured_response_body("REQ-old") - .is_none() - ); - ctx.process_async( - json!({"id":79_208,"method":"Target.closeTarget","params":{"targetId":"TID-durable"}}), - ) - .await; - ctx.expect_result(79_208, json!({"success":true}), None); - let bc = ctx.conn.browser_context.as_ref().unwrap(); - assert!(bc.page_target("TID-durable").is_none()); - assert!(bc.page_target("TID-other").is_some()); -} - fn bidi_network_context(session_id: &str) -> crate::devtools_runtime::DevToolsCommandContext { crate::devtools_runtime::DevToolsCommandContext { protocol: crate::devtools_runtime::DevToolsProtocol::WebDriverBidi, diff --git a/moli-protocol/src/domains/page.rs b/moli-protocol/src/domains/page.rs index 736d08141..c8aaf4912 100644 --- a/moli-protocol/src/domains/page.rs +++ b/moli-protocol/src/domains/page.rs @@ -4121,8 +4121,6 @@ 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(), "text/html".to_owned())], @@ -4261,8 +4259,6 @@ 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(), "text/html".to_owned())], @@ -4371,8 +4367,6 @@ 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(), "text/html".to_owned())], @@ -4414,110 +4408,6 @@ 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())], - )), - ), - 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(), "text/html".to_owned())], - )), - ), - ], - ), - 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())], - 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(), "text/html".to_owned())], - encoded_data_length: 0, - response_body: None, - from_cache: false, - }; - let mut background_events = Vec::new(); - - crate::domains::network::emit_child_document_navigation_network_background_events( - &mut conn, - &mut background_events, - Some("SID-1"), - "CHILD-FRAME-REDIRECT", - "LID-CHILD-REDIRECT", - "LID-CHILD-REDIRECT", - 12.5, - &snapshot, - ); - - let messages = protocol_messages_from_background_events(background_events); - let requests = messages - .iter() - .filter(|message| message["method"] == json!("Network.requestWillBeSent")) - .collect::>(); - assert_eq!(requests.len(), 2, "redirect hop must remain observable"); - assert_eq!(requests[0]["params"]["request"]["method"], json!("POST")); - assert_eq!(requests[1]["params"]["request"]["method"], json!("GET")); - assert_eq!( - requests[1]["params"]["redirectResponse"]["status"], - json!(302) - ); - assert_eq!( - requests[0]["params"]["request"]["headers"]["Referer"], - json!("https://example.test/source") - ); - let request_extra = messages - .iter() - .filter(|message| message["method"] == json!("Network.requestWillBeSentExtraInfo")) - .collect::>(); - assert_eq!(request_extra.len(), 2); - assert!(request_extra.iter().all(|message| { - message["params"]["headers"]["Referer"] == json!("https://example.test/source") - })); - } - #[tokio::test(flavor = "multi_thread")] async fn child_frame_activity_drain_preserves_prepared_attachment_only_token() { let mut conn = CdpConnection::default(); diff --git a/moli-protocol/src/domains/page/termination.rs b/moli-protocol/src/domains/page/termination.rs index a90b721ae..6fb233e3a 100644 --- a/moli-protocol/src/domains/page/termination.rs +++ b/moli-protocol/src/domains/page/termination.rs @@ -395,13 +395,11 @@ 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) { - 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"); - } + 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"); } let ( pending_navigations, diff --git a/moli-protocol/src/domains/page/tests/lifecycle.rs b/moli-protocol/src/domains/page/tests/lifecycle.rs index ea7c311b9..450912689 100644 --- a/moli-protocol/src/domains/page/tests/lifecycle.rs +++ b/moli-protocol/src/domains/page/tests/lifecycle.rs @@ -2391,15 +2391,11 @@ async fn network_navigations_use_unique_document_loader_ids() { axum::Router::new() .route( "/first", - axum::routing::get(|| async { - axum::response::Html("
first
") - }), + axum::routing::get(|| async { "
first
" }), ) .route( "/second", - axum::routing::get(|| async { - axum::response::Html("
second
") - }), + axum::routing::get(|| async { "
second
" }), ), ) .await @@ -2478,15 +2474,11 @@ async fn navigations_without_network_domain_still_use_unique_loader_ids() { axum::Router::new() .route( "/first", - axum::routing::get(|| async { - axum::response::Html("
first
") - }), + axum::routing::get(|| async { "
first
" }), ) .route( "/second", - axum::routing::get(|| async { - axum::response::Html("
second
") - }), + axum::routing::get(|| async { "
second
" }), ), ) .await diff --git a/moli-protocol/src/domains/page/tests/navigation.rs b/moli-protocol/src/domains/page/tests/navigation.rs index df2604123..b6fca0752 100644 --- a/moli-protocol/src/domains/page/tests/navigation.rs +++ b/moli-protocol/src/domains/page/tests/navigation.rs @@ -5464,373 +5464,6 @@ 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 1a1e48497..ab651a209 100644 --- a/moli-protocol/src/domains/page/tests/runtime.rs +++ b/moli-protocol/src/domains/page/tests/runtime.rs @@ -1269,22 +1269,20 @@ 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 { axum::response::Html("initial") }), + axum::routing::get(|| async { "initial" }), ) .route( "/history-a", - axum::routing::get(|| async { axum::response::Html("history-a") }), + axum::routing::get(|| async { "history-a" }), ) .route( "/history-b", - axum::routing::get(|| async { axum::response::Html("history-b") }), + axum::routing::get(|| async { "history-b" }), ), ) .await diff --git a/moli-protocol/src/domains/target/tests/tests_background_staging.rs b/moli-protocol/src/domains/target/tests/tests_background_staging.rs index 49bebc255..e52b86f54 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(""); - axum::response::Html(format!( + format!( "" - )) + ) } let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -3204,9 +3204,7 @@ 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 b4488778a..21a4dffa1 100644 --- a/moli-protocol/src/domains/target/worker_target.rs +++ b/moli-protocol/src/domains/target/worker_target.rs @@ -765,7 +765,6 @@ fn register_dedicated_worker_target( false, None, &[], - true, ); if !events.is_empty() { outputs.push(WorkerTargetLifecycleOutput::DedicatedWorkerEvents { @@ -3370,7 +3369,6 @@ 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 28ca83d04..13ce9879e 100644 --- a/moli-renderer-v8/src/document_runtime/mutation_commands.rs +++ b/moli-renderer-v8/src/document_runtime/mutation_commands.rs @@ -1826,7 +1826,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 9c50732d2..f1703887c 100644 --- a/moli-renderer-v8/src/dom_parser.rs +++ b/moli-renderer-v8/src/dom_parser.rs @@ -1,4 +1,5 @@ use crate::web_api_interfaces; +use html5ever::tree_builder::QuirksMode; use moli_web_mime::{is_dom_parser_xml_mime, is_html_document_mime}; use moli_webapi_declare::WebApiFunctionTemplate; use url::Url; @@ -366,11 +367,7 @@ 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(|mime| { - is_dom_parser_xml_mime(mime) || moli_web_mime::is_text_document_mime(mime) - }) - { + if source == "\u{feff}" && !content_type.is_some_and(is_dom_parser_xml_mime) { std::borrow::Cow::Borrowed("\u{feff}") } else { std::borrow::Cow::Borrowed(source) @@ -384,7 +381,7 @@ fn parse_browsing_context_document_snapshot( html_parser: HtmlParser, ) -> (DomHost, DetachedDocumentKind) { if content_type.is_some_and(is_dom_parser_xml_mime) - || (content_type.is_none() && child_document_url_is_xml_like(&document_url)) + || child_document_url_is_xml_like(&document_url) { let parser = XmlParser; return ( @@ -392,13 +389,13 @@ fn parse_browsing_context_document_snapshot( DetachedDocumentKind::Xml, ); } - if let Some(content_type) = - content_type.filter(|mime| moli_web_mime::is_text_document_mime(mime)) - { - let stream = HtmlParser::with_scripting_enabled(false) - .start_text_document(document_url, content_type); - stream.feed(source); - return (stream.finish_dom_host(), DetachedDocumentKind::Html); + if content_type.is_some_and(|mime| mime.eq_ignore_ascii_case("text/plain")) { + let mut document = + html_parser.parse_dom_host(document_url, plain_text_document_parser_input(source)); + // Text documents are HTML Documents whose mode is explicitly no-quirks, + // despite having no doctype that would select that mode through parsing. + document.set_html_quirks_mode_for_parser(QuirksMode::NoQuirks); + return (document, DetachedDocumentKind::Html); } ( html_parser.parse_dom_host(document_url, source.to_owned()), @@ -406,6 +403,22 @@ fn parse_browsing_context_document_snapshot( ) } +pub(crate) fn plain_text_document_parser_input(source: &str) -> String { + let mut input = String::with_capacity(source.len().saturating_add(64)); + // The HTML parser discards the first LF after
; preserve any source LF.
+    input.push_str("
\n");
+    for character in source.chars() {
+        match character {
+            '&' => input.push_str("&"),
+            '<' => input.push_str("<"),
+            '\0' => input.push('\u{fffd}'),
+            _ => input.push(character),
+        }
+    }
+    input.push_str("
"); + input +} + fn child_document_url_is_xml_like(url: &Url) -> bool { let path = url.path().to_ascii_lowercase(); path.ends_with(".xml") || path.ends_with(".xhtml") || path.ends_with(".svg") @@ -586,50 +599,6 @@ mod tests { assert!(disabled.element_handle_by_id("fallback").is_some()); } - #[test] - fn child_projection_explicit_mime_overrides_xml_extension() { - let source = "\n<&
"; - for mime in [ - "text/plain", - "application/json", - "application/problem+json", - "text/javascript", - ] { - let (document, kind) = parse_browsing_context_document_snapshot( - Url::parse("https://example.test/data.xml").unwrap(), - source, - Some(mime), - HtmlParser::SCRIPTING_ENABLED, - ); - let root = document.document_handle(); - assert_eq!(kind, DetachedDocumentKind::Html); - assert_eq!( - document.text_content(root).as_deref(), - Some(source), - "{mime}" - ); - let children = document.child_handles(root).collect::>(); - assert_eq!(children.len(), 1); - assert!(document.is_html_element_named(children[0], "html")); - assert_eq!( - document.document_quirks_mode_for_handle(root), - Some(selectors::matching::QuirksMode::NoQuirks) - ); - } - let (document, kind) = parse_browsing_context_document_snapshot( - Url::parse("https://example.test/page.svg").unwrap(), - "&", - Some("text/html"), - HtmlParser::SCRIPTING_ENABLED, - ); - assert_eq!(kind, DetachedDocumentKind::Html); - assert!(document.element_handle_by_id("parsed").is_some()); - assert_eq!( - document.text_content(document.document_handle()).as_deref(), - Some("&") - ); - } - #[test] fn child_plain_text_document_uses_pre_and_no_quirks_mode() { let (document, kind) = parse_browsing_context_document_snapshot( diff --git a/moli-renderer-v8/src/frame_owner_model.rs b/moli-renderer-v8/src/frame_owner_model.rs index 3304a765e..f05745081 100644 --- a/moli-renderer-v8/src/frame_owner_model.rs +++ b/moli-renderer-v8/src/frame_owner_model.rs @@ -78,8 +78,7 @@ pub(crate) use lifecycle_tasks::{ MainDocumentImageLoadDelayBinding, MainDocumentInteractiveLifecycleAction, MainDocumentMediaLoadDelayBinding, MainDocumentScriptLoadDelayKind, MainDocumentScriptLoadDelayLease, MainDocumentScriptLoadDelayRelease, - MainDocumentStyleLoadEventBinding, MainDocumentStyleLoadEventSettlement, - StylesheetSubresourceLoadDelayBinding, + MainDocumentStyleLoadEventBinding, 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 55197a233..f040b5374 100644 --- a/moli-renderer-v8/src/frame_owner_model/lifecycle_blockers.rs +++ b/moli-renderer-v8/src/frame_owner_model/lifecycle_blockers.rs @@ -94,11 +94,6 @@ 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 5d122d995..6a624156a 100644 --- a/moli-renderer-v8/src/frame_owner_model/lifecycle_tasks.rs +++ b/moli-renderer-v8/src/frame_owner_model/lifecycle_tasks.rs @@ -81,13 +81,6 @@ impl MainDocumentScriptLoadDelayLease { } } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum MainDocumentStyleLoadEventSettlement { - NotOwned, - Released, - CancelledAfterStop, -} - /// Exact main-document ownership for a connected ``; -document.body.innerHTML = `
`; -'installed' -"#, - )?; - page_vm - .vm_mut() - .prime_document_lifecycle_processing_and_record_stylesheet_network_results(); - - let geometry = page_vm.vm_mut().eval( - r#"JSON.stringify((()=>{const menu=document.getElementById('menu').getBoundingClientRect(),target=document.getElementById('target').getBoundingClientRect();return {menu:[menu.width,menu.height],target:[target.width,target.height]}})())"#, - )?; - assert_eq!( - geometry, - r#"{"menu":[120,35],"target":[120,35]}"#, - "a viewport scrollbar corrective pass must not reuse the text-bearing descendant's size-only cache entry" - ); - Ok::<_, anyhow::Error>(()) - }) - .await - .expect("viewport scrollbar absolute-descendant fixture should run"); -} - -#[tokio::test(flavor = "current_thread")] -async fn geometry_batch_refreshes_dirty_tree_on_demand_and_reuses_clean_tree() { +async fn geometry_batch_reuses_latest_tree_at_same_viewport_until_fresh_paint_replaces_it() { run_page_vm_async_test(async move { let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader"); @@ -339,18 +229,10 @@ document.body.innerHTML = '
< page_vm .vm_mut() .eval("document.getElementById('target').style.width='180px'; 'mutated'")?; - let after_mutation = page_vm.vm().layout_pass_observability_for_test(); - let cache_after_mutation = page_vm - .vm() - .layout_snapshot_cache_observability_for_test(); - assert_eq!(after_mutation.1, after_first.1); - assert_eq!(cache_after_mutation, cache_after_first); - - let second_viewport = moli_layout::LayoutViewport::new(480, 300, 2.0); let second = moli_layout::GeometryProvider::answer( page_vm.vm_mut(), moli_layout::LayoutFlushReason::SynchronousGeometry, - second_viewport, + viewport, &batch, )?; let after_second = page_vm.vm().layout_pass_observability_for_test(); @@ -358,23 +240,24 @@ document.body.innerHTML = '
< .vm() .layout_snapshot_cache_observability_for_test(); assert!(!after_second.0); - assert_eq!(after_second.1, before.1 + 2); - assert_eq!(cache_after_second.0, cache_before.0); - assert_eq!(cache_after_second.1, cache_before.1 + 2); - assert_eq!(cache_after_second.2, cache_before.2 + 2); + assert_eq!(after_second.1, before.1 + 1); + assert_eq!(cache_after_second.0, cache_before.0 + 1); + assert_eq!(cache_after_second.1, cache_before.1 + 1); + assert_eq!(cache_after_second.2, cache_before.2 + 1); let second_width = match &second.answers[1] { moli_layout::LayoutQueryAnswer::BoxModel(Some(model)) => { model.border.bounding_rect().width } answer => panic!("unexpected box-model answer: {answer:?}"), }; - assert!((second_width - 180.0).abs() <= 0.05, "{second_width}"); - assert_eq!(second.metrics.reason, moli_layout::LayoutFlushReason::SynchronousGeometry); - assert_eq!(second.metrics.paint_operation_count, 0); + assert!((second_width - 120.0).abs() <= 0.05, "{second_width}"); + assert_eq!(after_second.2, after_first.2); + assert_eq!(after_second.3, after_first.3); + assert_eq!(second.metrics, first.metrics); assert!(matches!( second.answers[0], moli_layout::LayoutQueryAnswer::DocumentMetrics(metrics) - if metrics.viewport == second_viewport + if metrics.viewport == viewport )); let snapshot = page_vm @@ -386,10 +269,10 @@ document.body.innerHTML = '
< .vm() .layout_snapshot_cache_observability_for_test(); assert!(!after_screenshot.0); - assert_eq!(after_screenshot.1, before.1 + 3); - assert_eq!(cache_after_screenshot.0, cache_before.0); - assert_eq!(cache_after_screenshot.1, cache_before.1 + 2); - assert_eq!(cache_after_screenshot.2, cache_before.2 + 3); + assert_eq!(after_screenshot.1, before.1 + 2); + assert_eq!(cache_after_screenshot.0, cache_before.0 + 1); + assert_eq!(cache_after_screenshot.1, cache_before.1 + 1); + assert_eq!(cache_after_screenshot.2, cache_before.2 + 2); let (_, retention) = cache_after_screenshot .3 .expect("fresh paint layout should publish its frozen tree"); @@ -410,20 +293,6 @@ document.body.innerHTML = '
< && diagnostic.code != "scroll-paint-deferred" })); - page_vm.vm_mut().eval("'clean turn'")?; - assert_eq!( - page_vm.vm().layout_pass_observability_for_test().1, - after_screenshot.1, - "a clean script turn must not eagerly rebuild layout" - ); - assert_eq!( - page_vm - .vm() - .layout_snapshot_cache_observability_for_test(), - cache_after_screenshot, - "turn-exit side-cache cleanup must retain the clean frozen tree" - ); - let third = moli_layout::GeometryProvider::answer( page_vm.vm_mut(), moli_layout::LayoutFlushReason::SynchronousGeometry, @@ -434,10 +303,10 @@ document.body.innerHTML = '
< let cache_after_third = page_vm .vm() .layout_snapshot_cache_observability_for_test(); - assert_eq!(after_third.1, before.1 + 3); - assert_eq!(cache_after_third.0, cache_before.0 + 1); - assert_eq!(cache_after_third.1, cache_before.1 + 2); - assert_eq!(cache_after_third.2, cache_before.2 + 3); + assert_eq!(after_third.1, before.1 + 2); + assert_eq!(cache_after_third.0, cache_before.0 + 2); + assert_eq!(cache_after_third.1, cache_before.1 + 1); + assert_eq!(cache_after_third.2, cache_before.2 + 2); let third_width = match &third.answers[1] { moli_layout::LayoutQueryAnswer::BoxModel(Some(model)) => { model.border.bounding_rect().width @@ -502,83 +371,23 @@ document.body.innerHTML = '
'; page_vm .vm_mut() .eval("document.getElementById('target').style.width='80px'; 'mutated'")?; - assert_eq!( - page_vm.vm().layout_pass_observability_for_test().1, - passes_before + 1, - "the style mutation must not eagerly rebuild layout", - ); - assert_eq!( - page_vm - .vm() - .layout_snapshot_cache_observability_for_test(), - cache_after_first, - "marking the first frame dirty must retain its frozen tree until demand", - ); - - let refreshed_on_demand = moli_layout::GeometryProvider::answer( + let stale = moli_layout::GeometryProvider::answer( page_vm.vm_mut(), moli_layout::LayoutFlushReason::SynchronousGeometry, moli_layout::LayoutViewport::new(320, 200, 1.0), &batch, )?; - let refreshed_on_demand_width = match &refreshed_on_demand.answers[0] { + let stale_width = match &stale.answers[0] { moli_layout::LayoutQueryAnswer::BoxModel(Some(model)) => { model.border.bounding_rect().width } - answer => panic!("unexpected refreshed box-model answer: {answer:?}"), + answer => panic!("unexpected stale box-model answer: {answer:?}"), }; - assert!( - (refreshed_on_demand_width - 80.0).abs() <= 0.05, - "{refreshed_on_demand_width}" - ); + assert!((stale_width - 40.0).abs() <= 0.05, "{stale_width}"); assert_eq!( page_vm.vm().layout_pass_observability_for_test().1, - passes_before + 2 + passes_before + 1 ); - let cache_after_demand = page_vm - .vm() - .layout_snapshot_cache_observability_for_test(); - assert_eq!(cache_after_demand.0, cache_before.0); - assert_eq!(cache_after_demand.1, cache_before.1 + 1); - assert_eq!(cache_after_demand.2, cache_before.2 + 2); - - page_vm.vm_mut().eval("'clean turn'")?; - assert_eq!( - page_vm.vm().layout_pass_observability_for_test().1, - passes_before + 2, - "a clean script turn must not rebuild the demanded layout", - ); - assert_eq!( - page_vm - .vm() - .layout_snapshot_cache_observability_for_test(), - cache_after_demand, - "turn-exit side-cache cleanup must retain the clean frozen tree", - ); - let reused = moli_layout::GeometryProvider::answer( - page_vm.vm_mut(), - moli_layout::LayoutFlushReason::SynchronousGeometry, - moli_layout::LayoutViewport::new(320, 200, 1.0), - &batch, - )?; - let reused_width = match &reused.answers[0] { - moli_layout::LayoutQueryAnswer::BoxModel(Some(model)) => { - model.border.bounding_rect().width - } - answer => panic!("unexpected reused box-model answer: {answer:?}"), - }; - assert!((reused_width - 80.0).abs() <= 0.05, "{reused_width}"); - assert_eq!( - page_vm.vm().layout_pass_observability_for_test().1, - passes_before + 2, - "a repeated exact read across a clean turn must reuse the frozen tree", - ); - let cache_after_reuse = page_vm - .vm() - .layout_snapshot_cache_observability_for_test(); - assert_eq!(cache_after_reuse.0, cache_before.0 + 1); - assert_eq!(cache_after_reuse.1, cache_before.1 + 1); - assert_eq!(cache_after_reuse.2, cache_before.2 + 2); page_vm .vm_mut() @@ -589,14 +398,14 @@ document.body.innerHTML = '
'; .expect("second screencast frame layout"); assert_eq!( page_vm.vm().layout_pass_observability_for_test().1, - passes_before + 3 + passes_before + 2 ); let cache_after_second = page_vm .vm() .layout_snapshot_cache_observability_for_test(); assert_eq!(cache_after_second.0, cache_before.0 + 1); - assert_eq!(cache_after_second.1, cache_before.1 + 1); - assert_eq!(cache_after_second.2, cache_before.2 + 3); + assert_eq!(cache_after_second.1, cache_before.1); + assert_eq!(cache_after_second.2, cache_before.2 + 2); assert!(cache_after_second.3.is_some()); let refreshed = moli_layout::GeometryProvider::answer( @@ -614,14 +423,14 @@ document.body.innerHTML = '
'; assert!((refreshed_width - 80.0).abs() <= 0.05, "{refreshed_width}"); assert_eq!( page_vm.vm().layout_pass_observability_for_test().1, - passes_before + 3 + passes_before + 2 ); let cache_after_query = page_vm .vm() .layout_snapshot_cache_observability_for_test(); assert_eq!(cache_after_query.0, cache_before.0 + 2); - assert_eq!(cache_after_query.1, cache_before.1 + 1); - assert_eq!(cache_after_query.2, cache_before.2 + 3); + assert_eq!(cache_after_query.1, cache_before.1); + assert_eq!(cache_after_query.2, cache_before.2 + 2); Ok::<_, anyhow::Error>(()) }) .await diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/stylesheet_task.rs b/moli-renderer-v8/src/runtime/page_vm/tests/stylesheet_task.rs index 31c86221f..b0441ba7d 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/stylesheet_task.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/stylesheet_task.rs @@ -42,106 +42,6 @@ fn take_next_link_element_event_task_for_test( Some(task) } -#[tokio::test(flavor = "current_thread")] -async fn connected_style_event_queued_before_stop_settles_without_reopening_load() { - run_page_vm_async_test(async move { - let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default())?; - let (mut page_vm, _resource_source, _wake_rx) = - page_vm_with_bound_task_sources_and_owner_wake( - &loader, - Url::parse("https://example.com/style-stop")?, - ); - let owner = page_vm.vm().current_main_document_task_owner(); - page_vm.vm_mut().eval( - r#" -globalThis.__styleStopEvents = []; -window.addEventListener('load', () => __styleStopEvents.push('window-load')); -const style = document.createElement('style'); -style.textContent = 'body { color: maroon; }'; -style.addEventListener('load', () => __styleStopEvents.push('style-load')); -document.head.append(style); -'queued' -"#, - )?; - page_vm - .vm_mut() - .prime_document_lifecycle_processing_and_record_stylesheet_network_results(); - // Hold the exact already-admitted task across the command, just as a - // terminal queued before stop can be selected after the stop turn. - let task = take_next_style_element_event_task_for_test(&mut page_vm) - .expect("connected style task must be ready before stopping"); - assert_ne!( - page_vm.vm_mut().eval("document.readyState")?, - "complete", - "the admitted style task must own a pre-complete load-delay lease" - ); - page_vm.dispatch_renderer_page_command(RendererPageCommand::StopDocumentLifecycle)?; - let body = page_vm.apply_selected_page_connected_style_event_turn(task)?; - assert!(matches!( - body.action.target_effect, - PageConnectedStyleEventTargetEffect::DispatchedToCurrentOwner { .. } - )); - assert_eq!(page_vm.vm().current_main_document_task_owner(), owner); - assert_eq!( - page_vm - .vm_mut() - .eval("document.readyState + '|' + __styleStopEvents.join(',')")?, - "complete|style-load" - ); - assert!(page_vm.document_lifecycle.current_snapshot().load.is_none()); - Ok::<_, anyhow::Error>(()) - }) - .await - .expect("stopping must not invalidate a live style task's exact settlement"); -} - -#[tokio::test(flavor = "current_thread")] -async fn stopped_document_style_event_cannot_settle_replacement_document() { - run_page_vm_async_test(async move { - let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default())?; - let (mut page_vm, _resource_source, _wake_rx) = - page_vm_with_bound_task_sources_and_owner_wake( - &loader, - Url::parse("https://example.com/style-stop-replace")?, - ); - let owner = page_vm.vm().current_main_document_task_owner(); - page_vm.vm_mut().eval( - r#" -globalThis.__retiredStyleEvent = false; -const style = document.createElement('style'); -style.textContent = 'body { color: maroon; }'; -style.onload = () => { __retiredStyleEvent = true; }; -document.head.append(style); -'queued' -"#, - )?; - page_vm - .vm_mut() - .prime_document_lifecycle_processing_and_record_stylesheet_network_results(); - let task = take_next_style_element_event_task_for_test(&mut page_vm) - .expect("style event must be admitted before stop"); - page_vm.dispatch_renderer_page_command(RendererPageCommand::StopDocumentLifecycle)?; - page_vm - .vm_mut() - .eval("document.open(); document.write('
replacement
'); 'replaced'")?; - assert_ne!(page_vm.vm().current_main_document_task_owner(), owner); - let body = page_vm.apply_selected_page_connected_style_event_turn(task)?; - assert_eq!( - body.action.target_effect, - PageConnectedStyleEventTargetEffect::DiscardedStaleOwner - ); - assert_eq!( - page_vm - .vm_mut() - .eval("document.readyState + '|' + __retiredStyleEvent")?, - "loading|false" - ); - Ok::<_, anyhow::Error>(()) - }) - .await - .expect("stopped style event must not affect a replacement Document"); -} - async fn wait_for_stylesheet_source( wake_rx: &mut tokio::sync::mpsc::UnboundedReceiver, expected: RendererOwnerWakeSource, diff --git a/moli-renderer-v8/src/runtime/phase_one/bootstrap.rs b/moli-renderer-v8/src/runtime/phase_one/bootstrap.rs index 454433e6c..5af62b821 100644 --- a/moli-renderer-v8/src/runtime/phase_one/bootstrap.rs +++ b/moli-renderer-v8/src/runtime/phase_one/bootstrap.rs @@ -25,7 +25,6 @@ impl ConcurrentParseTimeRuntime { let (page_vm, triggered_navigation) = { let buffered_document_preloads = &mut state.buffered_document_preloads; let service_worker_preload_context = state.service_worker_preload_context.as_ref(); - let document_character_set = &state.document_character_set; PageVm::new_from_parser_stream_and_run_document_start( page_id, local_executor, @@ -35,7 +34,6 @@ impl ConcurrentParseTimeRuntime { &mut state.parser_session, started, |page_vm| { - page_vm.set_document_character_set(document_character_set.clone()); admit_pending_preloads( page_vm, buffered_document_preloads, diff --git a/moli-renderer-v8/src/runtime/phase_one/pending_residence.rs b/moli-renderer-v8/src/runtime/phase_one/pending_residence.rs index b2f3787d3..d72eb80bb 100644 --- a/moli-renderer-v8/src/runtime/phase_one/pending_residence.rs +++ b/moli-renderer-v8/src/runtime/phase_one/pending_residence.rs @@ -69,14 +69,6 @@ pub(in crate::runtime) enum PendingPhaseOneResidence { } impl PendingPhaseOneResidence { - pub(in crate::runtime) fn into_stopped_page_vm(self) -> PageVm { - match self { - Self::ParserBlockingSourceLoad { runtime, .. } - | Self::ClosedInputPageWork { runtime, .. } => runtime.into_stopped_page_vm(), - Self::OpenStreaming(continuation) => continuation.into_stopped_page_vm(), - } - } - pub(in crate::runtime) fn parser_blocking_source_load( runtime: Box, started: Instant, diff --git a/moli-renderer-v8/src/runtime/phase_one/state.rs b/moli-renderer-v8/src/runtime/phase_one/state.rs index 8866cb1aa..fa8ecbc35 100644 --- a/moli-renderer-v8/src/runtime/phase_one/state.rs +++ b/moli-renderer-v8/src/runtime/phase_one/state.rs @@ -11,7 +11,6 @@ pub(super) struct ParseTimeDriverState { pub(super) buffered_document_preloads: Box, pub(super) service_worker_preload_context: Option, pub(super) input_closed: bool, - pub(super) is_text_document: bool, } impl ParseTimeDriverState { @@ -33,24 +32,6 @@ impl ParseTimeDriverState { buffered_document_preloads: Box::default(), service_worker_preload_context: None, input_closed: false, - is_text_document: false, - } - } - - pub(super) fn new_text(final_url: Url, mime: &str) -> Self { - Self { - parser_session: DocumentParserSession::start_main_text_document( - final_url.clone(), - mime, - ), - final_url, - document_character_set: "UTF-8".to_owned(), - scheduler: DocumentScriptScheduler::new(), - pending_parsing_blocking_script: PendingParsingBlockingClassicScriptRunner::empty(), - buffered_document_preloads: Box::default(), - service_worker_preload_context: None, - input_closed: false, - is_text_document: true, } } @@ -64,7 +45,6 @@ impl ParseTimeDriverState { buffered_document_preloads: Box::default(), service_worker_preload_context: None, input_closed: false, - is_text_document: false, } } @@ -219,12 +199,6 @@ impl ConcurrentParseTimeRuntime { self.page_vm } - pub(super) fn into_stopped_page_vm(mut self) -> PageVm { - self.state.parser_session.stop(ParserStopReason::Stopped); - drop(self.retire_main_parser_continuation()); - self.page_vm - } - pub(super) fn new_parser_owner( loader: ResourceRequestClient, stage: PageVmInitStage, diff --git a/moli-renderer-v8/src/runtime/phase_one/streaming.rs b/moli-renderer-v8/src/runtime/phase_one/streaming.rs index 28159400d..d3862df63 100644 --- a/moli-renderer-v8/src/runtime/phase_one/streaming.rs +++ b/moli-renderer-v8/src/runtime/phase_one/streaming.rs @@ -94,8 +94,10 @@ impl ConcurrentParseTimeRuntime { env.apply_navigation_response_headers(&response_final_url, &response_headers); let mut body_source = RawDocumentBodySource::fetch_response(response); - let (mut state, mut decoder) = - response_document_parser(response_final_url, &response_headers, &env); + let mut state = ParseTimeDriverState::new_with_scripting_enabled( + response_final_url, + main_document_parser_scripting_enabled(&env), + ); state .buffered_document_preloads .set_script_fetch_requires_owner_admission(script_preloads_require_owner_admission( @@ -122,6 +124,11 @@ impl ConcurrentParseTimeRuntime { ) }); state.service_worker_preload_context = service_worker_preload_context.clone(); + let mut decoder = HtmlDocumentStreamingDecoder::new_with_legacy_encoding_detector( + &response_headers, + state.final_url.as_str(), + detect_legacy_html_encoding, + ); // Raw navigation bodies are decoded during prebootstrap scan. The decoder // is carried forward so split multibyte sequences are not decoded twice or // lost between the scan and parser handoff. @@ -325,7 +332,10 @@ impl ConcurrentParseTimeRuntime { }, ))); } - let (mut state, mut decoder) = response_document_parser(final_url, &response_headers, &env); + let mut state = ParseTimeDriverState::new_with_scripting_enabled( + final_url, + main_document_parser_scripting_enabled(&env), + ); state .buffered_document_preloads .set_script_fetch_requires_owner_admission(script_preloads_require_owner_admission( @@ -352,6 +362,11 @@ impl ConcurrentParseTimeRuntime { ) }); state.service_worker_preload_context = service_worker_preload_context.clone(); + let mut decoder = HtmlDocumentStreamingDecoder::new_with_legacy_encoding_detector( + &response_headers, + state.final_url.as_str(), + detect_legacy_html_encoding, + ); // External raw bodies replay captured chunks. Pre-scan only what is already // buffered before bootstrap so the producer's backpressure boundary still // controls how far ahead the parser can get. @@ -436,10 +451,6 @@ impl ConcurrentParseTimeRuntime { chunk: String, service_worker_context: Option<&ServiceWorkerScriptPreloadContext>, ) { - if self.state.is_text_document { - self.state.parser_session.queue_arrived_chunk(chunk); - return; - } self.state .buffered_document_preloads .append_to_main_document_scan_with_service_worker_context( @@ -480,7 +491,6 @@ pub struct ExternalRawDocumentBodyStream { body_chunks: mpsc::Receiver>, completion: Option>>, page_creation_progress: Option, - stop_loading_cancellation: Option, } impl ExternalRawDocumentBodyStream { @@ -507,7 +517,6 @@ impl ExternalRawDocumentBodyStream { body_chunks, completion: Some(completion), page_creation_progress: Some(page_creation_progress), - stop_loading_cancellation: None, }, ) } @@ -520,21 +529,9 @@ impl ExternalRawDocumentBodyStream { body_chunks, completion: Some(completion), page_creation_progress: None, - stop_loading_cancellation: None, } } - /// Shares this exact transfer's cancellation authority with an explicit - /// document stop. Ordinary parser retirement does not cancel the external - /// producer, which may still own browser-side response capture. - pub fn with_stop_loading_cancellation( - mut self, - cancellation: moli_fetch::FetchCancelHandle, - ) -> Self { - self.stop_loading_cancellation = Some(cancellation); - self - } - pub fn from_bytes(body: Vec) -> Self { let (completion_tx, completion_rx) = oneshot::channel(); let (body_tx, body_stream) = Self::channel(completion_rx); @@ -559,13 +556,6 @@ pub(super) enum RawDocumentBodySource { } impl RawDocumentBodySource { - pub(super) fn stop_loading_cancellation(&self) -> Option { - match self { - Self::FetchResponse(response) => Some(response.cancellation_handle()), - Self::External(source) => source.stop_loading_cancellation.clone(), - } - } - fn fetch_response(response: Box) -> Self { Self::FetchResponse(response) } @@ -617,9 +607,6 @@ fn scan_prebootstrap_html_chunk_into_state( chunk: &str, service_worker_context: Option<&ServiceWorkerScriptPreloadContext>, ) { - if state.is_text_document { - return; - } state .buffered_document_preloads .append_to_main_document_prebootstrap_scan_with_service_worker_context( @@ -630,41 +617,6 @@ fn scan_prebootstrap_html_chunk_into_state( ); } -fn response_document_parser( - final_url: Url, - headers: &[(String, String)], - env: &PageVmEnvConfig, -) -> (ParseTimeDriverState, HtmlDocumentStreamingDecoder) { - let content_type = moli_web_mime::response_document_content_type(headers); - let text_type = content_type.filter(|mime| moli_web_mime::is_text_document_mime(mime)); - let mut state = if let Some(mime) = &text_type { - ParseTimeDriverState::new_text(final_url, mime) - } else { - ParseTimeDriverState::new_with_scripting_enabled( - final_url, - main_document_parser_scripting_enabled(env), - ) - }; - let decoder = if let Some(mime) = text_type { - HtmlDocumentStreamingDecoder::new_text_document( - headers, - state.final_url.as_str(), - detect_legacy_html_encoding, - moli_web_mime::is_json_module_mime(&mime) || mime == "text/json", - ) - } else { - HtmlDocumentStreamingDecoder::new_with_legacy_encoding_detector( - headers, - state.final_url.as_str(), - detect_legacy_html_encoding, - ) - }; - // Headers/defaults are observable even when no body chunk is ready yet. - // A later BOM or decoder decision can still replace this tentative value. - sync_state_document_character_set_from_decoder(&mut state, &decoder); - (state, decoder) -} - pub(super) fn enqueue_streaming_raw_chunk( runtime: &mut ConcurrentParseTimeRuntime, decoder: &mut HtmlDocumentStreamingDecoder, @@ -956,105 +908,6 @@ mod tests { use crate::parser::ScriptSource; use tokio::io::{AsyncReadExt, AsyncWriteExt}; - #[tokio::test] - async fn text_document_charset_is_visible_to_document_start_before_body_arrives() { - let _js_runtime_owner = crate::JsRuntime::initialize(); - tokio::task::LocalSet::new().run_until(async { - for (mime, chunk, expected) in [ - ("text/plain; charset=gbk", None, "GBK"), - ("application/json", None, "UTF-8"), - ("text/plain; charset=gbk", Some(b"\xef\xbb\xbfready".as_slice()), "UTF-8"), - ] { - let loader_owner = ResourceRequestClient::new(&FetchConfig::default()).unwrap(); - let loader = loader_owner.handle(); - let mut env = default_test_page_vm_env_config(); - env.document_start_scripts.push(crate::DocumentStartScript { - registry_key: None, - devtools_session: None, - source: "document.title = document.characterSet".to_owned(), - world_name: None, - has_bidi_channel_argument: false, - bidi_channel_handoffs: Vec::new(), - }); - let (mut state, mut decoder) = response_document_parser( - Url::parse("https://example.test/data").unwrap(), - &[("Content-Type".to_owned(), mime.to_owned())], &env, - ); - if let Some(chunk) = chunk { - let chunks = decoder.push(chunk); - sync_state_document_character_set_from_decoder(&mut state, &decoder); - for chunk in chunks { - state.parser_session.queue_arrived_chunk(chunk); - } - } - assert_eq!(state.document_character_set, expected); - let (_, page_vm, navigated) = ConcurrentParseTimeRuntime::bootstrap_page_vm_from_state_on_fresh_local_task( - PageId::new_for_testing(1), JsLocalExecutor::new(), loader, env, - PageVmRuntimeHooks::standalone_without_owner_reservation_for_test(), state, - Instant::now(), "charset test bootstrap closed", - ).await.unwrap(); - assert!(!navigated); - assert_eq!(page_vm.vm().snapshot_live_document().document_title(), expected, "{mime}"); - } - }).await; - } - - #[test] - fn response_document_dispatch_distinguishes_text_from_html_and_xml() { - for (mime, text) in [ - ("application/json", true), - ("application/problem+json", true), - ("text/plain; charset=gbk", true), - ("text/javascript", true), - ("text/html", false), - ("application/xhtml+xml", false), - ("application/xml", false), - ("text/xml", false), - ] { - let (state, _) = response_document_parser( - Url::parse("https://example.test/data").unwrap(), - &[("Content-Type".to_owned(), mime.to_owned())], - &default_test_page_vm_env_config(), - ); - assert_eq!(state.is_text_document, text, "{mime}"); - } - } - - #[tokio::test] - async fn text_document_does_not_preload_literal_script_tags() { - let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()) - .expect("default loader"); - let runtime_hooks = PageVmRuntimeHooks::standalone_without_owner_reservation_for_test(); - for (mime, expected_preload) in [ - ("text/plain", false), - ("application/json", false), - ("text/html", true), - ] { - let (mut state, _) = response_document_parser( - Url::parse("https://example.test/data").unwrap(), - &[("Content-Type".to_owned(), mime.to_owned())], - &default_test_page_vm_env_config(), - ); - state.buffered_document_preloads.bind_resource_runtime( - runtime_hooks.owner_wake(), - runtime_hooks.resource_task_runner(), - ); - scan_prebootstrap_html_chunk_into_state( - &mut state, - &loader, - "", - None, - ); - assert_eq!( - state.buffered_document_preloads.entries.contains_key( - &classic_preload_key_for_streaming_test("https://example.test/literal.js") - ), - expected_preload, - "{mime}", - ); - } - } - #[tokio::test] async fn external_raw_document_body_source_collects_chunks_and_completion() { let (completion_tx, completion_rx) = oneshot::channel(); diff --git a/moli-renderer-v8/src/runtime/phase_one/streaming_input.rs b/moli-renderer-v8/src/runtime/phase_one/streaming_input.rs index 97cbd2c43..839b26dbe 100644 --- a/moli-renderer-v8/src/runtime/phase_one/streaming_input.rs +++ b/moli-renderer-v8/src/runtime/phase_one/streaming_input.rs @@ -41,7 +41,6 @@ impl StreamingDocumentInputSender { /// parked in the owner-local Page slot. pub(super) struct StreamingDocumentInputSource { rx: mpsc::Receiver, - stop_loading_cancellation: Option, } impl StreamingDocumentInputSource { @@ -50,7 +49,6 @@ impl StreamingDocumentInputSource { parser_continuation: RendererPageMainParserContinuationProducer, task_runner: crate::network::RendererResourceTaskRunner, ) -> Self { - let stop_loading_cancellation = raw_body.stop_loading_cancellation(); let (tx, rx) = mpsc::channel(STREAMING_DOCUMENT_INPUT_BUFFERED_EVENTS); let sender = StreamingDocumentInputSender { tx, @@ -84,20 +82,7 @@ impl StreamingDocumentInputSource { .send(StreamingDocumentInputEvent::Finished(terminal)) .await; }); - Self { - rx, - stop_loading_cancellation, - } - } - - pub(super) fn stop_loading(self) { - if let Some(cancellation) = self.stop_loading_cancellation.as_ref() - && !cancellation.response_completion_is_committed() - { - cancellation.cancel(); - } - // Dropping the receiver retires queued bytes and wakes a bridge parked - // on input. This is separate from the transport's terminal result. + Self { rx } } pub(super) fn has_ready_input(&mut self) -> bool { @@ -290,45 +275,6 @@ mod tests { )); } - #[tokio::test] - async fn external_body_transfer_is_cancelled_only_by_explicit_stop() { - for explicit_stop in [false, true] { - let cancellation = FetchCancelHandle::new(); - let (completion_tx, completion_rx) = tokio::sync::oneshot::channel(); - let (body_tx, raw_body) = ExternalRawDocumentBodyStream::channel(completion_rx); - let raw_body = raw_body.with_stop_loading_cancellation(cancellation.clone()); - let (_networking, continuation, mut wake_rx) = continuation_fixture(95); - let mut source = StreamingDocumentInputSource::bridge( - RawDocumentBodySource::External(raw_body), - continuation, - crate::network::RendererResourceTaskRunner::from_current_tokio().unwrap(), - ); - body_tx - .send(b"queued unparsed tail".to_vec()) - .await - .unwrap(); - tokio::time::timeout(std::time::Duration::from_secs(1), wake_rx.recv()) - .await - .expect("queued body must wake its parser owner") - .expect("parser wake channel must stay open"); - assert!(source.has_ready_input()); - if explicit_stop { - source.stop_loading(); - } else { - drop(source); - } - tokio::time::timeout(std::time::Duration::from_secs(1), body_tx.closed()) - .await - .expect("retired parser must release external input and queued tail"); - assert_eq!( - cancellation.is_cancelled(), - explicit_stop, - "ordinary external parser retirement must leave browser response capture running" - ); - assert!(completion_tx.send(Ok(())).is_err()); - } - } - #[tokio::test] async fn dropping_input_source_cancels_fetch_waiting_for_another_body_chunk() { let (body_tx, completion_tx, cancel_handle, raw_body) = pending_fetch_body(); diff --git a/moli-renderer-v8/src/runtime/phase_one/streaming_residence.rs b/moli-renderer-v8/src/runtime/phase_one/streaming_residence.rs index ebf5ecfdd..9404fde33 100644 --- a/moli-renderer-v8/src/runtime/phase_one/streaming_residence.rs +++ b/moli-renderer-v8/src/runtime/phase_one/streaming_residence.rs @@ -28,12 +28,6 @@ pub(in crate::runtime) struct PendingStreamingPhaseOneContinuation { } impl PendingStreamingPhaseOneContinuation { - pub(in crate::runtime) fn into_stopped_page_vm(self) -> PageVm { - let Self { runtime, input, .. } = self; - input.stop_loading(); - runtime.into_stopped_page_vm() - } - pub(super) fn publish_pending_page_creation_phase(&self) { self.runtime.publish_pending_page_creation_phase(); } diff --git a/moli-renderer-v8/src/runtime/protocol_output/transport_memory.rs b/moli-renderer-v8/src/runtime/protocol_output/transport_memory.rs index f4ee47902..73c4dfc5f 100644 --- a/moli-renderer-v8/src/runtime/protocol_output/transport_memory.rs +++ b/moli-renderer-v8/src/runtime/protocol_output/transport_memory.rs @@ -254,45 +254,6 @@ fn child_frame_document_network_charge( .sum::() .saturating_add(headers_charge(&snapshot.request_headers)) .saturating_add(headers_charge(&snapshot.response_headers)) - .saturating_add(network_observation_journal_charge( - &snapshot.network_observation_journal, - )) - .saturating_add(snapshot.redirect_chain.iter().fold(0, |total, redirect| { - total - .saturating_add(512) - .saturating_add(string_charge(redirect.from_url.as_str())) - .saturating_add(string_charge(redirect.to_url.as_str())) - .saturating_add(headers_charge(&redirect.headers)) - .saturating_add( - redirect - .request_extra_info - .as_ref() - .map(|extra| { - headers_charge(&extra.headers) - .saturating_add(cookie_query_report_charge(&extra.cookie_report)) - }) - .unwrap_or_default(), - ) - .saturating_add( - redirect - .response_extra_info - .as_ref() - .map(|extra| { - headers_charge(&extra.headers).saturating_add(cookie_query_report_charge( - &extra.request_extra_info.cookie_report, - )) - }) - .unwrap_or_default(), - ) - .saturating_add( - redirect - .request_cookie_report - .as_ref() - .map(cookie_query_report_charge) - .unwrap_or_default(), - ) - .saturating_add(redirect.cookie_set_reports.len().saturating_mul(128)) - })) .saturating_add( snapshot .response_body @@ -302,66 +263,6 @@ fn child_frame_document_network_charge( ) } -fn network_observation_journal_charge(journal: &moli_fetch::NetworkObservationJournal) -> usize { - journal.exchanges().iter().fold(0, |total, exchange| { - let request = exchange.request(); - total - .saturating_add(512) - .saturating_add(request.method().map(string_charge).unwrap_or_default()) - .saturating_add(headers_charge(request.headers())) - .saturating_add( - request - .cookie_report() - .map(cookie_query_report_charge) - .unwrap_or_default(), - ) - .saturating_add( - exchange - .response() - .map(|response| headers_charge(response.headers())) - .unwrap_or_default(), - ) - }) -} - -fn cookie_query_report_charge(report: &moli_cookie_jar::StoredCookieQueryReport) -> usize { - report - .included_cookies - .iter() - .chain(&report.excluded_cookies) - .fold(0, |total, access| { - let cookie = &access.cookie; - total - .saturating_add(512) - .saturating_add(string_charge(&cookie.name)) - .saturating_add(string_charge(&cookie.value)) - .saturating_add(string_charge(&cookie.domain)) - .saturating_add(string_charge(&cookie.path)) - .saturating_add( - cookie - .partition_key - .as_ref() - .and_then(|key| key.top_level_site()) - .map(string_charge) - .unwrap_or_default(), - ) - .saturating_add( - access - .site_for_cookies_url - .as_ref() - .map(|url| string_charge(url.as_str())) - .unwrap_or_default(), - ) - .saturating_add( - access - .top_frame_origin_url - .as_ref() - .map(|url| string_charge(url.as_str())) - .unwrap_or_default(), - ) - }) -} - fn runtime_inspector_message_transport_charge_bytes( message: &crate::runtime::RendererRuntimeInspectorMessage, ) -> usize { @@ -586,37 +487,3 @@ fn navigation_response_transport_charge_bytes( .unwrap_or(0), ) } - -#[cfg(test)] -mod tests { - use super::network_observation_journal_charge; - - #[test] - fn network_observation_journal_charge_includes_methods_and_headers() { - let journal = moli_fetch::NetworkObservationJournal::from_exchanges(vec![ - moli_fetch::NetworkExchangeObservation::new( - moli_fetch::NetworkRequestObservation::new_with_method( - "POST", - vec![("X-Request".to_owned(), "request-value".to_owned())], - ), - Some(moli_fetch::NetworkResponseObservation::new( - 200, - vec![("X-Response".to_owned(), "response-value".to_owned())], - )), - ), - ]); - - let empty_exchange = moli_fetch::NetworkObservationJournal::from_exchanges(vec![ - moli_fetch::NetworkExchangeObservation::new( - moli_fetch::NetworkRequestObservation::new(Vec::new()), - None, - ), - ]); - - assert!( - network_observation_journal_charge(&journal) - > network_observation_journal_charge(&empty_exchange), - "transport accounting must charge retained method and header strings" - ); - } -} diff --git a/moli-renderer-v8/src/runtime/tests.rs b/moli-renderer-v8/src/runtime/tests.rs index 18bab24d5..7f08b8783 100644 --- a/moli-renderer-v8/src/runtime/tests.rs +++ b/moli-renderer-v8/src/runtime/tests.rs @@ -1127,284 +1127,6 @@ async fn capture_screenshot_for_renderer_page( } } -#[tokio::test(flavor = "current_thread")] -async fn mouse_input_completion_publishes_handler_navigation_once() { - let runtime = initialize_layout_test_runtime(); - let (output_tx, mut output_rx) = renderer_external_activity_test_channel(); - runtime.set_renderer_output_transport_sender(output_tx); - let loader = - ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("default loader"); - for (listener, event_name, microtask, navigates, cancel) in [ - ("mousedown", "mousedown", false, true, false), - ("pointerdown", "mousedown", false, true, false), - ("mousemove", "mousemove", false, true, false), - ("pointermove", "mousemove", false, true, false), - ("mousedown", "mousedown", true, true, false), - ("pointermove", "mousemove", true, true, false), - ("mousedown", "mousedown", false, true, true), - ("pointerdown", "mousedown", true, true, true), - ("mousedown", "mousedown", false, false, false), - ("mousemove", "mousemove", true, false, true), - ] { - let body = if navigates { - "location.href = 'https://example.test/next.html';" - } else { - "window.handlerRan = true;" - }; - let handler = if microtask { - format!("queueMicrotask(() => {{ {body} }});") - } else { - body.to_owned() - }; - let html = format!( - "" - ); - let mut page = create_test_html_page_with_navigation_dispatch( - &runtime, - &loader, - url::Url::parse("https://example.test/source.html").unwrap(), - &html, - RendererTopLevelNavigationDispatch::DelegateToBrowser, - ) - .await; - let (snapshot, _) = page - .run_async_command(RendererPageCommand::PageDiagnosticsSnapshot) - .await - .expect("source snapshot"); - let RendererPageReply::PageDiagnosticsSnapshot(snapshot) = snapshot else { - panic!("expected diagnostics snapshot"); - }; - let source_document = snapshot - .document_lifecycle_identity() - .expect("source identity"); - let (preflight, _) = page.run_async_command(RendererPageCommand::EvaluateExpression { - expression: "JSON.stringify([document.elementFromPoint(30,30).id,window.handlerInstalled,window.handlerRuns])".to_owned(), - await_promise: false, - }).await.expect("input fixture preflight"); - assert_eq!( - mouse_input_json_witness(&preflight), - serde_json::json!(["target", true, 0]) - ); - output_rx.drain(); - let (reply, _) = page - .run_async_command(RendererPageCommand::DispatchMouseEventAtPoint { - x: 30.0, - y: 30.0, - event_name: event_name.to_owned(), - button: if event_name == "mousedown" { 0 } else { -1 }, - buttons: Some(if event_name == "mousedown" { 1 } else { 0 }), - click_count: 1, - delta_x: 0.0, - delta_y: 0.0, - pointer: RendererPointerEventProperties::default(), - modifiers: 0, - }) - .await - .expect("input command"); - let RendererPageReply::InputDispatchOutcome(outcome) = reply else { - panic!("expected input outcome"); - }; - assert_eq!( - outcome.triggered_top_level_navigation, navigates, - "{listener}, microtask={microtask}" - ); - let navigations = output_rx - .drain() - .into_iter() - .flat_map(RendererOutputPublication::into_records) - .filter_map(|record| match record.into_parts().1 { - RendererOutputItem::OwnerAction( - RendererOwnerAction::TopLevelLocationNavigation(navigation), - ) => Some(navigation), - _ => None, - }) - .collect::>(); - assert_eq!( - navigations.len(), - usize::from(navigates), - "{listener}, microtask={microtask}" - ); - if let Some(navigation) = navigations.first() { - assert_eq!(navigation.source_document(), source_document); - assert_eq!(navigation.url(), "https://example.test/next.html"); - } - assert_eq!( - has_pending_location_navigation_for_test(&page).await, - Some(false), - "completed input must not leave navigation for a later Runtime command" - ); - // Read only after the command's navigation-output assertions: a - // Runtime command must not make a broken input publication look green. - let (handler_runs, _) = page - .run_async_command(RendererPageCommand::EvaluateExpression { - expression: "window.handlerRuns".to_owned(), - await_promise: false, - }) - .await - .expect("handler execution witness"); - assert_eq!( - renderer_json_value(handler_runs), - Some(serde_json::json!(1)) - ); - assert!( - output_rx - .drain() - .into_iter() - .flat_map(RendererOutputPublication::into_records) - .all(|record| !matches!( - record.item(), - RendererOutputItem::OwnerAction( - RendererOwnerAction::TopLevelLocationNavigation(_) - ) - )), - "the next command must not republish the input navigation" - ); - page.close_async().await.expect("close input fixture"); - } -} - -#[tokio::test(flavor = "current_thread")] -async fn mouse_input_completion_preserves_standalone_and_child_navigation_scope() { - let runtime = initialize_layout_test_runtime(); - let (output_tx, mut output_rx) = renderer_external_activity_test_channel(); - runtime.set_renderer_output_transport_sender(output_tx); - let loader = - ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("default loader"); - for (case, mode) in [ - ( - "standalone", - RendererTopLevelNavigationDispatch::FollowInStandaloneAdapter, - ), - ( - "child self", - RendererTopLevelNavigationDispatch::DelegateToBrowser, - ), - ( - "child top", - RendererTopLevelNavigationDispatch::DelegateToBrowser, - ), - ] { - let html = if case == "standalone" { - "".to_owned() - } else { - let navigation = if case == "child top" { - "top.location.href='https://example.test/next.html'" - } else { - "location.href='data:text/html,child-next'" - }; - let child = format!( - "" - ); - let child_literal = serde_json::to_string(&child).expect("child HTML literal"); - format!( - "" - ) - }; - let mut page = create_test_html_page_with_navigation_dispatch( - &runtime, - &loader, - url::Url::parse("https://example.test/source.html").unwrap(), - &html, - mode, - ) - .await; - let hit_expression = if case == "standalone" { - "document.elementFromPoint(30,30).id" - } else { - "document.getElementById('frame').contentDocument.elementFromPoint(30,30).id" - }; - let (hit, _) = page - .run_async_command(RendererPageCommand::EvaluateExpression { - expression: hit_expression.to_owned(), - await_promise: false, - }) - .await - .expect("scope fixture hit preflight"); - assert_eq!( - renderer_json_value(hit), - Some(serde_json::json!("target")), - "{case}" - ); - output_rx.drain(); - let (reply, _) = page - .run_async_command(RendererPageCommand::DispatchMouseEventAtPoint { - x: 30.0, - y: 30.0, - event_name: "mousedown".to_owned(), - button: 0, - buttons: Some(1), - click_count: 1, - delta_x: 0.0, - delta_y: 0.0, - pointer: RendererPointerEventProperties::default(), - modifiers: 0, - }) - .await - .expect("scope input command"); - let RendererPageReply::InputDispatchOutcome(outcome) = reply else { - panic!("expected input outcome"); - }; - assert_eq!( - outcome.triggered_top_level_navigation, - case != "child self", - "{case}" - ); - let navigations = output_rx - .drain() - .into_iter() - .flat_map(RendererOutputPublication::into_records) - .filter_map(|record| match record.into_parts().1 { - RendererOutputItem::OwnerAction( - RendererOwnerAction::TopLevelLocationNavigation(navigation), - ) => Some(navigation), - _ => None, - }) - .collect::>(); - assert_eq!( - navigations.len(), - usize::from(case == "child top"), - "{case}" - ); - if let Some(navigation) = navigations.first() { - assert_eq!(navigation.url(), "https://example.test/next.html"); - } - if case != "standalone" { - let (witness, _) = page - .run_async_command(RendererPageCommand::EvaluateExpression { - expression: - "JSON.stringify([window.handlerRuns,!!document.getElementById('frame')])" - .to_owned(), - await_promise: false, - }) - .await - .expect("child input witness after publication assertions"); - assert_eq!( - mouse_input_json_witness(&witness), - serde_json::json!([1, true]), - "{case}" - ); - } - page.close_async().await.expect("close scope fixture"); - } -} - -fn mouse_input_json_witness(reply: &RendererPageReply) -> serde_json::Value { - let RendererPageReply::RuntimeEvaluationResult(result) = reply else { - panic!( - "expected JSON-string input witness; unexpected reply variant: {:?}", - std::mem::discriminant(reply) - ); - }; - let payload = result.as_protocol_payload(); - let serialized = payload - .get("value") - .and_then(serde_json::Value::as_str) - .unwrap_or_else(|| panic!("expected JSON-string input witness; raw payload: {payload:?}")); - serde_json::from_str(serialized).unwrap_or_else(|error| { - panic!("invalid input witness JSON: {error}; raw payload: {payload:?}") - }) -} - async fn dispatch_wheel_for_action_window_test( page: &RendererPageHandle, delta_y: f64, diff --git a/moli-renderer-v8/src/runtime/tests/open_streaming.rs b/moli-renderer-v8/src/runtime/tests/open_streaming.rs index 52538ac74..b282b0041 100644 --- a/moli-renderer-v8/src/runtime/tests/open_streaming.rs +++ b/moli-renderer-v8/src/runtime/tests/open_streaming.rs @@ -21,77 +21,6 @@ struct OpenStreamingPage { activity_wake_rx: RendererExternalActivityTestReceiver, } -#[tokio::test(flavor = "multi_thread")] -async fn explicit_stop_retires_open_parser_and_preserves_committed_partial_document() { - let cancellation = moli_fetch::FetchCancelHandle::new(); - let mut stream = OpenStreamingPage::create_with_cancellation( - "https://example.test", - "
committed prefix
", - Some(cancellation.clone()), - ) - .await; - let (prefix, _) = tokio::time::timeout(Duration::from_secs(5), stream.page.run_async_command( - RendererPageCommand::EvaluateExpression { - expression: "new Promise(resolve => { const check = () => document.getElementById('prefix') ? resolve(document.getElementById('prefix').textContent) : setTimeout(check, 0); check(); })".to_owned(), - await_promise: true, - }, - )).await.unwrap().unwrap(); - assert_eq!( - renderer_json_value(prefix), - Some(serde_json::json!("committed prefix")) - ); - let original_agent = stream.page.devtools_agent_token(); - stream - .page - .run_async_command(RendererPageCommand::StopDocumentLifecycle) - .await - .unwrap(); - assert!(cancellation.is_cancelled()); - tokio::time::timeout(Duration::from_secs(2), stream.body_tx.closed()) - .await - .expect("stop must retire the parser's input receiver"); - assert!( - stream - .body_tx - .send(b"
must not parse
".to_vec()) - .await - .is_err() - ); - let (state, _) = stream.page.run_async_command(RendererPageCommand::EvaluateExpression { - expression: "[document.getElementById('prefix').textContent, !!document.getElementById('tail'), document.readyState].join('|')".to_owned(), - await_promise: false, - }).await.unwrap(); - assert_eq!( - renderer_json_value(state), - Some(serde_json::json!("committed prefix|false|complete")) - ); - assert_eq!(stream.page.devtools_agent_token(), original_agent); - // Repeated stops and new ordinary Page tasks must not destroy the owner. - stream - .page - .run_async_command(RendererPageCommand::StopDocumentLifecycle) - .await - .unwrap(); - let (timer, _) = tokio::time::timeout( - Duration::from_secs(2), - stream - .page - .run_async_command(RendererPageCommand::EvaluateExpression { - expression: "new Promise(resolve => setTimeout(() => resolve('still live'), 0))" - .to_owned(), - await_promise: true, - }), - ) - .await - .unwrap() - .unwrap(); - assert_eq!( - renderer_json_value(timer), - Some(serde_json::json!("still live")) - ); - stream.page.close_async().await.unwrap(); -} - #[tokio::test(flavor = "multi_thread")] async fn owner_loop_executes_async_script_while_main_document_stream_remains_open() { assert_open_stream_work_executes_before_eof(ASYNC_SCRIPT_HTML, "async script").await; @@ -231,14 +160,6 @@ async fn assert_open_stream_work_executes_before_eof(html: &str, work_label: &st impl OpenStreamingPage { async fn create(base_url: &str, html: &str) -> Self { - Self::create_with_cancellation(base_url, html, None).await - } - - async fn create_with_cancellation( - base_url: &str, - html: &str, - cancellation: Option, - ) -> Self { let runtime_owner = JsRuntime::initialize(); let runtime = runtime_owner.handle(); let (activity_wake_tx, activity_wake_rx) = renderer_external_activity_test_channel(); @@ -249,10 +170,6 @@ impl OpenStreamingPage { let page_url = url::Url::parse(&format!("{base_url}/page")).expect("page url"); let (completion_tx, completion_rx) = oneshot::channel(); let (body_tx, raw_body) = ExternalRawDocumentBodyStream::channel(completion_rx); - let raw_body = match cancellation { - Some(cancellation) => raw_body.with_stop_loading_cancellation(cancellation), - None => raw_body, - }; body_tx .send(html.as_bytes().to_vec()) .await diff --git a/moli-renderer-v8/src/script_vm.rs b/moli-renderer-v8/src/script_vm.rs index 3e742bfb7..e0830df64 100644 --- a/moli-renderer-v8/src/script_vm.rs +++ b/moli-renderer-v8/src/script_vm.rs @@ -956,7 +956,6 @@ pub(super) struct ScriptVm { active_touch_event_handle: Option, active_touch_point: Option, active_touch_points: BTreeMap, - suppress_next_keypress_after_canceled_raw_keydown: bool, suppress_compat_mouse_events: bool, active_scrollbar_drag: Option, active_drag_session: Option, @@ -2360,7 +2359,6 @@ impl ScriptVmDefaultWorldBootstrap { active_touch_event_handle: None, active_touch_point: None, active_touch_points: BTreeMap::new(), - suppress_next_keypress_after_canceled_raw_keydown: false, suppress_compat_mouse_events: false, active_scrollbar_drag: None, active_drag_session: None, diff --git a/moli-renderer-v8/src/script_vm/input_dispatch.rs b/moli-renderer-v8/src/script_vm/input_dispatch.rs index 863e85ab6..77e6e7b20 100644 --- a/moli-renderer-v8/src/script_vm/input_dispatch.rs +++ b/moli-renderer-v8/src/script_vm/input_dispatch.rs @@ -1,5 +1,4 @@ use anyhow::Result; -use std::cell::Cell; use std::rc::Rc; use super::input_dispatch_outcome; @@ -17,8 +16,7 @@ use crate::dom::{ native::{Node, SelectedFile}, }; use crate::native_bridge::element::{ - TouchEventPoint, activate_default_submit_button_via_keyboard, - activate_handle_after_pointer_release, activate_handle_via_click, + TouchEventPoint, activate_handle_via_click, activate_handle_via_click_with_detail_and_modifiers, cache_input_files_from_selected_files, construct_drag_event, construct_keyboard_event, construct_mouse_event_with_detail_and_modifiers, construct_mouse_event_with_modifiers, @@ -26,13 +24,12 @@ use crate::native_bridge::element::{ construct_pointer_event_with_modifiers, construct_pointer_event_with_related_target, construct_pointer_event_with_related_target_and_modifiers, construct_simple_event, construct_touch_event, construct_touch_event_with_points, construct_wheel_event, - contenteditable_editing_host, dispatch_public_event, form_associated_form_owner, - form_data_control_elements, is_text_control, is_valid_submit_button, observable_input_hit_test, - observable_input_surface_hit_test, perform_auxiliary_link_default_action, - perform_drop_default_action, perform_mouse_focus_default_action, - perform_scrollbar_scroll_default_action, perform_wheel_scroll_default_action, - replace_contenteditable_selection, replace_text_control_selection, - select_contenteditable_contents, submit_form_with_submit_event, + contenteditable_editing_host, dispatch_public_event, is_text_control, + observable_input_hit_test, observable_input_surface_hit_test, + perform_auxiliary_link_default_action, perform_drop_default_action, + perform_mouse_focus_default_action, perform_scrollbar_scroll_default_action, + perform_wheel_scroll_default_action, replace_contenteditable_selection, + replace_text_control_selection, select_contenteditable_contents, text_control_set_selection_range_internal, text_control_set_selection_range_with_direction_internal, text_control_value, update_focus, }; @@ -85,80 +82,6 @@ fn can_suppress_compat_mouse_event(event_name: &str) -> bool { const MOUSE_POINTER_ID: i32 = 1; const TOUCH_POINTER_ID: i32 = 2; -fn input_blocks_implicit_submission( - runtime: &crate::native_bridge::JsContextHost, - handle: DomHandle, -) -> bool { - runtime - .dom_host() - .node(handle) - .and_then(Node::as_element) - .is_some_and(|element| { - element.is_html_input() - && matches!( - element.input_type(), - InputType::Text - | InputType::Search - | InputType::Tel - | InputType::Url - | InputType::Email - | InputType::Password - | InputType::Date - | InputType::Month - | InputType::Week - | InputType::Time - | InputType::DatetimeLocal - | InputType::Number - ) - }) -} - -fn perform_implicit_form_submission( - scope: &mut v8::PinScope<'_, '_>, - runtime_ptr: *mut crate::native_bridge::JsContextHost, - handle: DomHandle, - modifiers: u8, -) -> RendererInputDispatchOutcome { - let runtime = unsafe { &*runtime_ptr }; - let Some(form_handle) = form_associated_form_owner(runtime, handle) else { - return input_dispatch_outcome(true); - }; - // The form.elements collection excludes image inputs, but an image input - // is still a submit button for the implicit-submission default-button - // search. Use the complete form-data control traversal here. - let controls = form_data_control_elements(runtime, form_handle); - if let Some(default_button) = controls - .iter() - .copied() - .find(|candidate| is_valid_submit_button(runtime, *candidate)) - { - return activate_default_submit_button_via_keyboard( - scope, - runtime_ptr, - default_button, - modifiers, - ); - } - if controls - .iter() - .filter(|candidate| input_blocks_implicit_submission(runtime, **candidate)) - .count() - > 1 - { - return input_dispatch_outcome(true); - } - - let had_pending_top_level_navigation = runtime.has_pending_location_navigation(); - let _ = submit_form_with_submit_event(scope, runtime_ptr, form_handle, None, true); - RendererInputDispatchOutcome { - handled: true, - triggered_top_level_navigation: !had_pending_top_level_navigation - && unsafe { &*runtime_ptr }.has_pending_location_navigation(), - pending_download: None, - pending_file_chooser: None, - } -} - struct PreparedMouseInputDispatch { button: i32, buttons: i32, @@ -1081,7 +1004,7 @@ impl ScriptVm { if event_name == "mouseup" { suppress_compat_mouse_events = false; } - let outcome = activate_handle_after_pointer_release( + let outcome = activate_handle_via_click_with_detail_and_modifiers( scope, runtime_ptr, handle, @@ -1879,14 +1802,6 @@ impl ScriptVm { auto_repeat: bool, should_insert_text: bool, ) -> Result { - if event_name == "keypress" && self.suppress_next_keypress_after_canceled_raw_keydown { - self.suppress_next_keypress_after_canceled_raw_keydown = false; - return Ok(input_dispatch_outcome(false)); - } - if matches!(event_name, "keydown" | "keyup") { - self.suppress_next_keypress_after_canceled_raw_keydown = false; - } - let _current_input_event = CurrentInputEventScope::enter( Rc::clone(&self._context_host), CurrentInputEvent::keyboard(key, modifiers), @@ -1904,7 +1819,6 @@ impl ScriptVm { let ctrl = modifiers & 2 == 2; let meta = modifiers & 4 == 4; let shift = modifiers & 8 == 8; - let canceled_raw_keydown = Cell::new(false); let result = self.with_default_context_scope(|scope, runtime_ptr| { let Some(event) = construct_keyboard_event( @@ -1922,9 +1836,6 @@ impl ScriptVm { }; let dispatched = dispatch_public_event(scope, runtime_ptr, handle, event); if !dispatched.allows_default() { - if event_name == "keydown" && !should_insert_text { - canceled_raw_keydown.set(true); - } return Ok(input_dispatch_outcome(false)); } @@ -1965,41 +1876,6 @@ impl ScriptVm { let runtime = unsafe { &mut *runtime_ptr }; let target = key_target_info(runtime, handle); - let is_enter_key = key_lower == "enter" || (should_insert_text && text.contains('\r')); - if is_enter_key { - let is_character_phase = - should_insert_text && matches!(event_name, "keydown" | "keypress"); - if target.is_textarea && is_character_phase { - return Ok(input_dispatch_outcome(replace_text_control_selection( - scope, - runtime_ptr, - handle, - "\n", - ))); - } - if input_blocks_implicit_submission(runtime, handle) && is_character_phase { - return Ok(perform_implicit_form_submission( - scope, - runtime_ptr, - handle, - modifiers, - )); - } - if event_name == "keydown" && (target.is_button_like || target.is_anchor_like) { - return Ok(activate_handle_via_click_with_detail_and_modifiers( - scope, - runtime_ptr, - handle, - 0.0, - 0.0, - 0, - 0, - 1, - modifiers, - )); - } - } - if should_insert_text { if text.is_empty() { return Ok(input_dispatch_outcome(true)); @@ -2053,6 +1929,30 @@ impl ScriptVm { return Ok(input_dispatch_outcome(true)); } + if key_lower == "enter" { + if target.is_textarea { + return Ok(input_dispatch_outcome(replace_text_control_selection( + scope, + runtime_ptr, + handle, + "\n", + ))); + } + if target.is_button_like || target.is_anchor_like { + return Ok(activate_handle_via_click_with_detail_and_modifiers( + scope, + runtime_ptr, + handle, + 0.0, + 0.0, + 0, + 0, + 1, + modifiers, + )); + } + } + if target.is_radio && matches!( key_lower.as_str(), @@ -2315,9 +2215,6 @@ impl ScriptVm { Ok(input_dispatch_outcome(true)) }); - if canceled_raw_keydown.get() { - self.suppress_next_keypress_after_canceled_raw_keydown = true; - } self.finish_input_event_dispatch_turn(result) } } diff --git a/moli-renderer-v8/src/script_vm/stylesheet_page_tasks.rs b/moli-renderer-v8/src/script_vm/stylesheet_page_tasks.rs index 431226bb0..0c4fdd86d 100644 --- a/moli-renderer-v8/src/script_vm/stylesheet_page_tasks.rs +++ b/moli-renderer-v8/src/script_vm/stylesheet_page_tasks.rs @@ -1,5 +1,4 @@ use super::ScriptVm; -use crate::frame_owner_model::MainDocumentStyleLoadEventSettlement; use crate::{ document_runtime::StylesheetImportCompletionAuthority, page_task_queue::{ @@ -116,7 +115,7 @@ impl ScriptVm { .borrow_mut() .settle_main_style_load_event(binding); assert!( - settled != MainDocumentStyleLoadEventSettlement::NotOwned, + settled, "a current connected style event must release its exact load-delay binding" ); tracing::debug!( @@ -125,9 +124,7 @@ impl ScriptVm { load_delay_token = ?binding.load_delay_token(), "settled main connected style load inside selected event body" ); - if settled == MainDocumentStyleLoadEventSettlement::CancelledAfterStop { - PageConnectedStyleLoadDelayEffect::ExactBindingCancelledByStop - } else if had_load_delay_token { + if had_load_delay_token { PageConnectedStyleLoadDelayEffect::ReleasedExactBinding } else { PageConnectedStyleLoadDelayEffect::NoBindingRequired diff --git a/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface.rs b/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface.rs index 532b614fb..5f01fecaa 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_elements/dom_surface.rs @@ -1,296 +1,5 @@ use super::*; -#[test] -fn offset_parent_refreshes_a_connected_insertion_before_any_other_geometry_read() { - let mut vm = new_storage_test_vm("https://offset-parent-layout.test/"); - vm.eval( - r#" - if (!document.documentElement) document.appendChild(document.createElement('html')); - if (!document.head) document.documentElement.appendChild(document.createElement('head')); - if (!document.body) document.documentElement.appendChild(document.createElement('body')); - document.head.innerHTML = ``; - document.body.innerHTML = '
'; - document.getElementById('existing').getBoundingClientRect().width; - globalThis.menu = document.createElement('div'); - menu.id = 'menu'; - globalThis.candidate = document.createElement('button'); - candidate.id = 'candidate'; - candidate.textContent = 'Abishek S abisubramanya27'; - menu.appendChild(candidate); - 'installed'; - "#, - ) - .expect("fixture and initial layout should evaluate"); - - let passes_after_initial_layout = vm.layout_pass_observability_for_test().1; - assert_eq!( - vm.eval("candidate.offsetParent === null && candidate.offsetWidth === 0") - .unwrap(), - "true", - "geometry for a detached candidate must remain empty" - ); - assert_eq!( - vm.layout_pass_observability_for_test().1, - passes_after_initial_layout, - "building and querying a detached subtree must not invalidate live layout" - ); - - vm.eval("document.body.appendChild(menu); 'connected'") - .expect("connected insertion should evaluate"); - assert_eq!( - vm.layout_pass_observability_for_test().1, - passes_after_initial_layout, - "the connected insertion must mark layout dirty without rebuilding it eagerly" - ); - - let result = vm - .eval( - r#" - (() => { - const parent = candidate.offsetParent; - const width = candidate.offsetWidth; - return JSON.stringify({ parentIsMenu: parent === menu, width }); - })() - "#, - ) - .expect("offsetParent-first geometry read should evaluate"); - assert_eq!( - result, r#"{"parentIsMenu":true,"width":240}"#, - "offsetParent must refresh a dirty tree before answering for the inserted element" - ); - assert_eq!( - vm.layout_pass_observability_for_test().1, - passes_after_initial_layout + 1, - "offsetParent and the following width read should share one fresh pass" - ); - - assert_eq!( - vm.eval("candidate.offsetParent === menu && candidate.offsetWidth === 240") - .unwrap(), - "true" - ); - assert_eq!( - vm.layout_pass_observability_for_test().1, - passes_after_initial_layout + 1, - "clean geometry reads must reuse the refreshed tree" - ); -} - -#[test] -fn geometry_reuses_clean_layout_after_detached_subtree_mutations() { - let mut vm = new_storage_test_vm("https://layout-detached.test/"); - vm.eval( - r#" - if (!document.documentElement) document.appendChild(document.createElement('html')); - if (!document.body) document.documentElement.appendChild(document.createElement('body')); - document.body.innerHTML = '
'; - globalThis.target = document.getElementById('target'); - globalThis.detached = document.createElement('div'); - target.getBoundingClientRect().width; - "#, - ) - .expect("fixture should evaluate"); - let passes = vm.layout_pass_observability_for_test().1; - for mutation in [ - "detached.textContent = 'not on the page'", - "detached.appendChild(document.createElement('span'))", - "detached.style.width = '300px'", - "detached.setAttribute('data-wide', '')", - "detached.firstChild.data = 'changed text'", - ] { - vm.eval(mutation) - .expect("detached mutation should evaluate"); - assert_eq!( - vm.eval("target.getBoundingClientRect().width").unwrap(), - "40" - ); - assert_eq!( - vm.layout_pass_observability_for_test().1, - passes, - "detached mutation must not invalidate page layout: {mutation}" - ); - } -} - -#[test] -fn geometry_refreshes_pseudo_class_inputs_on_demand() { - let mut vm = new_storage_test_vm("https://layout-state.test/"); - vm.eval(r#" - if (!document.documentElement) document.appendChild(document.createElement('html')); - if (!document.body) document.documentElement.appendChild(document.createElement('body')); - document.body.innerHTML = ''; - globalThis.target = document.getElementById('target'); - target.getBoundingClientRect().width; - "#).expect("fixture should evaluate"); - let mut passes = vm.layout_pass_observability_for_test().1; - for (mutation, expected) in [ - ("target.focus({preventScroll: true})", "80"), - ("target.checked = true", "120"), - ("target.checked = false", "80"), - ("target.blur()", "40"), - ] { - vm.eval(mutation).expect("element state should change"); - assert_eq!(vm.layout_pass_observability_for_test().1, passes); - assert_eq!( - vm.eval("target.getBoundingClientRect().width").unwrap(), - expected, - "geometry must reflect changed pseudo class: {mutation}" - ); - passes += 1; - assert_eq!(vm.layout_pass_observability_for_test().1, passes); - assert_eq!( - vm.eval("target.getBoundingClientRect().width").unwrap(), - expected - ); - assert_eq!(vm.layout_pass_observability_for_test().1, passes); - } -} - -#[test] -fn geometry_refreshes_attribute_selectors_and_cssom_on_demand() { - let mut vm = new_storage_test_vm("https://layout-inputs.test/"); - vm.eval(r#" - if (!document.documentElement) document.appendChild(document.createElement('html')); - if (!document.body) document.documentElement.appendChild(document.createElement('body')); - document.body.innerHTML = '
'; - globalThis.target = document.getElementById('target'); - target.getBoundingClientRect().width; - "#).expect("fixture should evaluate"); - let mut passes = vm.layout_pass_observability_for_test().1; - for (mutation, expected) in [ - ("target.setAttribute('data-wide', '')", "80"), - ( - "document.getElementById('rules').sheet.insertRule('#target[data-wide] { width: 120px }', 2)", - "120", - ), - ("target.removeAttribute('data-wide')", "40"), - ] { - vm.eval(mutation).expect("layout input should change"); - assert_eq!( - vm.layout_pass_observability_for_test().1, - passes, - "input changes must not eagerly lay out: {mutation}" - ); - assert_eq!( - vm.eval("target.getBoundingClientRect().width").unwrap(), - expected - ); - passes += 1; - assert_eq!(vm.layout_pass_observability_for_test().1, passes); - assert_eq!( - vm.eval("target.getBoundingClientRect().width").unwrap(), - expected - ); - assert_eq!( - vm.layout_pass_observability_for_test().1, - passes, - "clean geometry must be reusable across script turns" - ); - } -} - -#[test] -fn geometry_refreshes_inserted_and_existing_nodes_on_demand() { - let mut vm = new_storage_test_vm("https://layout-mutation.test/"); - - vm.eval( - r#" - (() => { - if (!document.documentElement) { - document.appendChild(document.createElement("html")); - } - if (!document.body) { - document.documentElement.appendChild(document.createElement("body")); - } - document.body.innerHTML = '
Marker
'; - return document.getElementById("marker").getBoundingClientRect().top; - })() - "#, - ) - .expect("initial layout read should evaluate"); - - let before_top = vm - .eval("document.getElementById('marker').getBoundingClientRect().top") - .expect("initial marker geometry should evaluate") - .parse::() - .expect("initial marker top should be numeric"); - - let passes_before = vm.layout_pass_observability_for_test().1; - vm.eval( - r#"document.getElementById("content").insertAdjacentHTML( - "afterbegin", - '
' - )"#, - ) - .expect("connected insertion should evaluate"); - assert_eq!( - vm.layout_pass_observability_for_test().1, - passes_before, - "a DOM mutation alone must not run layout" - ); - assert!( - vm.layout_snapshot_cache_observability_for_test() - .3 - .is_some(), - "mutation should retain the sampled frozen tree" - ); - - let result = vm - .eval( - r#" - (() => { - const rect = document.getElementById("target").getBoundingClientRect(); - const marker = document.getElementById("marker").getBoundingClientRect(); - return JSON.stringify({ target: [rect.width, rect.height], markerTop: marker.top }); - })() - "#, - ) - .expect("geometry after a connected insertion should evaluate"); - - assert_eq!( - vm.layout_pass_observability_for_test().1, - passes_before + 1, - "all post-mutation geometry reads should share one fresh pass" - ); - - let result: serde_json::Value = - serde_json::from_str(&result).expect("geometry result should be JSON"); - assert!( - result["target"][0] - .as_f64() - .is_some_and(|width| width > 0.0) - && result["target"][1] - .as_f64() - .is_some_and(|height| height > 0.0), - "inserted content should receive real geometry: {result}" - ); - assert!( - result["markerTop"] - .as_f64() - .is_some_and(|top| top > before_top), - "existing geometry must not be reused after a preceding sibling is inserted: {result}" - ); - vm.eval("const content = document.getElementById('content'); content.firstElementChild.remove(); content.firstElementChild.remove()") - .expect("connected removals should evaluate"); - assert_eq!( - vm.layout_pass_observability_for_test().1, - passes_before + 1, - "removal itself must not run layout" - ); - assert_eq!( - vm.eval("document.getElementById('marker').getBoundingClientRect().top") - .unwrap() - .parse::() - .unwrap(), - before_top, - "removing previously connected siblings must invalidate old geometry" - ); - assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 2); -} - #[test] fn svg_create_rect_supports_capability_detection_and_detached_float_values() { let mut vm = new_storage_test_vm("https://svg-rect.test/"); diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/computed_style.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/computed_style.rs index b49bca7af..53df860ce 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/computed_style.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/computed_style.rs @@ -2,191 +2,6 @@ use super::*; mod child_list; -#[test] -fn root_font_relative_units_follow_each_documents_live_root_style() { - let mut vm = new_parsed_test_vm( - "https://root-font-relative-units.test/", - r#" - - -
- - "#, - ); - - let result = vm - .eval( - r#" -(() => { - const root = document.documentElement; - const target = document.getElementById('target'); - const values = () => { - const targetStyle = getComputedStyle(target); - const targetValues = [targetStyle.width, targetStyle.height, targetStyle.fontSize]; - const rootFontSize = getComputedStyle(root).fontSize; - return [rootFontSize, ...targetValues].join(','); - }; - const initial = values(); - - root.style.fontSize = '20px'; - const updated = values(); - - root.style.fontSize = '0.625rem'; - const selfRelative = values(); - - root.style.lineHeight = '3'; - const updatedLineHeight = values(); - root.style.lineHeight = '2'; - - const frame = document.createElement('iframe'); - document.body.appendChild(frame); - const childDocument = frame.contentWindow.document; - childDocument.open(); - childDocument.write('
'); - childDocument.close(); - const childWidth = frame.contentWindow - .getComputedStyle(childDocument.getElementById('child')) - .width; - - return [initial, updated, selfRelative, updatedLineHeight, childWidth, values()].join('|'); -})() -"#, - ) - .expect("root font-relative units should track isolated live root styles"); - - assert_eq!( - result, - "10px,88px,20px,14px|20px,176px,40px,28px|10px,88px,20px,14px|10px,88px,30px,14px|70px|10px,88px,20px,14px" - ); -} - -#[test] -fn replacing_document_root_replaces_retained_root_font_state() { - let mut vm = new_parsed_test_vm( - "https://replaced-root-font-state.test/", - r#" -
- "#, - ); - - let initial = vm - .eval("getComputedStyle(document.getElementById('target')).width") - .expect("initial descendant should resolve root-relative width"); - assert_eq!(initial, "20px"); - - let replaced = vm - .eval( - r#" -(() => { - const nextRoot = document.createElement('html'); - nextRoot.style.fontSize = '7px'; - const nextBody = document.createElement('body'); - const nextTarget = document.createElement('div'); - nextTarget.id = 'next-target'; - nextTarget.style.width = '2rem'; - nextBody.appendChild(nextTarget); - nextRoot.appendChild(nextBody); - document.replaceChild(nextRoot, document.documentElement); - return getComputedStyle(nextTarget).width; -})() -"#, - ) - .expect("new descendant should replace the retained root state"); - assert_eq!(replaced, "14px"); - - vm.set_viewport_surface(Some(crate::protocol_types::ViewportSurface { - inner_width: 800, - inner_height: 600, - outer_width: 800, - outer_height: 600, - device_pixel_ratio: 1.0, - screen_width: 1920, - screen_height: 1080, - screen_avail_width: 1920, - screen_avail_height: 1040, - ..Default::default() - })) - .expect("viewport device should rebuild after root replacement"); - let rebuilt = vm - .eval("getComputedStyle(document.getElementById('next-target')).width") - .expect("rebuilt device should retain the replacement root state"); - assert_eq!(rebuilt, "14px"); -} - -#[test] -fn root_font_relative_units_survive_viewport_device_rebuilds() { - let mut vm = new_parsed_test_vm( - "https://root-rem-device-rebuild.test/", - "", - ); - let surface = |inner_width| crate::protocol_types::ViewportSurface { - inner_width, - inner_height: 600, - outer_width: inner_width, - outer_height: 600, - device_pixel_ratio: 1.0, - screen_width: 1920, - screen_height: 1080, - screen_avail_width: 1920, - screen_avail_height: 1040, - ..Default::default() - }; - vm.set_viewport_surface(Some(surface(1000))) - .expect("initial viewport should update"); - - let wide = vm - .eval( - r#" -(() => { - const style = document.createElement('style'); - style.textContent = ` - html { font-size: 10px; } - @media (min-width: 800px) { html { font-size: 20px; } } - #root-rem-target { width: 2rem; } - `; - document.head.appendChild(style); - const target = document.createElement('div'); - target.id = 'root-rem-target'; - document.body.appendChild(target); - globalThis.__rootRemTarget = target; - const width = getComputedStyle(target).width; - return [width, getComputedStyle(document.documentElement).fontSize].join('|'); -})() -"#, - ) - .expect("wide root rem styles should resolve"); - assert_eq!(wide, "40px|20px"); - - vm.set_viewport_surface(Some(surface(500))) - .expect("narrow viewport should update"); - let narrow = vm - .eval( - "[getComputedStyle(__rootRemTarget).width, getComputedStyle(document.documentElement).fontSize].join('|')", - ) - .expect("narrow root rem styles should resolve"); - assert_eq!(narrow, "20px|10px"); - - vm.set_viewport_surface(Some(surface(1000))) - .expect("restored viewport should update"); - let restored = vm - .eval( - "[getComputedStyle(__rootRemTarget).width, getComputedStyle(document.documentElement).fontSize].join('|')", - ) - .expect("restored root rem styles should resolve"); - assert_eq!(restored, "40px|20px"); - - vm.eval("document.documentElement.style.fontSize = '30px'; true") - .expect("pending root font mutation should apply"); - vm.set_viewport_surface(Some(surface(900))) - .expect("device should rebuild while the root mutation is pending"); - let pending_mutation = vm - .eval( - "[getComputedStyle(__rootRemTarget).width, getComputedStyle(document.documentElement).fontSize].join('|')", - ) - .expect("descendant should observe the pending root mutation after device rebuild"); - assert_eq!(pending_mutation, "60px|30px"); -} - #[test] fn element_current_css_zoom_observes_fresh_effective_style_for_rendered_boxes() { let mut vm = new_parsed_test_vm( diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom.rs index 30aa1a782..805312e52 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/dom.rs @@ -8591,11 +8591,8 @@ for (let index = 0; index < 128; index++) { ); assert_eq!(layout_after_first.1, layout_before.1 + 1); assert_eq!(layout_after_repeated.1, layout_after_first.1); - assert_eq!(layout_after_second.1, layout_after_first.1 + 1); - assert_eq!( - layout_after_stylesheet_mutation.1, - layout_after_second.1 + 1 - ); + assert_eq!(layout_after_second.1, layout_after_first.1); + assert_eq!(layout_after_stylesheet_mutation.1, layout_after_first.1); assert_eq!( update_materializations_after_first.saturating_sub(update_materializations_before), 1, @@ -8640,7 +8637,7 @@ for (let index = 0; index < 128; index++) { } #[test] -fn inner_text_refreshes_new_sources_on_demand_without_waiting_for_paint() { +fn inner_text_new_sources_wait_for_a_fresh_paint_layout() { let mut vm = new_parsed_test_vm( "https://inner-text-latest-layout.test/", "
a
", @@ -8662,42 +8659,31 @@ fn inner_text_refreshes_new_sources_on_demand_without_waiting_for_paint() { ); assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 1); - vm.eval("const added = document.createElement('span'); added.textContent = 'b'; target.append(added)") - .expect("text insertion should evaluate"); assert_eq!( - vm.layout_pass_observability_for_test().1, - passes_before + 1, - "text insertion alone must not run layout" - ); - assert_eq!( - vm.eval("target.innerText") - .expect("the warm innerText read should evaluate"), - "ab", - "innerText must sample newly connected text without requiring a screenshot" - ); - assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 2); - assert_eq!(vm.eval("target.innerText").unwrap(), "ab"); - assert_eq!( - vm.layout_pass_observability_for_test().1, - passes_before + 2, - "repeated innerText should reuse clean geometry across turns" + vm.eval( + "const added = document.createElement('span'); added.textContent = 'b'; target.append(added); target.innerText", + ) + .expect("the warm innerText read should evaluate"), + "a", + "a text source absent from the latest frozen layout tree remains unrendered until refresh" ); + assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 1); vm.screenshot_layout_snapshot(moli_layout::PaintViewport::new(320, 200, 1.0)) .expect("fresh paint layout should succeed") .expect("the fixture should have a layout root"); - assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 3); + assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 2); assert_eq!( vm.eval("target.innerText") .expect("innerText should read the refreshed geometry snapshot"), "ab" ); - assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 3); + assert_eq!(vm.layout_pass_observability_for_test().1, passes_before + 2); let cache_after = vm.layout_snapshot_cache_observability_for_test(); assert_eq!(cache_after.0, cache_before.0 + 2); - assert_eq!(cache_after.1, cache_before.1 + 2); - assert_eq!(cache_after.2, cache_before.2 + 3); + assert_eq!(cache_after.1, cache_before.1 + 1); + assert_eq!(cache_after.2, cache_before.2 + 2); } #[test] diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs index 0cc284ab8..3ec889f4c 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs @@ -2,7 +2,6 @@ use super::*; mod dialog; mod event_activation; -mod named_lookup; #[test] fn object_set_custom_validity_updates_validity_state() { @@ -4755,461 +4754,6 @@ fn submit_button_click_queues_pending_top_level_location_navigation() { "default form submission should not synthesize a history seed" ); } - -#[test] -fn enter_implicitly_submits_single_search_input_without_a_submit_button() { - let mut vm = new_parsed_test_vm( - "https://implicit-search-submit.test/", - r#" -
- - -
- "#, - ); - vm.eval( - r#" -window.__submitterWasNull = false; -document.querySelector('form').addEventListener('submit', event => { - window.__submitterWasNull = event.submitter === null; -}); -document.getElementById('query').focus(); -"#, - ) - .expect("implicit submission fixture should initialize"); - - let outcome = vm - .dispatch_key_event("keydown", "Enter", "Enter", "\r", 0, false, true) - .expect("Enter keydown should dispatch"); - - assert!(outcome.handled); - assert!(outcome.triggered_top_level_navigation); - assert_eq!( - vm.eval("String(window.__submitterWasNull)") - .expect("submitter state should evaluate"), - "true" - ); - let pending = vm - .take_pending_location_navigation_with_seed() - .expect("implicit submission should queue a location navigation"); - assert_eq!( - pending.url.as_str(), - "https://implicit-search-submit.test/search?q=Nvidia+RTX+4090" - ); -} - -#[test] -fn enter_implicitly_clicks_the_default_submit_button() { - let mut vm = new_parsed_test_vm( - "https://implicit-default-button.test/", - r#" -
- - -
- "#, - ); - vm.eval( - r#" -window.__events = []; -const submitter = document.getElementById('submitter'); -submitter.addEventListener('click', event => { - __events.push(`click:${event.detail}`); -}); -document.querySelector('form').addEventListener('submit', event => { - __events.push(`submit:${event.submitter === submitter}`); -}); -document.getElementById('query').focus(); -"#, - ) - .expect("default submit button fixture should initialize"); - - let outcome = vm - .dispatch_key_event("keydown", "Enter", "Enter", "\r", 0, false, true) - .expect("Enter keydown should dispatch"); - - assert!(outcome.handled); - assert!(outcome.triggered_top_level_navigation); - assert_eq!( - vm.eval("`${__events.join('|')}|focus:${document.activeElement.id}`") - .expect("implicit activation events should evaluate"), - "click:0|submit:true|focus:query" - ); - let pending = vm - .take_pending_location_navigation_with_seed() - .expect("default submit button should queue a location navigation"); - assert_eq!( - pending.url.as_str(), - "https://implicit-default-button.test/search?q=moli&source=header" - ); -} - -#[test] -fn enter_does_not_implicitly_submit_multiple_blocking_inputs_without_a_submit_button() { - let mut vm = new_parsed_test_vm( - "https://implicit-multiple-inputs.test/", - r#" -
- - -
- "#, - ); - vm.eval( - r#" -window.__submitCount = 0; -document.querySelector('form').addEventListener('submit', () => __submitCount++); -document.getElementById('query').focus(); -"#, - ) - .expect("multiple blocker fixture should initialize"); - - let outcome = vm - .dispatch_key_event("keydown", "Enter", "Enter", "\r", 0, false, true) - .expect("Enter keydown should dispatch"); - - assert!(outcome.handled); - assert!(!outcome.triggered_top_level_navigation); - assert_eq!( - vm.eval("String(window.__submitCount)") - .expect("submit count should evaluate"), - "0" - ); - assert!(vm.take_pending_location_navigation_with_seed().is_none()); -} - -#[test] -fn canceling_an_implicit_submit_event_prevents_navigation() { - let mut vm = new_parsed_test_vm( - "https://implicit-canceled-submit.test/", - r#" -
- -
- "#, - ); - vm.eval( - r#" -window.__submitCount = 0; -document.querySelector('form').addEventListener('submit', event => { - __submitCount++; - event.preventDefault(); -}); -document.getElementById('query').focus(); -"#, - ) - .expect("canceled implicit submission fixture should initialize"); - - let outcome = vm - .dispatch_key_event("keydown", "Enter", "Enter", "\r", 0, false, true) - .expect("Enter keydown should dispatch"); - - assert!(outcome.handled); - assert!(!outcome.triggered_top_level_navigation); - assert_eq!( - vm.eval("String(window.__submitCount)") - .expect("submit count should evaluate"), - "1" - ); - assert!(vm.take_pending_location_navigation_with_seed().is_none()); -} - -#[test] -fn disabled_default_submit_button_suppresses_implicit_submission() { - let mut vm = new_parsed_test_vm( - "https://implicit-disabled-default.test/", - r#"
- - - -
"#, - ); - vm.eval( - r#" -window.__events = []; -for (const id of ['disabled', 'second']) { - document.getElementById(id).addEventListener('click', () => __events.push(`click:${id}`)); -} -document.querySelector('form').addEventListener('submit', () => __events.push('submit')); -document.getElementById('query').focus(); -"#, - ) - .expect("disabled default button fixture should initialize"); - - vm.dispatch_key_event("keydown", "Enter", "Enter", "\r", 0, false, true) - .expect("Enter keydown should dispatch"); - - assert_eq!( - vm.eval("`${__events.join('|')}|focus:${document.activeElement.id}`") - .expect("disabled default state should evaluate"), - "|focus:query" - ); - assert!(vm.take_pending_location_navigation_with_seed().is_none()); -} - -#[test] -fn external_form_owned_submit_button_participates_in_document_tree_order() { - let mut vm = new_parsed_test_vm( - "https://implicit-external-default.test/", - r#" - - - "#, - ); - vm.eval( - r#" -window.__events = []; -for (const id of ['external', 'inside']) { - document.getElementById(id).addEventListener('click', event => { - __events.push(`click:${id}:${event.detail}:${event.isTrusted}`); - }); -} -document.getElementById('search').addEventListener('submit', event => { - __events.push(`submit:${event.submitter.id}`); - event.preventDefault(); -}); -document.getElementById('query').focus(); -"#, - ) - .expect("external default button fixture should initialize"); - - vm.dispatch_key_event("keydown", "Enter", "Enter", "\r", 0, false, true) - .expect("Enter keydown should dispatch"); - - assert_eq!( - vm.eval("`${__events.join('|')}|focus:${document.activeElement.id}`") - .expect("external default state should evaluate"), - "click:external:0:true|submit:external|focus:query" - ); - assert!(vm.take_pending_location_navigation_with_seed().is_none()); -} - -#[test] -fn image_input_is_the_default_submit_button_and_serializes_click_coordinates() { - let mut vm = new_parsed_test_vm( - "https://implicit-image-default.test/", - r#"
- - - -
"#, - ); - vm.eval( - r#" -window.__events = []; -document.getElementById('image').addEventListener('click', event => { - __events.push(`click:image:${event.detail}:${event.isTrusted}`); -}); -document.querySelector('form').addEventListener('submit', event => { - __events.push(`submit:${event.submitter.id}`); -}); -document.getElementById('query').focus(); -"#, - ) - .expect("image default button fixture should initialize"); - - let outcome = vm - .dispatch_key_event("keydown", "Enter", "Enter", "\r", 0, false, true) - .expect("Enter keydown should dispatch"); - - assert!(outcome.triggered_top_level_navigation); - assert_eq!( - vm.eval("`${__events.join('|')}|focus:${document.activeElement.id}`") - .expect("image default state should evaluate"), - "click:image:0:true|submit:image|focus:query" - ); - let pending = vm - .take_pending_location_navigation_with_seed() - .expect("image default button should queue a location navigation"); - assert_eq!( - pending.url.as_str(), - "https://implicit-image-default.test/search?q=moli&photo.x=0&photo.y=0" - ); -} - -#[test] -fn raw_keydown_waits_for_the_character_phase_before_implicit_submission() { - let mut vm = new_parsed_test_vm( - "https://implicit-character-phase.test/", - r#"
"#, - ); - vm.eval( - r#" -window.__events = []; -document.querySelector('form').addEventListener('submit', event => { - __events.push('submit'); - event.preventDefault(); -}); -document.getElementById('query').focus(); -"#, - ) - .expect("character phase fixture should initialize"); - - vm.dispatch_key_event("keydown", "Enter", "Enter", "", 0, false, false) - .expect("raw Enter keydown should dispatch"); - assert_eq!( - vm.eval("__events.join('|')") - .expect("raw keydown event state should evaluate"), - "" - ); - - vm.dispatch_key_event("keypress", "Enter", "Enter", "\r", 0, false, true) - .expect("Enter character phase should dispatch"); - assert_eq!( - vm.eval("__events.join('|')") - .expect("character phase event state should evaluate"), - "submit" - ); - assert!(vm.take_pending_location_navigation_with_seed().is_none()); -} - -#[test] -fn canceling_keydown_or_keypress_prevents_implicit_submission() { - for canceled_event in ["keydown", "keypress"] { - let mut vm = new_parsed_test_vm( - "https://implicit-canceled-key-event.test/", - r#"
"#, - ); - vm.eval(&format!( - r#" -window.__submitCount = 0; -const query = document.getElementById('query'); -query.addEventListener('{canceled_event}', event => event.preventDefault()); -document.querySelector('form').addEventListener('submit', () => __submitCount++); -query.focus(); -"# - )) - .expect("canceled key event fixture should initialize"); - - vm.dispatch_key_event("keydown", "Enter", "Enter", "\r", 0, false, true) - .expect("Enter keydown should dispatch"); - - assert_eq!( - vm.eval("String(window.__submitCount)") - .expect("submit count should evaluate"), - "0", - "canceling {canceled_event} must suppress implicit submission" - ); - assert!(vm.take_pending_location_navigation_with_seed().is_none()); - } -} - -#[test] -fn canceled_raw_keydown_suppresses_only_its_following_implicit_submit_char_phase() { - let mut vm = new_parsed_test_vm( - "https://implicit-canceled-raw-keydown.test/", - r#"
"#, - ); - vm.eval( - r#" -window.__cancelKeydown = true; -window.__submitCount = 0; -const query = document.getElementById('query'); -query.addEventListener('keydown', event => { - if (__cancelKeydown) event.preventDefault(); -}); -document.querySelector('form').addEventListener('submit', event => { - __submitCount++; - event.preventDefault(); -}); -query.focus(); -"#, - ) - .expect("canceled raw keydown fixture should initialize"); - - vm.dispatch_key_event("keydown", "Enter", "Enter", "", 0, false, false) - .expect("canceled raw Enter keydown should dispatch"); - vm.dispatch_key_event("keypress", "", "", "\r", 0, false, true) - .expect("suppressed Enter character phase should settle"); - assert_eq!( - vm.eval("String(window.__submitCount)") - .expect("suppressed submit count should evaluate"), - "0" - ); - - vm.eval("window.__cancelKeydown = false") - .expect("keydown cancellation should disable"); - vm.dispatch_key_event("keydown", "Enter", "Enter", "", 0, false, false) - .expect("allowed raw Enter keydown should dispatch"); - vm.dispatch_key_event("keypress", "", "", "\r", 0, false, true) - .expect("allowed Enter character phase should dispatch"); - assert_eq!( - vm.eval("String(window.__submitCount)") - .expect("allowed submit count should evaluate"), - "1" - ); - assert!(vm.take_pending_location_navigation_with_seed().is_none()); -} - -#[test] -fn implicit_submission_runs_constraint_validation() { - let mut vm = new_parsed_test_vm( - "https://implicit-constraint-validation.test/", - r#"
- - -
"#, - ); - vm.eval( - r#" -window.__events = []; -document.getElementById('submitter').addEventListener('click', () => __events.push('click')); -document.getElementById('query').addEventListener('invalid', () => __events.push('invalid')); -document.querySelector('form').addEventListener('submit', () => __events.push('submit')); -document.getElementById('query').focus(); -"#, - ) - .expect("constraint validation fixture should initialize"); - - vm.dispatch_key_event("keydown", "Enter", "Enter", "\r", 0, false, true) - .expect("Enter keydown should dispatch"); - - assert_eq!( - vm.eval("__events.join('|')") - .expect("constraint validation events should evaluate"), - "click|invalid" - ); - assert!(vm.take_pending_location_navigation_with_seed().is_none()); -} - -#[test] -fn textarea_enter_inserts_one_newline_during_the_character_phase_without_submitting() { - let mut vm = new_parsed_test_vm( - "https://textarea-enter-character-phase.test/", - r#"
"#, - ); - vm.eval( - r#" -window.__submitCount = 0; -document.querySelector('form').addEventListener('submit', () => __submitCount++); -const editor = document.getElementById('editor'); -editor.focus(); -editor.setSelectionRange(1, 1); -"#, - ) - .expect("textarea Enter fixture should initialize"); - - vm.dispatch_key_event("keydown", "Enter", "Enter", "", 0, false, false) - .expect("raw Enter keydown should dispatch"); - assert_eq!( - vm.eval("document.getElementById('editor').value") - .expect("textarea value after raw keydown should evaluate"), - "x" - ); - - vm.dispatch_key_event("keypress", "Enter", "Enter", "\r", 0, false, true) - .expect("Enter character phase should dispatch"); - assert_eq!( - vm.eval("`${document.getElementById('editor').value}|${__submitCount}`") - .expect("textarea character phase state should evaluate"), - "x\n|0" - ); - assert!(vm.take_pending_location_navigation_with_seed().is_none()); -} - #[test] fn empty_get_form_submit_replaces_existing_action_query() { let mut vm = new_storage_test_vm("https://form-empty-get.test/path/index.html"); diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms/event_activation.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms/event_activation.rs index 43e566841..d5c20a785 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms/event_activation.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms/event_activation.rs @@ -1,176 +1,5 @@ use super::*; -#[test] -fn script_control_click_preserves_focus_and_pending_text_commit() { - for dispatch in [false, true] { - for target in [ - "button", "submit", "text", "check", "radio", "select", "textarea", - ] { - let mut vm = new_parsed_test_vm( - "https://script-click-focus.test/", - r#"
- - - -
"#, - ); - vm.eval( - r#"window.events = []; window.submits = []; window.clicks = []; -const origin = document.getElementById('origin'); -for (const type of ['change', 'blur']) origin.addEventListener(type, () => events.push(type)); -document.addEventListener('click', e => clicks.push([e.target.id, e.isTrusted, e.detail])); -document.querySelector('form').addEventListener('submit', e => {e.preventDefault(); submits.push(e.submitter.id)}); -origin.focus();"#, - ) - .expect("script activation fixture should initialize"); - vm.dispatch_key_event("keydown", "e", "KeyE", "edited", 0, false, true) - .expect("actual text input should leave a pending change commit"); - let result = vm - .eval(&format!( - r#"(() => {{ -const target = document.getElementById('{target}'); -if ({dispatch}) target.dispatchEvent(new MouseEvent('click', {{bubbles:true, cancelable:true}})); -else target.click(); -return JSON.stringify({{active:document.activeElement.id, events, clicks, submits, -checked:document.getElementById('check').checked, radio:document.getElementById('radio').checked}}); -}})()"# - )) - .expect("script activation should evaluate"); - let actual: serde_json::Value = serde_json::from_str(&result).unwrap(); - let submits = if target == "submit" { - vec!["submit"] - } else { - vec![] - }; - assert_eq!( - actual, - serde_json::json!({"active":"origin", "events":[], - "clicks":[[target,false,0]], "submits":submits, - "checked":target == "check", "radio":target == "radio"}), - "target={target}, dispatch={dispatch}" - ); - assert_eq!( - vm.eval("origin.blur(); events.join('|')").unwrap(), - "change|blur", - "explicit blur must still commit the edited origin" - ); - } - } -} - -#[test] -fn canceled_script_click_keeps_focus_and_rolls_back_activation() { - for dispatch in [false, true] { - for target in ["check", "radio", "submit"] { - let mut vm = new_parsed_test_vm( - "https://script-click-cancel-focus.test/", - r#"
- -
"#, - ); - let result = vm.eval(&format!(r#"(() => {{ -const origin = document.getElementById('origin'); const target = document.getElementById('{target}'); -const seen = []; const submissions = []; -document.querySelector('form').addEventListener('submit', e => {{e.preventDefault(); submissions.push(e.submitter.id)}}); -target.addEventListener('click', e => {{seen.push([target.checked, e.isTrusted]); e.preventDefault()}}); -origin.focus(); -if ({dispatch}) target.dispatchEvent(new MouseEvent('click', {{bubbles:true,cancelable:true}})); else target.click(); -return JSON.stringify({{active:document.activeElement.id, checked:target.checked, seen, submissions}}); -}})()"#)).unwrap(); - let actual: serde_json::Value = serde_json::from_str(&result).unwrap(); - assert_eq!( - actual, - serde_json::json!({"active":"origin", "checked":false, - "seen":[[target != "submit",false]], "submissions":[]}), - "target={target}, dispatch={dispatch}" - ); - } - } -} - -#[test] -fn script_label_activation_keeps_its_independent_control_focus_rule() { - for dispatch in [false, true] { - for target in ["label", "nested"] { - let mut vm = new_parsed_test_vm( - "https://script-label-focus.test/", - r#""#, - ); - let result = vm.eval(&format!(r#"(() => {{ -const origin = document.getElementById('origin'); const check = document.getElementById('check'); -const target = document.getElementById('{target}'); const seen = []; -check.addEventListener('click', () => seen.push(document.activeElement === check)); -origin.focus(); -if ({dispatch}) target.dispatchEvent(new MouseEvent('click', {{bubbles:true,cancelable:true}})); else target.click(); -return JSON.stringify({{active:document.activeElement.id, checked:check.checked, seen}}); -}})()"#)).unwrap(); - assert_eq!( - result, r#"{"active":"check","checked":true,"seen":[true]}"#, - "target={target}, dispatch={dispatch}" - ); - } - } -} - -#[test] -fn pointer_release_does_not_reapply_canceled_mouse_focus() { - for canceled_event in ["none", "mousedown", "pointerdown"] { - for label in [false, true] { - let target = if label { "label" } else { "button" }; - let mut vm = new_parsed_test_vm( - "https://pointer-focus-ownership.test/", - r#" - - -"#, - ); - vm.eval(&format!( - r#"window.events = []; window.clicks = []; -const origin = document.getElementById('origin'); -for (const type of ['change','blur']) origin.addEventListener(type, () => events.push(type)); -document.addEventListener('click', e => clicks.push(e.target.id)); -document.getElementById('{target}').addEventListener('{canceled_event}', e => e.preventDefault()); -origin.focus();"# - )) - .unwrap(); - vm.dispatch_key_event("keydown", "e", "KeyE", "edited", 0, false, true) - .expect("text edit should create a pending commit"); - let y = if label { 85.0 } else { 25.0 }; - vm.dispatch_mouse_event_at_point(25.0, y, "mousedown", 0, None, 0.0, 0.0) - .expect("pointer down should dispatch"); - vm.dispatch_mouse_event_at_point(25.0, y, "mouseup", 0, None, 0.0, 0.0) - .expect("pointer up and click should dispatch"); - let result = vm.eval("JSON.stringify({active:document.activeElement.id, events, clicks, checked:document.getElementById('check').checked})").unwrap(); - let actual: serde_json::Value = serde_json::from_str(&result).unwrap(); - let moves_focus = label || canceled_event == "none"; - let events = if moves_focus { - vec!["change", "blur"] - } else { - vec![] - }; - let clicks = if label { - vec!["label", "check"] - } else { - vec!["button"] - }; - let active = if label { - "check" - } else if moves_focus { - "button" - } else { - "origin" - }; - assert_eq!( - actual, - serde_json::json!({"active":active, "events":events, - "clicks":clicks, "checked":label}), - "target={target}, canceled={canceled_event}" - ); - } - } -} - #[test] fn dispatched_bubbling_child_click_uses_ancestor_button_activation_behavior() { let mut vm = new_storage_test_vm("https://button-child-dispatched-click.test/"); diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms/named_lookup.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms/named_lookup.rs deleted file mode 100644 index 27c60ab28..000000000 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms/named_lookup.rs +++ /dev/null @@ -1,301 +0,0 @@ -use super::*; - -#[test] -fn form_named_lookup_misses_do_not_enumerate_or_traverse_controls() { - let mut vm = new_parsed_test_vm( - "https://form-lookup-work.test/", - "", - ); - vm.exec( - r#" - globalThis.lookupForms = []; - for (let i = 0; i < 32; ++i) { - const form = document.body.appendChild(document.createElement('form')); - form.innerHTML = '
text
'; - lookupForms.push(form); - } - lookupForms[0].absentLookupKey; - "#, - None, - ) - .expect("prepare named lookup workload"); - crate::native_bridge::element::take_form_lookup_work_for_test(); - let result = vm - .eval( - r#" - (() => { - for (const form of lookupForms) { - for (let i = 0; i < 8; ++i) { - if (form.absentLookupKey !== undefined) throw Error('getter'); - if ('absentLookupKey' in form) throw Error('query'); - if (Object.getOwnPropertyDescriptor(form, 'absentLookupKey') !== undefined) - throw Error('descriptor'); - } - } - return 'ok'; - })() - "#, - ) - .expect("misses should preserve ordinary property semantics"); - assert_eq!(result, "ok"); - assert_eq!( - crate::native_bridge::element::take_form_lookup_work_for_test(), - (0, 0) - ); - - // Positive control: the counter must observe real supported-property work. - assert_eq!(vm.eval("lookupForms[0].present.tagName").unwrap(), "INPUT"); - let (traversals, enumerations) = - crate::native_bridge::element::take_form_lookup_work_for_test(); - assert!(traversals > 0); - assert_eq!(enumerations, 0); - vm.eval("Object.getOwnPropertyNames(lookupForms[0]).length") - .unwrap(); - assert!(crate::native_bridge::element::take_form_lookup_work_for_test().1 > 0); -} - -#[test] -fn form_named_lookup_uncontested_own_properties_do_not_invoke_accessors_during_queries() { - let mut vm = new_parsed_test_vm( - "https://form-lookup-expando.test/", - "", - ); - let result = vm.eval(r#" - (() => { - const f = document.body.appendChild(document.createElement('form')); - let reads = 0; - let writes = 0; - Object.defineProperty(f, 'ownData', {value: 17, writable: true, configurable: true}); - Object.defineProperty(f, 'ownAccessor', { - get() { ++reads; return 23; }, set(v) { writes += v; }, configurable: true - }); - f.innerHTML = ''; - const proto = Object.create(Object.getPrototypeOf(f)); - Object.defineProperty(proto, 'protoKey', {value: 'prototype'}); - Object.setPrototypeOf(f, proto); - if (!('ownAccessor' in f) || reads !== 0) throw Error('query called getter'); - const descriptor = Object.getOwnPropertyDescriptor(f, 'ownAccessor'); - if (typeof descriptor.get !== 'function' || reads !== 0) throw Error('descriptor called getter'); - if (f.ownAccessor !== 23 || reads !== 1) throw Error('accessor value'); - f.ownAccessor = 3; - if (writes !== 3 || reads !== 1) throw Error('accessor setter'); - if (f.ownData !== 17 || f.protoKey !== 'prototype') throw Error('own/prototype values'); - if (Object.getOwnPropertyDescriptor(f, 'protoKey') !== undefined) throw Error('prototype is not own'); - if (!delete f.ownData || f.ownData !== undefined) throw Error('ordinary data deletion'); - if (!delete f.ownAccessor || f.ownAccessor !== undefined || reads !== 1) - throw Error('ordinary accessor deletion'); - return 'ok'; - })() - "#).expect("own properties must not be confused with intercepted or inherited properties"); - assert_eq!(result, "ok"); -} - -#[test] -fn form_named_lookup_keeps_native_overrides_descriptors_and_index_boundaries() { - let mut vm = new_parsed_test_vm( - "https://form-lookup-boundaries.test/", - r#"
"#, - ); - let result = vm.eval(r#" - (() => { - const f = document.getElementById('f'); - const inputs = document.querySelectorAll('input'); - for (let i = 0; i < inputs.length; ++i) { - const name = inputs[i].name; - if (f[name] !== inputs[i] || !(name in f)) throw Error('native or named override: ' + name); - if (Object.getOwnPropertyDescriptor(f, name).value !== inputs[i]) throw Error('descriptor'); - } - if (f[0] !== inputs[0] || f['01'] !== inputs[5]) throw Error('index routing'); - const symbol = Symbol('plain'); - f[symbol] = 9; - if (f[symbol] !== 9 || !delete f[symbol]) throw Error('symbol'); - return 'ok'; - })() - "#).expect("named overrides and property operations should keep their existing contract"); - assert_eq!(result, "ok"); -} - -#[test] -fn form_named_lookup_index_misses_become_hits_and_preserve_past_names() { - let mut vm = new_parsed_test_vm( - "https://form-lookup-mutations.test/", - r#"
"#, - ); - let result = vm - .eval( - r#" - (() => { - const f = document.getElementById('owner'); - const other = document.getElementById('other'); - if (f.lateKey !== undefined || 'lateKey' in f) throw Error('initial miss'); - const input = document.createElement('input'); - input.name = 'lateKey'; - f.appendChild(input); - if (f.lateKey !== input) throw Error('insert after miss'); - input.name = 'renamedKey'; - if (f.renamedKey !== input || f.lateKey !== input) throw Error('rename/past name'); - input.removeAttribute('name'); - if (f.lateKey !== input) throw Error('global index miss discarded past name'); - other.appendChild(input); - if (f.lateKey !== undefined) throw Error('past owner invalidation'); - input.id = 'lateId'; - input.setAttribute('form', 'owner'); - if (f.lateId !== input || other.lateId !== undefined) throw Error('external form owner'); - input.removeAttribute('id'); - input.setAttribute('form', 'missing'); - if (f.lateId !== undefined) throw Error('removed id and owner'); - const decoy = document.body.appendChild(document.createElement('div')); - decoy.id = 'decoyKey'; - if (f.decoyKey !== undefined) throw Error('global hit must not bypass control predicate'); - const img = f.appendChild(document.createElement('img')); - img.name = 'imageKey'; - if (f.imageKey !== img) throw Error('image fallback'); - const real = f.appendChild(document.createElement('input')); - real.name = 'imageKey'; - if (f.imageKey !== real) throw Error('control wins over image'); - return 'ok'; - })() - "#, - ) - .expect("named lookup must remain live without caching misses"); - assert_eq!(result, "ok"); -} - -#[test] -fn form_named_lookup_candidates_include_detached_shadow_and_child_documents() { - let mut vm = new_parsed_test_vm( - "https://form-lookup-scopes.test/", - "", - ); - let result = vm.eval(r#" - (() => { - function check(doc, parent, key) { - const form = doc.createElement('form'); - if (form[key] !== undefined) throw Error('initial scope miss'); - const input = doc.createElement('input'); - input.name = key; - form.appendChild(input); - if (parent) parent.appendChild(form); - if (form[key] !== input || !(key in form) || Object.getOwnPropertyDescriptor(form, key).value !== input) - throw Error('scope candidate lost: ' + key); - return form; - } - check(document, null, 'detachedKey'); - const host = document.body.appendChild(document.createElement('div')); - const shadow = host.attachShadow({mode: 'closed'}); - check(document, shadow, 'shadowKey'); - const child = document.body.appendChild(document.createElement('iframe')).contentWindow; - const childForm = check(child.document, child.document.body, 'childKey'); - if (Object.getPrototypeOf(childForm.childKey) !== child.HTMLInputElement.prototype) throw Error('child realm'); - return 'ok'; - })() - "#).expect("global candidate miss check must include every document and tree scope"); - assert_eq!(result, "ok"); -} - -#[test] -fn form_named_lookup_detached_document_and_adopted_wrappers() { - let mut vm = new_parsed_test_vm( - "https://form-lookup-detached-document.test/", - "", - ); - let result = vm.eval(r#" - (() => { - const results=[]; - for (const adopted of [false,true]) { - const doc=document.implementation.createHTMLDocument('detached'); - const form=doc.body.appendChild(doc.createElement('form')); - if (form.otherDocumentKey !== undefined) throw Error('initial miss'); - const input=form.appendChild(doc.createElement('input')); - input.name='otherDocumentKey'; - if (adopted) document.body.appendChild(document.adoptNode(form)); - const d=Object.getOwnPropertyDescriptor(form,'otherDocumentKey'); - results.push(form.otherDocumentKey===input, 'otherDocumentKey' in form, !!d&&d.value===input); - } - if (!results.every(Boolean)) throw Error('detached-document or adopted named property unavailable'); - return 'ok'; - })() - "#).expect("detached-document wrappers should expose named controls before and after adoption"); - assert_eq!(result, "ok"); -} - -#[test] -fn form_named_lookup_candidates_do_not_cache_custom_element_eligibility() { - let mut vm = new_parsed_test_vm( - "https://form-lookup-custom.test/", - r#"
"#, - ); - let result = vm.eval(r#" - (() => { - const f = document.getElementById('owner'); - if (f.faceKey !== undefined) throw Error('initial miss'); - const face = document.createElement('named-lookup-face'); - face.setAttribute('name', 'faceKey'); - f.appendChild(face); - if (f.faceKey !== undefined) throw Error('unregistered candidate'); - customElements.define('named-lookup-face', class extends HTMLElement { - static formAssociated = true; - constructor() { super(); this.attachInternals(); } - }); - if (f.faceKey !== face || !('faceKey' in f) || Object.getOwnPropertyDescriptor(f, 'faceKey').value !== face) - throw Error('upgrade must change eligibility without id/name mutation'); - const notFace = document.createElement('named-lookup-ordinary'); - notFace.setAttribute('name', 'ordinaryKey'); - f.appendChild(notFace); - customElements.define('named-lookup-ordinary', class extends HTMLElement {}); - if (f.ordinaryKey !== undefined) throw Error('non form-associated custom element'); - return 'ok'; - })() - "#).expect("named candidates are not a cached form eligibility decision"); - assert_eq!(result, "ok"); -} - -#[test] -fn form_named_lookup_preserves_identity_and_live_properties_across_document_scopes() { - let mut vm = new_parsed_test_vm( - "https://form-document-properties.test/", - "", - ); - let result = vm.eval(r#" - (() => { - for (const mode of ['active', 'detached', 'adopted']) { - const doc = mode === 'active' ? document : document.implementation.createHTMLDocument('forms'); - const f = doc.body.appendChild(doc.createElement('form')); - const input = f.appendChild(doc.createElement('input')); - input.name = 'originalKey'; - if (f.originalKey !== input) throw Error(mode + ': initial identity'); - if (mode === 'adopted') { - if (document.adoptNode(f) !== f) throw Error('adoption replaced wrapper'); - document.body.appendChild(f); - } - if (f[0] !== input || !(0 in f) || Object.getOwnPropertyDescriptor(f, '0').value !== input) - throw Error(mode + ': indexed identity'); - const d = Object.getOwnPropertyDescriptor(f, 'originalKey'); - if (d.value !== input || d.writable || d.enumerable || !d.configurable) - throw Error(mode + ': named descriptor'); - input.name = 'newKey'; - if (f.newKey !== input || f.originalKey !== input) throw Error(mode + ': past name'); - const second = f.appendChild(doc.createElement('input')); - second.name = 'newKey'; - const list = f.newKey; - if (!(list instanceof RadioNodeList) || list.length !== 2 || list[0] !== input || list[1] !== second) - throw Error(mode + ': multiple matches'); - second.remove(); - if (list.length !== 1 || f.newKey !== input) throw Error(mode + ': live list'); - input.name = 'submit'; - if (f.submit !== input || Object.getOwnPropertyDescriptor(f, 'submit').value !== input) - throw Error(mode + ': native member override'); - const symbol = Symbol('custom'); - f[symbol] = 42; - if (f[symbol] !== 42 || !delete f[symbol]) throw Error(mode + ': ordinary symbol'); - input.remove(); - if (f.originalKey !== undefined || f.newKey !== undefined || 'originalKey' in f || f[0] !== undefined) - throw Error(mode + ': removed control'); - if (typeof f.submit !== 'function') throw Error(mode + ': restored native member'); - f.remove(); - } - return 'ok'; - })() - "#).expect("form property behavior must not depend on Document attachment or adoption"); - assert_eq!(result, "ok"); -} diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs index ac71967d7..c0cd87c37 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/misc.rs @@ -1454,84 +1454,7 @@ body.setHTMLUnsafe(` } #[test] -fn adopted_stylesheet_geometry_refreshes_across_script_turns_without_rendering() { - for shadow in [false, true] { - let mut vm = new_parsed_test_vm( - "https://adopted-sheet-layout.test/", - "
", - ); - vm.eval(&format!(r#" - globalThis.styleRoot = {root}; - styleRoot.innerHTML = '
'; - globalThis.target = styleRoot.querySelector('#target'); - globalThis.sheet = new CSSStyleSheet(); - sheet.replaceSync('#target {{width:40px;height:10px}}'); - "#, root = if shadow {"document.getElementById('host').attachShadow({mode:'open'})"} else {"document.body"})) - .expect("create the same baseline rules in document and shadow scopes"); - // Document adoption is on Document, while its test markup lives in body. - vm.eval(if shadow { - "globalThis.adopter=styleRoot;" - } else { - "globalThis.adopter=document;" - }) - .unwrap(); - let read = "[target.getBoundingClientRect().width,parseFloat(getComputedStyle(target).width),target.offsetWidth].join('|')"; - assert_eq!(vm.eval(read).unwrap(), "20|20|20"); - for (mutation, width) in [ - ("adopter.adoptedStyleSheets=[sheet];", 40), - ("sheet.replaceSync('#target {width:80px;height:10px}');", 80), - ("sheet.deleteRule(0);", 20), - ( - "sheet.insertRule('#target {width:120px;height:10px}',0);", - 120, - ), - ("adopter.adoptedStyleSheets=[];", 20), - ("adopter.adoptedStyleSheets.push(sheet);", 120), - ("adopter.adoptedStyleSheets.pop();", 20), - ] { - let before = vm - ._context_host - .borrow() - .layout_pass_observability_for_test() - .1; - vm.eval(mutation).unwrap(); - assert_eq!( - vm._context_host - .borrow() - .layout_pass_observability_for_test() - .1, - before, - "stylesheet changes must not eagerly build layout: shadow={shadow} {mutation}" - ); - let expected = format!("{width}|{width}|{width}"); - assert_eq!( - vm.eval(read).unwrap(), - expected, - "shadow={shadow} {mutation}" - ); - assert_eq!( - vm._context_host - .borrow() - .layout_pass_observability_for_test() - .1, - before + 1, - "first geometry read must refresh exactly once: shadow={shadow} {mutation}" - ); - assert_eq!(vm.eval(read).unwrap(), expected); - assert_eq!( - vm._context_host - .borrow() - .layout_pass_observability_for_test() - .1, - before + 1, - "a later clean script turn must reuse layout" - ); - } - } -} - -#[test] -fn geometry_getters_refresh_after_mutation_and_reuse_clean_layout() { +fn geometry_getters_reuse_latest_layout_across_nodes_and_mutation() { let mut vm = new_storage_test_vm("https://oneshot-layout-demand.test/"); let passes_before = vm ._context_host @@ -1580,21 +1503,18 @@ fn geometry_getters_refresh_after_mutation_and_reuse_clean_layout() { ) .expect("latest layout snapshot reads should evaluate"); - assert_eq!(result, "38|8|38|8|38|43|8"); + assert_eq!(result, "38|8|38|8|38|38|0"); let passes = vm ._context_host .borrow() .layout_pass_observability_for_test() .1 .saturating_sub(passes_before); - assert_eq!( - passes, 2, - "cold and first post-mutation reads each build once" - ); + assert_eq!(passes, 1, "only the cold getter may build layout"); let cache_after = vm.layout_snapshot_cache_observability_for_test(); - assert_eq!(cache_after.0, cache_before.0 + 9); - assert_eq!(cache_after.1, cache_before.1 + 2); - assert_eq!(cache_after.2, cache_before.2 + 2); + assert_eq!(cache_after.0, cache_before.0 + 10); + assert_eq!(cache_after.1, cache_before.1 + 1); + assert_eq!(cache_after.2, cache_before.2 + 1); assert!(cache_after.3.is_some()); } diff --git a/moli-renderer-v8/src/style_engine/computed.rs b/moli-renderer-v8/src/style_engine/computed.rs index 344363018..7161ee441 100644 --- a/moli-renderer-v8/src/style_engine/computed.rs +++ b/moli-renderer-v8/src/style_engine/computed.rs @@ -4,7 +4,6 @@ use dom::ElementState as StyloElementState; use style::{ Atom, animation::DocumentAnimationSet, - computed_value_flags::ComputedValueFlags, context::{ QuirksMode, RegisteredSpeculativePainter, RegisteredSpeculativePainters, SharedStyleContext, StyleContext, StyleSystemOptions, ThreadLocalStyleContext, @@ -50,7 +49,6 @@ use super::{ cache::ComputedElementStyleCacheKey, document_world::DocumentStyleWorld, lazy_invalidation::StyleValidationPathEntry, - retained::synchronize_root_style_device, source_lifecycle::StyleSourceDocumentContext, world_key::StyleWorldKey, world_lifecycle::{ @@ -930,13 +928,12 @@ fn resolve_element_styles( shared: &shared, thread_local: &mut thread_local, }; - let (ancestor_resolution_count, mut resolved_root_style) = - materialize_ancestor_styles_for_resolution( - &mut context, - element, - world, - validation_path, - ); + let ancestor_resolution_count = materialize_ancestor_styles_for_resolution( + &mut context, + element, + world, + validation_path, + ); let target = DomHandle::new(element.as_node().debug_id()); world.pseudo_style_cache.invalidate_handles([target]); // `resolve_style` is the single-node initial-style API. Keep dirty @@ -952,14 +949,6 @@ fn resolve_element_styles( pseudo_element, None, ); - if let Some(root_style) = styles.primary.as_ref().filter(|computed| { - computed - .flags - .contains(ComputedValueFlags::IS_ROOT_ELEMENT_STYLE) - }) { - synchronize_root_style_device(context.shared.stylist.device(), root_style); - resolved_root_style = Some(root_style.clone()); - } unsafe { let mut data = element.ensure_data(); data.styles = styles.clone(); @@ -981,9 +970,6 @@ fn resolve_element_styles( world .document_state .note_element_style_resolutions(ancestor_resolution_count.saturating_add(1)); - if let Some(root_style) = resolved_root_style { - *retained.root_style.borrow_mut() = Some(root_style); - } styles }); world @@ -997,7 +983,7 @@ fn materialize_ancestor_styles_for_resolution( element: E, world: &DocumentStyleWorld, validation_path: Option<&[StyleValidationPathEntry]>, -) -> (u64, Option>) +) -> u64 where E: TElement, { @@ -1024,7 +1010,6 @@ where .note_ancestor_style_validation_visits(ancestors.len() as u64); let mut resolution_count = 0_u64; - let mut resolved_root_style = None; let mut force_descendants = false; for (ancestor, required_generation) in ancestors { let handle = DomHandle::new(ancestor.as_node().debug_id()); @@ -1043,14 +1028,6 @@ where ancestor.ensure_data().styles = ElementStyles::default(); } let styles = resolve_style(context, ancestor, RuleInclusion::All, None, None); - if let Some(root_style) = styles.primary.as_ref().filter(|computed| { - computed - .flags - .contains(ComputedValueFlags::IS_ROOT_ELEMENT_STYLE) - }) { - synchronize_root_style_device(context.shared.stylist.device(), root_style); - resolved_root_style = Some(root_style.clone()); - } resolution_count = resolution_count.saturating_add(1); unsafe { let mut data = ancestor.ensure_data(); @@ -1063,7 +1040,7 @@ where .mark_element_current(handle, required_generation); force_descendants = true; } - (resolution_count, resolved_root_style) + resolution_count } /// Maps the memoized DOM path suffix back to Stylo ancestors. diff --git a/moli-renderer-v8/src/style_engine/retained.rs b/moli-renderer-v8/src/style_engine/retained.rs index f86009039..51fd1b51c 100644 --- a/moli-renderer-v8/src/style_engine/retained.rs +++ b/moli-renderer-v8/src/style_engine/retained.rs @@ -2,9 +2,7 @@ use std::collections::HashSet; use std::sync::atomic::{AtomicU64, Ordering}; use style::{ author_styles::AuthorStyles, - device::Device, invalidation::stylesheets::StylesheetInvalidationSet, - properties::ComputedValues, servo_arc::Arc as ServoArc, shared_lock::{SharedRwLock, StylesheetGuards}, stylesheets::{CustomMediaMap, DocumentStyleSheet, Origin, OriginSet, UrlExtraData}, @@ -167,7 +165,6 @@ pub(super) fn build_retained_style_system( stylist_identity: NEXT_STYLIST_IDENTITY.fetch_add(1, Ordering::Relaxed), key, stylist, - root_style: Default::default(), document_stylesheets, shadow_scopes, stylesheet_resources, @@ -386,9 +383,6 @@ fn update_document_scope( if device_changed { let device = new_style_device_with_viewport(key.style_viewport(), key.environment, key.quirks_mode); - if let Some(root_style) = retained.root_style.borrow().as_ref() { - synchronize_root_style_device(&device, root_style); - } let guard = shared_lock.read(); let guards = StylesheetGuards::same(&guard); device_affected_origins = retained.stylist.set_device(device, &guards); @@ -443,32 +437,6 @@ fn update_document_scope( } } -/// Synchronizes the state Stylo uses for root font-relative units. -/// -/// Full Stylo traversal performs this in `matching.rs`, but Moli deliberately -/// uses the single-node `resolve_style` API and therefore must mirror the -/// update when the root element is materialized. Keeping this helper at the -/// Device boundary also lets a rebuilt Device inherit the current root state -/// before any descendant is resolved against it. -pub(super) fn synchronize_root_style_device( - device: &Device, - root_style: &ServoArc, -) { - device.set_root_style(root_style); - let font = root_style.get_font(); - let font_size = font.clone_font_size().computed_size(); - device.set_root_font_size(root_style.effective_zoom.unzoom(font_size.px())); - - let line_height = device - .calc_line_height(font, root_style.writing_mode, None) - .0; - device.set_root_line_height(root_style.effective_zoom.unzoom(line_height.px())); - - if device.used_root_font_metrics() { - device.update_root_font_metrics(); - } -} - fn refresh_retained_derived_state( retained: &mut RetainedStyleSystem, device_changed: bool, diff --git a/moli-renderer-v8/src/style_engine/state.rs b/moli-renderer-v8/src/style_engine/state.rs index c6cdcc37f..6bb6016f8 100644 --- a/moli-renderer-v8/src/style_engine/state.rs +++ b/moli-renderer-v8/src/style_engine/state.rs @@ -6,7 +6,6 @@ use std::{ use moli_selector::StyloSourceInvalidationFallbackReason; use selectors::context::SelectorCaches; use style::{ - properties::ComputedValues, servo_arc::Arc as ServoArc, stylist::{CascadeData, Stylist}, }; @@ -32,11 +31,6 @@ pub(super) struct RetainedStyleSystem { pub(super) stylist_identity: u64, pub(super) key: StyleWorldKey, pub(super) stylist: Stylist, - /// Last computed style for this Document's root element. Stylo normally - /// updates the Device's root-relative unit state during a full traversal; - /// Moli resolves demanded paths one element at a time, so retain the root - /// style explicitly both for those resolutions and for Device rebuilds. - pub(super) root_style: RefCell>>, pub(super) document_stylesheets: ActiveStylesheetCollection, pub(super) shadow_scopes: Vec, pub(super) stylesheet_resources: StylesheetResourceManifest, diff --git a/moli-renderer-v8/src/stylesheet_runtime/connected.rs b/moli-renderer-v8/src/stylesheet_runtime/connected.rs index e77a56186..36eb0782a 100644 --- a/moli-renderer-v8/src/stylesheet_runtime/connected.rs +++ b/moli-renderer-v8/src/stylesheet_runtime/connected.rs @@ -550,11 +550,11 @@ impl DocumentRuntime { owner = ?binding.owner(), element = ?binding.element(), load_delay_token = ?binding.load_delay_token(), - ?settled, + settled, reason, "settled main connected style load before event posting" ); - settled != crate::frame_owner_model::MainDocumentStyleLoadEventSettlement::NotOwned + settled } fn settle_connected_style_load_admission( diff --git a/moli-web-mime/src/classification.rs b/moli-web-mime/src/classification.rs index 35fca10b5..3ff3a0dd5 100644 --- a/moli-web-mime/src/classification.rs +++ b/moli-web-mime/src/classification.rs @@ -51,14 +51,6 @@ pub fn is_text_mime_essence(input: &str) -> bool { input.starts_with("text/") } -/// Navigation responses rendered as literal text rather than HTML or XML. -/// This also includes JSON and JavaScript resources opened as documents. -pub fn is_text_document_mime(input: &str) -> bool { - !is_html_document_mime(input) - && !is_dom_parser_xml_mime(input) - && (is_text_mime(input) || is_json_module_mime(input) || is_javascript_mime(input)) -} - /// Whether a `style` element's raw `type` attribute selects classic CSS. /// /// HTML's update-a-style-block algorithm compares the untrimmed attribute diff --git a/moli-web-mime/src/lib.rs b/moli-web-mime/src/lib.rs index ec09da8be..615e882d2 100644 --- a/moli-web-mime/src/lib.rs +++ b/moli-web-mime/src/lib.rs @@ -14,9 +14,9 @@ pub use classification::{ is_font_mime, is_font_mime_essence, is_form_urlencoded_mime, is_html_document_mime, is_image_mime, is_image_mime_essence, is_javascript_mime, is_javascript_mime_essence, is_json_module_mime, is_multipart_form_data_mime, is_png_image_mime, is_png_image_mime_essence, - is_supported_document_mime_type, is_svg_image_mime, is_svg_image_mime_essence, - is_text_document_mime, is_text_mime, is_text_mime_essence, is_video_mime, - is_video_mime_essence, is_webassembly_mime, multipart_form_data_boundary, + is_supported_document_mime_type, is_svg_image_mime, is_svg_image_mime_essence, is_text_mime, + is_text_mime_essence, is_video_mime, is_video_mime_essence, is_webassembly_mime, + multipart_form_data_boundary, }; pub use data_url::{ data_url_body_and_computed_mime_type, data_url_body_and_mime_type, data_url_mime_type, diff --git a/moli-web-mime/src/tests.rs b/moli-web-mime/src/tests.rs index a21ab2a60..e475520a9 100644 --- a/moli-web-mime/src/tests.rs +++ b/moli-web-mime/src/tests.rs @@ -648,25 +648,3 @@ fn script_like_mime_type_block_matches_fetch_response_rule() { )); assert!(!should_script_like_response_be_blocked_due_to_mime_type(&[])); } -#[test] -fn document_text_classification_excludes_html_and_xml() { - for mime in [ - "text/plain", - "Text/Css; charset=utf-8", - "application/json", - "application/ld+json", - "application/javascript", - ] { - assert!(crate::is_text_document_mime(mime), "{mime}"); - } - for mime in [ - "text/html", - "text/xml", - "application/xhtml+xml", - "image/svg+xml", - "image/png", - "application/octet-stream", - ] { - assert!(!crate::is_text_document_mime(mime), "{mime}"); - } -} diff --git a/moli/src/fetch_dump/tests.rs b/moli/src/fetch_dump/tests.rs index e9ed136c6..a1599c989 100644 --- a/moli/src/fetch_dump/tests.rs +++ b/moli/src/fetch_dump/tests.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use anyhow::Result; -use axum::{Router, response::Html, routing::get}; +use axum::{Router, routing::get}; use moli_core::{ LayoutPolicy, page::Page, @@ -36,7 +36,7 @@ async fn load_page_with_config( "/", get(move || { let body = Arc::clone(&server_body); - async move { Html((*body).clone()) } + async move { (*body).clone() } }), ); axum::serve(listener, app).await.unwrap(); diff --git a/moli/src/telemetry.rs b/moli/src/telemetry.rs index 626b89d53..830307bc7 100644 --- a/moli/src/telemetry.rs +++ b/moli/src/telemetry.rs @@ -19,7 +19,6 @@ pub fn init(log_filter: &str) { .with_env_filter(filter) .with_target(false) .with_writer(std::io::stderr) - .log_internal_errors(false) .try_init(); } @@ -108,31 +107,4 @@ mod tests { assert!(output.contains("fallback-info")); assert!(!output.contains("dependency-warning")); } - - #[test] - fn broken_diagnostic_writer_does_not_panic() { - const CHILD: &str = "MOLI_TEST_BROKEN_DIAGNOSTIC_SINK"; - if std::env::var_os(CHILD).is_some() { - super::init("error"); - tracing::error!("the diagnostic receiver has already exited"); - return; - } - let (reader, writer) = std::io::pipe().unwrap(); - drop(reader); - let status = std::process::Command::new(std::env::current_exe().unwrap()) - .args([ - "--exact", - "telemetry::tests::broken_diagnostic_writer_does_not_panic", - "--nocapture", - ]) - .env(CHILD, "1") - .stdout(std::process::Stdio::null()) - .stderr(writer) - .status() - .unwrap(); - assert!( - status.success(), - "logging to a closed diagnostic pipe terminated the process: {status}" - ); - } } diff --git a/moli/tests/fetch_cli.rs b/moli/tests/fetch_cli.rs index 4e2ffaa43..19dabe4c8 100644 --- a/moli/tests/fetch_cli.rs +++ b/moli/tests/fetch_cli.rs @@ -1315,73 +1315,6 @@ fn cli_dump_html_keeps_uncaught_script_errors_out_of_stdout() -> Result<()> { Ok(()) } -#[test] -fn cli_uncaught_script_error_is_logged_to_writable_stderr() -> Result<()> { - let output = Command::new(env!("CARGO_BIN_EXE_moli")) - .args([ - "fetch", - "--log-level", - "error", - "--wait-until", - "load", - "--timeout", - "5000", - "--dump", - "html", - "data:text/html,after", - ]) - .output()?; - - let stdout = clean_output(&output.stdout); - let stderr = clean_output(&output.stderr); - assert!(output.status.success(), "stdout={stdout}\nstderr={stderr}"); - assert!(stdout.contains("
after
")); - assert!(stderr.contains("Uncaught Error: stderr-control")); - Ok(()) -} - -#[cfg(unix)] -#[test] -fn cli_uncaught_script_error_survives_broken_stderr_pipe() -> Result<()> { - use std::{fs::File, os::fd::FromRawFd, process::Stdio}; - - let mut pipe_fds = [0; 2]; - // SAFETY: `pipe_fds` points to two writable integers. Each returned file - // descriptor is transferred to exactly one owner or closed below. - if unsafe { libc::pipe(pipe_fds.as_mut_ptr()) } != 0 { - return Err(std::io::Error::last_os_error().into()); - } - // Closing the read endpoint makes the child's first diagnostic write - // return BrokenPipe, matching a launcher whose stderr reader disappeared. - unsafe { libc::close(pipe_fds[0]) }; - // SAFETY: the write descriptor is valid and ownership moves into `File`. - let broken_stderr = unsafe { File::from_raw_fd(pipe_fds[1]) }; - let output = Command::new(env!("CARGO_BIN_EXE_moli")) - .args([ - "fetch", - "--log-level", - "error", - "--wait-until", - "load", - "--timeout", - "5000", - "--dump", - "html", - "data:text/html,after", - ]) - .stderr(Stdio::from(broken_stderr)) - .output()?; - - let stdout = clean_output(&output.stdout); - assert_eq!( - output.status.code(), - Some(0), - "broken diagnostic pipe must not abort the browser: stdout={stdout}" - ); - assert!(stdout.contains("
after
")); - Ok(()) -} - #[test] fn cli_http_cache_dir_reuses_cached_response_across_processes() -> Result<()> { let runtime = tokio::runtime::Runtime::new()?;