diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e26a4e3..f00fed5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,32 +48,6 @@ jobs: exit 1 fi - host: - name: Renderer host and live socket (macOS) - runs-on: macos-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable - with: - components: clippy - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - - name: Clippy host - run: cargo clippy -p qa-inspect-host --all-targets -- -D warnings - - name: Test real host transport - run: cargo test -p qa-inspect-host --release - - name: Test documented renderer-backed QA fixture - shell: zsh {0} - run: | - set -euo pipefail - cargo build -p qa-inspect-host -p ps-qa --release - target/release/ps-qa \ - --app crates/qa-inspect-host/tests/fixture/ps-qa.ron \ - qa-hosted fixture-text-entry \ - --host target/release/qa-inspect-host \ - --page crates/qa-inspect-host/tests/fixture/page.html \ - --checks crates/qa-inspect-host/tests/fixture/checks - package: name: Package boundaries runs-on: macos-latest @@ -96,6 +70,5 @@ jobs: # protocol source without installing a persistent workspace patch; # publish.yml repeats ordinary registry-backed `cargo package` after # publishing the protocol and before uploading the driver. - cargo package -p qa-inspect-host cargo --config 'patch.crates-io.blitz-control-protocol.path="crates/blitz-control-protocol"' \ package -p ps-qa diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c2d3a1a..62848f7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -79,4 +79,3 @@ jobs: # The host embeds tauri-runtime-blitz, which in turn consumes the # protocol above. Keep it last so a new protocol and driver can ship # before the corresponding runtime release reaches crates.io. - publish_if_new qa-inspect-host diff --git a/Cargo.toml b/Cargo.toml index d09dda7..4693840 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,6 @@ members = [ "crates/blitz-control-protocol", "crates/ps-blitz-debug-control", "crates/ps-qa", - "crates/qa-inspect-host", ] [workspace.package] diff --git a/README.md b/README.md index a1ad3ea..fd017cc 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,32 @@ # ps-observability The observability and QA stack for native Blitz applications. This workspace -keeps the protocol, transports, renderer host, driver, fixtures, and release -documentation together so the system has one ownership boundary. +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 - ▲ ▲ - │ │ - qa-inspect-host ────────────────┘ + ▲ + │ + chuzz-headless ────────┘ renderer embedder ── ps-blitz-debug-control ── WebDriver-style HTTP client ``` +The headless host is not here. It used to be, as `qa-inspect-host`, and that +made two headless browsers: one a person browses with and one QA drives, with +the web platform in only the first of them. A host without `URLSearchParams`, +`matchMedia`, storage, the observers and `performance.getEntriesByType` blanks +every routed page before its first render, so every gap closed for the browser +had to be closed a second time here, by hand, or the harness measured a browser +nobody ships. + +So the host is a mode of the browser now: `chuzz-headless`, in +[pathscale/chuzz](https://github.com/pathscale/chuzz), which loads through the +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`. @@ -30,34 +44,42 @@ instrumentation hooks but do not own a control server. - `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. -- `qa-inspect-host`: a real renderer host for headless fixtures and CI. - `ps-qa`: the lightweight driver, audit runner, and report generator. ## Quick start -From this workspace, install the driver and build the real headless renderer -host: +Install the driver, and build the host from the chuzz checkout beside this one: ```zsh cargo install ps-qa -cargo build -p qa-inspect-host +cargo build --manifest-path ../chuzz/Cargo.toml --bin chuzz-headless --release +``` + +Serve one page in one terminal. A directory is served as a site, on a loopback +origin, so a built application's absolute asset paths and its client routing +both work; a single file or an `http(s)` URL is taken as given: + +```zsh +../chuzz/target/release/chuzz-headless ../support.cafe/dist ``` -Start the supplied renderer fixture in one terminal: +The host prints its descriptor path when ready. In a second terminal, drive it; +`ps-qa` discovers the live descriptor automatically: ```zsh -QA_INSPECT_PAGE="$PWD/crates/qa-inspect-host/tests/fixture/page.html" \ - target/debug/qa-inspect-host +ps-qa find --role button +ps-qa audit ``` -The host prints its descriptor path when ready. In a second terminal, run the -fixture's outcome check; `ps-qa` discovers the live descriptor automatically: +`qa-hosted` does both halves at once, launching the host, running a group of +checks and stopping it again: ```zsh -ps-qa \ - --app crates/qa-inspect-host/tests/fixture/ps-qa.ron \ - qa fixture-text-entry \ - --checks crates/qa-inspect-host/tests/fixture/checks +ps-qa --app tests/ps-qa/ps-qa.ron \ + qa-hosted \ + --host ../chuzz/target/release/chuzz-headless \ + --page dist \ + --checks tests/ps-qa ``` This is a renderer-backed check: it enters text through the control protocol @@ -82,10 +104,9 @@ browser remote-debugging port and must remain disabled in production builds. The protocol, HTTP transport, and `ps-qa` driver are continuously checked on Linux; `ps-qa` connects through a Unix-domain socket and currently supports -macOS and Linux, not Windows. The renderer-backed `qa-inspect-host` artifact is -currently validated on macOS. Linux renderer-host packaging remains explicit -follow-up work, so “headless” here means no window or display interaction—not a -claim that the current host package has completed Linux portability. +macOS and Linux, not Windows. The host's own platform status belongs to chuzz +now, and "headless" there means no window or display interaction rather than a +claim about which platforms the host has been packaged for. ## Releases diff --git a/crates/ps-qa/Cargo.toml b/crates/ps-qa/Cargo.toml index 6d15f56..55b5248 100644 --- a/crates/ps-qa/Cargo.toml +++ b/crates/ps-qa/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "ps-qa" description = "Drive a running Blitz app through its MCP control socket and assert what the renderer did" -version = "0.6.2" +version = "0.6.3" edition = "2024" rust-version = "1.88" license = "MIT OR Apache-2.0" diff --git a/crates/ps-qa/src/cli.rs b/crates/ps-qa/src/cli.rs index 716d0fc..57652ac 100644 --- a/crates/ps-qa/src/cli.rs +++ b/crates/ps-qa/src/cli.rs @@ -88,9 +88,14 @@ pub struct Cli { /// Multiply interaction and rendered-outcome deadlines on an overloaded /// runner. The default remains the strict local latency contract; CI must /// opt in explicitly rather than silently weakening every check. + /// + /// `QA_TIMEOUT_SCALE` sets the same thing. A workflow sets it once for the + /// job rather than repeating a flag on every invocation, and the fleet's + /// workflows were already written that way while nothing read it. #[arg( long, global = true, + env = "QA_TIMEOUT_SCALE", default_value_t = 1.0, value_parser = parse_timeout_scale )] @@ -112,8 +117,8 @@ pub struct Cli { /// by node id. /// /// For a host with no font catalogue, which is what a Linux CI runner is - /// and what `qa-inspect-host` is on any platform now that nothing enables - /// `system-fonts`. Text there shapes to no glyphs, so a control whose whole + /// and what any host built without `system-fonts` is on every platform. + /// Text there shapes to no glyphs, so a control whose whole /// size comes from its label lays out at its line width and zero height -- /// `button:Open dialog` is in the tree, enabled, with a box, and is /// rejected by the geometry gate that every coordinate-driven step needs. @@ -764,7 +769,7 @@ mod tests { "qa-hosted", "fixture-text-entry", "--host", - "qa-inspect-host", + "chuzz-headless", "--page", "page.html", "--checks", @@ -783,7 +788,7 @@ mod tests { panic!("qa-hosted did not parse as the hosted QA command"); }; assert_eq!(selector.as_deref(), Some("fixture-text-entry")); - assert_eq!(host, PathBuf::from("qa-inspect-host")); + assert_eq!(host, PathBuf::from("chuzz-headless")); assert_eq!(page, PathBuf::from("page.html")); assert_eq!(checks, Some(PathBuf::from("checks"))); assert_eq!(startup_timeout, 30); diff --git a/crates/ps-qa/src/diagnostics.rs b/crates/ps-qa/src/diagnostics.rs index 6563c99..fc60ddc 100644 --- a/crates/ps-qa/src/diagnostics.rs +++ b/crates/ps-qa/src/diagnostics.rs @@ -21,6 +21,22 @@ pub(crate) async fn metrics(client: &mut Client) -> Result { } } +/// Frame metrics when the host has them, and `None` when it says it does not. +/// +/// A headless host is a document with no compositor: there are no frames, so +/// there is nothing to count. Commands that only print the numbers as context +/// around an action use this and carry on without them. Commands that exist to +/// judge frame timing -- `idle`, `frames`, `drift`, `blink` -- keep using +/// [`metrics`] and keep failing, because a zeroed reading from a host that +/// never painted would pass every one of them while proving nothing. +pub(crate) async fn metrics_if_supported(client: &mut Client) -> Result> { + match metrics(client).await { + Ok(metrics) => Ok(Some(metrics)), + Err(error) if crate::inspector::is_unsupported(&error) => Ok(None), + Err(error) => Err(error), + } +} + pub(crate) async fn transcript(client: &mut Client) -> Result<()> { let answer = client .diagnostics(&DiagnosticsRequest::Snapshot(SnapshotRequest { diff --git a/crates/ps-qa/src/inspector.rs b/crates/ps-qa/src/inspector.rs index c12aafa..410db42 100644 --- a/crates/ps-qa/src/inspector.rs +++ b/crates/ps-qa/src/inspector.rs @@ -184,6 +184,20 @@ impl std::fmt::Display for InspectorResponseError { 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 { diff --git a/crates/ps-qa/src/interaction.rs b/crates/ps-qa/src/interaction.rs index c709744..3e1cbfb 100644 --- a/crates/ps-qa/src/interaction.rs +++ b/crates/ps-qa/src/interaction.rs @@ -9,7 +9,7 @@ use blitz_control_protocol::{ }; use eyre::{Result, bail, eyre}; -use crate::diagnostics::metrics; +use crate::diagnostics::metrics_if_supported; use crate::inspector::{Client, inspect}; use crate::target::{locate_control, selector_matches_node}; use crate::timing::{pace, sleep_pace}; @@ -315,7 +315,7 @@ pub(crate) async fn type_keys(client: &mut Client, count: usize, want: &str) -> })) .await?; - let before = metrics(client).await?; + let before = metrics_if_supported(client).await?; let mut latencies = Vec::with_capacity(count); for index in 0..count { let letter = (b'a' + (index % 26) as u8) as char; @@ -335,12 +335,10 @@ pub(crate) async fn type_keys(client: &mut Client, count: usize, want: &str) -> latencies.push(started.elapsed().as_secs_f64() * 1000.0); sleep_pace().await; } - let after = metrics(client).await?; + let after = metrics_if_supported(client).await?; report::show_latencies("keystrokes", count, &mut latencies); - report::show("before", &before); - report::show("after", &after); - report::show_delta(&before, &after, count); + report_frames(before.as_ref(), after.as_ref(), count); Ok(()) } @@ -385,7 +383,7 @@ pub(crate) async fn click_named(client: &mut Client, want: &str) -> Result<()> { // of the viewport gets a `pointerdown` at a point nothing is at and no // click at all. "Show 12 earlier messages" sat at y=-2246 and every attempt // to press it read as the button doing nothing. - let before = metrics(client).await?; + let before = metrics_if_supported(client).await?; let started = Instant::now(); client .agent(&AgentControlRequest::Act(AgentAction::Click { @@ -393,11 +391,29 @@ pub(crate) async fn click_named(client: &mut Client, want: &str) -> Result<()> { })) .await?; let ack = started.elapsed().as_secs_f64() * 1000.0; - let after = metrics(client).await?; + let after = metrics_if_supported(client).await?; println!("click acked in {ack:.1}ms"); - report::show("before", &before); - report::show("after", &after); - report::show_delta(&before, &after, 1); + report_frames(before.as_ref(), after.as_ref(), 1); Ok(()) } + +/// The frame numbers either side of an action, when there are any. +/// +/// A headless host has no compositor and says so. The click still happened and +/// its acknowledgement is still timed; what is missing is the frame context, +/// and saying that out loud is better than printing zeroes that read as a +/// renderer doing nothing. +fn report_frames( + before: Option<&blitz_control_protocol::RendererMetrics>, + after: Option<&blitz_control_protocol::RendererMetrics>, + actions: usize, +) { + let (Some(before), Some(after)) = (before, after) else { + println!("frames: the host reports no renderer metrics; it has no compositor"); + return; + }; + report::show("before", before); + report::show("after", after); + report::show_delta(before, after, actions); +} diff --git a/crates/ps-qa/src/qa.rs b/crates/ps-qa/src/qa.rs index 07b0867..9219829 100644 --- a/crates/ps-qa/src/qa.rs +++ b/crates/ps-qa/src/qa.rs @@ -99,8 +99,8 @@ pub enum Expect { /// /// That is not a hypothetical. All six of ps-blitz's activation fixtures /// asserted their outcomes with [`PaintsNamed`](Expect::PaintsNamed) over - /// heading text, and all six failed the moment `qa-inspect-host` stopped - /// enabling `system-fonts`. They had only ever passed because the host + /// heading text, and all six failed the moment the host of the day stopped + /// enabling `system-fonts`. They had only ever passed because that host /// carried a font catalogue, which is the thing a headless check must not /// need. /// @@ -575,6 +575,14 @@ pub fn checks(dir: Option<&std::path::Path>) -> Result, String> { .filter_map(Result::ok) .map(|entry| entry.path()) .filter(|path| path.extension().is_some_and(|ext| ext == "ron")) + // The application profile is not a check group. + // + // The documented layout puts `ps-qa.ron` beside the checks, and this + // glob then read it as one and failed the whole run with + // "Expected opening `[`" at line 5 -- pointing at the profile's + // syntax, which is correct, rather than at the file being included by + // mistake. The documented layout could not be used. + .filter(|path| path.file_name().is_some_and(|name| name != "ps-qa.ron")) .collect(); // Name order, so a run is reproducible rather than dependent on whatever // order the filesystem happens to hand back. diff --git a/crates/ps-qa/src/runner.rs b/crates/ps-qa/src/runner.rs index 388a3f2..fd53efe 100644 --- a/crates/ps-qa/src/runner.rs +++ b/crates/ps-qa/src/runner.rs @@ -743,9 +743,10 @@ fn start_host( let mut child = HostProcess( std::process::Command::new(host) - // The page this host is to serve. `QA_INSPECT_PAGE` is - // `qa-inspect-host`'s interface; a host with a different one can - // read its own environment and ignore this. + // The page this host is to serve. The variable is the host + // interface, not a particular host's: `chuzz-headless` reads it, + // and a host with a different one can read its own environment and + // ignore this. .env("QA_INSPECT_PAGE", page) .stdout(std::process::Stdio::piped()) // Host diagnostics belong to the sweep artifact. Discarding them @@ -2276,6 +2277,14 @@ fn merge_outcome_snapshot( scoped } +/// How often a scoped outcome poll re-reads the whole document. +/// +/// The scope exists so a settling poll does not serialise a large tree every +/// turn. The interval exists so it cannot be blind to the rest of the page for +/// a whole deadline. Long enough that the saving is real, short enough that a +/// three-second check gets a dozen chances to see a change outside its pane. +const FULL_DOCUMENT_PROBE_INTERVAL: Duration = Duration::from_millis(250); + /// Wait for the declared result, not merely for a tree that already contains /// the subject. /// @@ -2297,7 +2306,9 @@ async fn settle_for_outcome( let mut stability = OutcomeStability::default(); let mut iterations = 0; let mut scope = outcome_poll_scope(before); - let mut probed_full_document = false; + // When the whole tree was last serialised, so a scoped poll cannot go + // permanently blind to the rest of the page. See the interval below. + let mut last_full_probe: Option = None; let event_driven = client.arm_paint_events().await.unwrap_or(false); loop { let mut after = if let Some(scoped) = scope.as_ref() { @@ -2318,13 +2329,21 @@ async fn settle_for_outcome( let now = tokio::time::Instant::now(); let mut passing = outcome_verdict(check, before, &after.nodes, action_target, action_node_id).is_ok(); - if !passing && scope.is_some() && !probed_full_document { - // Portalled dialogs and global toasts may live outside the active - // pane. Probe the full document once when the scoped verdict says - // the result is absent; subsequent stability samples remain - // scoped if the outcome belongs to the pane after all. + let due_for_full_probe = + last_full_probe.is_none_or(|at| now.duration_since(at) >= FULL_DOCUMENT_PROBE_INTERVAL); + if !passing && scope.is_some() && due_for_full_probe { + // Portalled dialogs, global toasts and a shell whose header is not + // in the active pane all live outside the scope. Probe the whole + // tree when the scoped verdict says the result is absent, and keep + // probing on an interval rather than once: a sign-in that swaps a + // header Login for a Logout is exactly this shape, and a one-shot + // probe taken before the swap left the poll scoped for the rest of + // its deadline. Measured on honey.id, where thirty seconds of + // polling never saw a control a single fresh snapshot found at + // once. Between probes the stability samples stay scoped, which is + // what keeps this from serialising the document every turn. after = inspect(client).await?.0; - probed_full_document = true; + last_full_probe = Some(now); passing = outcome_verdict(check, before, &after.nodes, action_target, action_node_id).is_ok(); scope = if passing { @@ -3627,7 +3646,17 @@ fn inventory_class( isolated: bool, duplicate_ids: &std::collections::HashSet, ) -> InventoryClass { - if node.dom_id.as_deref().is_none_or(|id| id.trim().is_empty()) { + // The declared exceptions come first. A control the application has + // excluded from the audit -- a link that leaves for a third party, a + // native panel, a session-ending action -- is never going to be addressed + // by a check, so failing it for the addressability a check would have + // needed reports a defect that cannot be fixed except by withdrawing the + // exception. + if manual { + InventoryClass::Manual + } else if isolated { + InventoryClass::Isolated + } else if node.dom_id.as_deref().is_none_or(|id| id.trim().is_empty()) { InventoryClass::MissingId } else if node.dom_id.as_deref().is_some_and(generated_dom_id) { InventoryClass::UnstableId @@ -3637,10 +3666,6 @@ fn inventory_class( .is_some_and(|id| duplicate_ids.contains(id)) { InventoryClass::DuplicateId - } else if manual { - InventoryClass::Manual - } else if isolated { - InventoryClass::Isolated } else if node.name.trim().is_empty() { InventoryClass::Anonymous } else if !reach::onscreen(node) { @@ -6129,11 +6154,11 @@ mod tests { } #[test] - fn inventory_rejects_missing_and_duplicate_dom_ids_before_exclusions() { + fn inventory_rejects_missing_and_duplicate_dom_ids() { let mut missing = component("Import data", true, true); missing.dom_id = None; assert_eq!( - inventory_class(&missing, true, false, &HashSet::new()), + inventory_class(&missing, false, false, &HashSet::new()), InventoryClass::MissingId ); @@ -6145,6 +6170,27 @@ mod tests { ); } + /// A declared exception outranks an id defect, which is what + /// `fix(qa): count a declared exception as excluded, not as unaddressable` + /// decided. An excluded control is one no check will ever drive, so + /// reporting it as unaddressable names a defect that can only be fixed by + /// withdrawing the exception. This test is here because the ordering is a + /// choice rather than an accident: the previous version of the test above + /// asserted the opposite and was left behind when the choice was made. + #[test] + fn inventory_counts_an_excluded_control_as_excluded() { + let mut missing = component("Import data", true, true); + missing.dom_id = None; + assert_eq!( + inventory_class(&missing, true, false, &HashSet::new()), + InventoryClass::Manual + ); + assert_eq!( + inventory_class(&missing, false, true, &HashSet::new()), + InventoryClass::Isolated + ); + } + #[test] fn inventory_rejects_framework_creation_order_ids() { assert!(generated_dom_id("cl-0-trigger")); diff --git a/crates/qa-inspect-host/Cargo.toml b/crates/qa-inspect-host/Cargo.toml deleted file mode 100644 index e1110c4..0000000 --- a/crates/qa-inspect-host/Cargo.toml +++ /dev/null @@ -1,56 +0,0 @@ -[package] -name = "qa-inspect-host" -description = "Host a Blitz document and serve it over the inspection socket" -version = "0.1.12" -edition = "2024" -rust-version = "1.88" -license = "MIT OR Apache-2.0" -repository = "https://github.com/pathscale/ps-observability" -homepage = "https://github.com/pathscale/ps-observability" -documentation = "https://docs.rs/qa-inspect-host" -readme = "README.md" -keywords = ["qa", "testing", "blitz", "inspection", "ui"] -categories = ["development-tools::testing"] -publish = true - -# This is the half of a QA sweep that must link a renderer. `ps-qa` drives it -# over a socket and is forbidden from depending on blitz, tauri, winit or wgpu, -# precisely so that driving a control does not build a browser engine. Keeping -# the host in its own crate is what lets both stay true. -[dependencies] -# Plain defaults, which as of ps-blitz 0.4.4 no longer include `system-fonts`. -# -# This first said `default-features = false` and then retyped the six remaining -# defaults by hand to drop the seventh. tauri-runtime-blitz's branch had grown -# the identical list independently, and two consumers arriving at the same -# workaround is what showed the problem was upstream: `system-fonts` should -# never have been a default of `blitz-dom`. A copy of someone else's default -# list also goes stale the next time they add one, silently and in the -# direction of dragging in more than you asked for. -# -# Fixed at the source in pathscale/ps-blitz#87, which also adds a `cargo deny` -# gate there so the default cannot come back. -# -# Nothing here needs a face regardless. What a check reads is the semantic tree -# and box geometry, and both survive with no font registered: measured over the -# 14 engine fixtures and all 74 component sweeps. A document that wants a -# specific face declares it, the same way it does in a browser; `woff` is in -# the default set and is what lets that be fetched. -blitz-dom = { package = "ps-blitz-dom", version = "^0.4" } -blitz-script = { package = "ps-blitz-script", version = "^0.4" } -blitz-traits = { package = "ps-blitz-traits", version = "^0.4" } -brotli = { version = "^8.0.4", default-features = false, features = ["std"] } -tauri-runtime-blitz = { version = "^0.3.6", default-features = false, features = ["agent-control"] } -# Only the channel: `ControlBridge` hands its answer back on a -# `tokio::sync::oneshot`, so the type has to match. No runtime, no reactor. -tokio = { version = "^1", default-features = false, features = ["sync"] } -url = "^2.5.8" - -[features] -# Native visual QA is a core host capability, not a special build. Keeping the -# feature named lets minimal embedders opt out with `--no-default-features`. -default = ["diagnostics"] -diagnostics = ["tauri-runtime-blitz/diagnostics"] - -[dev-dependencies] -tokio = { version = "1", features = ["io-util", "net", "rt", "time"] } diff --git a/crates/qa-inspect-host/LICENSE-APACHE b/crates/qa-inspect-host/LICENSE-APACHE deleted file mode 100644 index 1b5ec8b..0000000 --- a/crates/qa-inspect-host/LICENSE-APACHE +++ /dev/null @@ -1,176 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - -TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - -1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - -2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - -3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - -4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - -5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - -6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - -7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - -8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - -9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - -END OF TERMS AND CONDITIONS diff --git a/crates/qa-inspect-host/LICENSE-MIT b/crates/qa-inspect-host/LICENSE-MIT deleted file mode 100644 index 31aa793..0000000 --- a/crates/qa-inspect-host/LICENSE-MIT +++ /dev/null @@ -1,23 +0,0 @@ -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. diff --git a/crates/qa-inspect-host/README.md b/crates/qa-inspect-host/README.md deleted file mode 100644 index ed67f0a..0000000 --- a/crates/qa-inspect-host/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# qa-inspect-host - -Host a Blitz document over the inspection socket, with no window. - -```zsh -QA_INSPECT_PAGE=/path/to/one/built/page qa-inspect-host -``` - -It prints its descriptor path on stdout once it is serving, then serves until -killed. `ps-qa sweep-components` launches one per component and waits for that -line. - -The input is a bundler-generated component shell, not a general-purpose web -page loader. The host reads the page's first double-quoted script `src`, first -double-quoted stylesheet `href`, and `data-theme`, then mounts that bundle into -an empty `#root`. Authored body markup is intentionally not copied. Component -fixtures must therefore create their content from the referenced bundle; use a -browser integration test for a static or multi-script document. - -## Why - -A component sweep asks what happens when a control is pressed. Answering that -needs a live document behind a socket: a screenshot says only that something -painted, and a semantic tree written to a file says only what was on screen at -one instant, so every check involving a click is undecidable against either. - -Hosting the socket used to require opening a window, because -`AgentControlServer::start` was private to the runtime. A sweep of 71 components -meant 71 windows over whatever the person at the machine was doing. Nothing -about that server needs a window, and this crate is what that buys: a process -that owns a document, serves inspection, and paints nothing. - -## Why not part of ps-qa - -`ps-qa` may not depend on blitz, tauri, winit or wgpu, so that driving a control -does not build a browser engine. A host has to link a renderer. Two crates, one -socket between them. - -## What it answers - -`Inspect`, `Focus`, `Hover`, `Click`, `DoubleClick`, `SetValue` and `Key`. -`ScrollIntoView` is an acknowledged no-op because a single component page is -already in view. Anything else returns `unsupported` rather than a plausible -`Ack`, because a check that silently did nothing reports a working component as -broken. - -Coordinate pointer and wheel streams are not implemented: those carry window -position and button state on the runtime itself, which a windowless host has -nothing to attach to. Semantic hover targets an exact inspected node and is -fully supported. - -`WindowComposition` is answered as explicitly unsupported because a headless -document has no native window. That keeps composition checks testable without -inventing a plausible native result. - -The current packaged host is validated on macOS. The protocol and driver run on -Linux, but Linux renderer-host packaging is not yet claimed by this crate. diff --git a/crates/qa-inspect-host/src/lib.rs b/crates/qa-inspect-host/src/lib.rs deleted file mode 100644 index 28ae458..0000000 --- a/crates/qa-inspect-host/src/lib.rs +++ /dev/null @@ -1,662 +0,0 @@ -//! Host a Blitz document over the inspection socket, with no window. -//! -//! # Why this exists -//! -//! A component sweep drives one component at a time and asks what happens when -//! a control is pressed. Answering that needs a live document on the other end -//! of a socket: a screenshot says only that something painted, and a semantic -//! tree written to a file says only what was on screen at one instant, so every -//! check involving a click is undecidable against either. -//! -//! Hosting that socket used to require opening a window, because -//! `AgentControlServer::start` was private to the runtime. A sweep of 71 -//! components then meant 71 windows over whatever the person at the machine was -//! doing. Nothing about the server needs a window, and this is what that fact -//! buys: a process that owns a document, serves inspection and only paints -//! offscreen when a visual assertion explicitly asks for pixels. -//! -//! # Why it is not part of ps-qa -//! -//! `ps-qa` is forbidden from depending on blitz, tauri, winit or wgpu, so that -//! driving a control does not build a browser engine. A host has to link a -//! renderer. They are two crates for that reason, talking over the socket. -//! -//! # Use -//! -//! ```sh -//! QA_INSPECT_PAGE=/path/to/one/components/dist qa-inspect-host -//! ``` -//! -//! It prints its descriptor path on stdout when it is ready, then serves until -//! killed. `ps-qa sweep-components` launches one of these per component and -//! waits for that line. - -use blitz_dom::Document; -use blitz_dom::DocumentConfig; -use blitz_script::{DefaultScriptFetcher, FetchError, ScriptDocument, ScriptFetcher}; -use brotli::Decompressor; -use std::fs; -use std::io::Read; -use std::num::NonZeroUsize; -use std::path::{Component, Path, PathBuf}; -use url::Url; - -const MAX_DECOMPRESSED_ASSET_BYTES: u64 = 32 * 1024 * 1024; - -fn trace(message: &str) { - eprintln!("qa-inspect-host: {message}"); -} - -struct DistScriptFetcher { - url: String, - javascript: String, -} - -impl ScriptFetcher for DistScriptFetcher { - fn fetch(&self, url: &Url) -> Result { - if url.as_str() == self.url { - Ok(self.javascript.clone()) - } else { - DefaultScriptFetcher.fetch(url) - } - } -} - -fn decompress_utf8(compressed: &[u8], label: &str) -> Result { - let mut decoder = Decompressor::new(compressed, 4096); - let mut decoded = Vec::new(); - decoder - .by_ref() - .take(MAX_DECOMPRESSED_ASSET_BYTES + 1) - .read_to_end(&mut decoded) - .map_err(|error| format!("could not decompress embedded {label}: {error}"))?; - if decoded.len() as u64 > MAX_DECOMPRESSED_ASSET_BYTES { - return Err(format!( - "decompressed {label} exceeds the {} MiB safety limit", - MAX_DECOMPRESSED_ASSET_BYTES / (1024 * 1024) - )); - } - String::from_utf8(decoded) - .map_err(|error| format!("decompressed {label} is not UTF-8: {error}")) -} - -fn asset_path(root: &Path, reference: &str) -> Result { - let reference = reference.split('?').next().unwrap_or(reference); - let relative = Path::new(reference.trim_start_matches('/')); - if relative.components().any(|component| { - matches!( - component, - Component::ParentDir | Component::RootDir | Component::Prefix(_) - ) - }) { - return Err(format!( - "asset path escapes the page directory: {reference:?}" - )); - } - let canonical_root = fs::canonicalize(root) - .map_err(|error| format!("could not resolve asset root {}: {error}", root.display()))?; - let candidate = fs::canonicalize(canonical_root.join(relative)).map_err(|error| { - format!( - "could not resolve asset {} below {}: {error}", - relative.display(), - canonical_root.display() - ) - })?; - if !candidate.starts_with(&canonical_root) { - return Err(format!( - "asset path escapes the page directory: {reference:?}" - )); - } - Ok(candidate) -} - -fn create_dist_document(dist: &std::path::Path, url: &str) -> Result { - fn asset_url<'a>(html: &'a str, attribute: &str) -> Result<&'a str, String> { - let marker = format!("{attribute}=\""); - let start = html - .find(&marker) - .map(|index| index + marker.len()) - .ok_or_else(|| format!("the page has no {attribute} asset"))?; - let end = html[start..] - .find('"') - .map(|index| start + index) - .ok_or_else(|| format!("the page has an unterminated {attribute} asset"))?; - Ok(&html[start..end]) - } - - /* - * Brotli or plain, decided by the bytes rather than by configuration. - * - * The capture path is fed a Brotli dist, and AgencyZero's own `dist` is - * plain text; a harness dist is whatever its bundler emitted. Requiring one - * of the two produced `could not decompress embedded external CSS: Invalid - * Data` on a perfectly good stylesheet, and the page then rendered with no - * styles at all, which reads as broken components rather than a rejected - * asset. - */ - fn read_brotli_asset(dist: &std::path::Path, url: &str, label: &str) -> Result { - let path = asset_path(dist, url)?; - let bytes = fs::read(&path) - .map_err(|error| format!("could not read {}: {error}", path.display()))?; - match decompress_utf8(&bytes, label) { - Ok(text) => Ok(text), - Err(compressed_error) => String::from_utf8(bytes).map_err(|_| compressed_error), - } - } - - /* - * A page, or a directory holding one. - * - * Pointing this at a directory and demanding `index.html` inside it forced - * every consumer to reshape its build first: a bundler that emits one page - * per component (`button.html` beside `button.js`) has no `index.html` at - * all, so the QA harness carried a `stage.ts` whose whole job was copying - * one page into a throwaway directory under a different name. Accepting the - * page directly deletes that step from every project. - * - * Assets resolve against the page's own directory, which is where a - * bundler's relative `src=` and `href=` already point. - */ - let (page_path, asset_root) = if dist.is_dir() { - (dist.join("index.html"), dist.to_path_buf()) - } else { - let parent = dist - .parent() - .ok_or_else(|| format!("{} has no parent directory", dist.display()))?; - (dist.to_path_buf(), parent.to_path_buf()) - }; - let dist = asset_root.as_path(); - - trace(&format!("loading page: {}", page_path.display())); - let index = fs::read_to_string(&page_path) - .map_err(|error| format!("could not read {}: {error}", page_path.display()))?; - /* - * A page that carries its own markup is served as it stands. - * - * Everything below rebuilds the document: it pulls the one external - * stylesheet and the one external script out of a bundler's `index.html` - * and synthesises a shell around them, because that is the shape a - * component harness emits and the `
` it mounts into is not - * in the file. - * - * That shape is not the only useful one. A repository testing the engine - * itself, or a reduction of a bug, writes the markup by hand: a control, a - * listener, and a heading naming what the listener saw. Demanding a bundle - * from those meant standing up a JavaScript toolchain to assert that a - * checkbox toggles, so they went and wrote their own driver instead, which - * is how a renderer ends up with two testing stories and one of them - * untested. - * - * Detection is the absence of an external script, not a flag: a hand-written - * page has inline script or none, and a built one always has `src=`. - */ - let Ok(javascript_url) = asset_url(&index, "src") else { - trace("no external bundle; serving the page as written"); - let config = DocumentConfig { - base_url: Some(url.into()), - ..DocumentConfig::default() - }; - return Ok(ScriptDocument::from_html(&index, config)); - }; - - let stylesheet_url = asset_url(&index, "href")?; - let css = read_brotli_asset(dist, stylesheet_url, "external CSS")?; - let javascript = read_brotli_asset(dist, javascript_url, "external JavaScript")?; - /* - * `data-theme` rides along from the source document. Every design token in - * `@pathscale/ui` is defined under a `[data-theme=...]` selector, so a body - * without one leaves `var(--color-base-100)` and friends unresolved: the - * page renders, and every component in it is transparent and unconstrained. - * That reads as broken components rather than a dropped attribute. - */ - let theme = index - .find("data-theme=\"") - .map(|start| start + "data-theme=\"".len()) - .and_then(|start| { - index[start..] - .find('"') - .map(|end| &index[start..start + end]) - }) - .unwrap_or("dark"); - let html = format!( - "
" - ); - let base_url = Url::parse(url).map_err(|error| format!("invalid base URL: {error}"))?; - let script_url = base_url - .join(javascript_url) - .map_err(|error| format!("invalid JavaScript asset URL: {error}"))? - .to_string(); - let config = DocumentConfig { - base_url: Some(url.into()), - ..DocumentConfig::default() - }; - Ok( - ScriptDocument::from_html(&html, config).with_fetcher(DistScriptFetcher { - url: script_url, - javascript, - }), - ) -} - -pub fn serve() -> Result<(), String> { - use blitz_traits::events::{BlitzImeEvent, UiEvent}; - use blitz_traits::shell::{ColorScheme, Viewport}; - use std::sync::mpsc; - #[cfg(feature = "diagnostics")] - use tauri_runtime_blitz::control_protocol::DiagnosticsRequest; - use tauri_runtime_blitz::control_protocol::{ - AgentAction, AgentControlRequest, DebugError, DebugEvent, DebugResponse, InputCommand, - KeyPhase, - }; - use tauri_runtime_blitz::{ - AgentControlServer, ControlBridgeRequest, DocumentCapture, click_agent_node, - focus_agent_node, hover_agent_node, inspect_document, press_agent_key, snapshot_document, - }; - - fn dimension(variable: &str, default: u32) -> Result { - let Some(value) = std::env::var_os(variable) else { - return Ok(default); - }; - let text = value - .into_string() - .map_err(|_| format!("{variable} is not valid UTF-8"))?; - text.parse::() - .ok() - .filter(|value| *value > 0) - .ok_or_else(|| format!("{variable} must be a positive integer, got {text:?}")) - } - - // Drain synchronous script and reactive work without imposing a timer on - // every control. Delayed outcomes are polled by ps-qa against the exact - // declared verdict, so sleeping here only makes fast controls slow and - // duplicates the caller's timeout. - struct SettleFailure { - error: DebugError, - painted: bool, - } - - fn settle_immediate( - document: &mut ScriptDocument, - clock: &std::time::Instant, - deadline: std::time::Duration, - ) -> Result { - let before = document.inner().paint_damage().generation; - let started = std::time::Instant::now(); - let mut iterations = 0_u32; - loop { - if !document.poll(None) { - break; - } - iterations = iterations.saturating_add(1); - if started.elapsed() >= deadline { - document.inner_mut().resolve(clock.elapsed().as_secs_f64()); - return Err(SettleFailure { - painted: document.inner().paint_damage().generation != before, - error: DebugError { - code: "documentNotQuiescent".into(), - message: format!( - "the document still had immediate work after {iterations} settle iterations and {}ms", - deadline.as_millis() - ), - }, - }); - } - } - document.inner_mut().resolve(clock.elapsed().as_secs_f64()); - Ok(document.inner().paint_damage().generation != before) - } - - fn settle_response( - document: &mut ScriptDocument, - clock: &std::time::Instant, - deadline: std::time::Duration, - painted: &mut bool, - ) -> DebugResponse { - match settle_immediate(document, clock, deadline) { - Ok(did_paint) => { - *painted = did_paint; - DebugResponse::Ack - } - Err(failure) => { - *painted = failure.painted; - DebugResponse::Error(failure.error) - } - } - } - - fn commit_render(events: &tokio::sync::watch::Sender>, revision: &mut u64) { - *revision = revision.saturating_add(1); - events.send_replace(Some(DebugEvent::PaintCommitted { - revision: *revision, - })); - } - - let width = dimension("QA_HOST_WIDTH", 1344)?; - let height = dimension("QA_HOST_HEIGHT", 900)?; - let settle_deadline = - std::time::Duration::from_millis(u64::from(dimension("QA_HOST_SETTLE_MS", 100)?)); - - trace("inspection host started"); - let dist = std::env::var_os("QA_INSPECT_PAGE") - .ok_or_else(|| "QA_INSPECT_PAGE is not set; point it at one built page".to_owned())?; - let mut document = create_dist_document(std::path::Path::new(&dist), "tauri://localhost/")?; - document - .inner_mut() - .set_viewport(Viewport::new(width, height, 1.0, ColorScheme::Dark)); - document.inner_mut().set_paint_damage_tracking(true); - document.execute_scripts(); - - // Script execution is synchronous; drain the reactive work it queued - // before announcing the socket instead of sleeping for a fixed 800 ms. - let animation_clock = std::time::Instant::now(); - if let Err(failure) = settle_immediate(&mut document, &animation_clock, settle_deadline) { - trace(&format!( - "initial document reached the settle deadline: {}", - failure.error.message - )); - } - trace("document ready"); - - /* - * The bridge hands a request to this thread and waits for the answer. - * - * A `SyncSender` with a zero-capacity channel would rendezvous, but the - * server thread must not block indefinitely if this loop has gone away, so - * the reply travels on a per-request oneshot the caller owns. - */ - const MAX_PENDING_REQUESTS: usize = 64; - let (request_tx, request_rx) = mpsc::sync_channel::<( - ControlBridgeRequest, - tokio::sync::oneshot::Sender, - )>(MAX_PENDING_REQUESTS); - - let bridge: tauri_runtime_blitz::ControlBridge = std::sync::Arc::new(move |request| { - let (response_tx, response_rx) = tokio::sync::oneshot::channel(); - match request_tx.try_send((request, response_tx)) { - Ok(()) => response_rx, - Err(mpsc::TrySendError::Full((_, response_tx))) => { - let _ = response_tx.send(DebugResponse::Error(DebugError { - code: "documentBusy".into(), - message: format!( - "the document already has {MAX_PENDING_REQUESTS} pending inspection requests" - ), - })); - response_rx - } - Err(mpsc::TrySendError::Disconnected((_, response_tx))) => { - let _ = response_tx.send(DebugResponse::Error(DebugError { - code: "documentUnavailable".into(), - message: "the document is no longer serving".into(), - })); - response_rx - } - } - }); - - let (render_events, render_event_receiver) = tokio::sync::watch::channel(None); - let server = AgentControlServer::start_with_events(bridge, render_event_receiver) - .map_err(|error| format!("could not host the control socket: {error}"))?; - trace(&format!( - "inspection socket listening: {}", - server.descriptor_path().display() - )); - // The descriptor path on stdout, so a caller can attach without guessing - // it. `ps-qa --app` takes a descriptor, and a sweep that has to search a - // directory races every other instance on the machine. - println!("{}", server.descriptor_path().display()); - use std::io::Write as _; - let _ = std::io::stdout().flush(); - - let mut revision = 0_u64; - let mut render_revision = 0_u64; - #[cfg(feature = "diagnostics")] - let mut capture = DocumentCapture::new(); - while let Ok((request, reply)) = request_rx.recv() { - let mut painted = false; - let response = match request { - ControlBridgeRequest::Agent(request) => match request { - AgentControlRequest::Inspect { root, max_depth } => { - 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 { .. }) => { - /* - * Acknowledged rather than refused. A driver scrolls a control - * into view before hovering it, which is right for an - * application with a scrolling region and a no-op on a page - * holding one component: everything is already in view. - * - * Refusing it failed every hovering check with "unsupported" - * before the hover was ever attempted, which reads as a host - * that cannot hover rather than one that cannot scroll. - */ - DebugResponse::Ack - } - 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, and this host has no font - * catalogue: every glyph shapes to nothing, so - * there are no lines to walk, the selection comes - * back collapsed, and the commit below inserts at - * the caret instead of replacing. - * - * Setting a pre-filled field therefore appended to - * it. Measured on @pathscale/ui: InlineEdit - * committed "Original titleRenamed title", and the - * connection panel built - * "wss://api.example.comws://qa-committed" and then - * correctly refused it as not an address -- a - * component reported broken for doing its job on a - * value the harness had mistyped. - * - * `delete_bytes_before_selection` and - * `delete_bytes_after_selection` do byte arithmetic - * on the buffer and clamp to its ends, so between - * them they empty it from wherever the caret is, - * with no layout involved. The commit then inserts - * into an empty field, which is what `select_all` - * was reaching for on a host that can shape text. - */ - 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), - } - } - } - // Everything else needs runtime state this host does not have, and - // saying so is better than a plausible-looking Ack: a check that - // silently did nothing reports the component as broken. - _ => DebugResponse::Error(DebugError { - code: "unsupported".into(), - message: "this host serves Inspect, Focus, Hover, Click, DoubleClick, SetValue and Key only".into(), - }), - }, - #[cfg(feature = "diagnostics")] - ControlBridgeRequest::Diagnostics(DiagnosticsRequest::Capture(request)) => { - if !request.scale.is_finite() || !(0.25..=8.0).contains(&request.scale) { - DebugResponse::Error(DebugError { - code: "invalidArgument".into(), - message: "capture scale must be finite and between 0.25 and 8".into(), - }) - } else { - match capture.capture(&mut document, request) { - Ok(captured) => DebugResponse::Captured(captured), - Err(error) => DebugResponse::Error(error), - } - } - } - #[cfg(feature = "diagnostics")] - ControlBridgeRequest::Diagnostics(DiagnosticsRequest::Snapshot(request)) => { - revision += 1; - match snapshot_document(&mut document, request, revision) { - Ok(snapshot) => DebugResponse::Snapshot(snapshot), - Err(error) => DebugResponse::Error(error), - } - } - #[cfg(feature = "diagnostics")] - ControlBridgeRequest::Diagnostics(DiagnosticsRequest::WindowComposition) => { - DebugResponse::WindowComposition( - tauri_runtime_blitz::control_protocol::WindowComposition::default(), - ) - } - #[cfg(feature = "diagnostics")] - ControlBridgeRequest::Diagnostics(_) => DebugResponse::Error(DebugError { - code: "unsupported".into(), - message: "the headless host serves diagnostics Capture, Snapshot and WindowComposition only".into(), - }), - }; - if painted { - commit_render(&render_events, &mut render_revision); - } - if reply.send(response).is_err() { - break; - } - } - - trace("inspection host finished"); - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::asset_path; - - #[test] - fn assets_cannot_escape_the_page_directory() { - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../target") - .join(format!( - "qa-host-assets-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock is after the epoch") - .as_nanos() - )); - std::fs::create_dir(&root).expect("create fixture root"); - std::fs::write(root.join("inside.js"), "fixture").expect("write fixture asset"); - assert_eq!( - asset_path(&root, "inside.js?cache=1").expect("local asset"), - std::fs::canonicalize(root.join("inside.js")).unwrap() - ); - assert!(asset_path(&root, "../outside.js").is_err()); - assert!(asset_path(&root, "/../../outside.js").is_err()); - let _ = std::fs::remove_dir_all(root); - } -} diff --git a/crates/qa-inspect-host/src/main.rs b/crates/qa-inspect-host/src/main.rs deleted file mode 100644 index fa79e0e..0000000 --- a/crates/qa-inspect-host/src/main.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! Serve one Blitz document over the inspection socket, with no window. - -fn main() { - if let Err(error) = qa_inspect_host::serve() { - eprintln!("qa-inspect-host: {error}"); - std::process::exit(1); - } -} diff --git a/crates/qa-inspect-host/tests/fixture/checks/smoke.ron b/crates/qa-inspect-host/tests/fixture/checks/smoke.ron deleted file mode 100644 index d9bc833..0000000 --- a/crates/qa-inspect-host/tests/fixture/checks/smoke.ron +++ /dev/null @@ -1,18 +0,0 @@ -[ - ( - id: "fixture-text-entry", - group: "fixture", - what: "the headless host accepts text input and reports its rendered value", - open: None, - prepare: None, - hover: None, - click: None, - type_into: Some("Fixture value"), - text: Some("after"), - key: None, - key_on: None, - compare: None, - subject: "textbox:Fixture value", - expect: ValueChanges, - ), -] diff --git a/crates/qa-inspect-host/tests/fixture/page.css b/crates/qa-inspect-host/tests/fixture/page.css deleted file mode 100644 index cec6ff5..0000000 --- a/crates/qa-inspect-host/tests/fixture/page.css +++ /dev/null @@ -1,3 +0,0 @@ -body { margin: 0; } -button { width: 120px; height: 32px; } -button:hover { background: rgb(200 20 20); } diff --git a/crates/qa-inspect-host/tests/fixture/page.html b/crates/qa-inspect-host/tests/fixture/page.html deleted file mode 100644 index d956fd7..0000000 --- a/crates/qa-inspect-host/tests/fixture/page.html +++ /dev/null @@ -1,9 +0,0 @@ - - - - -

Fixture

-
- - - diff --git a/crates/qa-inspect-host/tests/fixture/page.js b/crates/qa-inspect-host/tests/fixture/page.js deleted file mode 100644 index c4c90f4..0000000 --- a/crates/qa-inspect-host/tests/fixture/page.js +++ /dev/null @@ -1,14 +0,0 @@ -const button = document.createElement("button"); -button.setAttribute("aria-label", "Press me"); -button.textContent = "Press me"; -let presses = 0; -button.addEventListener("click", () => { - presses += 1; - button.setAttribute("aria-label", `Pressed ${presses}`); -}); -document.getElementById("root").appendChild(button); -const input = document.createElement("input"); -input.setAttribute("aria-label", "Fixture value"); -input.value = "before"; -document.getElementById("root").appendChild(input); -globalThis.__mounted = true; diff --git a/crates/qa-inspect-host/tests/fixture/ps-qa.ron b/crates/qa-inspect-host/tests/fixture/ps-qa.ron deleted file mode 100644 index 12c4522..0000000 --- a/crates/qa-inspect-host/tests/fixture/ps-qa.ron +++ /dev/null @@ -1,3 +0,0 @@ -// The fixture has no application-specific surfaces. A real profile is still -// required so the harness never invents product navigation rules. -AppProfile() diff --git a/crates/qa-inspect-host/tests/serves_inspection.rs b/crates/qa-inspect-host/tests/serves_inspection.rs deleted file mode 100644 index 73cf4ce..0000000 --- a/crates/qa-inspect-host/tests/serves_inspection.rs +++ /dev/null @@ -1,410 +0,0 @@ -//! The host serves a real page over a real socket. -//! -//! # Why this exists -//! -//! Every other test in this stack is a unit test. `ps-qa` cannot cover the host -//! at all, because it is forbidden from linking blitz, so until now nothing in -//! CI ever launched a host or checked that a page reaches the socket. The whole -//! path was verified by hand, once, and would have broken silently. -//! -//! The fixture is deliberately self-contained: a page, a stylesheet and a -//! script under `tests/fixture`, with no bundler and no other repository -//! involved. A test that needs a sibling checkout built first is a test that -//! does not run. - -use std::io::{BufRead, BufReader}; -use std::process::{Command, Stdio}; -use std::time::{Duration, Instant}; - -use tauri_runtime_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, - encode_diagnostics_request, framed_json, -}; - -async fn request( - stream: &mut dyn MessageStream, - next_id: &mut i64, - request: &AgentControlRequest, -) -> DebugResponse { - *next_id += 1; - let id = JsonRpcId::Number(*next_id); - stream - .send(encode_agent_request(id.clone(), request).expect("encode agent request")) - .await - .expect("send agent request"); - loop { - let message = tokio::time::timeout(Duration::from_secs(5), stream.recv()) - .await - .unwrap_or_else(|_| panic!("host did not answer {request:?}")) - .expect("the host keeps serving") - .expect("read agent response"); - if let Ok((response_id, response)) = decode_response(message) - && response_id == id - { - return response; - } - } -} - -async fn observe_paint(stream: &mut dyn MessageStream, next_id: &mut i64) { - *next_id += 1; - let id = JsonRpcId::Number(*next_id); - stream - .send( - encode_diagnostics_request( - id.clone(), - &DiagnosticsRequest::Observe { - streams: vec![DebugStream::Paint], - }, - ) - .expect("encode observe request"), - ) - .await - .expect("send observe request"); - loop { - let message = tokio::time::timeout(Duration::from_secs(5), stream.recv()) - .await - .expect("host did not answer paint observation") - .expect("the host keeps serving") - .expect("read observe response"); - if let Ok((response_id, DebugResponse::Ack)) = decode_response(message) - && response_id == id - { - return; - } - } -} - -async fn diagnostics( - stream: &mut dyn MessageStream, - next_id: &mut i64, - request: &DiagnosticsRequest, -) -> DebugResponse { - *next_id += 1; - let id = JsonRpcId::Number(*next_id); - stream - .send(encode_diagnostics_request(id.clone(), request).expect("encode diagnostic request")) - .await - .expect("send diagnostic request"); - loop { - let message = tokio::time::timeout(Duration::from_secs(5), stream.recv()) - .await - .unwrap_or_else(|_| panic!("host did not answer {request:?}")) - .expect("the host keeps serving") - .expect("read diagnostic response"); - if let Ok((response_id, response)) = decode_response(message) - && response_id == id - { - return response; - } - } -} - -/// Kill the host however the test ends, including on a panic. -struct Host(std::process::Child); - -impl Drop for Host { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } -} - -#[test] -fn serves_a_page_over_the_inspection_socket() { - let page = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixture/page.html"); - let binary = env!("CARGO_BIN_EXE_qa-inspect-host"); - - let mut host = Host( - Command::new(binary) - .env("QA_INSPECT_PAGE", page) - .stdout(Stdio::piped()) - .stderr(Stdio::inherit()) - .spawn() - .expect("the host binary should start"), - ); - - // The descriptor path, which the host prints once it is serving. Read on a - // thread with a deadline around it: a host that dies before announcing - // would otherwise block for ever on a pipe that will never produce a line. - let stdout = host.0.stdout.take().expect("stdout was piped"); - let (sender, receiver) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let mut line = String::new(); - let _ = BufReader::new(stdout).read_line(&mut line); - let _ = sender.send(line); - }); - - let announced = receiver - .recv_timeout(Duration::from_secs(60)) - .expect("the host should announce a descriptor"); - let descriptor = std::path::PathBuf::from(announced.trim()); - - assert!( - descriptor.is_file(), - "the announced descriptor should exist: {}", - descriptor.display() - ); - - // The socket lives beside the descriptor, and the host writes the - // descriptor before it binds, so a connection can lose that race. - let socket = descriptor.with_extension("sock"); - let deadline = Instant::now() + Duration::from_secs(30); - let connected = loop { - match std::os::unix::net::UnixStream::connect(&socket) { - Ok(stream) => break Some(stream), - Err(_) if Instant::now() < deadline => { - std::thread::sleep(Duration::from_millis(100)); - } - Err(_) => break None, - } - }; - - assert!( - connected.is_some(), - "the inspection socket should accept a connection at {}", - socket.display() - ); - - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("test runtime"); - runtime.block_on(async { - let socket = tokio::net::UnixStream::connect(&socket) - .await - .expect("connect async client"); - let mut stream = TransportStream::new(framed_json(socket)); - let mut next_id = 0; - let snapshot = request( - &mut stream, - &mut next_id, - &AgentControlRequest::Inspect { - root: None, - max_depth: 20, - }, - ) - .await; - let DebugResponse::AgentSnapshot(snapshot) = snapshot else { - panic!("inspect should return a semantic snapshot"); - }; - let button = snapshot - .nodes - .iter() - .find(|node| node.role.eq_ignore_ascii_case("button")) - .unwrap_or_else(|| panic!("fixture button missing from {:?}", snapshot.nodes)) - .id; - let input = snapshot - .nodes - .iter() - .find(|node| node.role.eq_ignore_ascii_case("textbox")) - .unwrap_or_else(|| panic!("fixture input missing from {:?}", snapshot.nodes)) - .id; - - assert!(matches!( - diagnostics( - &mut stream, - &mut next_id, - &DiagnosticsRequest::WindowComposition, - ) - .await, - DebugResponse::WindowComposition(composition) if !composition.supported - )); - assert!(matches!( - diagnostics( - &mut stream, - &mut next_id, - &DiagnosticsRequest::Capture(CaptureRequest { - node_id: Some(button), - scale: 0.0, - }), - ) - .await, - DebugResponse::Error(error) if error.code == "invalidArgument" - )); - assert!(matches!( - request( - &mut stream, - &mut next_id, - &AgentControlRequest::Act(AgentAction::SetValue { - node_id: button, - value: "not editable".into(), - }), - ) - .await, - DebugResponse::Error(error) if error.code == "notEditable" - )); - - assert!(matches!( - request( - &mut stream, - &mut next_id, - &AgentControlRequest::Act(AgentAction::SetValue { - node_id: input, - value: "after".into(), - }), - ) - .await, - DebugResponse::Ack - )); - assert!(matches!( - request( - &mut stream, - &mut next_id, - &AgentControlRequest::Act(AgentAction::Focus { node_id: input }), - ) - .await, - DebugResponse::Ack - )); - for phase in [KeyPhase::Down, KeyPhase::Up] { - assert!(matches!( - request( - &mut stream, - &mut next_id, - &AgentControlRequest::Act(AgentAction::Input(InputCommand::Key { - phase, - key: "ArrowLeft".into(), - code: "ArrowLeft".into(), - modifiers: Modifiers::default(), - })), - ) - .await, - DebugResponse::Ack - )); - } - - assert!(matches!( - request( - &mut stream, - &mut next_id, - &AgentControlRequest::Act(AgentAction::Click { node_id: button }), - ) - .await, - DebugResponse::Ack - )); - assert!(matches!( - request( - &mut stream, - &mut next_id, - &AgentControlRequest::Act(AgentAction::DoubleClick { node_id: button }), - ) - .await, - DebugResponse::Ack - )); - let clicked = request( - &mut stream, - &mut next_id, - &AgentControlRequest::Inspect { - root: Some(button), - max_depth: 1, - }, - ) - .await; - let DebugResponse::AgentSnapshot(clicked) = clicked else { - panic!("inspect should return the clicked button"); - }; - assert!( - clicked - .nodes - .iter() - .any(|node| node.id == button && node.name.starts_with("Pressed ")), - "click actions must expose their authored outcome: {:?}", - clicked.nodes - ); - let changed = request( - &mut stream, - &mut next_id, - &AgentControlRequest::Inspect { - root: Some(input), - max_depth: 1, - }, - ) - .await; - let DebugResponse::AgentSnapshot(changed) = changed else { - panic!("inspect should return the changed input"); - }; - assert!( - changed - .nodes - .iter() - .any(|node| node.id == input && node.value.as_deref() == Some("after")), - "SetValue must be observable before its Ack: {:?}", - changed.nodes - ); - - observe_paint(&mut stream, &mut next_id).await; - assert!(matches!( - request( - &mut stream, - &mut next_id, - &AgentControlRequest::Act(AgentAction::ScrollIntoView { node_id: button }), - ) - .await, - DebugResponse::Ack - )); - assert!( - tokio::time::timeout(Duration::from_millis(50), stream.recv()) - .await - .is_err(), - "a supported no-op must not fabricate a paint event" - ); - - assert!(matches!( - request( - &mut stream, - &mut next_id, - &AgentControlRequest::Act(AgentAction::ScrollBy { - node_id: button, - delta_x: 0.0, - delta_y: 10.0, - }), - ) - .await, - DebugResponse::Error(error) if error.code == "unsupported" - )); - - assert!(matches!( - request( - &mut stream, - &mut next_id, - &AgentControlRequest::Act(AgentAction::Hover { node_id: button }), - ) - .await, - DebugResponse::Ack - )); - let event = tokio::time::timeout(Duration::from_millis(50), stream.recv()) - .await - .expect("a real hover repaint should emit an event") - .expect("the host keeps serving") - .expect("read paint event"); - assert!(matches!( - decode_diagnostics_event(event), - Ok(DebugEvent::PaintCommitted { .. }) - )); - - for unsupported in [ - AgentControlRequest::Act(AgentAction::Input(InputCommand::Pointer { - phase: PointerPhase::Move, - x: 1.0, - y: 1.0, - button: 0, - modifiers: Modifiers::default(), - })), - AgentControlRequest::Act(AgentAction::Input(InputCommand::Wheel { - delta_x: 0.0, - delta_y: 1.0, - phase: WheelPhase::Moved, - modifiers: Modifiers::default(), - })), - AgentControlRequest::Relaunch, - AgentControlRequest::Quit, - ] { - assert!(matches!( - request(&mut stream, &mut next_id, &unsupported).await, - DebugResponse::Error(error) if error.code == "unsupported" - )); - } - }); -}