From 94f3f042369d2dc148c0b3d886b512bb09efe6a8 Mon Sep 17 00:00:00 2001 From: meh Date: Wed, 9 Sep 2026 17:26:13 +0700 Subject: [PATCH 01/11] fix(release): read the version without a lockfile `cargo pkgid` resolves the dependency graph, so it requires a `Cargo.lock`. This repo stopped committing one, and the command has failed ever since: error: a Cargo.lock must exist for this command The step did not fail with it. `VERSION` was assigned the empty string from a failed substitution, the job continued, and the bundle verification compared `0.1.37` against nothing: ##[error]bundle reports 0.1.37 but this release is So 0.1.37 was merged, built and signed, and never published. `cargo metadata --no-deps` reads the workspace manifests without resolving anything, needs no lock, and creates none. Verified both ways with the lock moved aside: it returns 0.1.37 offline, while `pkgid` reproduces the error above. The empty-version guard is the other half. A release that cannot name itself should stop rather than publish under a blank version. --- .github/workflows/release.yml | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b0713b2..4c7fb29 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,10 +62,23 @@ jobs: - name: Compare the committed version against the published one id: decide run: | - # `cargo pkgid` rather than a TOML parser. The version is resolved by - # the tool that owns it, so this cannot disagree with what the build - # actually produces, and it needs no interpreter on the runner. - VERSION=$(cargo pkgid -p chuzz-gui | sed 's/.*@//') + # Cargo reads the version rather than a TOML parser, so this cannot + # disagree with what the build produces and needs no interpreter on + # the runner. `--no-deps` reads the workspace manifests only. + # + # Not `cargo pkgid`: that resolves the dependency graph and so refuses + # to run without a `Cargo.lock`, which this repo deliberately does not + # commit. It exited non-zero here, left VERSION empty, and the job ran + # on to compare the built bundle against an empty string. 0.1.37 was + # merged, tagged and never published that way. Hence the guard below: + # an unreadable version fails the release instead of publishing a + # nameless one. + VERSION=$(cargo metadata --no-deps --format-version 1 \ + | sed -n 's/.*"name":"chuzz-gui","version":"\([^"]*\)".*/\1/p') + if [ -z "$VERSION" ]; then + echo "::error::could not read chuzz-gui's version from cargo metadata" + exit 1 + fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" # `latest.json` is written by this workflow further down, so its shape From 7fff90222525b4e31afc3c8e0e3a076f02b4ef02 Mon Sep 17 00:00:00 2001 From: meh Date: Wed, 9 Sep 2026 17:37:44 +0700 Subject: [PATCH 02/11] fix(build): do not build the browser chrome for a headless binary `build_frontend` ran unconditionally, so `cargo build --bin chuzz-headless --no-default-features` shelled out to `bun run build` in `apps/chuzz/frontend` for assets that binary never links. `frontend.rs` is the only consumer of the generated module and is already behind `gui`. On a machine without `apps/chuzz/frontend/node_modules` the build script panicked instead: error: script "layouts:local" exited with code 127 error: script "prebuild" exited with code 127 That is every runner using the `headless-host` action, which installs the site under test's dependencies and has no reason to install this crate's, so the whole fleet's QA went red in the host build step. Verified both ways with `node_modules` moved aside: the headless build now finishes, and `--bin chuzz-gui` still fails there with the error above, which is the proof the gate did it rather than something else. The helpers move behind the same feature so an unused import does not become a denied warning. --- apps/chuzz/build.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/chuzz/build.rs b/apps/chuzz/build.rs index 03f37e1..e5940b4 100644 --- a/apps/chuzz/build.rs +++ b/apps/chuzz/build.rs @@ -1,11 +1,21 @@ +use std::process::Command; + +// Everything below is the embedded browser chrome, which only a `gui` build +// compiles. Off that feature these are dead, and an unused import is a warning +// the workspace denies. +#[cfg(feature = "gui")] use std::fs; +#[cfg(feature = "gui")] use std::io::Write; +#[cfg(feature = "gui")] use std::path::{Path, PathBuf}; -use std::process::Command; +#[cfg(feature = "gui")] use brotli::CompressorWriter; +#[cfg(feature = "gui")] const CSS_MARKER: &str = "__CHUZZ_EMBEDDED_CSS__"; +#[cfg(feature = "gui")] const JS_URL: &str = "chuzz://ui/__chuzz__/app.js"; /// First line of a command's stdout, or `None` when it fails or prints nothing. @@ -51,6 +61,7 @@ fn stamp_build() { println!("cargo:rerun-if-changed=src"); } +#[cfg(feature = "gui")] fn only_file_with_extension(directory: &Path, extension: &str) -> PathBuf { let mut matches = fs::read_dir(directory) .unwrap_or_else(|error| panic!("cannot read {}: {error}", directory.display())) @@ -68,6 +79,7 @@ fn only_file_with_extension(directory: &Path, extension: &str) -> PathBuf { path } +#[cfg(feature = "gui")] fn compress_asset(path: &Path, output: &Path, quality: u32) -> usize { let input = fs::read(path).unwrap_or_else(|error| panic!("cannot read {}: {error}", path.display())); @@ -84,6 +96,7 @@ fn compress_asset(path: &Path, output: &Path, quality: u32) -> usize { /// Compile and Brotli-embed the Solid browser chrome using the same asset /// loading shape as AgencyZero's Blitz document factory. +#[cfg(feature = "gui")] fn build_frontend() { let manifest_dir = PathBuf::from( std::env::var_os("CARGO_MANIFEST_DIR").expect("Cargo sets CARGO_MANIFEST_DIR"), @@ -170,6 +183,15 @@ fn strip_unused_frameworks() { fn main() { strip_unused_frameworks(); stamp_build(); + // The Solid browser chrome, consumed only by `frontend.rs`, which is itself + // behind `gui`. Building it unconditionally meant `cargo build --bin + // chuzz-headless --no-default-features` shelled out to `bun run build` for + // assets that binary never links, and then failed on any machine where + // `apps/chuzz/frontend/node_modules` was not installed. That is every CI + // runner using the headless-host action, which installs the site's + // dependencies and has no reason to install this crate's. It took the whole + // fleet's QA red. + #[cfg(feature = "gui")] build_frontend(); // Generates the Tauri context, which only the `chuzz-gui` binary consumes. // A headless build has no `tauri` in its graph for the context to describe, From 70e190cd796d11db62c912f3d879fae99c016221 Mon Sep 17 00:00:00 2001 From: meh Date: Wed, 9 Sep 2026 17:37:54 +0700 Subject: [PATCH 03/11] fix(tls): name the crypto provider instead of implying it `rustls` picks its provider from crate features and panics at the first handshake when the graph enables neither `ring` nor `aws-lc-rs`, or both. Features are additive across a graph, so which of those holds is an outcome of resolution rather than a decision anyone made, and with no lockfile it is not fixed at any point in time: one dependency picking up `ring` in a later release is enough to turn every `https://` fetch and every `wss://` connection into a panicked worker on the next runner that resolves it. It was hit on a fresh resolution during QA work, on `rustls 0.23.43`, and went away on re-resolution to 0.23.44. Today's graph enables `aws-lc-rs` alone, so this is latent rather than reproducible here, which is exactly the problem: nothing holds it there. The failure does not look like a browser failure. The socket never opens, Solid halts reactivity on the escaped error, and the page collapses to unnamed nodes, so a QA run reports a broken site. Both binaries now install a named provider before anything can reach the network. Verified by capturing an https page end to end. --- Cargo.toml | 8 ++++++++ apps/chuzz/Cargo.toml | 1 + apps/chuzz/src/headless_main.rs | 4 ++++ apps/chuzz/src/lib.rs | 22 ++++++++++++++++++++++ apps/chuzz/src/tauri_main.rs | 5 +++++ 5 files changed, 40 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 87791f8..a194a13 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -98,6 +98,14 @@ tokio-tungstenite = { version = "0.30", default-features = false, features = [ "handshake", "rustls-tls-native-roots", ] } +# Named directly only so the binary can install a crypto provider by name. See +# `install_crypto_provider`: rustls otherwise picks one from crate features and +# panics at the first handshake when the resolution enables neither provider or +# both, and with no lockfile that is not fixed at any point in time. +rustls = { version = "0.23", default-features = false, features = [ + "aws-lc-rs", + "std", +] } futures-util = { version = "0.3", default-features = false, features = ["sink"] } tokio = "1" url = "2.5" diff --git a/apps/chuzz/Cargo.toml b/apps/chuzz/Cargo.toml index 790f0bc..9147919 100644 --- a/apps/chuzz/Cargo.toml +++ b/apps/chuzz/Cargo.toml @@ -151,6 +151,7 @@ url.workspace = true serde.workspace = true serde_json.workspace = true tauri = { workspace = true, optional = true } +rustls.workspace = true tauri-runtime-blitz.workspace = true tokio-tungstenite.workspace = true futures-util.workspace = true diff --git a/apps/chuzz/src/headless_main.rs b/apps/chuzz/src/headless_main.rs index bf50d0d..0ab4c17 100644 --- a/apps/chuzz/src/headless_main.rs +++ b/apps/chuzz/src/headless_main.rs @@ -14,6 +14,10 @@ use chuzz_gui::serve; fn main() { + // Before the loader can reach the network. See the function's own comment + // for why the provider is named here rather than left to the resolution. + chuzz_gui::install_crypto_provider(); + let args: Vec = std::env::args().collect(); let target = match serve::target_from(&args) { Ok(target) => target, diff --git a/apps/chuzz/src/lib.rs b/apps/chuzz/src/lib.rs index fa63d6e..7ce675c 100644 --- a/apps/chuzz/src/lib.rs +++ b/apps/chuzz/src/lib.rs @@ -14,6 +14,28 @@ //! the harness, so the harness measured a browser nobody ships. There is one //! loader now, and one place a gap gets fixed. +/// Name the TLS provider, rather than letting the resolver imply one. +/// +/// `rustls` selects its cryptographic provider from crate features, and panics +/// at the first handshake when the graph enables neither `ring` nor `aws-lc-rs` +/// or enables both. Features are additive across a dependency graph, so which +/// of those holds is an outcome of resolution rather than a decision anyone +/// made. This repository commits no lockfile, so it is not fixed at any point +/// in time either: one crate picking up `ring` in a later release is enough to +/// turn every `https://` fetch and every `wss://` connection into a panicked +/// worker on the next runner that resolves it. +/// +/// It presents as a site bug rather than a browser one. The socket never +/// opens, Solid halts reactivity on the escaped error, and the page collapses +/// to unnamed nodes, so a QA run reports a broken site. +/// +/// Both binaries call this before anything can reach the network. An `Err` +/// means a provider is already installed, which is the outcome being asked +/// for, so it is discarded. +pub fn install_crypto_provider() { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); +} + // The window and its Tauri command surface. Behind `gui` because `tauri` is, // and because a headless build has no window to drive. #[cfg(feature = "gui")] diff --git a/apps/chuzz/src/tauri_main.rs b/apps/chuzz/src/tauri_main.rs index 21e2c6c..c9056ec 100644 --- a/apps/chuzz/src/tauri_main.rs +++ b/apps/chuzz/src/tauri_main.rs @@ -114,6 +114,11 @@ fn flag_value(args: &[String], flag: &str) -> Option { } fn main() { + // Before any of the entry points below can reach the network. See the + // function's own comment for why the provider is named here rather than + // left to the resolution. + chuzz_gui::install_crypto_provider(); + // Before `--capture`, and matched by equality rather than by prefix, so the // two flags cannot be confused for each other in either direction. #[cfg(feature = "capture")] From a053e9686578769f8f642d4bb791c2c58ad50fb1 Mon Sep 17 00:00:00 2001 From: meh Date: Wed, 9 Sep 2026 18:31:09 +0700 Subject: [PATCH 04/11] chore: drop the ignore rule for Python that is no longer here The corpus tooling was rewritten out of the repository, so nothing under scripts/corpus is Python any more and no build step produces bytecode. The ignore rule outlived the files it was written for. Leaving it in place is worse than merely dead. Python is not allowed in this tree, and an ignore rule for its bytecode is the one thing that would keep a reintroduction out of git status, so the rule quietly works against the convention it now has nothing to serve. --- .gitignore | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.gitignore b/.gitignore index 3326ed4..039ce90 100644 --- a/.gitignore +++ b/.gitignore @@ -22,10 +22,6 @@ apps/chuzz/gen/ # exist on one machine. See scripts/local-engine.sh. .cargo/local-engine.toml -# Python bytecode from scripts/corpus/ -__pycache__/ -*.pyc - # Lockfiles are not committed here. Every dependency is a caret range on a # published version, so a build resolves the newest thing that satisfies it and # a broken upstream release fails the build that introduced it. A committed lock From 5042abe03d0e74615e6eab3f90316d742ab7829d Mon Sep 17 00:00:00 2001 From: meh Date: Wed, 9 Sep 2026 23:42:53 +0700 Subject: [PATCH 05/11] refactor: reach the control surface directly, not through a runtime The headless browser depended on `tauri-runtime-blitz` with `default-features = false` and `agent-control` plus `diagnostics`: a Tauri runtime with the runtime switched off, taken purely to reach an inspection socket. It also read the wire vocabulary through that crate's re-export rather than depending on the protocol at all, so naming an `AgentControlRequest` meant compiling Tauri, and on Linux that reaches GTK. The control surface now lives in `blitz-control-protocol`, so this takes it directly. `tauri-runtime-blitz` becomes optional and is pulled only by `gui`, which is the build that actually opens a window. `tauri-build` goes the same way. `build.rs` only calls it under `gui`, but the build-dependency was unconditional, so a headless build compiled it and its two transitives for a binary that never uses it. The same fault, one plane down. Measured on the headless feature set, 1810 lines of dependency tree: tauri-runtime-blitz: 0 any tauri: 0 and cargo says so itself, unprompted: warning: patch `tauri-runtime-blitz v0.4.0` was not used in the crate graph --- Cargo.toml | 4 ++++ apps/chuzz/Cargo.toml | 10 +++++++-- apps/chuzz/src/serve.rs | 30 ++++++++++++++++++--------- apps/chuzz/tests/follows_a_link.rs | 4 ++-- apps/chuzz/tests/serves_inspection.rs | 2 +- 5 files changed, 35 insertions(+), 15 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index a194a13..858179f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,10 @@ blitz-wasm = { package = "ps-blitz-wasm", version = "^0.4" } dioxus-native = { package = "ps-dioxus-native", version = "^0.7.3", default-features = false } # The MCP framing the control server speaks, same source and feature as # tauri-runtime-blitz uses for its agent-control surface. +# The one control surface: wire vocabulary, the document core, and both +# transports. It used to be reached through `tauri-runtime-blitz`, which meant a +# headless browser compiled a Tauri runtime with the runtime switched off. +blitz-control-protocol = { version = "^0.5", default-features = false } endpoint-libs = { version = "^3", default-features = false, features = ["agent-control"] } brotli = { version = "8", default-features = false, features = ["std"] } image = { version = "^0.25.6", default-features = false } diff --git a/apps/chuzz/Cargo.toml b/apps/chuzz/Cargo.toml index 9147919..9bcd067 100644 --- a/apps/chuzz/Cargo.toml +++ b/apps/chuzz/Cargo.toml @@ -52,6 +52,8 @@ default = [ # rasteriser and the inspection socket, which is everything ps-qa drives. gui = [ "dep:tauri", + "dep:tauri-runtime-blitz", + "dep:tauri-build", "tauri-runtime-blitz/runtime", # The window paints glyphs, so it wants real faces. On Linux this reaches # parley's enumeration and therefore fontconfig, which is why it is here and @@ -152,7 +154,8 @@ serde.workspace = true serde_json.workspace = true tauri = { workspace = true, optional = true } rustls.workspace = true -tauri-runtime-blitz.workspace = true +blitz-control-protocol = { workspace = true, features = ["engine", "server", "capture"] } +tauri-runtime-blitz = { workspace = true, optional = true } tokio-tungstenite.workspace = true futures-util.workspace = true @@ -178,4 +181,7 @@ wat = "1" [build-dependencies] brotli.workspace = true -tauri-build = { version = "2", features = [] } +# Only the window needs a Tauri context. Optional so a headless build does not +# compile `tauri-build` and its two transitives for a binary whose `build.rs` +# never calls it: the same fault as the runtime dependency, one plane down. +tauri-build = { version = "2", features = [], optional = true } diff --git a/apps/chuzz/src/serve.rs b/apps/chuzz/src/serve.rs index ce0a45b..aa766a6 100644 --- a/apps/chuzz/src/serve.rs +++ b/apps/chuzz/src/serve.rs @@ -37,19 +37,20 @@ use std::path::Path; use std::sync::Arc; use std::sync::mpsc; +use blitz_control_protocol::document::{ + DocumentCapture, click_agent_node, focus_agent_node, hover_agent_node, inspect_document, + press_agent_key, snapshot_document, +}; +use blitz_control_protocol::server::{AgentControlServer, ControlBridgeRequest, Host}; +use blitz_control_protocol::{ + AgentAction, AgentControlRequest, DebugError, DebugEvent, DebugResponse, DiagnosticsRequest, + InputCommand, KeyPhase, WindowComposition, +}; use blitz_dom::Document as _; use blitz_script::ScriptDocument; use blitz_traits::events::{BlitzImeEvent, UiEvent}; use blitz_traits::net::Url; use blitz_traits::shell::{ColorScheme, Viewport}; -use tauri_runtime_blitz::control_protocol::{ - AgentAction, AgentControlRequest, DebugError, DebugEvent, DebugResponse, DiagnosticsRequest, - InputCommand, KeyPhase, WindowComposition, -}; -use tauri_runtime_blitz::{ - AgentControlServer, ControlBridgeRequest, DocumentCapture, click_agent_node, focus_agent_node, - hover_agent_node, inspect_document, press_agent_key, snapshot_document, -}; fn trace(message: &str) { eprintln!("chuzz-headless: {message}"); @@ -545,7 +546,7 @@ pub fn serve(target: &str) -> Result<(), String> { tokio::sync::oneshot::Sender, )>(MAX_PENDING_REQUESTS); - let bridge: tauri_runtime_blitz::ControlBridge = Arc::new(move |request| { + let bridge: blitz_control_protocol::server::ControlBridge = Arc::new(move |request| { let (response_tx, response_rx) = tokio::sync::oneshot::channel(); match request_tx.try_send((request, response_tx)) { Ok(()) => response_rx, @@ -572,7 +573,16 @@ pub fn serve(target: &str) -> Result<(), String> { }); let (render_events, render_event_receiver) = tokio::sync::watch::channel(None); - let server = AgentControlServer::start_with_events(bridge, render_event_receiver) + // What this process calls itself over MCP. `diagnostics` is true because + // this binary is built with the protocol's `capture` feature, so + // `blitz.diagnostics` answers rather than erroring: advertising a tool that + // fails every call reads as a broken application instead of a plain build. + let host = Host { + name: "chuzz-headless".to_owned(), + version: env!("CARGO_PKG_VERSION").to_owned(), + diagnostics: true, + }; + let server = AgentControlServer::start_with_events(bridge, host, render_event_receiver) .map_err(|error| format!("could not host the control socket: {error}"))?; trace(&format!( "inspection socket listening: {}", diff --git a/apps/chuzz/tests/follows_a_link.rs b/apps/chuzz/tests/follows_a_link.rs index 04930d2..38f3eb9 100644 --- a/apps/chuzz/tests/follows_a_link.rs +++ b/apps/chuzz/tests/follows_a_link.rs @@ -22,7 +22,7 @@ use std::io::{BufRead, BufReader}; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; -use tauri_runtime_blitz::control_protocol::{ +use blitz_control_protocol::{ AgentAction, AgentControlRequest, DebugResponse, JsonRpcId, MessageStream, TransportStream, decode_response, encode_agent_request, framed_json, }; @@ -65,7 +65,7 @@ async fn request( async fn tree( stream: &mut dyn MessageStream, next_id: &mut i64, -) -> tauri_runtime_blitz::control_protocol::AgentSnapshot { +) -> blitz_control_protocol::AgentSnapshot { let answer = request( stream, next_id, diff --git a/apps/chuzz/tests/serves_inspection.rs b/apps/chuzz/tests/serves_inspection.rs index 94430a1..d55badb 100644 --- a/apps/chuzz/tests/serves_inspection.rs +++ b/apps/chuzz/tests/serves_inspection.rs @@ -23,7 +23,7 @@ use std::io::{BufRead, BufReader}; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; -use tauri_runtime_blitz::control_protocol::{ +use blitz_control_protocol::{ AgentAction, AgentControlRequest, CaptureRequest, DebugEvent, DebugResponse, DebugStream, DiagnosticsRequest, InputCommand, JsonRpcId, KeyPhase, MessageStream, Modifiers, PointerPhase, TransportStream, WheelPhase, decode_diagnostics_event, decode_response, encode_agent_request, From c53403d5c0be664d40971cbab25e47eeff0c107f Mon Sep 17 00:00:00 2001 From: meh Date: Wed, 9 Sep 2026 23:54:09 +0700 Subject: [PATCH 06/11] refactor(lib): publish the browser so an embedder can depend on it The package was `chuzz-gui` with `publish = false`, so the library its own header calls "the browser, as a library, so that more than one binary can be it" was named after one of its binaries and could not be depended on at all. AgencyZero is about to embed it. The package is now `chuzz` and publishable. Both binaries keep their names, because build-app.sh, the release workflow and the Homebrew cask all reference them. `serve` gains the name for what it is: the headless runtime for Blitz, the pair to `tauri-runtime-blitz`. It stays a module rather than becoming a crate because it needs `document_loader`, `page_server`, `nav` and `identity`, which is most of a browser, and the last attempt to package it separately is the `qa-inspect-host` story in its own header. The unused `chuzz-control` dependency goes: it was declared and never imported, and the vocabulary it duplicated now comes from `blitz-control-protocol`. --- apps/chuzz/Cargo.toml | 10 ++++++---- apps/chuzz/src/headless_main.rs | 4 ++-- apps/chuzz/src/serve.rs | 12 +++++++++++- apps/chuzz/src/tauri_main.rs | 6 +++--- 4 files changed, 22 insertions(+), 10 deletions(-) diff --git a/apps/chuzz/Cargo.toml b/apps/chuzz/Cargo.toml index 9bcd067..609bb36 100644 --- a/apps/chuzz/Cargo.toml +++ b/apps/chuzz/Cargo.toml @@ -1,6 +1,9 @@ [package] -# Package and binary share the name, matching AgencyZero's `apps/gui` -> `az-gui`. -name = "chuzz-gui" +# The package is the browser as a library, so an embedder can depend on it. +# The binaries keep their own names: `chuzz-gui` is the window, `chuzz-headless` +# the inspection host, and both are referenced by build-app.sh, the release +# workflow and the Homebrew cask. +name = "chuzz" description = "A pure Rust web browser" version.workspace = true edition.workspace = true @@ -9,7 +12,7 @@ license.workspace = true authors.workspace = true repository.workspace = true homepage.workspace = true -publish = false +publish = true [[bin]] name = "chuzz-gui" @@ -133,7 +136,6 @@ blitz-script = { workspace = true, optional = true } blitz-traits = { workspace = true, default-features = true } blitz-wasm = { workspace = true, optional = true } wasmi = { workspace = true, optional = true } -chuzz-control = { path = "../../crates/chuzz-control" } brotli.workspace = true image = { workspace = true, default-features = false } anyrender = { workspace = true, optional = true } diff --git a/apps/chuzz/src/headless_main.rs b/apps/chuzz/src/headless_main.rs index 0ab4c17..6dde359 100644 --- a/apps/chuzz/src/headless_main.rs +++ b/apps/chuzz/src/headless_main.rs @@ -11,12 +11,12 @@ //! uses. See that module for why the host is a mode of the browser instead of a //! second one. -use chuzz_gui::serve; +use chuzz::serve; fn main() { // Before the loader can reach the network. See the function's own comment // for why the provider is named here rather than left to the resolution. - chuzz_gui::install_crypto_provider(); + chuzz::install_crypto_provider(); let args: Vec = std::env::args().collect(); let target = match serve::target_from(&args) { diff --git a/apps/chuzz/src/serve.rs b/apps/chuzz/src/serve.rs index aa766a6..ed075b8 100644 --- a/apps/chuzz/src/serve.rs +++ b/apps/chuzz/src/serve.rs @@ -1,4 +1,14 @@ -//! Serve one page over the inspection socket, with no window. +//! The headless runtime for Blitz: one page, no window, driven over the +//! control socket. +//! +//! The pair to `tauri-runtime-blitz`, which is the same engine embedded in a +//! Tauri window. That one is an adapter, about 4,000 lines letting Tauri host +//! a Blitz document. This is the browser itself with the window left off, so +//! it is a module here rather than a crate of its own: it needs +//! `document_loader`, `page_server`, `nav` and `identity`, which is most of a +//! browser, and the last attempt to package it separately is the cautionary +//! tale below. +//! //! //! # Why this is here and not in a second crate //! diff --git a/apps/chuzz/src/tauri_main.rs b/apps/chuzz/src/tauri_main.rs index c9056ec..52f9e86 100644 --- a/apps/chuzz/src/tauri_main.rs +++ b/apps/chuzz/src/tauri_main.rs @@ -95,8 +95,8 @@ const MENU_VIEW_SOURCE: &str = "menu-view-source"; // The modules live in the library beside this binary, because the headless // host is the same browser and compiles the same tree. See `lib.rs`. #[cfg(feature = "capture")] -use chuzz_gui::capture; -use chuzz_gui::{browser, frontend, nav}; +use chuzz::capture; +use chuzz::{browser, frontend, nav}; use browser::Browser; @@ -117,7 +117,7 @@ fn main() { // Before any of the entry points below can reach the network. See the // function's own comment for why the provider is named here rather than // left to the resolution. - chuzz_gui::install_crypto_provider(); + chuzz::install_crypto_provider(); // Before `--capture`, and matched by equality rather than by prefix, so the // two flags cannot be confused for each other in either direction. From 09bac6c4df2f8ba4c8c6b1bf75d7ae705aa09c4d Mon Sep 17 00:00:00 2001 From: meh Date: Thu, 10 Sep 2026 00:00:43 +0700 Subject: [PATCH 07/11] build: no GPU renderer in a browser that opens no window Nothing in this crate imports `dioxus_native`. It was an unconditional dependency, and because it rejects a build that names no renderer, the headless feature set carried `vello` to satisfy it. `vello` is wgpu, naga, winit and AppKit, in a binary whose only rasteriser is the CPU one. It is optional now and `gui` turns it on. Measured on the headless feature set, with `vello` no longer named at all: wgpu 17 -> 0 naga 6 -> 0 dioxus-native 2 -> 0 objc2-app-kit 4 -> 1 tree 1810 -> 1378 lines The CPU rasteriser is untouched: `ps-anyrender-vello-cpu` is still there, which is what `capture` was always for. `winit` survives at 6, and its path is now exact: winit <- ps-blitz-shell <- blitz-control-protocol <- chuzz The protocol crate's `capture` feature takes blitz-shell for frame timings and the deep-profiling session. Splitting the rasteriser from the diagnostics would finish the job; it is a feature split in that crate, not here. --- apps/chuzz/Cargo.toml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/chuzz/Cargo.toml b/apps/chuzz/Cargo.toml index 609bb36..1117ed2 100644 --- a/apps/chuzz/Cargo.toml +++ b/apps/chuzz/Cargo.toml @@ -63,6 +63,7 @@ gui = [ # not in the base dependencies: a headless build must not want a font stack. "blitz-dom/system-fonts", "blitz-script?/system-fonts", + "dep:dioxus-native", "dioxus-native/system-fonts", ] # Let a WebAssembly guest build a page, through `--wasm` in the window and @@ -106,8 +107,8 @@ capture = [ "wasm", ] avif = ["image/avif"] -vello = ["dioxus-native/vello"] -vello-hybrid = ["dioxus-native/vello-hybrid"] +vello = ["dioxus-native?/vello"] +vello-hybrid = ["dioxus-native?/vello-hybrid"] # `incremental` was a feature flag upstream; PR #599 made it a runtime setting on # DocumentConfig, so the forwarding feature no longer has anything to forward to. scrollbars = ["blitz-dom/scrollbars"] @@ -143,7 +144,12 @@ anyrender_vello_cpu = { workspace = true, optional = true } blitz-paint = { workspace = true, optional = true } png = { workspace = true, optional = true } flate2.workspace = true -dioxus-native = { workspace = true, features = [ +# Nothing in this crate imports `dioxus_native`. It is here for the window's +# renderer selection, so it is optional and `gui` turns it on. While it was +# unconditional it rejected a build that named no renderer, which is why the +# headless feature set carried `vello`, and `vello` is wgpu, winit and AppKit in +# a browser that opens no window. +dioxus-native = { workspace = true, optional = true, features = [ "accessibility", "clipboard", "net", From a1c2552aaeecaf6f5eb6d0cf05e30d9e0092723d Mon Sep 17 00:00:00 2001 From: meh Date: Sat, 12 Sep 2026 00:57:19 +0700 Subject: [PATCH 08/11] fix(control): share native actions and enable fonts for rendered QA --- .github/actions/headless-host/action.yml | 16 +- Cargo.toml | 2 +- README.md | 21 +++ apps/chuzz/Cargo.toml | 8 +- apps/chuzz/src/serve.rs | 181 +++-------------------- 5 files changed, 55 insertions(+), 173 deletions(-) diff --git a/.github/actions/headless-host/action.yml b/.github/actions/headless-host/action.yml index 887d3e1..b6a8ba0 100644 --- a/.github/actions/headless-host/action.yml +++ b/.github/actions/headless-host/action.yml @@ -26,10 +26,10 @@ inputs: file can use a field an older driver does not know, and the failure then reads as a broken workflow rather than a driver that is too old. - 0.6.3 is the floor because it is the first that reads `QA_TIMEOUT_SCALE` - from the environment. Every site workflow sets it, so an older driver - silently runs a shared runner against the strict local latency contract. - default: "^0.6.3" + 0.7.1 uses the shared control protocol and rejects checks that silently + inherit another file's page. Keep the driver on the same protocol line + as the headless host. + default: "^0.7.1" outputs: host: @@ -52,6 +52,12 @@ runs: - name: Install Rust uses: dtolnay/rust-toolchain@stable + - name: Install fonts for rendered outcomes + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y pkg-config libfontconfig1-dev fonts-dejavu-core + - uses: Swatinem/rust-cache@v2 with: workspaces: .qa-host @@ -77,5 +83,5 @@ runs: # development headers to build a browser that never opens a window. cargo build --release --manifest-path .qa-host/Cargo.toml \ --bin chuzz-headless --no-default-features \ - --features capture,javascript,vello,scrollbars,webp + --features capture,javascript,scrollbars,webp,system-fonts echo "host=$PWD/.qa-host/target/release/chuzz-headless" >> "$GITHUB_OUTPUT" diff --git a/Cargo.toml b/Cargo.toml index 858179f..2cd68d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -130,7 +130,7 @@ tauri = { version = "^2.11.5", default-features = false } # explaining. A window that would not paint could not be photographed by the # one thing that could photograph it, because the capability had been optimised # out of the binary that needed it. -tauri-runtime-blitz = { version = "^0.3.7", default-features = false, features = [ +tauri-runtime-blitz = { version = "^0.4.0", default-features = false, features = [ "agent-control", "diagnostics", ] } diff --git a/README.md b/README.md index fd2853f..9cf20e6 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,27 @@ Events are not wired up yet: the page renders and does not respond. ## Rendering without a window +For interactive website QA, build the headless host with fonts and use ps-qa +0.7.1 or newer: + +```sh +cargo build --release --bin chuzz-headless --no-default-features \ + --features capture,javascript,scrollbars,webp,system-fonts +ps-qa --app ../worktables.dev/tests/ps-qa/ps-qa.ron qa-hosted \ + --host target/release/chuzz-headless --page ../worktables.dev/dist \ + --checks ../worktables.dev/tests/ps-qa/checks +``` + +Linux needs `pkg-config`, `libfontconfig1-dev`, and a font catalogue such as +`fonts-dejavu-core`; the shared headless-host CI action installs these. No desktop +server is needed. The `system-fonts` feature is optional for embedders supplying +their own fonts, but rendered website checks need real glyphs. + +The host dispatches input through the shared `DocumentControl` implementation, +including pointer gestures, key-down/up, and scrolling. Once an action has been +applied, a page that continues animating does not turn it into a failed action +that a caller might repeat; subsequent inspection observes the resulting state. + ```sh chuzz-gui --capture out.png https://example.com # a fetched page chuzz-gui --capture-wasm demo.wasm --out out.png --tree out.txt diff --git a/apps/chuzz/Cargo.toml b/apps/chuzz/Cargo.toml index 1117ed2..1865edb 100644 --- a/apps/chuzz/Cargo.toml +++ b/apps/chuzz/Cargo.toml @@ -60,9 +60,8 @@ gui = [ "tauri-runtime-blitz/runtime", # The window paints glyphs, so it wants real faces. On Linux this reaches # parley's enumeration and therefore fontconfig, which is why it is here and - # not in the base dependencies: a headless build must not want a font stack. - "blitz-dom/system-fonts", - "blitz-script?/system-fonts", + # not in the base dependencies. Rendered QA opts in through the same feature. + "system-fonts", "dep:dioxus-native", "dioxus-native/system-fonts", ] @@ -75,6 +74,9 @@ wasm = ["dep:blitz-wasm", "dep:wasmi"] # Most of the web builds its DOM in JavaScript: without this a script-rendered # page parses to an empty mount point and paints nothing. javascript = ["dep:blitz-script"] +# Opt in for rendered QA as well as GUI builds. A minimal headless embedder +# can still omit font discovery and provide its own faces. +system-fonts = ["blitz-dom/system-fonts", "blitz-script?/system-fonts"] # The `image` crate is pulled in with default features off, so without these a # .webp or .avif downloads fine and then cannot be decoded: the element simply # never paints, which reads as a missing asset rather than a missing codec. diff --git a/apps/chuzz/src/serve.rs b/apps/chuzz/src/serve.rs index ed075b8..0be5575 100644 --- a/apps/chuzz/src/serve.rs +++ b/apps/chuzz/src/serve.rs @@ -42,23 +42,19 @@ //! waits for the line, so a component that wedges the engine cannot poison the //! next one's verdict. -use std::num::NonZeroUsize; use std::path::Path; use std::sync::Arc; use std::sync::mpsc; -use blitz_control_protocol::document::{ - DocumentCapture, click_agent_node, focus_agent_node, hover_agent_node, inspect_document, - press_agent_key, snapshot_document, -}; +use blitz_control_protocol::document::{DocumentCapture, inspect_document, snapshot_document}; +use blitz_control_protocol::in_process::DocumentControl; use blitz_control_protocol::server::{AgentControlServer, ControlBridgeRequest, Host}; use blitz_control_protocol::{ - AgentAction, AgentControlRequest, DebugError, DebugEvent, DebugResponse, DiagnosticsRequest, - InputCommand, KeyPhase, WindowComposition, + AgentControlRequest, DebugError, DebugEvent, DebugResponse, DiagnosticsRequest, + WindowComposition, }; use blitz_dom::Document as _; use blitz_script::ScriptDocument; -use blitz_traits::events::{BlitzImeEvent, UiEvent}; use blitz_traits::net::Url; use blitz_traits::shell::{ColorScheme, Viewport}; @@ -285,7 +281,12 @@ fn settle_response( } Err(failure) => { *painted = failure.painted; - DebugResponse::Error(failure.error) + // The action has already been applied. A busy page is not a failed + // click, and reporting it as one invites callers to repeat writes. + // Leave the remaining work to the idle loop; ps-qa waits for the + // declared outcome within its own deadline. + verbose(&failure.error.message); + DebugResponse::Ack } } } @@ -608,6 +609,7 @@ pub fn serve(target: &str) -> Result<(), String> { let mut revision = 0_u64; let mut render_revision = 0_u64; let mut capture = DocumentCapture::new(); + let mut control = DocumentControl::new(); // When the page was last allowed to run. A request resets nothing on its // own, so this is what keeps the guarantee below true under load. let mut last_tick = std::time::Instant::now(); @@ -635,170 +637,21 @@ pub fn serve(target: &str) -> Result<(), String> { revision += 1; inspect_document(&mut document, root, max_depth, revision) } - AgentControlRequest::Act(AgentAction::Focus { node_id }) => { - let node_id = blitz_dom::NodeId::from_u64(node_id); - match focus_agent_node(&mut document, node_id) { - Ok(()) => settle_response( - &mut document, - &animation_clock, - settle_deadline, - &mut painted, - ), - Err(error) => DebugResponse::Error(error), - } - } - AgentControlRequest::Act(AgentAction::Click { node_id }) => { - match click_agent_node(&mut document, node_id, 1) { - Ok(_) => settle_response( - &mut document, - &animation_clock, - settle_deadline, - &mut painted, - ), - Err(error) => DebugResponse::Error(error), - } - } - AgentControlRequest::Act(AgentAction::ScrollIntoView { node_id }) => { - /* - * Really scrolled, not acknowledged. - * - * The host this replaces served one component on a page - * that never overflowed, so it answered Ack and was right - * by accident. A fleet site scrolls, and a control below - * the fold that is never brought into view is hovered at - * whatever happens to be at its coordinates, which reads as - * a control that does not respond. - */ - let node_id = blitz_dom::NodeId::from_u64(node_id); - document.inner_mut().scroll_to_node(node_id); - settle_response( + AgentControlRequest::Act(action) => match control.act(&mut document, action) { + Ok(()) => settle_response( &mut document, &animation_clock, settle_deadline, &mut painted, - ) - } - AgentControlRequest::Act(AgentAction::Hover { node_id }) => { - /* - * A control revealed on hover is unreachable without this, - * and a defect that only shows on the second entry is - * unreachable even with one hover: a pill whose hover - * appends a shadow layer and never removes it looks right - * once. - */ - match hover_agent_node(&mut document, node_id) { - Ok(_) => settle_response( - &mut document, - &animation_clock, - settle_deadline, - &mut painted, - ), - Err(error) => DebugResponse::Error(error), - } - } - AgentControlRequest::Act(AgentAction::DoubleClick { node_id }) => { - match click_agent_node(&mut document, node_id, 2) { - Ok(_) => settle_response( - &mut document, - &animation_clock, - settle_deadline, - &mut painted, - ), - Err(error) => DebugResponse::Error(error), - } - } - AgentControlRequest::Act(AgentAction::SetValue { node_id, value }) => { - let node_id = blitz_dom::NodeId::from_u64(node_id); - let current = document - .inner() - .get_node(node_id) - .and_then(|node| node.element_data()) - .and_then(|element| element.text_input_data()) - .map(|input| input.editor.text().to_string()); - match current { - None => DebugResponse::Error(DebugError { - code: "notEditable".into(), - message: "node is not a text input".into(), - }), - Some(current) => { - document.inner_mut().set_focus_to(node_id); - /* - * Clear by byte count, not by selecting the text - * first. - * - * `select_all` builds its selection with - * `move_lines(&layout, isize::MAX)`, and - * `select_byte_range` resolves its ends through - * `Cursor::from_byte_index(&layout, ..)`. Both read - * the laid out text, so both depend on a font - * catalogue being present: with none registered - * every glyph shapes to nothing, the selection - * comes back collapsed, and the commit below - * inserts at the caret instead of replacing. - * - * This build has faces, so `select_all` would work - * here. It stays byte arithmetic anyway, because a - * host that behaves differently depending on which - * fonts the machine has is a harness that reports - * different verdicts on CI and on a laptop. - * `delete_bytes_before_selection` and - * `delete_bytes_after_selection` clamp to the ends - * of the buffer, so between them they empty it from - * wherever the caret is, with no layout involved. - */ - if let Some(len) = NonZeroUsize::new(current.len()) { - document.inner_mut().with_text_input(node_id, |mut editor| { - editor.delete_bytes_before_selection(len); - editor.delete_bytes_after_selection(len); - }); - } - document.handle_ui_event(UiEvent::Ime(BlitzImeEvent::Commit(value))); - settle_response( - &mut document, - &animation_clock, - settle_deadline, - &mut painted, - ) - } - } - } - AgentControlRequest::Act(AgentAction::Input(InputCommand::Key { - key, - code, - phase, - .. - })) => { - /* - * One press per Down, and nothing on the matching Up. - * - * `press_agent_key` sends both halves, because a control - * that acts on keyup never fires if only a keydown arrives. - * A client that sends the pair would otherwise press the - * key twice, and Escape pressed twice closes a menu and - * then whatever was behind it. - */ - if matches!(phase, KeyPhase::Up) { - DebugResponse::Ack - } else { - match press_agent_key(&mut document, &key, &code) { - Ok(()) => settle_response( - &mut document, - &animation_clock, - settle_deadline, - &mut painted, - ), - Err(error) => DebugResponse::Error(error), - } - } - } + ), + Err(error) => DebugResponse::Error(error), + }, // Everything else needs runtime state this mode does not have, // and saying so is better than a plausible-looking Ack: a check // that silently did nothing reports the page as broken. _ => DebugResponse::Error(DebugError { code: "unsupported".into(), - message: "the headless page serves Inspect, Focus, Hover, Click, \ - DoubleClick, ScrollIntoView, SetValue and Key only" - .into(), + message: "the headless page does not handle process lifecycle requests".into(), }), }, ControlBridgeRequest::Diagnostics(DiagnosticsRequest::Capture(request)) => { From 4ad2d73e5179c6568f3bd7f8f55594e8957c01ef Mon Sep 17 00:00:00 2001 From: meh Date: Sat, 12 Sep 2026 01:15:37 +0700 Subject: [PATCH 09/11] fix(websocket): deliver open to every queued RPC listener --- README.md | 4 ++++ apps/chuzz/src/ws_bridge.rs | 12 ++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9cf20e6..9116131 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,10 @@ The host dispatches input through the shared `DocumentControl` implementation, including pointer gestures, key-down/up, and scrolling. Once an action has been applied, a page that continues animating does not turn it into a failed action that a caller might repeat; subsequent inspection observes the resulting state. +WebSocket dispatch snapshots the registered listeners, so a listener removing +itself cannot skip another queued RPC waiting for the same connection to open. +Listeners removed before their turn are skipped; newly added listeners wait for +the next dispatch. ```sh chuzz-gui --capture out.png https://example.com # a fetched page diff --git a/apps/chuzz/src/ws_bridge.rs b/apps/chuzz/src/ws_bridge.rs index b0040a6..085fdf2 100644 --- a/apps/chuzz/src/ws_bridge.rs +++ b/apps/chuzz/src/ws_bridge.rs @@ -354,8 +354,16 @@ pub const WEBSOCKET_SHIM: &str = r#" function fire(type, event) { var direct = socket["on" + type]; if (typeof direct === "function") { direct.call(socket, event); } - var registered = listeners[type]; - for (var i = 0; i < registered.length; i++) { registered[i].call(socket, event); } + // A listener may remove itself while handling the event (the RPC adapter + // does this when sending queued requests on open). Iterating the live + // array skips the next request after that removal. New listeners belong + // to the next dispatch; explicitly removed listeners must not run. + var registered = listeners[type].slice(); + for (var i = 0; i < registered.length; i++) { + if (listeners[type].indexOf(registered[i]) >= 0) { + registered[i].call(socket, event); + } + } } this.__deliver = function (event) { From 020ab9b1ea20f8719d3e128fc0dcbdb1a5810095 Mon Sep 17 00:00:00 2001 From: meh Date: Sat, 12 Sep 2026 01:37:46 +0700 Subject: [PATCH 10/11] fix(build): use the published browser package name --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 4 ++-- README.md | 6 +++--- scripts/local-engine.sh | 4 ++-- scripts/render-check.sh | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6f2200..33a3e9d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -155,7 +155,7 @@ jobs: shared-key: chuzz-macos-ci - name: Clippy - run: cargo clippy -p chuzz-gui --all-targets -- -D warnings + run: cargo clippy -p chuzz --all-targets -- -D warnings - name: Test run: cargo test --workspace diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4c7fb29..fb6f0cc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,9 +74,9 @@ jobs: # an unreadable version fails the release instead of publishing a # nameless one. VERSION=$(cargo metadata --no-deps --format-version 1 \ - | sed -n 's/.*"name":"chuzz-gui","version":"\([^"]*\)".*/\1/p') + | sed -n 's/.*"name":"chuzz","version":"\([^"]*\)".*/\1/p') if [ -z "$VERSION" ]; then - echo "::error::could not read chuzz-gui's version from cargo metadata" + echo "::error::could not read chuzz's version from cargo metadata" exit 1 fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" diff --git a/README.md b/README.md index 9116131..935bcd7 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ chrome is a SolidJS app interpreted by Boa, the same way page content is, and ev it is Rust. ```sh -cargo run -p chuzz-gui # opens a blank tab -cargo run -p chuzz-gui -- example.com # opens a bare hostname over HTTPS -cargo run -p chuzz-gui -- --wasm demo.wasm # a tab a WebAssembly guest builds +cargo run -p chuzz --bin chuzz-gui # opens a blank tab +cargo run -p chuzz --bin chuzz-gui -- example.com # opens a bare hostname over HTTPS +cargo run -p chuzz --bin chuzz-gui -- --wasm demo.wasm # a tab a WebAssembly guest builds ``` A non-URL argument is not a search: anything that is neither a URL nor a hostname diff --git a/scripts/local-engine.sh b/scripts/local-engine.sh index 34ba107..679503d 100755 --- a/scripts/local-engine.sh +++ b/scripts/local-engine.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Run a cargo command against the engine working checkouts instead of the pins. # -# scripts/local-engine.sh check -p chuzz-gui -# scripts/local-engine.sh run -p chuzz-gui -- --wasm demo.wasm +# scripts/local-engine.sh check -p chuzz +# scripts/local-engine.sh run -p chuzz --bin chuzz-gui -- --wasm demo.wasm # # Everything after the script name is passed through to cargo unchanged. # diff --git a/scripts/render-check.sh b/scripts/render-check.sh index 005582d..8ba02bd 100755 --- a/scripts/render-check.sh +++ b/scripts/render-check.sh @@ -33,7 +33,7 @@ mkdir -p "$out_dir" if [ ! -x "$binary" ]; then echo "building chuzz-gui with the capture feature" >&2 - ( cd "$repo_dir" && cargo build -q -p chuzz-gui --release --features capture ) + ( cd "$repo_dir" && cargo build -q -p chuzz --bin chuzz-gui --release --features capture ) fi capture_one() { From 5590227dbeb692720f249d7cb1a0fc0fe4003f06 Mon Sep 17 00:00:00 2001 From: meh Date: Sat, 12 Sep 2026 01:40:32 +0700 Subject: [PATCH 11/11] test(control): verify supported headless input commands --- apps/chuzz/tests/serves_inspection.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/chuzz/tests/serves_inspection.rs b/apps/chuzz/tests/serves_inspection.rs index d55badb..6e4d8bc 100644 --- a/apps/chuzz/tests/serves_inspection.rs +++ b/apps/chuzz/tests/serves_inspection.rs @@ -411,7 +411,7 @@ fn serves_a_page_over_the_inspection_socket() { }), ) .await, - DebugResponse::Error(error) if error.code == "unsupported" + DebugResponse::Ack )); assert!(matches!( @@ -433,7 +433,9 @@ fn serves_a_page_over_the_inspection_socket() { Ok(DebugEvent::PaintCommitted { .. }) )); - for unsupported in [ + // These inputs now use the shared document-control implementation. + // A page without a scroll range still accepts a wheel as a no-op. + for supported in [ AgentControlRequest::Act(AgentAction::Input(InputCommand::Pointer { phase: PointerPhase::Move, x: 1.0, @@ -447,9 +449,13 @@ fn serves_a_page_over_the_inspection_socket() { phase: WheelPhase::Moved, modifiers: Modifiers::default(), })), - AgentControlRequest::Relaunch, - AgentControlRequest::Quit, ] { + assert!(matches!( + request(&mut stream, &mut next_id, &supported).await, + DebugResponse::Ack + )); + } + for unsupported in [AgentControlRequest::Relaunch, AgentControlRequest::Quit] { assert!(matches!( request(&mut stream, &mut next_id, &unsupported).await, DebugResponse::Error(error) if error.code == "unsupported"