From f8c4178d5b8af46c233f501c822c2a8248f51b9a Mon Sep 17 00:00:00 2001 From: meh Date: Sat, 26 Sep 2026 10:28:48 +0700 Subject: [PATCH 1/5] Let QA pointer drags target a painted destination --- crates/ps-qa/README.md | 5 ++++ crates/ps-qa/src/interaction.rs | 8 ++++++ crates/ps-qa/src/qa.rs | 44 ++++++++++++++++++++++++++++++--- crates/ps-qa/src/runner.rs | 4 ++- 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/crates/ps-qa/README.md b/crates/ps-qa/README.md index 864cdb6..7f976ef 100644 --- a/crates/ps-qa/README.md +++ b/crates/ps-qa/README.md @@ -125,6 +125,11 @@ name, `click --id` selects the intended row without coordinates. For repeatable drag outcomes, declare `pointer_drag: Some((from: "button:Drag handle", dx: 60.0, dy: 30.0, steps: 3))` +can instead use a painted destination: +`pointer_drag: Some((from: "button:Drag handle", to: Some("#target"), steps: 12))`. +ps-qa resolves both centers at run time, so a data-derived target can move +without copying its coordinates into the check file. Use either `to` or +`dx`/`dy` for one drag. on a check, then assert the resulting state through `subject` and `expect`. Adding `cancel: true` sends pointercancel instead of pointerup. This drives pointer capture and movement; the older `drag` diagnostic directly scrolls a diff --git a/crates/ps-qa/src/interaction.rs b/crates/ps-qa/src/interaction.rs index 3d6b62e..6944e53 100644 --- a/crates/ps-qa/src/interaction.rs +++ b/crates/ps-qa/src/interaction.rs @@ -80,12 +80,20 @@ pub(crate) async fn pointer_drag( dy: f64, steps: u32, cancel: bool, + to: Option<&str>, ) -> Result<()> { if !dx.is_finite() || !dy.is_finite() || steps == 0 || steps > 240 { bail!("pointer drag needs finite offsets and 1..=240 steps"); } + let destination = if let Some(to) = to { + let (_, bounds) = locate_control(client, to, &[]).await?; + Some((bounds[0] + bounds[2] / 2.0, bounds[1] + bounds[3] / 2.0)) + } else { + None + }; let (_, bounds) = locate_control(client, want, &[]).await?; let start = (bounds[0] + bounds[2] / 2.0, bounds[1] + bounds[3] / 2.0); + let (dx, dy) = destination.map_or((dx, dy), |end| (end.0 - start.0, end.1 - start.1)); let request = |phase, x, y| { AgentControlRequest::Act(AgentAction::Input(InputCommand::Pointer { phase, diff --git a/crates/ps-qa/src/qa.rs b/crates/ps-qa/src/qa.rs index cb2166a..dda503c 100644 --- a/crates/ps-qa/src/qa.rs +++ b/crates/ps-qa/src/qa.rs @@ -354,7 +354,14 @@ pub enum Expect { #[serde(deny_unknown_fields)] pub struct PointerDrag { pub from: String, + /// Optional painted destination; its center determines the drag offset. + /// This lets a QA page expose data-derived coordinates without copying them + /// into a static check manifest. + #[serde(default)] + pub to: Option, + #[serde(default)] pub dx: f64, + #[serde(default)] pub dy: f64, pub steps: u32, #[serde(default)] @@ -856,6 +863,17 @@ fn validate_check( )); } + if let Some(drag) = &check.pointer_drag + && let Some(to) = &drag.to + && (to.is_empty() || drag.dx != 0.0 || drag.dy != 0.0) + { + return Err(format!( + "{}: check {:?} must use either a non-empty pointer drag destination or dx/dy offsets", + file.display(), + check.id, + )); + } + /* * A weakening that does nothing must not look like it did something. * @@ -1711,10 +1729,12 @@ fn action_description(check: &Check) -> String { } if let Some(drag) = &check.pointer_drag { let description = format!( - "drag {:?} by {},{} in {} steps{}", + "drag {:?} {} in {} steps{}", drag.from, - drag.dx, - drag.dy, + drag.to.as_ref().map_or_else( + || format!("by {},{}", drag.dx, drag.dy), + |to| format!("to {to:?}"), + ), drag.steps, if drag.cancel { " and cancel" @@ -2057,6 +2077,24 @@ mod tests { ); } + #[test] + fn a_drag_can_name_its_painted_destination() { + let mut check = + parse("pointer_drag:Some((from:\"button:Piece\",to:Some(\"#target\"),steps:12)),"); + check.click = None; + let drag = check.pointer_drag.as_ref().expect("drag is configured"); + assert_eq!(drag.to.as_deref(), Some("#target")); + assert_eq!(drag.dx, 0.0); + assert_eq!(drag.dy, 0.0); + assert!(action_description(&check).contains("drag \"button:Piece\" to \"#target\"")); + + let mut invalid = check.clone(); + invalid.pointer_drag.as_mut().unwrap().dx = 20.0; + let error = validate_check(&invalid, Path::new("drag.ron"), &mut HashMap::new()) + .expect_err("a named destination and displacement are ambiguous"); + assert!(error.contains("either a non-empty pointer drag destination or dx/dy")); + } + #[test] fn checks_can_prepare_with_a_key_without_changing_the_measured_action() { let check = parse("prepare:Some(\"Menu\"),prepare_key:Some(\"ArrowDown\"),"); diff --git a/crates/ps-qa/src/runner.rs b/crates/ps-qa/src/runner.rs index 0df9b17..8183afe 100644 --- a/crates/ps-qa/src/runner.rs +++ b/crates/ps-qa/src/runner.rs @@ -1987,6 +1987,7 @@ async fn run_qa( drag.dy, drag.steps, drag.cancel, + drag.to.as_deref(), ) .await { @@ -6045,7 +6046,7 @@ pub async fn run() -> Result<()> { steps, cancel, } => { - pointer_drag(&mut client, &name, dx, dy, steps, cancel).await?; + pointer_drag(&mut client, &name, dx, dy, steps, cancel, None).await?; } // Direct container scrolling, retained for existing diagnostic commands. cli::Command::Drag { name, dy, steps } => { @@ -7381,6 +7382,7 @@ mod tests { drag.expect = Expect::ValueChanges; drag.pointer_drag = Some(PointerDrag { from: "slider:Hue".into(), + to: None, dx: 20.0, dy: 0.0, steps: 2, From 240820516b52beff229ce87cd9cfba2a11006a06 Mon Sep 17 00:00:00 2001 From: meh Date: Tue, 29 Sep 2026 04:06:46 +0700 Subject: [PATCH 2/5] Send an injected Cmd as SUPER as well as META Real Cmd arrives from winit as SUPER|META, and blitz-dom's text editing tests SUPER as the macOS action modifier. An injected Cmd+V carried META alone, so a text field typed a literal "v" instead of pasting. --- crates/blitz-control-protocol/src/document.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/blitz-control-protocol/src/document.rs b/crates/blitz-control-protocol/src/document.rs index dd93c70..e50a51d 100644 --- a/crates/blitz-control-protocol/src/document.rs +++ b/crates/blitz-control-protocol/src/document.rs @@ -1898,6 +1898,7 @@ pub(crate) fn keyboard_modifiers(modifiers: ControlModifiers) -> KeyboardModifie output.set(KeyboardModifiers::CONTROL, modifiers.control); output.set(KeyboardModifiers::ALT, modifiers.alt); output.set(KeyboardModifiers::META, modifiers.meta); + output.set(KeyboardModifiers::SUPER, modifiers.meta); output } @@ -2151,6 +2152,16 @@ mod tests { use super::*; use blitz_dom::{Document, DocumentConfig}; + #[test] + fn meta_also_sets_the_super_modifier() { + let modifiers = keyboard_modifiers(ControlModifiers { + meta: true, + ..ControlModifiers::default() + }); + assert!(modifiers.contains(KeyboardModifiers::META)); + assert!(modifiers.contains(KeyboardModifiers::SUPER)); + } + /// A control whose whole size is its label, next to one with padding. /// /// `font-size: 0` stands in for the host this exists for: a Linux CI From aba882da26c17c15c8346b1b631381e20a9c09f2 Mon Sep 17 00:00:00 2001 From: meh Date: Tue, 29 Sep 2026 04:06:46 +0700 Subject: [PATCH 3/5] ps-qa: a check can navigate to a URL as a fresh document `navigate` runs before `open` and sends the protocol's Navigate. A click in a client-routed app never loads a document, so a direct load (reloading /account while signed in) could not be checked at all. The check waits for its arrival target within open_timeout_ms, and a host that refuses Navigate fails the check with its error. --- crates/ps-qa/src/qa.rs | 51 +++++++++++++++++++++++++------------- crates/ps-qa/src/runner.rs | 34 +++++++++++++++++++++++-- 2 files changed, 66 insertions(+), 19 deletions(-) diff --git a/crates/ps-qa/src/qa.rs b/crates/ps-qa/src/qa.rs index dda503c..84c7f69 100644 --- a/crates/ps-qa/src/qa.rs +++ b/crates/ps-qa/src/qa.rs @@ -383,7 +383,15 @@ pub struct Check { pub group: String, /// What this proves, in the words you would use to report it. pub what: String, - /// Press this first, to reach the surface the check is about. + /// Load a URL in the standing document before opening a surface. + /// + /// Relative URLs resolve against the document that is up. Use this when a + /// route must load as a fresh document, such as reloading a signed-in + /// account page that client-side navigation cannot reach. + #[serde(default)] + pub navigate: Option, + /// Activate this after [`navigate`](Self::navigate), when set, to reach the + /// surface the check is about. /// /// Checks run in sequence against one instance and start wherever the app /// opens, so anything not on that first surface is unreachable without a @@ -552,18 +560,16 @@ pub struct Check { /// rendered action still has to pass the one-second budget. #[serde(default)] pub settle_after_ms: u64, - /// Deadline for this check's [`open`](Self::open) step. - /// - /// Navigation is not the interaction contract. `open` may be a route change - /// that fetches before it can paint, and a live network round trip lands on - /// either side of the 900ms every other step gets. The failure that - /// produces is also the wrong sentence: `could not open "Crates"` reads as - /// a missing tab rather than as a deadline, and the reader goes looking for - /// a control that is there. - /// - /// So a route that is known to fetch declares what it costs here, and every - /// other navigation in the suite keeps the strict default rather than being - /// weakened to cover the slow one. + /// Deadline for this check's [`navigate`](Self::navigate) and + /// [`open`](Self::open) steps. + /// + /// Navigation is not the interaction contract. `navigate` loads a document, + /// and `open` can activate a route that fetches before it paints. A live + /// network round trip can exceed the 900ms default, so the route can declare + /// its arrival budget without weakening every other check. + /// + /// Use this only for a route known to fetch. Without it, the failure reads + /// as a missing control rather than as a deadline. #[serde(default)] pub open_timeout_ms: u64, /// Deadline for this check's rendered outcome. @@ -734,13 +740,14 @@ pub fn checks(dir: Option<&std::path::Path>) -> Result, String> { */ if index == 0 && check.open.is_none() + && check.navigate.is_none() && let Some((previous_file, opener)) = &established { return Err(format!( concat!( - "{}: check {:?} is the first in its file and declares no `open`, so it ", - "would run on {:?}, which {} navigated to. Declare the surface this ", - "file starts on." + "{}: check {:?} is the first in its file and declares neither `navigate` nor ", + "`open`, so it would run on {:?}, which {} navigated to. Declare how this ", + "file establishes its starting surface." ), file.display(), check.id, @@ -748,7 +755,7 @@ pub fn checks(dir: Option<&std::path::Path>) -> Result, String> { previous_file.display(), )); } - if let Some(opener) = check.open.as_deref() { + if let Some(opener) = check.open.as_deref().or(check.navigate.as_deref()) { established = Some((file.to_path_buf(), opener.to_owned())); } } @@ -1794,6 +1801,15 @@ mod tests { ron::from_str(&ron).expect("check parses") } + #[test] + fn navigate_is_optional_and_read_from_check_files() { + assert_eq!(parse("").navigate, None); + assert_eq!( + parse("navigate:Some(\"/account\"),").navigate.as_deref(), + Some("/account") + ); + } + fn painted_node(id: u64, name: &str, width: f64, height: f64) -> SemanticNode { SemanticNode { dom_id: None, @@ -2301,6 +2317,7 @@ mod tests { group: "settings".into(), what: "the slider moves".into(), open: None, + navigate: None, prepare: None, prepare_unless: None, prepare_press: false, diff --git a/crates/ps-qa/src/runner.rs b/crates/ps-qa/src/runner.rs index 8183afe..e2dcf9b 100644 --- a/crates/ps-qa/src/runner.rs +++ b/crates/ps-qa/src/runner.rs @@ -1157,7 +1157,7 @@ async fn run_qa( * on another surface. */ /* - * Navigation is best-effort: already being on the surface is success. + * `open` is best-effort: already being on the surface is success. * * Checks run in sequence, so a later one often inherits exactly the * screen it would have navigated to, and the control it navigates *by* @@ -1171,7 +1171,36 @@ async fn run_qa( let mut open_error = None; let mut pixel_outcome: Option> = None; let open_budget = declared_open_timeout(check); - if let Some(want) = check.open.as_deref() { + if let Some(url) = check.navigate.as_deref() { + let navigated = client + .agent(&AgentControlRequest::Navigate { + url: url.to_owned(), + }) + .await; + if let Err(error) = navigated { + open_error = Some(format!("could not navigate to {url:?}: {error}")); + } else { + let target = check + .open + .as_deref() + .or_else(|| check.hover.as_ref().map(qa::Hover::target)) + .or(check.prepare.as_deref()) + .or(check.click.as_deref()) + .or(check.type_into.as_deref()) + .or(check.key_on.as_deref()) + .unwrap_or(&check.subject); + if !wait_for_arrival(client, None, target, open_budget).await? { + open_error = Some(format!( + "could not navigate to {url:?}: the arrival target {target:?} did not paint within \ + {}ms. Raise open_timeout_ms if this route fetches.", + open_budget.as_millis() + )); + } + } + } + if open_error.is_none() + && let Some(want) = check.open.as_deref() + { /* * A permanent surface marker can answer "already there". A * document marker cannot: every document renders the same @@ -6946,6 +6975,7 @@ mod tests { group: "coverage".into(), what: "a rendered outcome".into(), open: None, + navigate: None, prepare: None, prepare_unless: None, prepare_press: false, From deef74be54b087ccdb6bdbac17ae439927fc79b6 Mon Sep 17 00:00:00 2001 From: meh Date: Tue, 29 Sep 2026 04:06:47 +0700 Subject: [PATCH 4/5] release(ps-qa): 0.7.6 --- 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 e292f21..561f8c4 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.7.5" +version = "0.7.6" edition = "2024" rust-version = "1.88" license = "MIT OR Apache-2.0" From 992fdec97672b885739d3eae54cedaab527bff61 Mon Sep 17 00:00:00 2001 From: meh Date: Tue, 29 Sep 2026 04:06:47 +0700 Subject: [PATCH 5/5] release(blitz-control-protocol): 0.5.4 --- crates/blitz-control-protocol/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/blitz-control-protocol/Cargo.toml b/crates/blitz-control-protocol/Cargo.toml index 43a5a1c..a32b808 100644 --- a/crates/blitz-control-protocol/Cargo.toml +++ b/crates/blitz-control-protocol/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "blitz-control-protocol" description = "The Blitz agent-control and diagnostics surface: one vocabulary, one core, two transports" -version = "0.5.3" +version = "0.5.4" edition.workspace = true rust-version.workspace = true license.workspace = true