diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f00fed5..79b6c6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: dtolnay/rust-toolchain@2eae45db285e407f22119950686d47e1101e071b # 1.88 - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - - run: cargo check -p blitz-control-protocol -p ps-blitz-debug-control -p ps-qa + - run: cargo check -p blitz-control-protocol -p ps-qa core: name: Protocol, transports, and driver (Linux) @@ -37,9 +37,16 @@ jobs: - name: Format run: cargo fmt --all --check - name: Clippy - run: cargo clippy -p blitz-control-protocol -p ps-blitz-debug-control -p ps-qa --all-targets -- -D warnings + run: cargo clippy --workspace --all-targets -- -D warnings - name: Test - run: cargo test -p blitz-control-protocol -p ps-blitz-debug-control -p ps-qa + run: cargo test --workspace + # The workspace above builds the protocol with no features, which is the + # vocabulary and both transports' framing. The core is behind `engine` + # and `capture` because it needs the renderer, and a job that never + # enables them would leave the semantic tree, the capture surface and the + # in-process transport untested here. + - name: The core, and both transports over it + run: cargo test -p blitz-control-protocol --all-features - name: Keep the driver renderer-free run: | cargo tree -p ps-qa > /tmp/ps-qa-tree.txt @@ -47,6 +54,17 @@ jobs: echo 'ps-qa pulled a renderer or window runtime into its dependency tree' >&2 exit 1 fi + # The same boundary from the other side. The protocol crate carries the + # core now, so with no features enabled it must still cost a client + # nothing: an accidental non-optional renderer dependency would put the + # engine into every consumer of the vocabulary, including this one. + - name: Keep the vocabulary renderer-free + run: | + cargo tree -p blitz-control-protocol > /tmp/protocol-tree.txt + if grep -Eq '(^| )((ps-)?blitz-(dom|html|paint|shell)|tauri|winit|wgpu) v' /tmp/protocol-tree.txt; then + echo 'the default protocol build pulled in a renderer' >&2 + exit 1 + fi package: name: Package boundaries @@ -64,7 +82,6 @@ jobs: set -euo pipefail cargo package -p blitz-control-protocol - cargo package -p ps-blitz-debug-control # The new protocol cannot exist in the registry before this PR is # merged. Verify the driver tarball against this exact # protocol source without installing a persistent workspace patch; diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 62848f7..36d724d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -74,7 +74,6 @@ jobs: } publish_if_new blitz-control-protocol - publish_if_new ps-blitz-debug-control publish_if_new ps-qa # The host embeds tauri-runtime-blitz, which in turn consumes the # protocol above. Keep it last so a new protocol and driver can ship diff --git a/Cargo.toml b/Cargo.toml index 4693840..e4d1143 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,6 @@ resolver = "3" members = [ "crates/blitz-control-protocol", - "crates/ps-blitz-debug-control", "crates/ps-qa", ] @@ -13,7 +12,7 @@ license = "MIT OR Apache-2.0" repository = "https://github.com/pathscale/ps-observability" [workspace.dependencies] -blitz-control-protocol = { version = "^0.4", path = "crates/blitz-control-protocol" } +blitz-control-protocol = { version = "^0.5", path = "crates/blitz-control-protocol" } endpoint-libs = { version = "^3", default-features = false, features = ["agent-control"] } serde = { version = "^1", features = ["derive"] } serde_json = "^1" diff --git a/README.md b/README.md index fd017cc..020db15 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,19 @@ keeps the protocol, transports, driver, and release documentation together so the system has one ownership boundary. ```text -application ── tauri-runtime-blitz ── blitz-control-protocol ── ps-qa - ▲ - │ - chuzz-headless ────────┘ - -renderer embedder ── ps-blitz-debug-control ── WebDriver-style HTTP client + blitz-control-protocol + ┌──────────┴──────────────┐ + the vocabulary the core + │ (over blitz-dom) + ┌─────────────┴─────────────┐ │ + socket transport in-process transport + │ │ │ + server client an embedder holding + │ │ the document + │ └── ps-qa (no blitz, no window) + │ + ├── tauri-runtime-blitz, for the application window + └── chuzz-headless, for a page with no window ``` The headless host is not here. It used to be, as `qa-inspect-host`, and that @@ -27,12 +34,24 @@ same loader and the same engine a tab uses and serves this protocol over the same socket. `ps-qa` still links no renderer, because that constraint was always about the socket rather than about which repository the host lives in. -These are two deliberate alternatives, not two stacked transports. -`blitz-control-protocol` is the typed MCP/JSON-RPC inspection plane used by -`tauri-runtime-blitz`, the headless host, and `ps-qa`. -`ps-blitz-debug-control` is a smaller HTTP adapter for embedders that need a -WebDriver-shaped session and command channel; it does not depend on or duplicate -the typed protocol crate. +`blitz-control-protocol` is the whole surface: one vocabulary, one core, and +two transports over that core. The core reads and drives a `blitz-dom` +document and knows nothing about sockets. The socket transport is how a harness +or an agent outside the process reaches a running application; the in-process +transport is how an embedder that already holds the document calls straight in. +Both halves of the socket are here, because a peer can be both: an application +driven by agents while it drives a browser it embeds is a client and a server +at once. + +Everything that needs the renderer is behind a feature, so a client pays for +none of it. `cargo tree -p ps-qa` showing no renderer, window runtime or GPU +stack is the check, and CI fails on it. + +The loopback WebDriver-shaped adapter that used to be here, `ps-blitz-debug-control`, +lives in [pathscale/ps-blitz](https://github.com/pathscale/ps-blitz) now. It is +what `blitz-script` takes behind its `debug-control` feature, so keeping it here +made ps-blitz depend on this repository while this repository needs `blitz-dom`. +Two repositories pointing at each other have no release order. `endpoint-libs` owns framing and MCP/JSON-RPC wire primitives. This workspace owns observability semantics: commands, events, revision rules, session @@ -41,9 +60,9 @@ instrumentation hooks but do not own a control server. ## Crates -- `blitz-control-protocol`: transport-neutral observability domain types and - their MCP wire encoding. It deliberately has no renderer dependency. -- `ps-blitz-debug-control`: loopback WebDriver-style transport adapter. +- `blitz-control-protocol`: the vocabulary, the core, and both transports. The + vocabulary and the transports have no renderer dependency; the core is behind + a feature and is the only part that does. - `ps-qa`: the lightweight driver, audit runner, and report generator. ## Quick start diff --git a/crates/blitz-control-protocol/Cargo.toml b/crates/blitz-control-protocol/Cargo.toml index c158bbe..56b7e4a 100644 --- a/crates/blitz-control-protocol/Cargo.toml +++ b/crates/blitz-control-protocol/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "blitz-control-protocol" -description = "Wire types for the Blitz agent-control and diagnostics protocol" -version = "0.4.0" +description = "The Blitz agent-control and diagnostics surface: one vocabulary, one core, two transports" +version = "0.5.0" edition.workspace = true rust-version.workspace = true license.workspace = true @@ -13,19 +13,108 @@ keywords = ["observability", "testing", "blitz", "mcp", "ui"] categories = ["development-tools::testing", "development-tools::debugging"] publish = true -# Deliberately tiny. This crate exists so a client can speak the protocol -# without building the runtime that serves it: adding anything here that pulls -# in a window, a renderer or a font stack defeats the whole point. The check -# is `cargo tree -p blitz-control-protocol`, which should stay in the dozens -# of crates rather than the hundreds. +# The vocabulary is what this crate is by default, and it stays deliberately +# tiny. A client speaks the protocol without building the renderer that serves +# it: adding anything to the *default* dependencies that pulls in a window, a +# renderer or a font stack defeats the whole point. The check is +# `cargo tree -p blitz-control-protocol`, which should stay in the dozens of +# crates rather than the hundreds, and `cargo tree -p ps-qa`, which CI fails if +# a renderer or a window runtime appears in it. +# +# Everything that needs blitz is behind a feature, and the features are cut on +# that boundary rather than on what each consumer happened to want: +# +# engine the core. Reads and drives a `blitz-dom` document. +# capture the core's expensive half: offscreen paint, layout and +# computed-style snapshots, renderer metrics. +# server the socket transport, listening half. No blitz. +# client the socket transport, connecting half. No blitz. +# +# `server` and `client` carry no engine because a transport does not need one: +# the framing is shared, the document is not. `ps-qa` takes `client` and gets +# serde, endpoint-libs and tokio. A peer that is both, such as AgencyZero +# driving an embedded browser while agents drive it, takes both halves. +[features] +default = [] +engine = [ + "dep:blitz-dom", + "dep:blitz-script", + "dep:blitz-traits", + "dep:keyboard-types", + "dep:style", + # The role rules live in blitz-dom, which owns them for the AccessKit tree + # as well. Naming the feature rather than relying on it being a default: + # a consumer that turns blitz-dom's defaults off must still get the roles. + "blitz-dom/accessibility", +] +capture = [ + "engine", + "dep:anyrender", + "dep:anyrender_vello_cpu", + "dep:base64", + "dep:blitz-paint", + # Frame timings and the deep-profiling session, which blitz-shell owns + # because it is what presents frames. This is the only reason the shell is + # in the graph, so a build that reads the semantic tree and drives input + # gets no window stack at all. + "dep:blitz-shell", + # Where the frame timings and the sampling guard are: `blitz-shell` keeps + # both behind this feature, and it forwards to `blitz-script/debug-control` + # for the script half of the same capture. + "blitz-shell/debug-control", +] +server = ["dep:tokio"] +client = ["dep:eyre", "dep:tokio"] + [dependencies] endpoint-libs.workspace = true serde.workspace = true serde_json.workspace = true schemars.workspace = true +# The core, and nothing else, reaches for these. +blitz-dom = { package = "ps-blitz-dom", version = "^0.4.8", optional = true } +# Naming stylo directly: the computed-style reader and the visibility check read +# `style::values::computed`, and blitz-dom does not re-export it. The version is +# the one blitz-dom resolves, so cargo unifies them instead of putting two +# incompatible copies of every computed value in the graph. +style = { version = "0.20.0", package = "stylo", optional = true } +# No `system-fonts`. It reaches `fontique/system` and, on Linux, the +# `yeslogic-fontconfig-sys` system library, so enabling it here would decide +# that every consumer needs one installed, including a headless one that reads +# no font catalogue. An application that wants the machine's fonts asks itself. +blitz-script = { package = "ps-blitz-script", version = "^0.4.8", optional = true } +blitz-traits = { package = "ps-blitz-traits", version = "^0.4.8", optional = true } +keyboard-types = { version = "0.7", optional = true } + +# Capture. The CPU renderer on purpose: a capture must not need a GPU, a +# surface or a display server, or it stops working in the places it is most +# needed. It draws the same scene through the same `blitz-paint` entry point +# the window uses, so what it returns is the real frame rather than a second +# opinion about it. +anyrender = { package = "ps-anyrender", version = "^0.13.0", optional = true } +anyrender_vello_cpu = { package = "ps-anyrender-vello-cpu", version = "^0.17.0", optional = true } +blitz-paint = { package = "ps-blitz-paint", version = "^0.4.8", optional = true } +blitz-shell = { package = "ps-blitz-shell", version = "^0.4.8", default-features = false, optional = true } +base64 = { version = "0.22", optional = true } + +# Both transports. A listener and a connector need the same runtime and the +# same framing, and nothing else. +tokio = { version = "1", features = [ + "io-util", + "macros", + "net", + "rt", + "sync", + "time", +], optional = true } +eyre = { version = "0.6", optional = true } + [dev-dependencies] # The framing round-trip test drives a real duplex pipe rather than asserting # on a string, because the bug this protocol keeps hitting is at the seam # between the typed value and the bytes, not inside serde. -tokio = { version = "1", features = ["io-util", "macros", "rt"] } +tokio = { version = "1", features = ["io-util", "macros", "net", "rt", "time"] } + +[package.metadata.docs.rs] +all-features = true diff --git a/crates/blitz-control-protocol/src/client.rs b/crates/blitz-control-protocol/src/client.rs new file mode 100644 index 0000000..2352676 --- /dev/null +++ b/crates/blitz-control-protocol/src/client.rs @@ -0,0 +1,622 @@ +//! The socket transport, connecting half: finding a running host, and talking +//! to it. +//! +//! # Why this is a sibling of the server and not a layer above it +//! +//! A peer can be both. AgencyZero is driven by agents over this protocol while +//! it drives a browser it embeds, and a QA harness that launches a headless +//! host is a client of one socket in a process that could serve another. The +//! two halves share the vocabulary, the framing and the error taxonomy, and +//! neither is built on the other, so a build takes whichever halves it needs. +//! +//! Neither half needs blitz. The framing is the protocol; the document is not. +//! `ps-qa` takes this feature and nothing else, and `cargo tree -p ps-qa` +//! showing no renderer, window runtime or GPU stack is the check that keeps it +//! that way. +//! +//! The transport is deliberately not hand-rolled. Frames are length-prefixed +//! rather than newline-delimited, which is why a naive socket read hangs, and +//! `endpoint_libs`' `framed_json` is the same codec the server writes with. +//! +//! This was `ps-qa`'s `inspector` module. It moved here so there is one client +//! rather than one per consumer: the browser's own `chuzz-inspect` had grown a +//! second one that built its requests as untyped JSON by hand, with a comment +//! explaining which fields the runtime would and would not accept. + +use std::collections::VecDeque; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use std::time::Instant; + +use crate::{ + AgentControlRequest, AgentSnapshot, DEBUG_PROTOCOL_VERSION, DebugDescriptor, DebugEvent, + DebugProtocolError, DebugResponse, DebugStream, DiagnosticsRequest, JsonRpcId, JsonRpcMessage, + JsonRpcRequest, MCP_PROTOCOL_VERSION, MessageStream, TransportStream, WireMessage, + decode_diagnostics_event_value, decode_response_value, decode_wire_value, encode_agent_request, + encode_diagnostics_request, encode_rpc, framed_json, peek_value_request_id, +}; +use eyre::{Context, Result, bail, eyre}; +use tokio::net::UnixStream; +use tokio::time::timeout; + +/// Matches the Python client's bench timeout. Long because a driven +/// interaction can leave the app resolving for a while before it answers. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); +const MAX_QUEUED_EVENTS: usize = 256; + +/// Where an inspector announced itself, and what it said. +#[derive(Debug)] +pub struct Descriptor { + pub path: PathBuf, + pub descriptor: DebugDescriptor, + /// The descriptor verbatim, for the dump modes. Reprinting a re-serialized + /// struct would hide any field this build of the tool does not know about. + pub raw: serde_json::Value, + verified_reachable: bool, +} + +impl Descriptor { + pub fn socket_path(&self) -> PathBuf { + match self.descriptor.address.strip_prefix("unix://") { + Some(path) => PathBuf::from(path), + // The Python fell back to the descriptor path with the extension + // swapped, and the server does name the socket that way. + None => self.path.with_extension("sock"), + } + } + + /// Trap 8 in docs/performance.md: an unpinned descriptor directory keeps + /// dead instances around. Pid existence is not enough: macOS reuses pids, + /// so an unrelated process can make a stale descriptor look current. The + /// control socket is the service, and a successful connection is the only + /// liveness check that proves the descriptor can actually be used. + fn is_reachable(&self) -> bool { + std::os::unix::net::UnixStream::connect(self.socket_path()).is_ok() + } + + pub fn warn_if_stale(&self) { + if !self.verified_reachable && !self.is_reachable() { + eprintln!( + "warning: descriptor {} names pid {}, but its control socket is unreachable", + self.path.display(), + self.descriptor.pid + ); + } + } +} + +/// Locate a running inspector, preferring an explicitly pinned descriptor. +/// +/// `--descriptor ` wins. Otherwise the build's own pinned path is tried, +/// then the temporary directory is scanned, which is the fallback for a +/// hand-launched build and the one that can find a stale instance. +/// +/// A named descriptor that is not there is an error, not an invitation to +/// scan. Falling through to discovery attached to the newest *other* host on +/// the machine, which on a machine running several suites at once is another +/// site's document: the tree came back, it was plausible, and it described a +/// page nobody had asked about. A typo in a path is not consent to inspect +/// somebody else's application. +pub fn discover(explicit: Option<&str>) -> Result { + if let Some(path) = explicit { + let path = PathBuf::from(path); + if !path.exists() { + bail!( + "descriptor {} does not exist. ps-qa attaches to the descriptor you \ + name and to no other; omit --descriptor to discover a running host.", + path.display() + ); + } + return read_descriptor(&path); + } + + // The delivery script pins this path into the bundle's `Info.plist`, so a + // locally built app announces itself here and nowhere else. Scanning only + // $TMPDIR meant the one instance that was definitely running was the one + // instance discovery could not see, and it picked a dead descriptor from a + // previous run instead, which is how preferring a live pid still failed. + let pinned = PathBuf::from("target/blitz-control.json"); + if pinned.exists() + && let Ok(mut descriptor) = read_descriptor(&pinned) + && descriptor.is_reachable() + { + descriptor.verified_reachable = true; + return Ok(descriptor); + } + + let root = PathBuf::from(std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".into())) + .join("tauri-blitz-agent"); + let mut found: Vec<(std::time::SystemTime, PathBuf)> = std::fs::read_dir(&root) + .into_iter() + .flatten() + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "json")) + .filter_map(|path| Some((path.metadata().ok()?.modified().ok()?, path))) + .collect(); + found.sort(); + + // Newest *reachable* instance, not simply newest. + // + // Descriptors outlive the process that wrote them, and a machine that has + // run the app more than once has a directory full of them. Taking the most + // recent file connected to whichever instance happened to exit last: at + // best a refused connection, at worst a successful attach to a stale socket + // and a set of numbers describing a process nobody is looking at. The + // warning for that case already existed and was printed immediately before + // connecting anyway. + for (_, path) in found.iter().rev() { + let Ok(mut descriptor) = read_descriptor(path) else { + continue; + }; + if descriptor.is_reachable() { + descriptor.verified_reachable = true; + return Ok(descriptor); + } + } + + bail!( + "no reachable inspector descriptor found; is a diagnostics build running?\n\ + looked at target/blitz-control.json and {}. Pass --descriptor \ + to inspect a specific descriptor.", + root.display() + ) +} + +fn read_descriptor(path: &Path) -> Result { + let text = std::fs::read_to_string(path) + .with_context(|| format!("reading descriptor {}", path.display()))?; + let raw: serde_json::Value = serde_json::from_str(&text) + .with_context(|| format!("parsing descriptor {}", path.display()))?; + let descriptor: DebugDescriptor = serde_json::from_value(raw.clone()) + .with_context(|| format!("descriptor {} is not a DebugDescriptor", path.display()))?; + if descriptor.protocol_version != DEBUG_PROTOCOL_VERSION { + bail!( + "descriptor {} uses debug protocol {}, but this client speaks {}", + path.display(), + descriptor.protocol_version, + DEBUG_PROTOCOL_VERSION + ); + } + Ok(Descriptor { + path: path.to_path_buf(), + descriptor, + raw, + verified_reachable: false, + }) +} + +/// A connected inspector client. +/// +/// `MessageStream` is the object-safe half of the endpoint-libs transport seam, +/// so the concrete `framed_json` type, which is opaque, never has to be named. +pub struct Client { + stream: Box, + next_id: i64, + request_timeout: Duration, + events: VecDeque, +} + +#[derive(Debug)] +struct InspectorResponseError { + code: String, + message: String, +} + +impl std::fmt::Display for InspectorResponseError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "inspector returned {}: {}", + self.code, self.message + ) + } +} + +impl std::error::Error for InspectorResponseError {} + +/// Whether a host refused a request because it does not have the thing asked +/// for, rather than because something went wrong. +/// +/// The distinction matters for a headless host. It owns a document and no +/// compositor, so there are no frame metrics to report and saying "unsupported" +/// is the true answer. A caller that only wanted the numbers as context can +/// carry on without them; one that exists to judge frame timing cannot, and +/// should still fail. +pub fn is_unsupported(error: &eyre::Report) -> bool { + error + .downcast_ref::() + .is_some_and(|refusal| refusal.code == "unsupported") +} + +impl Client { + fn queue_event(&mut self, event: DebugEvent) { + if self.events.len() == MAX_QUEUED_EVENTS { + self.events.pop_front(); + } + self.events.push_back(event); + } + + pub async fn connect(socket: &Path) -> Result { + const CONNECT_DEADLINE: Duration = Duration::from_millis(500); + const RETRY_DELAY: Duration = Duration::from_millis(20); + + let started = Instant::now(); + let stream = loop { + match UnixStream::connect(socket).await { + Ok(stream) => break stream, + Err(_) if started.elapsed() < CONNECT_DEADLINE => { + tokio::time::sleep(RETRY_DELAY).await; + } + Err(error) => { + return Err(error) + .with_context(|| format!("connecting to {}", socket.display())); + } + } + }; + Ok(Self { + stream: Box::new(TransportStream::new(framed_json(stream))), + next_id: 0, + request_timeout: REQUEST_TIMEOUT, + events: VecDeque::new(), + }) + } + + /// Bound every inspector exchange for a latency-sensitive command. + /// + /// Interactive dump/diagnostic modes retain the generous default. QA and + /// coverage explicitly lower it so a dead action cannot multiply a + /// minute-long transport wait across a suite. + pub fn set_request_timeout(&mut self, request_timeout: Duration) { + self.request_timeout = request_timeout; + } + + pub fn request_timeout(&self) -> Duration { + self.request_timeout + } + + fn next_id(&mut self) -> JsonRpcId { + self.next_id += 1; + JsonRpcId::Number(self.next_id) + } + + /// Send one request and return the frame that answers *it*. + /// + /// Matching on the id is not pedantry. The server pushes notifications on + /// the same socket, so a client that returns the next frame it sees will + /// eventually hand a console message back as though it were metrics. + async fn exchange( + &mut self, + request: WireMessage, + id: &JsonRpcId, + ) -> Result { + self.stream + .send(request) + .await + .map_err(|error| eyre!("sending to the inspector failed: {error}"))?; + loop { + let message = timeout(self.request_timeout, self.stream.recv()) + .await + .map_err(|_| { + eyre!( + "the inspector did not answer within {:?}", + self.request_timeout + ) + })? + .ok_or_else(|| eyre!("the inspector closed the connection"))? + .map_err(|error| eyre!("reading from the inspector failed: {error}"))?; + let value = decode_wire_value(message).map_err(protocol_error)?; + if peek_value_request_id(&value).as_ref() == Some(id) { + return Ok(value); + } + if let Ok(event) = decode_diagnostics_event_value(value) { + self.queue_event(event); + } + } + } + + /// A raw JSON-RPC call, for `initialize` and `tools/list`, which are not + /// tool calls and so have no typed request in the protocol crate. + pub async fn raw_request( + &mut self, + method: &str, + params: serde_json::Value, + ) -> Result { + let id = self.next_id(); + let request = encode_rpc(JsonRpcMessage::Request(JsonRpcRequest::call( + id.clone(), + method, + params, + ))) + .map_err(protocol_error)?; + self.exchange(request, &id).await + } + + pub async fn initialize(&mut self) -> Result { + self.raw_request( + "initialize", + serde_json::json!({"protocolVersion": MCP_PROTOCOL_VERSION}), + ) + .await + } + + pub async fn tools_list(&mut self) -> Result { + self.raw_request("tools/list", serde_json::json!({})).await + } + + /// An agent-control call, encoded from the server's own type. + /// + /// This is the whole reason the protocol crate exists. `AgentAction` is + /// adjacently tagged, so the `{"action":"click","node_id":9}` that reads + /// correctly is not what the server accepts, and getting it wrong used to + /// present as a hung application rather than as an encoding mistake. + pub async fn agent(&mut self, request: &AgentControlRequest) -> Result { + let id = self.next_id(); + let frame = encode_agent_request(id.clone(), request).map_err(protocol_error)?; + let value = self.exchange(frame, &id).await?; + Answer::new(value) + } + + pub async fn diagnostics(&mut self, request: &DiagnosticsRequest) -> Result { + let id = self.next_id(); + let frame = encode_diagnostics_request(id.clone(), request).map_err(protocol_error)?; + let value = self.exchange(frame, &id).await?; + Answer::new(value) + } + + /// Establish a paint-event baseline immediately before driving input. + /// + /// New runtimes answer with `Ack` and suppress any revision that predates + /// this call. Older runtimes answer `streamingUnavailable`; callers retain + /// their bounded compatibility fallback in that case. + pub async fn arm_paint_events(&mut self) -> Result { + self.events + .retain(|event| !matches!(event, DebugEvent::PaintCommitted { .. })); + match self + .diagnostics(&DiagnosticsRequest::Observe { + streams: vec![DebugStream::Paint], + }) + .await + { + Ok(_) => Ok(true), + Err(error) + if error + .downcast_ref::() + .is_some_and(|error| error.code == "streamingUnavailable") => + { + Ok(false) + } + Err(error) => Err(error), + } + } + + /// Wait for the first real frame committed after an armed interaction. + pub async fn wait_for_paint(&mut self, within: Duration) -> Result { + if let Some(index) = self + .events + .iter() + .position(|event| matches!(event, DebugEvent::PaintCommitted { .. })) + { + self.events.remove(index); + return Ok(true); + } + + let deadline = tokio::time::Instant::now() + within; + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return Ok(false); + } + let message = match timeout(remaining, self.stream.recv()).await { + Ok(Some(message)) => message, + Ok(None) | Err(_) => return Ok(false), + }; + let message = message + .map_err(|error| eyre!("reading paint event from inspector failed: {error}"))?; + let value = decode_wire_value(message).map_err(protocol_error)?; + match decode_diagnostics_event_value(value) { + Ok(DebugEvent::PaintCommitted { .. }) => return Ok(true), + Ok(event) => self.queue_event(event), + Err(_) => {} + } + } + } + + /// The same call, but returning a protocol-level error rather than failing + /// on it. + /// + /// `watch` needs this because `observe` is not implemented server-side: it + /// answers `streamingUnavailable`. Printing that answer and then draining + /// is what the previous client did, and it is the more useful behaviour: + /// the mode reports what the server said instead of dying on it. + pub async fn diagnostics_envelope( + &mut self, + request: &DiagnosticsRequest, + ) -> Result { + let id = self.next_id(); + let frame = encode_diagnostics_request(id.clone(), request).map_err(protocol_error)?; + self.exchange(frame, &id).await + } + + pub async fn agent_envelope( + &mut self, + request: &AgentControlRequest, + ) -> Result { + let id = self.next_id(); + let frame = encode_agent_request(id.clone(), request).map_err(protocol_error)?; + self.exchange(frame, &id).await + } + + /// Collect pushed notifications for a while, as `watch` does. + pub async fn drain(&mut self, seconds: f64) -> Result> { + let deadline = tokio::time::Instant::now() + Duration::from_secs_f64(seconds); + let mut out = Vec::new(); + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return Ok(out); + } + match timeout(remaining, self.stream.recv()).await { + Err(_) => return Ok(out), + Ok(None) => return Ok(out), + Ok(Some(Ok(message))) => { + out.push(decode_wire_value(message).map_err(protocol_error)?) + } + Ok(Some(Err(error))) => bail!("reading from the inspector failed: {error}"), + } + } + } +} + +/// Read the application's semantic tree and report the inspector round-trip. +/// +/// Most commands need this exact request. Keeping it beside the transport +/// prevents every command module from rebuilding the protocol exchange. +pub async fn inspect(client: &mut Client) -> Result<(AgentSnapshot, f64)> { + inspect_from(client, None).await +} + +/// Read only one semantic subtree. +/// +/// Polling a known destination from the document root makes interaction +/// latency proportional to every unrelated node in the application. The +/// protocol already accepts a semantic root, so stabilization can acquire the +/// target once and observe only the component that must remain mounted. +pub async fn inspect_subtree(client: &mut Client, root: u64) -> Result<(AgentSnapshot, f64)> { + inspect_from(client, Some(root)).await +} + +async fn inspect_from(client: &mut Client, root: Option) -> Result<(AgentSnapshot, f64)> { + let started = Instant::now(); + let answer = client + .agent(&AgentControlRequest::Inspect { + root, + max_depth: 40, + }) + .await?; + let elapsed = started.elapsed().as_secs_f64() * 1000.0; + match answer.response { + DebugResponse::AgentSnapshot(snapshot) => Ok((snapshot, elapsed)), + other => bail!("asked for a semantic snapshot, got {other:?}"), + } +} + +pub struct Answer { + pub response: DebugResponse, +} + +impl Answer { + fn new(value: serde_json::Value) -> Result { + let (_, response) = decode_response_value(value).map_err(protocol_error)?; + if let DebugResponse::Error(error) = &response { + return Err(eyre::Report::new(InspectorResponseError { + code: error.code.clone(), + message: error.message.clone(), + })); + } + Ok(Self { response }) + } +} + +fn protocol_error(error: DebugProtocolError) -> eyre::Report { + eyre!("{error}") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{JsonRpcResponse, encode_diagnostics_event}; + use tokio::net::UnixListener; + + #[tokio::test(flavor = "current_thread")] + async fn connect_retries_startup_and_exchange_preserves_events() { + let socket = std::env::temp_dir().join(format!( + "ps-qa-transport-{}-{}.sock", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock is after the epoch") + .as_nanos() + )); + let server_socket = socket.clone(); + let server = async move { + // The descriptor can be visible before its socket is bound. Make + // that production race deterministic and require the client retry. + tokio::time::sleep(Duration::from_millis(40)).await; + let listener = UnixListener::bind(&server_socket).expect("bind test socket"); + let (stream, _) = listener.accept().await.expect("accept test client"); + let mut stream = TransportStream::new(framed_json(stream)); + let _request = stream + .recv() + .await + .expect("client keeps connection open") + .expect("read initialize request"); + stream + .send( + encode_diagnostics_event(&DebugEvent::PaintCommitted { revision: 7 }) + .expect("encode paint event"), + ) + .await + .expect("send paint event"); + stream + .send( + encode_rpc(JsonRpcMessage::Response(JsonRpcResponse::result( + Some(JsonRpcId::Number(1)), + serde_json::json!({"protocolVersion": MCP_PROTOCOL_VERSION}), + ))) + .expect("encode initialize response"), + ) + .await + .expect("send initialize response"); + }; + + let client_socket = socket.clone(); + let client = async move { + let mut client = Client::connect(&client_socket) + .await + .expect("client retries until socket is bound"); + client.initialize().await.expect("initialize completes"); + assert!( + client + .wait_for_paint(Duration::ZERO) + .await + .expect("queued paint remains readable"), + "an event arriving before the response must not steal the response or be discarded" + ); + }; + + tokio::join!(server, client); + let _ = std::fs::remove_file(socket); + } + + #[test] + fn descriptor_protocol_mismatch_is_rejected_before_connecting() { + let path = std::env::temp_dir().join(format!( + "ps-qa-descriptor-version-{}-{}.json", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock is after the epoch") + .as_nanos() + )); + std::fs::write( + &path, + serde_json::json!({ + "protocolVersion": DEBUG_PROTOCOL_VERSION + 1, + "pid": 1, + "instanceId": "fixture", + "address": "unix:///tmp/fixture.sock", + "renderer": "fixture", + "rendererRevision": "test" + }) + .to_string(), + ) + .expect("write descriptor fixture"); + let error = read_descriptor(&path).expect_err("newer protocol must not be guessed"); + assert!( + error.to_string().contains("this client speaks"), + "unexpected error: {error}" + ); + let _ = std::fs::remove_file(path); + } +} diff --git a/crates/blitz-control-protocol/src/document.rs b/crates/blitz-control-protocol/src/document.rs new file mode 100644 index 0000000..0dbaf3d --- /dev/null +++ b/crates/blitz-control-protocol/src/document.rs @@ -0,0 +1,3581 @@ +//! Inspecting, capturing and driving a document, with no window involved. +//! +//! This is the core the two transports sit on. It answers a request against a +//! `blitz-dom` document and returns a response, and it knows nothing about +//! sockets, listeners, event loops or windows: [`crate::in_process`] calls it +//! directly and [`crate::server`] calls it across a socket, and neither is +//! visible from here. +//! +//! It used to live in `tauri-runtime-blitz`, beside the Tauri runtime, which +//! meant that depending on it meant compiling Tauri. On Linux that means GTK: +//! system libraries pulled in to build a binary that never creates a window, +//! and a crate that would not compile there at all. The dependency edge was +//! wrong, not the platform. A runtime bridges Tauri to Blitz and owns a native +//! window; it is not where an inspection service lives. + +use std::collections::HashMap; + +use crate::{ + AgentSnapshot, DebugError, DebugResponse, KeyPhase, Modifiers as ControlModifiers, SemanticNode, +}; +#[cfg(feature = "capture")] +use crate::{ + DebugSnapshot, FrameMetrics, FrameWindowMetrics, LayoutBounds, LayoutDiagnosticRow, + LayoutEdges, LayoutOffset, LayoutSize, RendererMetrics, RevisionSet, ScriptMetrics, + ScriptSource, SnapshotCost, SnapshotRequest, TimingStats, +}; +use blitz_dom::Document; +use blitz_script::ScriptDocument; +use blitz_traits::events::{ + BlitzKeyEvent, BlitzPointerEvent, BlitzPointerId, DomEvent, DomEventData, KeyState, + MouseEventButton, MouseEventButtons, Point, PointerCoords, PointerDetails, UiEvent, +}; +#[cfg(feature = "capture")] +use blitz_traits::node_id::NodeId; +use keyboard_types::{Code, Key, Location, Modifiers as KeyboardModifiers}; + +/// The live inspector's reusable offscreen surface. +/// +/// A capture used to construct this whole renderer for every frame. Besides +/// reallocating the viewport-sized RGBA buffer, that threw away the CPU text +/// renderer's glyph resources, so a stability assertion shaped and rasterised +/// every label four times. The surface belongs to one runtime and is resized +/// only when the window or requested scale changes. +#[cfg(feature = "capture")] +pub(crate) struct CaptureSurface { + pub(crate) width: u32, + pub(crate) height: u32, + pub(crate) renderer: anyrender_vello_cpu::VelloCpuImageRenderer, + pub(crate) rgba: Vec, +} + +/// Reusable offscreen renderer for captures of one document. +/// +/// A headless inspection host asks for several adjacent frames when it checks +/// visual stability. Reusing this object preserves the CPU renderer's glyph +/// resources and pixel allocation between those requests instead of rebuilding +/// an entire renderer for every sample. +#[cfg(feature = "capture")] +pub struct DocumentCapture { + surface: Option, +} + +#[cfg(feature = "capture")] +impl DocumentCapture { + pub fn new() -> Self { + Self { surface: None } + } + + pub fn capture( + &mut self, + document: &mut ScriptDocument, + request: crate::CaptureRequest, + ) -> Result { + capture_document_with_surface(document, request, &mut self.surface) + } +} + +#[cfg(feature = "capture")] +impl Default for DocumentCapture { + fn default() -> Self { + Self::new() + } +} + +#[cfg(feature = "capture")] +impl CaptureSurface { + pub(crate) fn new(width: u32, height: u32) -> Self { + use anyrender::ImageRenderer as _; + + Self { + width, + height, + renderer: anyrender_vello_cpu::VelloCpuImageRenderer::new(width, height), + rgba: Vec::with_capacity((width as usize) * (height as usize) * 4), + } + } + + pub(crate) fn size_to(&mut self, width: u32, height: u32) { + use anyrender::ImageRenderer as _; + + if self.width == width && self.height == height { + return; + } + self.renderer.resize(width, height); + self.width = width; + self.height = height; + } +} + +/// Draw a standalone script document through the same CPU paint path used by +/// runtime diagnostics. +/// +/// Headless QA hosts intentionally have no `RuntimeApplication`, but they must +/// not substitute a second renderer for native visual checks. Keeping the +/// capture implementation here makes a host capture and a live-app capture +/// byte-for-byte comparable. +#[cfg(feature = "capture")] +pub fn capture_document( + script_document: &mut ScriptDocument, + request: crate::CaptureRequest, +) -> Result { + DocumentCapture::new().capture(script_document, request) +} + +#[cfg(feature = "capture")] +pub(crate) fn capture_document_with_surface( + script_document: &mut ScriptDocument, + request: crate::CaptureRequest, + surface: &mut Option, +) -> Result { + use anyrender::ImageRenderer; + use base64::Engine as _; + + // Clamped rather than trusted. A scale of zero produces a zero-sized + // buffer and a negative one panics inside the rasteriser, and neither + // should be reachable from a debug socket. + let scale = if request.scale.is_finite() && request.scale > 0.0 { + request.scale.clamp(0.1, 8.0) + } else { + 1.0 + }; + + let node_id = request.node_id; + + // Style and layout first, so the capture reflects pending mutations + // rather than the frame before them. Same call `collect_diagnostics` + // makes, for the same reason. + script_document.inner_mut().resolve(0.0); + + // Copied out rather than held: the guard is a `Ref` and the borrow has + // to end before the mutable one the paint below needs. + let (full_width, full_height) = { + let inner = script_document.inner(); + let viewport = inner.viewport(); + (viewport.window_size.0, viewport.window_size.1) + }; + if full_width == 0 || full_height == 0 { + return Err(debug_error( + "captureUnavailable", + "the document has no viewport to draw", + )); + } + + // The region to keep, in unscaled document pixels. + let (crop_x, crop_y, crop_width, crop_height) = match node_id { + None => ( + 0.0_f64, + 0.0_f64, + f64::from(full_width), + f64::from(full_height), + ), + Some(id) => { + let inner = script_document.inner(); + let node = inner + .get_node(NodeId::from_u64(id)) + .ok_or_else(|| debug_error("unknownNode", &format!("no node {id}")))?; + let layout = node.final_layout(); + let position = node.absolute_position(0.0, 0.0); + if layout.size.width <= 0.0 || layout.size.height <= 0.0 { + return Err(debug_error( + "captureEmpty", + &format!("node {id} has a zero-sized box, so there is nothing to capture"), + )); + } + let box_ = ( + f64::from(position.x), + f64::from(position.y), + f64::from(layout.size.width), + f64::from(layout.size.height), + ); + drop(inner); + box_ + } + }; + + let full_pixel_width = ((f64::from(full_width) * f64::from(scale)).round() as u32).max(1); + let full_pixel_height = ((f64::from(full_height) * f64::from(scale)).round() as u32).max(1); + // Clamp before painting: a node partly offscreen yields the visible part, + // and the regional renderer never allocates pixels that will be discarded. + let left = ((crop_x * f64::from(scale)).round().max(0.0) as u32).min(full_pixel_width); + let top = ((crop_y * f64::from(scale)).round().max(0.0) as u32).min(full_pixel_height); + let width = ((crop_width * f64::from(scale)).round() as u32) + .min(full_pixel_width.saturating_sub(left)) + .max(1); + let height = ((crop_height * f64::from(scale)).round() as u32) + .min(full_pixel_height.saturating_sub(top)) + .max(1); + // Leave room for the JSON-RPC and MCP envelopes inside the transport's + // fixed frame ceiling. The old 64-million-pixel limit allowed a 256 MiB + // raster and a 341 MiB base64 string, only for protocol encoding to reject + // the result against its 16 MiB frame limit after all that work was done. + const FRAME_ENVELOPE_RESERVE: usize = 64 * 1024; + const MAX_BASE64_BYTES: usize = crate::MAX_DEBUG_FRAME_BYTES - FRAME_ENVELOPE_RESERVE; + const MAX_RAW_BYTES: usize = (MAX_BASE64_BYTES / 4) * 3; + const MAX_PIXELS: u64 = (MAX_RAW_BYTES / 4) as u64; + if u64::from(width) * u64::from(height) > MAX_PIXELS { + return Err(debug_error( + "captureTooLarge", + &format!( + "{width}x{height} cannot fit in one diagnostic frame; capture a node or lower the scale" + ), + )); + } + + let surface = surface.get_or_insert_with(|| CaptureSurface::new(width, height)); + surface.size_to(width, height); + // `ImageRenderer` retains its scene between calls. A capture is a complete + // frame, not an incremental paint, so carrying the previous command list + // forward duplicates every shape and makes each sample slower than the + // last. Keep reusable renderer resources, but always begin with an empty + // scene. + surface.renderer.reset(); + let mut document = script_document.inner_mut(); + surface.renderer.render_to_vec( + |scene| { + if node_id.is_some() { + blitz_paint::paint_scene_region( + scene, + &mut document, + blitz_paint::PaintRegion::crop( + f64::from(scale), + f64::from(left) / f64::from(scale), + f64::from(top) / f64::from(scale), + width, + height, + ), + ); + } else { + blitz_paint::paint_scene( + scene, + &mut document, + f64::from(scale), + width, + height, + 0, + 0, + ); + } + }, + &mut surface.rgba, + ); + + Ok(crate::CapturedImage { + width, + height, + rgba_base64: base64::engine::general_purpose::STANDARD.encode(&surface.rgba), + node_id, + }) +} + +/// Collect the same typed diagnostic snapshot from a standalone Blitz document +/// that the windowed runtime exposes over its control socket. +/// +/// Headless component hosts own a `ScriptDocument` without a Tauri event loop. +/// Keeping snapshot collection here gives those hosts the renderer's real DOM, +/// layout and computed paint data instead of a partial or reimplemented view. +#[cfg(feature = "capture")] +pub fn snapshot_document( + document: &mut ScriptDocument, + request: SnapshotRequest, + revision: u64, +) -> Result { + let started = std::time::Instant::now(); + let poll_started = std::time::Instant::now(); + let mut polls = 0u64; + for _ in 0..100 { + polls += 1; + if !document.poll(None) { + break; + } + } + let poll_ms = poll_started.elapsed().as_secs_f64() * 1_000.0; + // This forces a style and layout pass so the snapshot reports current + // geometry. It is work the observer caused, so it is reported as snapshot + // cost, never as the cost of a frame the application drew. + let resolve_started = std::time::Instant::now(); + document.inner_mut().resolve(0.0); + let snapshot_resolve_ms = resolve_started.elapsed().as_secs_f64() * 1_000.0; + let inner = document.inner(); + let layout_node_limit = inner.tree().iter().count(); + let active_element = inner.get_focussed_node_id().map(|id| id.as_u64()); + // Once for the whole snapshot: the question a control asks is "which label + // points at me", and answering it from the control costs a document scan + // each time. + let labels = LabelIndex::build(&inner); + let nodes: Vec = inner + .tree() + .iter() + .filter_map(|(id, node)| { + if !request.node_ids.is_empty() && !request.node_ids.contains(&id.as_u64()) { + return None; + } + let element = node.element_data()?; + if !dom_chain_is_attached(&inner, id, layout_node_limit) + || !layout_chain_is_valid(&inner, id, layout_node_limit) + { + return None; + } + let rect = inner.get_client_bounding_rect(id); + let visible = node_is_visible(&inner, id) + && rect + .as_ref() + .is_some_and(|rect| rect.width > 0.0 && rect.height > 0.0); + let role = semantic_role(element); + let value = if role == "generic" { + Some( + element + .attrs() + .iter() + .map(|attribute| format!("{}={}", attribute.name.local, attribute.value)) + .collect::>() + .join(" "), + ) + } else { + semantic_value(element, &inner, id) + }; + Some(SemanticNode { + dom_id: element_attr(element, "id").map(str::to_owned), + id: id.as_u64(), + parent: semantic_parent(&inner, id, None).map(|id| id.as_u64()), + name: semantic_name(element, node, &role, &inner, id, &labels), + role, + value, + enabled: element_attr(element, "disabled").is_none() + && element_attr(element, "aria-disabled") != Some("true"), + visible, + selected: semantic_selected(element, &inner, id), + bounds: rect.and_then(|rect| { + let bounds = [rect.x, rect.y, rect.width, rect.height]; + bounds + .iter() + .all(|value| value.is_finite()) + .then_some(bounds) + }), + slot: element_attr(element, "data-slot").map(str::to_owned), + }) + }) + .collect(); + let total_ms = started.elapsed().as_secs_f64() * 1_000.0; + // The runtime keeps one counter and stamps it onto all four revision + // fields. Style, layout and paint are not versioned independently + // anywhere in blitz, so four copies of one number would claim a + // resolution that does not exist. Report the counter once, as the + // document revision, and leave the rest at zero. + let revisions = RevisionSet { + document: revision, + style: 0, + layout: 0, + paint: 0, + }; + // Real per-frame timings, published by blitz-shell from `View::redraw`. + // These describe frames the application actually presented. Everything + // measured inside this function describes the snapshot collection instead, + // and is reported under `snapshot` so the two never get mixed up again. + let frame_stats = blitz_shell::latest_frame_stats(); + let metrics = RendererMetrics { + revisions: revisions.clone(), + queue_depth: None, + invalidations_coalesced: polls.saturating_sub(1), + frame: frame_stats.as_ref().map(|stats| FrameMetrics { + input_to_present_ms: None, + style_ms: None, + layout_ms: None, + resolve_ms: stats.latest.resolve_ms, + scene_ms: stats.latest.paint_ms, + submit_ms: None, + present_ms: None, + renderer_ms: stats.latest.renderer_ms, + total_ms: stats.latest.total_ms, + age_ms: stats.latest.age_ms, + }), + frame_window: frame_stats.as_ref().map(|stats| FrameWindowMetrics { + frames_total: stats.frames_total, + window_frames: stats.window_frames, + resolve: timing_stats(stats.resolve), + scene: timing_stats(stats.paint), + renderer: timing_stats(stats.renderer), + total: timing_stats(stats.frame_total), + interval: timing_stats(stats.interval), + active_fps: stats.active_fps, + missed_refreshes: stats.missed_refreshes, + display_refresh_hz: stats.display_refresh_hz, + }), + snapshot: Some(SnapshotCost { + poll_ms, + resolve_ms: snapshot_resolve_ms, + total_ms, + }), + // The other half of a frame. Everything above this line is the + // engine; this is the language runtime the application actually + // spends its time in. + script: blitz_script::script_stats::latest_script_stats().map(|stats| ScriptMetrics { + mean_ms: stats.mean_ms, + p95_ms: stats.p95_ms, + max_ms: stats.max_ms, + window_polls: stats.window_polls, + total_polls: stats.total_polls, + productive_polls: stats.productive_polls, + spent_ms: stats.spent_ms, + breakdown: blitz_script::script_stats::work_breakdown() + .into_iter() + .take(12) + .map(|(label, calls, total_ms, worst_ms)| ScriptSource { + label, + calls, + total_ms, + worst_ms, + }) + .collect(), + }), + resident_bytes: resident_bytes(), + }; + let dom = request + .include_dom + .then(|| serde_json::to_value(&nodes).unwrap_or(serde_json::Value::Null)); + let layout = request.include_layout.then(|| { + nodes + .iter() + .filter_map(|node| diagnostic_layout_row(&inner, node)) + .collect() + }); + /* + * Resolved colours, folded into the layout rows. + * + * This used to answer `computedStyleUnavailable`, which left one class + * of bug unanswerable from outside: an element whose *declared* colour + * is correct and whose *painted* colour is not. Reading the stylesheet + * cannot settle that - the cascade, the custom-property chain and the + * `@supports` gating all sit between the two - and neither can a DOM + * test environment, which has no cascade at all. + * + * Only the four that decide legibility, rather than a full style dump: + * a snapshot of every longhand for 4,500 nodes is megabytes of JSON + * nobody reads, and these are what a "why is this text invisible" + * question actually needs. + */ + let computed_style = request.include_computed_style.then(|| { + serde_json::Value::Array( + nodes + .iter() + .filter_map(|node| diagnostic_style_row(&inner, node)) + .collect(), + ) + }); + Ok(DebugSnapshot { + revisions, + active_window: Some("blitz-main".into()), + active_element, + dom, + layout, + computed_style, + metrics, + }) +} + +pub(crate) fn element_attr<'a>(element: &'a blitz_dom::ElementData, name: &str) -> Option<&'a str> { + element + .attrs() + .iter() + .find(|attribute| attribute.name.local.as_ref() == name) + // `as_ref`, not `as_str`. Attribute values are an interned atom as of + // ps-blitz-dom 0.3.0-beta.11, and `str::as_str` is still unstable, so + // `as_str` here resolved to the nightly-only inherent method and + // failed to build on stable. `as_ref` borrows the atom as a `&str`, + // which is what this signature returns. + .map(|attribute| attribute.value.as_ref()) +} + +pub(crate) fn semantic_role(element: &blitz_dom::ElementData) -> String { + semantic_role_ref(element).to_owned() +} + +/// The same answer without owning it. +/// +/// A text node asks every element above it whether that element is already +/// named by the words in question, and the owned form allocated a `String` per +/// ancestor per text node to be compared against a fixed list and dropped. The +/// role is either a `&'static str` or the `role` attribute's own text, so +/// nothing here needs a copy. +pub(crate) fn semantic_role_ref(element: &blitz_dom::ElementData) -> &str { + // An author's explicit role wins, and travels verbatim. ARIA is a + // vocabulary the page may extend with roles no HTML element implies, and a + // harness addressing `role="switch"` needs the word the page used. + if let Some(role) = element_attr(element, "role") { + return role; + } + wire_role(blitz_dom::accessibility::implicit_role(element)) +} + +/// What this surface calls one of blitz-dom's roles. +/// +/// # Why there is a projection at all +/// +/// The rules are blitz-dom's, in `accessibility::implicit_role`, and there is +/// one copy of them. What is here is only the naming: blitz-dom answers in +/// AccessKit's vocabulary, which is what a platform screen-reader adapter +/// consumes, and this wire answers in ARIA role names, which is what a check +/// is written against. +/// +/// The mapping is deliberately lossy, and the loss is the compatibility +/// guarantee. Eleven fleet sites and roughly 1,700 checks are written against +/// the role strings this surface has always reported, so every arm below +/// reproduces one of them. `_ => "generic"` is not a fallback for roles nobody +/// thought about: it is the answer this surface has always given for +/// `
`, `

`, `