From 0422287b814eeb12c585dbe2576a753f874c233a Mon Sep 17 00:00:00 2001 From: meh Date: Tue, 8 Sep 2026 11:59:27 +0700 Subject: [PATCH 1/9] fix(qa): let click and type work against a host with no compositor `ps-qa click` reads frame metrics either side of the action, as context. A headless host owns a document and no compositor, so it answers "unsupported", and the whole command failed with inspector returned unsupported: the headless page serves diagnostics Capture, Snapshot and WindowComposition only before the click was ever dispatched. The one thing that could drive a built site could not be clicked. The numbers are now optional where they are only context, and the command says the host has none rather than printing zeroes. Where they are the point -- `idle`, `frames`, `drift`, `blink` -- nothing changes and the refusal still fails the command: a zeroed reading from a host that never painted would pass every one of those while proving nothing. Co-Authored-By: Claude Opus 5 --- crates/ps-qa/src/diagnostics.rs | 16 ++++++++++++++ crates/ps-qa/src/inspector.rs | 14 ++++++++++++ crates/ps-qa/src/interaction.rs | 38 +++++++++++++++++++++++---------- 3 files changed, 57 insertions(+), 11 deletions(-) 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); +} From a5fe27508a5a77ac2b6221737f4e3577bc397627 Mon Sep 17 00:00:00 2001 From: meh Date: Tue, 8 Sep 2026 12:02:22 +0700 Subject: [PATCH 2/9] refactor: delete qa-inspect-host, so there is one headless browser It was a second one. `ps-qa` drove it, chuzz is what ships, and the web platform existed in only chuzz: `URLSearchParams`, `matchMedia`, storage, the observers and `performance.getEntriesByType` are supplied by chuzz's document loader, and a host without them blanks every routed page before its first render. So the harness measured a browser nobody uses, and every gap closed for the browser had to be closed a second time here, by hand. It is `chuzz-headless` now, in pathscale/chuzz, loading through the same loader and the same engine a tab uses and serving this protocol over the same socket. `ps-qa` still links no renderer, and the `core` job's dependency-tree gate still proves it: that constraint was about the socket, never about which repository the host lives in. `--host` was always a path, so nothing in `ps-qa` needed changing to point at it. What goes with the crate is the `host` CI job, which was the one place `ps-qa` drove a live page end to end. That coverage moves to chuzz, where both halves exist, rather than being dropped: pathscale/chuzz#37 carries the fixture and the socket test. The published `qa-inspect-host` 0.1.12 stays on the registry, so the `@pathscale/ui` component sweep keeps working until it is pointed at the new host. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 27 - .github/workflows/publish.yml | 1 - Cargo.toml | 1 - README.md | 65 +- crates/ps-qa/src/cli.rs | 8 +- crates/ps-qa/src/qa.rs | 4 +- crates/ps-qa/src/runner.rs | 7 +- crates/qa-inspect-host/Cargo.toml | 56 -- crates/qa-inspect-host/LICENSE-APACHE | 176 ----- crates/qa-inspect-host/LICENSE-MIT | 23 - crates/qa-inspect-host/README.md | 57 -- crates/qa-inspect-host/src/lib.rs | 662 ------------------ crates/qa-inspect-host/src/main.rs | 8 - .../tests/fixture/checks/smoke.ron | 18 - crates/qa-inspect-host/tests/fixture/page.css | 3 - .../qa-inspect-host/tests/fixture/page.html | 9 - crates/qa-inspect-host/tests/fixture/page.js | 14 - .../qa-inspect-host/tests/fixture/ps-qa.ron | 3 - .../tests/serves_inspection.rs | 410 ----------- 19 files changed, 53 insertions(+), 1499 deletions(-) delete mode 100644 crates/qa-inspect-host/Cargo.toml delete mode 100644 crates/qa-inspect-host/LICENSE-APACHE delete mode 100644 crates/qa-inspect-host/LICENSE-MIT delete mode 100644 crates/qa-inspect-host/README.md delete mode 100644 crates/qa-inspect-host/src/lib.rs delete mode 100644 crates/qa-inspect-host/src/main.rs delete mode 100644 crates/qa-inspect-host/tests/fixture/checks/smoke.ron delete mode 100644 crates/qa-inspect-host/tests/fixture/page.css delete mode 100644 crates/qa-inspect-host/tests/fixture/page.html delete mode 100644 crates/qa-inspect-host/tests/fixture/page.js delete mode 100644 crates/qa-inspect-host/tests/fixture/ps-qa.ron delete mode 100644 crates/qa-inspect-host/tests/serves_inspection.rs 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/src/cli.rs b/crates/ps-qa/src/cli.rs index 716d0fc..064eac0 100644 --- a/crates/ps-qa/src/cli.rs +++ b/crates/ps-qa/src/cli.rs @@ -112,8 +112,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 +764,7 @@ mod tests { "qa-hosted", "fixture-text-entry", "--host", - "qa-inspect-host", + "chuzz-headless", "--page", "page.html", "--checks", @@ -783,7 +783,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/qa.rs b/crates/ps-qa/src/qa.rs index 07b0867..9ee6110 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. /// diff --git a/crates/ps-qa/src/runner.rs b/crates/ps-qa/src/runner.rs index 388a3f2..698c9b6 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 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" - )); - } - }); -} From 6f9f5d4d730c02e4764fdf6e9d487d9fe6031efe Mon Sep 17 00:00:00 2001 From: meh Date: Tue, 8 Sep 2026 14:13:02 +0700 Subject: [PATCH 3/9] fix(qa): do not read the application profile as a check group The documented layout puts `ps-qa.ron` beside the checks, and the check glob read it as one: every run failed 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 layout the documentation describes could not be used. Found writing the first site's checks. Co-Authored-By: Claude Opus 5 --- crates/ps-qa/src/qa.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/ps-qa/src/qa.rs b/crates/ps-qa/src/qa.rs index 9ee6110..9219829 100644 --- a/crates/ps-qa/src/qa.rs +++ b/crates/ps-qa/src/qa.rs @@ -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. From b1bcc975b087764192cc93f15451a81a8b3a38e1 Mon Sep 17 00:00:00 2001 From: meh Date: Tue, 8 Sep 2026 14:40:38 +0700 Subject: [PATCH 4/9] release: ps-qa 0.6.3 Publishing is per-crate and triggered by the version, and a site's CI installs the driver from the registry. Without this the documented check layout still fails there: `ps-qa.ron` beside the checks is read as a check group and every run stops with "Expected opening `[`". Co-Authored-By: Claude Opus 5 --- crates/ps-qa/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From 8c7d33b2aadc7709abe5874d6e3476761ccebc5a Mon Sep 17 00:00:00 2001 From: meh Date: Tue, 8 Sep 2026 16:50:47 +0700 Subject: [PATCH 5/9] fix(cli): read the deadline multiplier from the environment Every site's `qa.yml` sets `QA_TIMEOUT_SCALE` in the job environment and nothing read it, so all eleven of them would have run a shared runner against the strict local latency contract. The knob was `--timeout-scale` and only that. Found on nofilter.io, where it is not a runner-speed nicety: every navigation reloads and re-executes a 1.8MB bundle, so arrival takes about a second of engine time and the 900ms default gave up, went home, and reported the surface as unreachable. The flag still wins when both are given. Co-Authored-By: Claude Opus 5 --- crates/ps-qa/src/cli.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/ps-qa/src/cli.rs b/crates/ps-qa/src/cli.rs index 064eac0..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 )] From 2a5870708d9e7ecf34b1d254b0befb7ffdd90ee0 Mon Sep 17 00:00:00 2001 From: meh Date: Tue, 8 Sep 2026 22:53:42 +0700 Subject: [PATCH 6/9] fix(qa): re-read the whole document while a scoped outcome settles An outcome poll narrows to the pane the action happened in, so a settling check does not serialise a large tree every turn. It probed the full document once when the scoped verdict said the result was absent, and never again. That made it permanently blind to anything outside the pane that arrived after that single probe. A sign-in is exactly this shape: the credential is accepted, the account page replaces the pane, and the shell's header swaps a Login for a Logout. The probe was taken before the swap, so the poll stayed scoped for the rest of its deadline. Measured on honey.id against auth-dev: thirty seconds of polling never saw `button:Logout`, and one fresh snapshot taken a second after the harness disconnected found it at once. The check now passes in three. The one-shot becomes an interval. Between probes the stability samples stay scoped, which is what the scope was for. Co-Authored-By: Claude Opus 5 --- crates/ps-qa/src/runner.rs | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/crates/ps-qa/src/runner.rs b/crates/ps-qa/src/runner.rs index 698c9b6..bc3931b 100644 --- a/crates/ps-qa/src/runner.rs +++ b/crates/ps-qa/src/runner.rs @@ -2277,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. /// @@ -2298,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() { @@ -2319,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 { From 632a05c8d716998414074ff4863e8cb8779018c8 Mon Sep 17 00:00:00 2001 From: meh Date: Tue, 8 Sep 2026 23:04:43 +0700 Subject: [PATCH 7/9] fix(qa): count a declared exception as excluded, not as unaddressable `inventory` classified a control by its DOM id before it looked at whether the application had declared it an exception. A link that leaves for a third party, a native file panel, a session-ending action: each was reported as `failed-missing-id`, so the only way to clear the audit was to give an id to a control no check will ever address, or to withdraw the exception. The exceptions now come first. This is what makes the mechanism usable for the fleet, where most links on a marketing page go to GitHub, crates.io or docs.rs and following them would make a run depend on those services rather than on the site under test. Co-Authored-By: Claude Opus 5 --- crates/ps-qa/src/runner.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/ps-qa/src/runner.rs b/crates/ps-qa/src/runner.rs index bc3931b..84bbe47 100644 --- a/crates/ps-qa/src/runner.rs +++ b/crates/ps-qa/src/runner.rs @@ -3646,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 @@ -3656,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) { From df340afdbd93260ce3d1ec8589c0b43d19229195 Mon Sep 17 00:00:00 2001 From: meh Date: Wed, 9 Sep 2026 16:10:21 +0700 Subject: [PATCH 8/9] style: run cargo fmt on the scoped-probe branch One line in the outcome loop was wrapped the other way; `cargo fmt --check` is the only failing job on this branch. --- crates/ps-qa/src/runner.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ps-qa/src/runner.rs b/crates/ps-qa/src/runner.rs index 84bbe47..8a73443 100644 --- a/crates/ps-qa/src/runner.rs +++ b/crates/ps-qa/src/runner.rs @@ -2329,8 +2329,8 @@ 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(); - let due_for_full_probe = last_full_probe - .is_none_or(|at| now.duration_since(at) >= FULL_DOCUMENT_PROBE_INTERVAL); + 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 From dcb8d0ff278538fff1d8fd67bbff80360d510b49 Mon Sep 17 00:00:00 2001 From: meh Date: Wed, 9 Sep 2026 16:26:03 +0700 Subject: [PATCH 9/9] test: the inventory test asserted an ordering that was deliberately changed `fix(qa): count a declared exception as excluded, not as unaddressable` moved the manual and isolated exclusions ahead of the id checks in `inventory_class`, so a control the application has excluded is reported as excluded rather than as missing an id. The test still asserted the previous order and was not updated, so it failed with left: Manual right: MissingId The function is right: an exclusion says no check will drive this control, and reporting it as unaddressable names a defect that can only be fixed by withdrawing the exception. Split in two rather than just corrected. The first test keeps its subject, id defects on a control that is not excluded. The second asserts the precedence directly, because it is a decision rather than an accident and the last test that touched it was silently wrong about it. --- crates/ps-qa/src/runner.rs | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/ps-qa/src/runner.rs b/crates/ps-qa/src/runner.rs index 8a73443..fd53efe 100644 --- a/crates/ps-qa/src/runner.rs +++ b/crates/ps-qa/src/runner.rs @@ -6154,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 ); @@ -6170,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"));