From 9bc4829997d2665b5ecf701578b8310196818193 Mon Sep 17 00:00:00 2001 From: meh Date: Sat, 12 Sep 2026 05:56:54 +0700 Subject: [PATCH 1/9] fix(qa): prefer visible controls in headless mode --- crates/ps-qa/src/target.rs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/ps-qa/src/target.rs b/crates/ps-qa/src/target.rs index 5df36db..b62497f 100644 --- a/crates/ps-qa/src/target.rs +++ b/crates/ps-qa/src/target.rs @@ -237,6 +237,15 @@ pub(crate) async fn locate_control( // disabled. Filtering it first let a longer enabled substring steal // the action (`Send` became “Parse … before sending”). candidates.retain(|(node, _)| node.enabled); + /* + * Headless mode may keep a zero-area control as a last-resort target + * when missing fonts collapse its label. That fallback must not beat + * a visible, painted control with the same accessible name. Responsive + * components commonly retain a hidden mobile copy beside the visible + * desktop control; tree order alone otherwise selects the hidden copy + * and the runtime correctly rejects the click as not interactable. + */ + prioritize_actionable_candidates(&mut candidates); // Prefer the modal in front, then the active surface, then global // chrome. Retained panes can keep enabled, painted controls with the // same name; tree order is not a statement about which one owns the @@ -351,6 +360,10 @@ pub(crate) async fn locate_control( target.1 ) } + +fn prioritize_actionable_candidates(candidates: &mut [(&SemanticNode, [f64; 4])]) { + candidates.sort_by_key(|(node, _)| (!node.visible, painted_bounds(node).is_none())); +} fn selector_slot(selector: &str) -> Option<&str> { selector.strip_prefix('@') } @@ -511,6 +524,26 @@ mod tests { assert!(selector_matches_node(&save, "save")); } + #[test] + fn a_visible_painted_copy_precedes_a_hidden_headless_fallback() { + let mut hidden = node(None, "Next page"); + hidden.visible = false; + hidden.bounds = Some([0.0, 0.0, 0.0, 0.0]); + + let mut desktop = node(None, "Next page"); + desktop.id = 2; + desktop.bounds = Some([1200.0, 400.0, 48.0, 48.0]); + + let mut candidates = vec![ + (&hidden, hidden.bounds.expect("hidden bounds")), + (&desktop, desktop.bounds.expect("desktop bounds")), + ]; + prioritize_actionable_candidates(&mut candidates); + + assert_eq!(candidates[0].0.id, desktop.id); + assert_eq!(candidates[1].0.id, hidden.id); + } + /// The audit's old predicate is written out here because the point is that /// it *passes* the document it should have rejected. fn contains_either(node: &SemanticNode, want: &str) -> bool { From ccb73f0b66d80eebe9ba180df5df5cfb13931a6b Mon Sep 17 00:00:00 2001 From: meh Date: Sat, 12 Sep 2026 07:27:36 +0700 Subject: [PATCH 2/9] fix(qa): target the active rendered surface --- crates/blitz-control-protocol/Cargo.toml | 2 +- crates/blitz-control-protocol/src/document.rs | 77 ++++++++++++++----- crates/ps-qa/Cargo.toml | 2 +- crates/ps-qa/src/runner.rs | 42 +++++++--- crates/ps-qa/src/target.rs | 53 +++++++++++-- 5 files changed, 138 insertions(+), 38 deletions(-) diff --git a/crates/blitz-control-protocol/Cargo.toml b/crates/blitz-control-protocol/Cargo.toml index 56b7e4a..ebc0b0a 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.0" +version = "0.5.1" edition.workspace = true rust-version.workspace = true license.workspace = true diff --git a/crates/blitz-control-protocol/src/document.rs b/crates/blitz-control-protocol/src/document.rs index 0dbaf3d..805e08e 100644 --- a/crates/blitz-control-protocol/src/document.rs +++ b/crates/blitz-control-protocol/src/document.rs @@ -171,25 +171,21 @@ pub(crate) fn capture_document_with_surface( ), Some(id) => { let inner = script_document.inner(); - let node = inner - .get_node(NodeId::from_u64(id)) + let rect = inner + .get_client_bounding_rect(NodeId::from_u64(id)) .ok_or_else(|| debug_error("unknownNode", &format!("no node {id}")))?; - let layout = node.final_layout(); - let position = node.absolute_position(0.0, 0.0); - if layout.size.width <= 0.0 || layout.size.height <= 0.0 { + if rect.width <= 0.0 || rect.height <= 0.0 { return Err(debug_error( "captureEmpty", &format!("node {id} has a zero-sized box, so there is nothing to capture"), )); } - let box_ = ( - f64::from(position.x), - f64::from(position.y), - f64::from(layout.size.width), - f64::from(layout.size.height), - ); - drop(inner); - box_ + ( + f64::from(rect.x), + f64::from(rect.y), + f64::from(rect.width), + f64::from(rect.height), + ) } }; @@ -1249,13 +1245,16 @@ pub fn hover_agent_node( document: &mut ScriptDocument, node_id: u64, ) -> Result<(f32, f32), DebugError> { - let position = resolve_agent_node(document, node_id)?.1; - document.handle_ui_event(UiEvent::PointerMove(pointer_event( - position, - MouseEventButton::Main, - MouseEventButtons::default(), - KeyboardModifiers::empty(), - ))); + let (node_id, position) = resolve_agent_node(document, node_id)?; + document.handle_pointer_move_to_node( + pointer_event( + position, + MouseEventButton::Main, + MouseEventButtons::default(), + KeyboardModifiers::empty(), + ), + node_id, + ); Ok(position) } @@ -3447,6 +3446,44 @@ mod runtime_tests { assert_eq!(node_rgba, expected); } + #[cfg(feature = "capture")] + #[test] + fn fixed_node_capture_uses_viewport_coordinates_after_document_scroll() { + use blitz_traits::shell::{ColorScheme, Viewport}; + + let mut document = ScriptDocument::from_html( + "\ + \ + ", + DocumentConfig::default(), + ); + document + .inner_mut() + .set_viewport(Viewport::new(320, 200, 1.0, ColorScheme::Dark)); + document.inner_mut().resolve(0.0); + document + .inner_mut() + .set_viewport_scroll(blitz_dom::Point { x: 0.0, y: 600.0 }); + let target = document.inner().query_selector("#target").unwrap().unwrap(); + let expected = document + .inner() + .get_client_bounding_rect(target) + .expect("the fixed textarea is laid out"); + + let image = capture_document( + &mut document, + crate::CaptureRequest { + node_id: Some(target.as_u64()), + scale: 1.0, + }, + ) + .unwrap(); + + assert!(expected.width > 0.0 && expected.height > 1.0); + assert_eq!(image.width, expected.width.round() as u32); + assert_eq!(image.height, expected.height.round() as u32); + } + #[cfg(feature = "capture")] #[test] fn diagnostic_layout_reports_scroll_state_without_script_evaluation() { diff --git a/crates/ps-qa/Cargo.toml b/crates/ps-qa/Cargo.toml index 114e379..25564bc 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.1" +version = "0.7.2" edition = "2024" rust-version = "1.88" license = "MIT OR Apache-2.0" diff --git a/crates/ps-qa/src/runner.rs b/crates/ps-qa/src/runner.rs index 6f8a92d..92829e1 100644 --- a/crates/ps-qa/src/runner.rs +++ b/crates/ps-qa/src/runner.rs @@ -300,12 +300,16 @@ impl Drop for HostProcess { /// always present, and it is never the small control a check is hovering. async fn away_target(client: &mut Client) -> Result> { let (snapshot, _) = inspect(client).await?; - Ok(snapshot - .nodes + Ok(away_target_in(&snapshot.nodes)) +} + +fn away_target_in(nodes: &[SemanticNode]) -> Option { + nodes .iter() - .filter_map(|node| node.bounds.map(|b| (node.id, b[2] * b[3]))) + .filter(|node| node.visible) + .filter_map(|node| painted_bounds(node).map(|b| (node.id, b[2] * b[3]))) .max_by(|a, b| a.1.total_cmp(&b.1)) - .map(|(id, _)| id)) + .map(|(id, _)| id) } /// Move the pointer to the document root and let authored hover state settle. @@ -5938,9 +5942,9 @@ pub async fn run() -> Result<()> { mod tests { use super::{ InventoryClass, OutcomeStability, accumulated_hover_signatures, arrival_sample_matches, - arrived_without_navigation, assess_pixel_change, capture_node_id, declared_open_timeout, - declared_outcome_timeout, duplicate_dom_ids, generated_dom_id, hover_signature_counts, - inventory_class, is_pagination_control, measure_ink, name_matches, + arrived_without_navigation, assess_pixel_change, away_target_in, capture_node_id, + declared_open_timeout, declared_outcome_timeout, duplicate_dom_ids, generated_dom_id, + hover_signature_counts, inventory_class, is_pagination_control, measure_ink, name_matches, named_document_is_active, named_document_is_active_with_permanent, named_document_opener_for, ordered_checks, outcome_check_ids, outcome_verdict, pagination_advanced, painted_bounds, painted_named, pixels_change, pixels_hold, @@ -6050,8 +6054,14 @@ mod tests { #[test] fn surface_content_uses_main_viewport_while_chrome_uses_the_window() { + let mut root = component("", true, true); + root.id = 9; + root.role = "generic".into(); + root.bounds = Some([0.0, 0.0, 1344.0, 960.0]); + let mut main = component("", true, true); main.id = 10; + main.parent = Some(root.id); main.role = "main".into(); main.bounds = Some([0.0, 58.0, 1344.0, 842.0]); @@ -6062,14 +6072,15 @@ mod tests { let mut chrome = component("Project tab", true, true); chrome.id = 12; + chrome.parent = Some(root.id); chrome.bounds = Some([20.0, 15.0, 120.0, 35.0]); let snapshot = AgentSnapshot { - nodes: vec![main, content, chrome], + nodes: vec![root, main, content, chrome], ..AgentSnapshot::default() }; assert_eq!(viewport_for_node(&snapshot, 11), (58.0, 900.0)); - assert_eq!(viewport_for_node(&snapshot, 12), (0.0, 900.0)); + assert_eq!(viewport_for_node(&snapshot, 12), (0.0, 960.0)); } #[test] @@ -6436,6 +6447,19 @@ mod tests { ); } + #[test] + fn pointer_parking_ignores_a_larger_retained_hidden_surface() { + let mut visible = component("Active page", true, true); + visible.id = 41; + visible.bounds = Some([0.0, 0.0, 800.0, 600.0]); + + let mut retained = component("Previous page", true, false); + retained.id = 42; + retained.bounds = Some([0.0, 0.0, 1600.0, 1200.0]); + + assert_eq!(away_target_in(&[retained, visible]), Some(41)); + } + #[test] fn a_retained_hidden_pager_is_not_activated() { let scope = HashSet::from([1]); diff --git a/crates/ps-qa/src/target.rs b/crates/ps-qa/src/target.rs index b62497f..059bc4b 100644 --- a/crates/ps-qa/src/target.rs +++ b/crates/ps-qa/src/target.rs @@ -121,6 +121,7 @@ pub(crate) fn viewport_for_node(snapshot: &AgentSnapshot, node_id: u64) -> (f64, pub(crate) fn viewport_for_node_in(nodes: &[SemanticNode], node_id: u64) -> (f64, f64) { let mut cursor = Some(node_id); + let mut root_bounds = None; for _ in 0..32 { let Some(id) = cursor else { break }; let Some(node) = nodes.iter().find(|node| node.id == id) else { @@ -131,8 +132,14 @@ pub(crate) fn viewport_for_node_in(nodes: &[SemanticNode], node_id: u64) -> (f64 { return (bounds[1], bounds[1] + bounds[3]); } + if node.parent.is_none() { + root_bounds = node.bounds; + } cursor = node.parent; } + if let Some(bounds) = root_bounds { + return (bounds[1], bounds[1] + bounds[3]); + } viewport_of_nodes(nodes) } @@ -303,11 +310,36 @@ pub(crate) async fn locate_control( return Ok((id, bounds)); } - if cli::trace() { - println!(" {want:?} is off-screen at {bounds:?}, scrolling it in"); - } let mut target = (id, bounds); let mut latest = snapshot; + + /* + * A hosted page can advertise its control socket after the first layout + * while web fonts and responsive containers are still settling. Do not + * turn that transient box into a scroll: fixed chrome can begin one frame + * below the window and move into place without any user input. A short + * arrival window keeps genuine below-the-fold controls on the reveal path + * while letting initial layout finish on its own. + */ + for _ in 0..4 { + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + let (settled, _) = inspect(client).await?; + if let Some(found) = pick(&settled) { + target = found; + let viewport = viewport_for_node(&settled, target.0); + if !offscreen(target.1, viewport) { + return Ok(target); + } + } + latest = settled; + } + + if cli::trace() { + println!( + " {want:?} is off-screen at {:?}, scrolling it in", + target.1 + ); + } for _ in 0..4 { for node_id in reach::reveal_chain(&latest.nodes, target.0) { client @@ -361,8 +393,15 @@ pub(crate) async fn locate_control( ) } -fn prioritize_actionable_candidates(candidates: &mut [(&SemanticNode, [f64; 4])]) { - candidates.sort_by_key(|(node, _)| (!node.visible, painted_bounds(node).is_none())); +fn prioritize_actionable_candidates(candidates: &mut Vec<(&SemanticNode, [f64; 4])>) { + let has_visible_painted = candidates + .iter() + .any(|(node, _)| node.visible && painted_bounds(node).is_some()); + if has_visible_painted { + candidates.retain(|(node, _)| node.visible && painted_bounds(node).is_some()); + } else { + candidates.sort_by_key(|(node, _)| (!node.visible, painted_bounds(node).is_none())); + } } fn selector_slot(selector: &str) -> Option<&str> { selector.strip_prefix('@') @@ -525,7 +564,7 @@ mod tests { } #[test] - fn a_visible_painted_copy_precedes_a_hidden_headless_fallback() { + fn a_visible_painted_copy_excludes_a_hidden_headless_fallback() { let mut hidden = node(None, "Next page"); hidden.visible = false; hidden.bounds = Some([0.0, 0.0, 0.0, 0.0]); @@ -540,8 +579,8 @@ mod tests { ]; prioritize_actionable_candidates(&mut candidates); + assert_eq!(candidates.len(), 1); assert_eq!(candidates[0].0.id, desktop.id); - assert_eq!(candidates[1].0.id, hidden.id); } /// The audit's old predicate is written out here because the point is that From 910306bdb99e11584009ba4c1f47c16b75f79cb0 Mon Sep 17 00:00:00 2001 From: meh Date: Sat, 12 Sep 2026 07:56:24 +0700 Subject: [PATCH 3/9] fix(qa): accept undoubled document tab names --- crates/ps-qa/src/runner.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/ps-qa/src/runner.rs b/crates/ps-qa/src/runner.rs index 92829e1..d67035b 100644 --- a/crates/ps-qa/src/runner.rs +++ b/crates/ps-qa/src/runner.rs @@ -188,7 +188,8 @@ async fn wait_for_navigation_arrival( .iter() .find(|node| { node.role.eq_ignore_ascii_case("button") - && node.name.eq_ignore_ascii_case(&tab_name) + && (node.name.eq_ignore_ascii_case(document_name) + || node.name.eq_ignore_ascii_case(&tab_name)) && node.selected && reach::onscreen(node) }) @@ -2656,7 +2657,7 @@ fn named_document_is_active_with_permanent( let tab_name = format!("{want}{want}"); let exact_document_selected = nodes.iter().any(|node| { node.role == "button" - && node.name.eq_ignore_ascii_case(&tab_name) + && (node.name.eq_ignore_ascii_case(want) || node.name.eq_ignore_ascii_case(&tab_name)) && node.selected && node.visible && painted_bounds(node).is_some() @@ -7000,6 +7001,13 @@ mod tests { tab.selected = true; assert!(named_document_is_active(&[tab.clone()], "Fixture project")); + let mut undoubled_tab = component("Fixture project", true, true); + undoubled_tab.selected = true; + assert!(named_document_is_active( + &[undoubled_tab], + "Fixture project" + )); + let permanent_name = "Settings".to_owned(); let mut permanent = component(&permanent_name, true, true); permanent.selected = true; From 6442d1d1d9906cc9406c55e781b0e822d22eddb5 Mon Sep 17 00:00:00 2001 From: meh Date: Sat, 12 Sep 2026 09:20:24 +0700 Subject: [PATCH 4/9] feat(qa): time actions from visible prepared states --- crates/blitz-control-protocol/src/document.rs | 7 +- crates/ps-qa/src/qa.rs | 65 +++++++++++++++++++ crates/ps-qa/src/runner.rs | 51 +++++++++++++-- 3 files changed, 112 insertions(+), 11 deletions(-) diff --git a/crates/blitz-control-protocol/src/document.rs b/crates/blitz-control-protocol/src/document.rs index 805e08e..8586873 100644 --- a/crates/blitz-control-protocol/src/document.rs +++ b/crates/blitz-control-protocol/src/document.rs @@ -180,12 +180,7 @@ pub(crate) fn capture_document_with_surface( &format!("node {id} has a zero-sized box, so there is nothing to capture"), )); } - ( - f64::from(rect.x), - f64::from(rect.y), - f64::from(rect.width), - f64::from(rect.height), - ) + (rect.x, rect.y, rect.width, rect.height) } }; diff --git a/crates/ps-qa/src/qa.rs b/crates/ps-qa/src/qa.rs index 346505c..0281030 100644 --- a/crates/ps-qa/src/qa.rs +++ b/crates/ps-qa/src/qa.rs @@ -414,6 +414,21 @@ pub struct Check { /// measure an option selection without depending on an earlier check. #[serde(default)] pub prepare_key: Option, + /// Deliberate interval between [`prepare`](Self::prepare) and the measured action. + /// + /// This is for contracts whose subject is an interaction during a known + /// intermediate state, such as choosing another carousel page while its + /// first transition is active. It is not an outcome timeout and is not a + /// substitute for waiting on a rendered precondition. + #[serde(default)] + pub prepare_wait_ms: u64, + /// Rendered target that must arrive after [`prepare`](Self::prepare). + /// + /// Use this when the preparation starts a state change and the measured + /// action must be timed from that state, rather than from delivery of its + /// input event. + #[serde(default)] + pub prepare_until: Option, /// Hover this node first, if the control is revealed on hover. /// /// Either a name, or a name and a count: `hover: Some("Trigger")` enters @@ -766,6 +781,30 @@ fn validate_check( )); } + if check.prepare_wait_ms > 0 && check.prepare.is_none() { + return Err(format!( + "{}: check {:?} declares prepare_wait_ms without a prepare action", + file.display(), + check.id + )); + } + + if check.prepare_wait_ms > 0 && check.prepare_until.is_none() { + return Err(format!( + "{}: check {:?} declares prepare_wait_ms without a rendered prepare_until marker", + file.display(), + check.id + )); + } + + if check.prepare_until.is_some() && check.prepare.is_none() { + return Err(format!( + "{}: check {:?} declares prepare_until without a prepare action", + file.display(), + check.id + )); + } + if check.setup_type_into.is_some() != check.setup_text.is_some() { return Err(format!( "{}: check {:?} must declare setup_type_into and setup_text together", @@ -1953,6 +1992,30 @@ mod tests { ); } + #[test] + fn checks_can_time_an_action_inside_a_prepared_intermediate_state() { + let check = parse( + "prepare:Some(\"Page 6\"),prepare_until:Some(\"region:Page 6\"),prepare_wait_ms:50,", + ); + assert_eq!(check.prepare_wait_ms, 50); + assert_eq!(check.prepare_until.as_deref(), Some("region:Page 6")); + + let invalid = parse("prepare_wait_ms:50,"); + let error = validate_check(&invalid, Path::new("carousel.ron"), &mut HashMap::new()) + .expect_err("a preparation interval requires a preparation action"); + assert!(error.contains("prepare_wait_ms without a prepare action")); + + let invalid = parse("prepare:Some(\"Page 6\"),prepare_wait_ms:50,"); + let error = validate_check(&invalid, Path::new("carousel.ron"), &mut HashMap::new()) + .expect_err("a timed preparation requires an observed state"); + assert!(error.contains("without a rendered prepare_until marker")); + + let invalid = parse("prepare_until:Some(\"Page 6\"),"); + let error = validate_check(&invalid, Path::new("carousel.ron"), &mut HashMap::new()) + .expect_err("a preparation target requires a preparation action"); + assert!(error.contains("prepare_until without a prepare action")); + } + #[test] fn checks_can_make_preparation_idempotent() { let check = parse("prepare:Some(\"Menu\"),prepare_unless:Some(\"menuitem:First\"),"); @@ -2187,6 +2250,8 @@ mod tests { prepare_unless: None, prepare_press: false, prepare_key: None, + prepare_wait_ms: 0, + prepare_until: None, hover: None, hover_unless: None, after_prepare_hover: None, diff --git a/crates/ps-qa/src/runner.rs b/crates/ps-qa/src/runner.rs index d67035b..f1cf6c0 100644 --- a/crates/ps-qa/src/runner.rs +++ b/crates/ps-qa/src/runner.rs @@ -97,6 +97,33 @@ async fn wait_for_arrival( } } +/// Wait for a preparation marker that is actually on screen. +/// +/// Ordinary destination matching permits retained, hidden geometry because +/// some callers are proving layout rather than visibility. A prepared state is +/// different: the measured action must be timed from the state a person can +/// see, otherwise an already-retained carousel page satisfies the precondition +/// before the preparation click has taken effect. +async fn wait_for_visible_arrival( + client: &mut Client, + want_here: &str, + within: Duration, +) -> Result { + let deadline = tokio::time::Instant::now() + within; + let mut painted_streak = 0; + loop { + let (tree, _) = inspect(client).await?; + let arrived = arrival_anchor(&tree.nodes, None, want_here).is_some(); + if stable_arrival(&mut painted_streak, arrived) { + return Ok(true); + } + if tokio::time::Instant::now() >= deadline { + return Ok(false); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } +} + fn arrival_sample_matches( nodes: &[SemanticNode], destination: Option<&reach::Surface>, @@ -1521,14 +1548,26 @@ async fn run_qa( }; open_error = Some(format!("could not prepare {want:?}: {error}{nearby}")); } - if let Some(next) = check - .click - .as_deref() - .or(check.type_into.as_deref()) - .or(check.key_on.as_deref()) + if open_error.is_none() + && let Some(until) = check.prepare_until.as_deref() + && !wait_for_visible_arrival(client, until, check_timeout(900)).await? + { + open_error = Some(format!( + "preparing {want:?} did not render the declared precondition {until:?}" + )); + } + if open_error.is_none() + && let Some(next) = check + .click + .as_deref() + .or(check.type_into.as_deref()) + .or(check.key_on.as_deref()) { let _ = wait_for_arrival(client, None, next, check_timeout(900)).await?; } + if open_error.is_none() && check.prepare_wait_ms > 0 { + tokio::time::sleep(Duration::from_millis(check.prepare_wait_ms)).await; + } } // Overlay contents do not exist until their trigger has prepared @@ -6699,6 +6738,8 @@ mod tests { prepare_unless: None, prepare_press: false, prepare_key: None, + prepare_wait_ms: 0, + prepare_until: None, hover: None, hover_unless: None, after_prepare_hover: None, From 25a5af9f0a32301392bbf92902cd088b502c5abd Mon Sep 17 00:00:00 2001 From: meh Date: Sat, 12 Sep 2026 11:13:44 +0700 Subject: [PATCH 5/9] fix(qa): drive pointers in the rendered viewport --- Cargo.toml | 2 +- crates/blitz-control-protocol/Cargo.toml | 6 +- crates/blitz-control-protocol/src/document.rs | 92 ++++++++++++++++--- .../blitz-control-protocol/src/in_process.rs | 9 +- crates/blitz-control-protocol/src/lib.rs | 7 ++ crates/ps-qa/src/runner.rs | 26 ++++++ crates/ps-qa/src/target.rs | 31 ++++++- 7 files changed, 147 insertions(+), 26 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e4d1143..e36fd98 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ license = "MIT OR Apache-2.0" repository = "https://github.com/pathscale/ps-observability" [workspace.dependencies] -blitz-control-protocol = { version = "^0.5", path = "crates/blitz-control-protocol" } +blitz-control-protocol = { version = "^0.5.1", path = "crates/blitz-control-protocol" } endpoint-libs = { version = "^3", default-features = false, features = ["agent-control"] } serde = { version = "^1", features = ["derive"] } serde_json = "^1" diff --git a/crates/blitz-control-protocol/Cargo.toml b/crates/blitz-control-protocol/Cargo.toml index ebc0b0a..b14fc2a 100644 --- a/crates/blitz-control-protocol/Cargo.toml +++ b/crates/blitz-control-protocol/Cargo.toml @@ -73,7 +73,7 @@ serde_json.workspace = true schemars.workspace = true # The core, and nothing else, reaches for these. -blitz-dom = { package = "ps-blitz-dom", version = "^0.4.8", optional = true } +blitz-dom = { package = "ps-blitz-dom", version = "^0.4.9", optional = true } # Naming stylo directly: the computed-style reader and the visibility check read # `style::values::computed`, and blitz-dom does not re-export it. The version is # the one blitz-dom resolves, so cargo unifies them instead of putting two @@ -83,8 +83,8 @@ style = { version = "0.20.0", package = "stylo", optional = true } # `yeslogic-fontconfig-sys` system library, so enabling it here would decide # that every consumer needs one installed, including a headless one that reads # no font catalogue. An application that wants the machine's fonts asks itself. -blitz-script = { package = "ps-blitz-script", version = "^0.4.8", optional = true } -blitz-traits = { package = "ps-blitz-traits", version = "^0.4.8", optional = true } +blitz-script = { package = "ps-blitz-script", version = "^0.4.9", optional = true } +blitz-traits = { package = "ps-blitz-traits", version = "^0.4.9", optional = true } keyboard-types = { version = "0.7", optional = true } # Capture. The CPU renderer on purpose: a capture must not need a GPU, a diff --git a/crates/blitz-control-protocol/src/document.rs b/crates/blitz-control-protocol/src/document.rs index 8586873..4d180f2 100644 --- a/crates/blitz-control-protocol/src/document.rs +++ b/crates/blitz-control-protocol/src/document.rs @@ -27,8 +27,8 @@ use crate::{ use blitz_dom::Document; use blitz_script::ScriptDocument; use blitz_traits::events::{ - BlitzKeyEvent, BlitzPointerEvent, BlitzPointerId, DomEvent, DomEventData, KeyState, - MouseEventButton, MouseEventButtons, Point, PointerCoords, PointerDetails, UiEvent, + BlitzKeyEvent, BlitzPointerEvent, BlitzPointerId, KeyState, MouseEventButton, + MouseEventButtons, Point, PointerCoords, PointerDetails, UiEvent, }; #[cfg(feature = "capture")] use blitz_traits::node_id::NodeId; @@ -1242,7 +1242,8 @@ pub fn hover_agent_node( ) -> Result<(f32, f32), DebugError> { let (node_id, position) = resolve_agent_node(document, node_id)?; document.handle_pointer_move_to_node( - pointer_event( + pointer_event_for_document( + document, position, MouseEventButton::Main, MouseEventButtons::default(), @@ -1345,6 +1346,14 @@ pub fn inspect_document( return control_error("unknownNode", "the requested root node does not exist"); } let focused_node = inner.get_focussed_node_id().map(|id| id.as_u64()); + let viewport = inner.viewport(); + let viewport_scale = viewport.scale_f64(); + let viewport_bounds = Some([ + 0.0, + 0.0, + viewport.window_size.0 as f64 / viewport_scale, + viewport.window_size.1 as f64 / viewport_scale, + ]); // Built over the whole document even when a subtree was asked for: a label // is frequently a sibling of the control rather than a descendant of the // node the caller rooted at. @@ -1428,6 +1437,7 @@ pub fn inspect_document( revision, active_window: Some("blitz-main".into()), focused_node, + viewport: viewport_bounds, nodes, }) } @@ -1621,6 +1631,38 @@ pub(crate) fn pointer_event( } } +/// Build a pointer event from viewport coordinates without losing document +/// coordinates after scrolling. +/// +/// Agent input is expressed in the same client-space coordinates as semantic +/// bounds. DOM `clientX/Y` keep those values, while `pageX/Y` include the +/// document's current viewport scroll. Treating both as the client position +/// makes hit testing and component pointer math miss after `ScrollIntoView`. +pub(crate) fn pointer_event_for_document( + document: &ScriptDocument, + position: (f32, f32), + button: MouseEventButton, + buttons: MouseEventButtons, + modifiers: KeyboardModifiers, +) -> BlitzPointerEvent { + let mut event = pointer_event(position, button, buttons, modifiers); + let scroll = document.inner().viewport_scroll(); + event.coords.page_x += scroll.x as f32; + event.coords.page_y += scroll.y as f32; + event +} + +pub(crate) fn pointer_coords_for_document( + document: &ScriptDocument, + position: (f32, f32), +) -> PointerCoords { + let mut coords = pointer_coords(position); + let scroll = document.inner().viewport_scroll(); + coords.page_x += scroll.x as f32; + coords.page_y += scroll.y as f32; + coords +} + pub(crate) struct SemanticCandidate { pub(crate) id: blitz_dom::NodeId, pub(crate) parent: Option, @@ -2025,32 +2067,39 @@ pub(crate) fn activate_agent_node( .is_some_and(focuses_on_click); for _ in 0..count { - let down = pointer_event( + let movement = pointer_event_for_document( + document, + position, + MouseEventButton::Main, + MouseEventButtons::default(), + KeyboardModifiers::empty(), + ); + let down = pointer_event_for_document( + document, position, MouseEventButton::Main, MouseEventButtons::Primary, KeyboardModifiers::empty(), ); - let up = pointer_event( + let up = pointer_event_for_document( + document, position, MouseEventButton::Main, MouseEventButtons::default(), KeyboardModifiers::empty(), ); - for data in [ - DomEventData::PointerDown(down.clone()), - DomEventData::MouseDown(down), - DomEventData::PointerUp(up.clone()), - DomEventData::MouseUp(up.clone()), - DomEventData::Click(up), + for event in [ + UiEvent::PointerMove(movement), + UiEvent::PointerDown(down), + UiEvent::PointerUp(up), ] { - // A mousedown handler can deliberately replace its own control. - // The action already happened; later phases have no surviving - // target and must not be retargeted to whatever took its place. + // A pointer handler can deliberately replace its own control. The + // action already happened; later phases have no surviving target + // and must not be retargeted to whatever took its place. if document.inner().get_node(node_id).is_none() { break; } - document.dispatch_dom_event(DomEvent::new(node_id, data)); + document.handle_ui_event_to_node(event, node_id); } if focusable && document.inner().get_node(node_id).is_some() { document.inner_mut().set_focus_to(node_id); @@ -2146,6 +2195,19 @@ mod tests { "unexpected error: {error:?}" ); } + + #[test] + fn pointer_coordinates_keep_client_and_page_space_distinct_after_scroll() { + let mut document = document(); + document + .inner_mut() + .set_viewport_scroll(blitz_dom::Point { x: 17.0, y: 600.0 }); + + let coords = pointer_coords_for_document(&document, (24.0, 32.0)); + + assert_eq!((coords.client_x, coords.client_y), (24.0, 32.0)); + assert_eq!((coords.page_x, coords.page_y), (41.0, 632.0)); + } } /// What the semantic tree says about one small document. diff --git a/crates/blitz-control-protocol/src/in_process.rs b/crates/blitz-control-protocol/src/in_process.rs index 0aff6ea..6738ccd 100644 --- a/crates/blitz-control-protocol/src/in_process.rs +++ b/crates/blitz-control-protocol/src/in_process.rs @@ -39,7 +39,7 @@ use keyboard_types::{Code, Key}; use crate::document::{ activate_agent_node, control_error, debug_error, hover_agent_node, inspect_document, key_event, - keyboard_modifiers, pointer_coords, pointer_event, resolve_agent_node, + keyboard_modifiers, resolve_agent_node, }; use crate::{ AgentAction, AgentControlRequest, DebugError, DebugResponse, InputCommand, KeyPhase, @@ -178,7 +178,7 @@ impl DocumentControl { let mut events = Vec::new(); document .inner_mut() - .scroll_to_node_with_events(node_id, |event| events.push(event)); + .scroll_to_node_centered_with_events(node_id, |event| events.push(event)); for event in events { document.dispatch_dom_event(event); } @@ -246,7 +246,8 @@ impl DocumentControl { PointerPhase::Up | PointerPhase::Cancel => self.buttons.remove(button.into()), PointerPhase::Move => {} } - let event = pointer_event( + let event = crate::document::pointer_event_for_document( + document, self.pointer, button, self.buttons, @@ -267,7 +268,7 @@ impl DocumentControl { } => { let event = BlitzWheelEvent { delta: BlitzWheelDelta::Pixels(delta_x, delta_y), - coords: pointer_coords(self.pointer), + coords: crate::document::pointer_coords_for_document(document, self.pointer), buttons: self.buttons, mods: keyboard_modifiers(modifiers), element: Point::default(), diff --git a/crates/blitz-control-protocol/src/lib.rs b/crates/blitz-control-protocol/src/lib.rs index 6846b26..deda50e 100644 --- a/crates/blitz-control-protocol/src/lib.rs +++ b/crates/blitz-control-protocol/src/lib.rs @@ -575,6 +575,13 @@ pub struct AgentSnapshot { pub revision: u64, pub active_window: Option, pub focused_node: Option, + /// The client-space viewport in CSS pixels. + /// + /// The root and `main` boxes may be as tall as the whole document, so + /// their semantic bounds cannot tell an external pointer driver where the + /// physical window ends. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub viewport: Option<[f64; 4]>, pub nodes: Vec, } diff --git a/crates/ps-qa/src/runner.rs b/crates/ps-qa/src/runner.rs index f1cf6c0..ab81f7d 100644 --- a/crates/ps-qa/src/runner.rs +++ b/crates/ps-qa/src/runner.rs @@ -6123,6 +6123,32 @@ mod tests { assert_eq!(viewport_for_node(&snapshot, 12), (0.0, 960.0)); } + #[test] + fn document_height_does_not_expand_the_pointer_viewport() { + let mut root = component("", true, true); + root.id = 20; + root.role = "generic".into(); + root.bounds = Some([0.0, 0.0, 1344.0, 2200.0]); + + let mut main = component("", true, true); + main.id = 21; + main.parent = Some(root.id); + main.role = "main".into(); + main.bounds = Some([0.0, 64.0, 1344.0, 2200.0]); + + let mut content = component("Below the fold", true, true); + content.id = 22; + content.parent = Some(main.id); + content.bounds = Some([40.0, 1200.0, 200.0, 40.0]); + + let snapshot = AgentSnapshot { + nodes: vec![root, main, content], + viewport: Some([0.0, 0.0, 1344.0, 960.0]), + ..AgentSnapshot::default() + }; + assert_eq!(viewport_for_node(&snapshot, 22), (64.0, 960.0)); + } + #[test] fn pixel_stability_reports_a_changed_rendered_pixel() { use base64::Engine as _; diff --git a/crates/ps-qa/src/target.rs b/crates/ps-qa/src/target.rs index 059bc4b..96825bc 100644 --- a/crates/ps-qa/src/target.rs +++ b/crates/ps-qa/src/target.rs @@ -82,6 +82,9 @@ pub(crate) fn name_matches(name: &str, pattern: &str) -> bool { /// `cover` already read it this way; `open_named` and `press_named` did not, /// which is the bug the two helpers below exist to close. pub(crate) fn viewport_of(snapshot: &AgentSnapshot) -> (f64, f64) { + if let Some(bounds) = snapshot.viewport { + return (bounds[1], bounds[1] + bounds[3]); + } viewport_of_nodes(&snapshot.nodes) } @@ -100,6 +103,18 @@ pub(crate) fn viewport_of_nodes(nodes: &[SemanticNode]) -> (f64, f64) { * Taking the top of the window keeps the below-the-fold case, which is what * this bound is actually for, without swallowing the header. */ + let window = nodes + .iter() + .filter(|node| node.parent.is_none()) + .filter_map(|node| node.bounds) + .max_by(|a, b| { + (a[2] * a[3]) + .partial_cmp(&(b[2] * b[3])) + .unwrap_or(std::cmp::Ordering::Equal) + }); + if let Some(bounds) = window { + return (bounds[1], bounds[1] + bounds[3]); + } let bottom = nodes .iter() .filter(|node| node.role == "main") @@ -116,12 +131,18 @@ pub(crate) fn viewport_of_nodes(nodes: &[SemanticNode]) -> (f64, f64) { /// scroll coordinates as window-visible sends pointer events behind the tab /// strip instead of revealing the row inside its panel. pub(crate) fn viewport_for_node(snapshot: &AgentSnapshot, node_id: u64) -> (f64, f64) { - viewport_for_node_in(&snapshot.nodes, node_id) + let inferred = viewport_for_node_in(&snapshot.nodes, node_id); + if let Some(bounds) = snapshot.viewport { + let window = (bounds[1], bounds[1] + bounds[3]); + return (inferred.0.max(window.0), inferred.1.min(window.1)); + } + inferred } pub(crate) fn viewport_for_node_in(nodes: &[SemanticNode], node_id: u64) -> (f64, f64) { let mut cursor = Some(node_id); let mut root_bounds = None; + let mut main_bounds = None; for _ in 0..32 { let Some(id) = cursor else { break }; let Some(node) = nodes.iter().find(|node| node.id == id) else { @@ -130,7 +151,7 @@ pub(crate) fn viewport_for_node_in(nodes: &[SemanticNode], node_id: u64) -> (f64 if node.role == "main" && let Some(bounds) = node.bounds { - return (bounds[1], bounds[1] + bounds[3]); + main_bounds = Some(bounds); } if node.parent.is_none() { root_bounds = node.bounds; @@ -138,7 +159,11 @@ pub(crate) fn viewport_for_node_in(nodes: &[SemanticNode], node_id: u64) -> (f64 cursor = node.parent; } if let Some(bounds) = root_bounds { - return (bounds[1], bounds[1] + bounds[3]); + let window = (bounds[1], bounds[1] + bounds[3]); + if let Some(main) = main_bounds { + return (window.0.max(main[1]), window.1.min(main[1] + main[3])); + } + return window; } viewport_of_nodes(nodes) } From 8b90f10196d27c99bd9f6a7d0d9172df1837ed48 Mon Sep 17 00:00:00 2001 From: meh Date: Sat, 12 Sep 2026 13:18:34 +0700 Subject: [PATCH 6/9] fix(qa): keep sweeps on intended visible controls --- crates/ps-qa/src/qa.rs | 36 +++++++++++++++++++++- crates/ps-qa/src/runner.rs | 34 ++++++++++++++------- crates/ps-qa/src/sweep.rs | 61 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 11 deletions(-) diff --git a/crates/ps-qa/src/qa.rs b/crates/ps-qa/src/qa.rs index 0281030..1d17971 100644 --- a/crates/ps-qa/src/qa.rs +++ b/crates/ps-qa/src/qa.rs @@ -1151,7 +1151,15 @@ pub fn verdict( } } Expect::PaintsNamed => { - if !found.iter().any(|node| shows(check, node)) { + if !found.iter().any(|node| { + shows(check, node) + && !node.bounds.is_some_and(|bounds| { + crate::target::offscreen( + bounds, + crate::target::viewport_for_node_in(after, node.id), + ) + }) + }) { let state = found .iter() .map(|node| { @@ -2479,6 +2487,32 @@ mod tests { } } + #[test] + fn paints_named_rejects_a_box_translated_outside_the_viewport() { + let node = SemanticNode { + dom_id: None, + id: 7, + parent: None, + role: "heading".into(), + name: "Drawer outcome".into(), + value: None, + enabled: true, + visible: true, + selected: false, + bounds: Some([-384.0, 40.0, 336.0, 24.0]), + slot: None, + }; + let mut check = parse(""); + check.subject = "heading:Drawer outcome".into(); + check.expect = Expect::PaintsNamed; + + assert!(verdict(&check, &[], std::slice::from_ref(&node)).is_err()); + + let mut onscreen = node; + onscreen.bounds = Some([24.0, 40.0, 336.0, 24.0]); + assert!(verdict(&check, &[], &[onscreen]).is_ok()); + } + #[test] fn a_positioned_family_rejects_stacked_controls() { let mut check = parse(""); diff --git a/crates/ps-qa/src/runner.rs b/crates/ps-qa/src/runner.rs index ab81f7d..4e5f11b 100644 --- a/crates/ps-qa/src/runner.rs +++ b/crates/ps-qa/src/runner.rs @@ -3337,8 +3337,20 @@ async fn run_sweep(client: &mut Client, family: Option<&str>) -> Result { } println!("clicking {} buttons\n", planned.len()); + let mut occurrences = HashMap::::new(); + let planned: Vec<(sweep::Case, usize)> = planned + .into_iter() + .map(|case| { + let key = case.name.to_lowercase(); + let occurrence = occurrences.entry(key).or_insert(0); + let planned_occurrence = *occurrence; + *occurrence += 1; + (case, planned_occurrence) + }) + .collect(); + let mut outcomes: Vec = Vec::new(); - for case in planned { + for (case, occurrence) in planned { let (before, _) = inspect(client).await?; /* @@ -3354,37 +3366,39 @@ async fn run_sweep(client: &mut Client, family: Option<&str>) -> Result { * from the plan, because a working button re-renders its own row and a * stale id is a click on nothing. */ - let Some(node) = before.nodes.iter().find(|node| node.id == case.id) else { + let Some(node) = sweep::resolve_case(&case, occurrence, &before.nodes) else { // Gone since the plan was made, which a working button often // causes: closing one tab removes the close buttons of its // neighbours. Not a failure. continue; }; - if !node.visible || !node.enabled { - continue; - } + let node_id = node.id; + let node_bounds = node.bounds; /* * Off the viewport is not clickable, and clicking it anyway tests the * harness rather than the application. A transcript keeps hundreds of * controls at negative coordinates and the panel's lower sections sit * below the fold; both reported as failures until they were skipped. */ - if node - .bounds + if node_bounds .is_some_and(|b| b[1] + b[3] < viewport.0 || b[0] + b[2] < 0.0 || b[1] > viewport.1) { continue; } - if let Err(error) = click_by_id(client, case.id).await { + if let Err(error) = click_by_id(client, node_id).await { outcomes.push(sweep::Outcome { case, failure: Some(format!("could not be clicked: {error}")), }); continue; } - let after = settle_sweep_case(client, &case, &before.nodes).await?; - let failure = sweep::judge(&case, &before.nodes, &after.nodes); + let active_case = sweep::Case { + id: node_id, + ..case.clone() + }; + let after = settle_sweep_case(client, &active_case, &before.nodes).await?; + let failure = sweep::judge(&active_case, &before.nodes, &after.nodes); outcomes.push(sweep::Outcome { case, failure }); } diff --git a/crates/ps-qa/src/sweep.rs b/crates/ps-qa/src/sweep.rs index 55c05bd..53155b4 100644 --- a/crates/ps-qa/src/sweep.rs +++ b/crates/ps-qa/src/sweep.rs @@ -137,6 +137,29 @@ pub fn cases( .collect() } +/// Find the same planned control in a fresh semantic tree. +/// +/// Node ids are renderer-owned and may be recycled when an earlier click +/// rerenders the page. An id match is therefore only valid while the role and +/// accessible name still match. If the control was remounted, fall back to its +/// occurrence among controls with the same name. +pub fn resolve_case<'a>( + case: &Case, + occurrence: usize, + nodes: &'a [SemanticNode], +) -> Option<&'a SemanticNode> { + if let Some(node) = nodes.iter().find(|node| { + node.id == case.id && node.name == case.name && painted_button(node) && node.enabled + }) { + return Some(node); + } + + nodes + .iter() + .filter(|node| node.name == case.name && painted_button(node) && node.enabled) + .nth(occurrence) +} + /// Whether a button of this name exists in the tree. pub fn has_button(nodes: &[SemanticNode], name: &str) -> bool { let wanted = name.to_lowercase(); @@ -362,6 +385,44 @@ mod tests { } } + #[test] + fn a_recycled_id_cannot_redirect_a_sweep_click() { + let mut planned = button("Dismiss coverage notice"); + planned.id = 7; + let case = Case { + id: planned.id, + name: planned.name.clone(), + family: "other", + expect: Expectation::Changes, + }; + + let mut recycled = button("Another action"); + recycled.id = 7; + let mut remounted = planned; + remounted.id = 12; + + assert_eq!( + resolve_case(&case, 0, &[recycled, remounted]).unwrap().id, + 12 + ); + } + + #[test] + fn a_remounted_duplicate_keeps_its_planned_occurrence() { + let case = Case { + id: 99, + name: "Open row".to_owned(), + family: "other", + expect: Expectation::Changes, + }; + let mut first = button("Open row"); + first.id = 10; + let mut second = button("Open row"); + second.id = 11; + + assert_eq!(resolve_case(&case, 1, &[first, second]).unwrap().id, 11); + } + #[test] fn a_delete_that_asks_first_has_done_its_job() { // The real shape from a running list: the row grows an inline From 2415dbe0fa3289730025617be928afc0e76b57ff Mon Sep 17 00:00:00 2001 From: meh Date: Sat, 12 Sep 2026 16:10:42 +0700 Subject: [PATCH 7/9] fix(qa): preserve native viewport and input semantics --- crates/blitz-control-protocol/src/document.rs | 63 ++++++ .../blitz-control-protocol/src/in_process.rs | 70 ++++++- crates/blitz-control-protocol/src/lib.rs | 16 ++ crates/ps-qa/src/interaction.rs | 39 +++- crates/ps-qa/src/paint_audit.rs | 2 + crates/ps-qa/src/qa.rs | 43 ++++- crates/ps-qa/src/reach.rs | 41 +++- crates/ps-qa/src/runner.rs | 180 ++++++++++++++++-- crates/ps-qa/src/sweep.rs | 2 + crates/ps-qa/src/target.rs | 11 ++ 10 files changed, 443 insertions(+), 24 deletions(-) diff --git a/crates/blitz-control-protocol/src/document.rs b/crates/blitz-control-protocol/src/document.rs index 4d180f2..b6e68ec 100644 --- a/crates/blitz-control-protocol/src/document.rs +++ b/crates/blitz-control-protocol/src/document.rs @@ -334,6 +334,8 @@ pub fn snapshot_document( value, enabled: element_attr(element, "disabled").is_none() && element_attr(element, "aria-disabled") != Some("true"), + focusable: focuses_on_click(element), + viewport_fixed: viewport_fixed_descendant(&inner, id, layout_node_limit), visible, selected: semantic_selected(element, &inner, id), bounds: rect.and_then(|rect| { @@ -1205,6 +1207,38 @@ pub(crate) fn layout_chain_is_valid( false } +/// Whether layout places this node inside a fixed box anchored to the window. +/// +/// Fixed boxes retain their DOM ancestry, but Blitz reparents their layout box +/// to the root element. Following layout parents therefore covers every child +/// in the fixed surface and still excludes CSS fixed boxes whose transformed +/// ancestor makes them locally positioned. +pub(crate) fn viewport_fixed_descendant( + document: &blitz_dom::BaseDocument, + node_id: blitz_dom::NodeId, + node_limit: usize, +) -> bool { + use style::properties::generated::longhands::position::computed_value::T as Position; + + let root_element = document.root_element().id; + let mut current = Some(node_id); + for _ in 0..=node_limit { + let Some(id) = current else { return false }; + let Some(node) = document.get_node(id) else { + return false; + }; + if node + .primary_styles() + .is_some_and(|styles| styles.clone_position() == Position::Fixed) + && node.layout_parent.get() == Some(root_element) + { + return true; + } + current = node.layout_parent.get(); + } + false +} + /// Activate the node the caller selected, without asking hit-testing to select /// it a second time from a screen coordinate. /// @@ -1404,6 +1438,8 @@ pub fn inspect_document( name: text, value: None, enabled: true, + focusable: false, + viewport_fixed: false, visible: candidate.visible, selected: false, bounds, @@ -1426,6 +1462,8 @@ pub fn inspect_document( value, enabled: element_attr(element, "disabled").is_none() && element_attr(element, "aria-disabled") != Some("true"), + focusable: focuses_on_click(element), + viewport_fixed: viewport_fixed_descendant(&inner, id, node_limit), visible, selected: semantic_selected(element, &inner, id), bounds, @@ -2320,6 +2358,27 @@ mod semantic_tests { ); } + #[test] + fn fixed_boxes_are_identified_as_viewport_anchored() { + let nodes = tree( + "
\ +
\ + \ +
", + ); + let flow = nodes + .iter() + .find(|node| node.dom_id.as_deref() == Some("flow")) + .expect("flow button is inspected"); + let fixed = nodes + .iter() + .find(|node| node.dom_id.as_deref() == Some("fixed")) + .expect("fixed button is inspected"); + + assert!(!flow.viewport_fixed); + assert!(fixed.viewport_fixed); + } + #[test] fn a_text_run_refuses_a_click_rather_than_panicking() { // Text runs are new ids in a tree a harness drives by id, so the @@ -3577,6 +3636,8 @@ mod runtime_tests { name: "Scrollable region".into(), value: None, enabled: true, + focusable: false, + viewport_fixed: false, visible: true, selected: false, bounds: Some([0.0, 0.0, 100.0, 100.0]), @@ -3611,6 +3672,8 @@ mod runtime_tests { name: "Readable".into(), value: None, enabled: true, + focusable: false, + viewport_fixed: false, visible: true, selected: false, bounds: Some([0.0, 0.0, 100.0, 24.0]), diff --git a/crates/blitz-control-protocol/src/in_process.rs b/crates/blitz-control-protocol/src/in_process.rs index 6738ccd..4763da0 100644 --- a/crates/blitz-control-protocol/src/in_process.rs +++ b/crates/blitz-control-protocol/src/in_process.rs @@ -32,14 +32,14 @@ use blitz_dom::Document as _; use blitz_script::ScriptDocument; use blitz_traits::events::{ - BlitzImeEvent, BlitzWheelDelta, BlitzWheelEvent, MouseEventButton, MouseEventButtons, Point, - UiEvent, + BlitzImeEvent, BlitzInputEvent, BlitzWheelDelta, BlitzWheelEvent, DomEvent, DomEventData, + MouseEventButton, MouseEventButtons, Point, UiEvent, }; use keyboard_types::{Code, Key}; use crate::document::{ - activate_agent_node, control_error, debug_error, hover_agent_node, inspect_document, key_event, - keyboard_modifiers, resolve_agent_node, + activate_agent_node, control_error, debug_error, element_attr, hover_agent_node, + inspect_document, key_event, keyboard_modifiers, resolve_agent_node, }; use crate::{ AgentAction, AgentControlRequest, DebugError, DebugResponse, InputCommand, KeyPhase, @@ -346,7 +346,32 @@ pub(crate) fn set_node_value( .and_then(|element| element.text_input_data()) .map(|input| input.editor.text().to_string()); let Some(current) = current else { - return Err(debug_error("notEditable", "node is not a text input")); + let is_value_input = document + .inner() + .get_node(node_id) + .and_then(|node| node.element_data()) + .is_some_and(|element| { + element.name.local.as_ref() == "input" + && matches!( + element_attr(element, "type").unwrap_or("text"), + "date" | "datetime-local" | "month" | "time" | "week" | "color" + ) + }); + if !is_value_input { + return Err(debug_error("notEditable", "node is not a text input")); + } + + document.inner_mut().set_focus_to(node_id); + document.inner_mut().mutate().set_attribute( + node_id, + blitz_dom::qual_name!("value"), + &value, + ); + document.dispatch_dom_event(DomEvent::new( + node_id, + DomEventData::Input(BlitzInputEvent { value }), + )); + return Ok(()); }; document.inner_mut().set_focus_to(node_id); if let Some(len) = std::num::NonZeroUsize::new(current.len()) { @@ -390,12 +415,17 @@ mod tests { +
tall
+ "#, DocumentConfig::default(), ); @@ -514,6 +544,36 @@ mod tests { ); } + /// Date and time controls expose values to accessibility clients even + /// though Blitz does not give them a text editor. They still need the same + /// observable SetValue contract as a text box: update the DOM value and + /// deliver an input event to the application. + #[test] + fn setting_a_date_value_updates_the_dom_and_dispatches_input() { + let mut document = document(); + let mut control = DocumentControl::new(); + let date = node(&mut control, &mut document, "date"); + + assert_eq!( + control.agent( + &mut document, + AgentControlRequest::Act(AgentAction::SetValue { + node_id: date, + value: "2025-06-24".into(), + }), + ), + DebugResponse::Ack + ); + + let value = document + .inner() + .get_node(blitz_dom::NodeId::from_u64(date)) + .and_then(|node| node.element_data()) + .and_then(|element| element_attr(element, "value").map(str::to_owned)); + assert_eq!(value.as_deref(), Some("2025-06-24")); + assert_eq!(text(&document, "#date-log"), "2025-06-24"); + } + /// Focus is a state change the protocol reports back, so this asserts the /// action landed rather than that it was acknowledged. /// diff --git a/crates/blitz-control-protocol/src/lib.rs b/crates/blitz-control-protocol/src/lib.rs index deda50e..4e9820d 100644 --- a/crates/blitz-control-protocol/src/lib.rs +++ b/crates/blitz-control-protocol/src/lib.rs @@ -601,6 +601,20 @@ pub struct SemanticNode { pub name: String, pub value: Option, pub enabled: bool, + /// Whether the element participates in keyboard focus or native activation. + /// + /// Role alone cannot answer this for elements such as sortable column + /// headers: a plain `` and `` share a role, while only + /// the latter is an operable control. + #[serde(default)] + pub focusable: bool, + /// Whether this box is anchored to the window viewport. + /// + /// Fixed descendants keep their DOM parent even though layout hoists them + /// to the viewport. An external driver needs this bit so it does not clip + /// a visible fixed control against a scrolled ancestor's content box. + #[serde(default)] + pub viewport_fixed: bool, pub visible: bool, pub selected: bool, pub bounds: Option<[f64; 4]>, @@ -1582,6 +1596,8 @@ mod tests { name: "Save settings".into(), value: None, enabled: true, + focusable: true, + viewport_fixed: false, visible: true, selected: false, bounds: Some([1.0, 2.0, 30.0, 20.0]), diff --git a/crates/ps-qa/src/interaction.rs b/crates/ps-qa/src/interaction.rs index a242686..62ca8e6 100644 --- a/crates/ps-qa/src/interaction.rs +++ b/crates/ps-qa/src/interaction.rs @@ -189,7 +189,20 @@ pub(crate) async fn scroll_events( Ok(latencies) } -/// The painted textbox whose semantic name mentions `want` on the active layer. +/// Whether a semantic role accepts literal text entry. +/// +/// An editable combobox is still a text field. Restricting this to `textbox` +/// made `type_into` unable to exercise exactly the input side of ComboBox, +/// even though the protocol's SetValue action already supports its underlying +/// input element. +fn is_text_field_role(role: &str) -> bool { + matches!( + role, + "textbox" | "textarea" | "input" | "combobox" | "searchbox" | "spinbutton" + ) +} + +/// The painted text-entry control whose semantic name mentions `want` on the active layer. fn find_text_field<'a>(nodes: &'a [SemanticNode], want: &str) -> Option<&'a SemanticNode> { let modal_scope: HashSet = reach::dismissers(nodes) .first() @@ -207,7 +220,7 @@ fn find_text_field<'a>(nodes: &'a [SemanticNode], want: &str) -> Option<&'a Sema let fields: Vec<&SemanticNode> = nodes .iter() .filter(|node| { - matches!(node.role.as_str(), "textbox" | "textarea" | "input") + is_text_field_role(node.role.as_str()) && node.enabled && node .bounds @@ -227,6 +240,28 @@ fn find_text_field<'a>(nodes: &'a [SemanticNode], want: &str) -> Option<&'a Sema fields.into_iter().find(matches_name) } +#[cfg(test)] +mod tests { + use super::is_text_field_role; + + #[test] + fn editable_aria_roles_are_literal_text_targets() { + for role in [ + "textbox", + "textarea", + "input", + "combobox", + "searchbox", + "spinbutton", + ] { + assert!(is_text_field_role(role), "{role}"); + } + for role in ["button", "listbox", "option", "slider"] { + assert!(!is_text_field_role(role), "{role}"); + } + } +} + /// Drive real key events into a focused text field and price them. /// /// Typing is the interaction the composer autosizes on: it writes diff --git a/crates/ps-qa/src/paint_audit.rs b/crates/ps-qa/src/paint_audit.rs index 639a8ba..906b3ce 100644 --- a/crates/ps-qa/src/paint_audit.rs +++ b/crates/ps-qa/src/paint_audit.rs @@ -390,6 +390,8 @@ mod tests { name: "Task manager prompt".into(), value: None, enabled: false, + focusable: false, + viewport_fixed: false, visible: true, selected: false, bounds: Some([0.0, 0.0, 200.0, 24.0]), diff --git a/crates/ps-qa/src/qa.rs b/crates/ps-qa/src/qa.rs index 1d17971..cb2166a 100644 --- a/crates/ps-qa/src/qa.rs +++ b/crates/ps-qa/src/qa.rs @@ -339,6 +339,8 @@ pub enum Expect { /// than inventing a string value. This follows the activated node id, so a /// neighbouring swatch cannot satisfy the verdict. SelectionChanges, + /// Focus moved to the exact semantic node named by the subject. + FocusMoves, /// The exact subject node's accessible name changed after the action. /// /// Use this for status text that reports a completed refresh or re-check. @@ -826,7 +828,7 @@ fn validate_check( */ if matches!( check.expect, - Expect::ValueChanges | Expect::NameChanges | Expect::SelectionChanges + Expect::ValueChanges | Expect::NameChanges | Expect::SelectionChanges | Expect::FocusMoves ) && check.setup_type_into.as_deref() == Some(check.subject.as_str()) && check.click.is_none() && check.text.is_none() @@ -887,7 +889,7 @@ fn validate_check( Ok(()) } -fn matching<'a>(nodes: &'a [SemanticNode], want: &str) -> Vec<&'a SemanticNode> { +pub(crate) fn matching<'a>(nodes: &'a [SemanticNode], want: &str) -> Vec<&'a SemanticNode> { nodes .iter() .filter(|node| selector_matches_node(node, want)) @@ -898,7 +900,7 @@ fn matching<'a>(nodes: &'a [SemanticNode], want: &str) -> Vec<&'a SemanticNode> /// /// A zero-area box is the failure this exists to catch: present in the tree, /// absent from the window. -fn paints(node: &SemanticNode) -> bool { +pub(crate) fn paints(node: &SemanticNode) -> bool { /* * Geometry alone, because `visible` and the renderer disagree. * @@ -1542,6 +1544,11 @@ pub fn verdict( })?; selection_changed(before_node.id, before, after)?; } + Expect::FocusMoves => { + return Err( + "FocusMoves is evaluated by the live runner with snapshot focus state".to_owned(), + ); + } Expect::NameChanges => { let before_node = matching(before, &check.subject) .into_iter() @@ -1776,6 +1783,8 @@ mod tests { name: name.into(), value: None, enabled: true, + focusable: false, + viewport_fixed: false, visible: true, selected: false, bounds: Some([0.0, 0.0, width, height]), @@ -2298,6 +2307,8 @@ mod tests { name: "Output level".into(), value: Some(value.into()), enabled: true, + focusable: false, + viewport_fixed: false, visible: true, selected: false, bounds: Some([0.0, 0.0, 100.0, 20.0]), @@ -2331,6 +2342,8 @@ mod tests { name: name.into(), value: None, enabled: true, + focusable: false, + viewport_fixed: false, visible: true, selected: false, bounds: Some([0.0, 0.0, 100.0, 24.0]), @@ -2372,6 +2385,8 @@ mod tests { name: "Theme colour".into(), value: None, enabled: true, + focusable: false, + viewport_fixed: false, visible: true, selected: false, bounds: Some([0.0, 0.0, 20.0, 20.0]), @@ -2407,6 +2422,8 @@ mod tests { name: name.into(), value: None, enabled: true, + focusable: false, + viewport_fixed: false, visible: true, selected: false, bounds: Some([0.0, 0.0, 100.0, 20.0]), @@ -2439,6 +2456,8 @@ mod tests { name: "Rename project".into(), value: None, enabled: true, + focusable: false, + viewport_fixed: false, visible: bounds.is_some(), selected: false, bounds, @@ -2466,6 +2485,8 @@ mod tests { name: "Rename project".into(), value: None, enabled: true, + focusable: false, + viewport_fixed: false, visible: true, selected: false, bounds: Some([10.0, 10.0, 100.0, 20.0]), @@ -2497,6 +2518,8 @@ mod tests { name: "Drawer outcome".into(), value: None, enabled: true, + focusable: false, + viewport_fixed: false, visible: true, selected: false, bounds: Some([-384.0, 40.0, 336.0, 24.0]), @@ -2526,6 +2549,8 @@ mod tests { name: format!("Theme color {id}"), value: None, enabled: true, + focusable: false, + viewport_fixed: false, visible: true, selected: false, bounds: Some([x, y, 20.0, 20.0]), @@ -2551,6 +2576,8 @@ mod tests { name: format!("Theme color {id}"), value: None, enabled: true, + focusable: false, + viewport_fixed: false, visible: true, selected: false, bounds: Some([x, y, 20.0, 20.0]), @@ -2564,6 +2591,8 @@ mod tests { name: "Surface colour".into(), value: None, enabled: true, + focusable: false, + viewport_fixed: false, visible: true, selected: false, bounds: Some([10.0, 10.0, 190.0, 190.0]), @@ -2604,6 +2633,8 @@ mod tests { name: String::new(), value: None, enabled: true, + focusable: false, + viewport_fixed: false, visible: true, selected: false, bounds: Some(bounds), @@ -2633,6 +2664,8 @@ mod tests { name: "Save".into(), value: None, enabled, + focusable: true, + viewport_fixed: false, visible: true, selected: false, bounds, @@ -2667,6 +2700,8 @@ mod tests { name: "Continue".into(), value: None, enabled, + focusable: true, + viewport_fixed: false, visible: true, selected: false, bounds, @@ -2752,6 +2787,8 @@ mod tests { name: "Send".into(), value: None, enabled: false, + focusable: false, + viewport_fixed: false, visible: true, selected: false, bounds: Some([0.0, 0.0, 20.0, 20.0]), diff --git a/crates/ps-qa/src/reach.rs b/crates/ps-qa/src/reach.rs index f705c8e..38c1d21 100644 --- a/crates/ps-qa/src/reach.rs +++ b/crates/ps-qa/src/reach.rs @@ -192,6 +192,7 @@ pub fn reveal_chain(nodes: &[SemanticNode], target: u64) -> Vec { /// omitting those would make a component audit silently button-only. pub fn interactive(node: &SemanticNode) -> bool { (node.role == "option" && node.visible) + || (node.focusable && matches!(node.role.as_str(), "columnheader" | "rowheader")) || matches!( node.role.as_str(), "button" @@ -203,14 +204,21 @@ pub fn interactive(node: &SemanticNode) -> bool { | "menuitemradio" | "radio" | "slider" + | "searchbox" | "spinbutton" | "switch" | "tab" + | "textarea" | "textbox" | "treeitem" ) } +/// Whether a component control can actually be operated in the captured frame. +pub fn operable(node: &SemanticNode) -> bool { + interactive(node) && node.enabled && onscreen(node) +} + /// Whether pressing this leaves the surface, invalidating the rest of the plan. /// /// A sweep that presses a navigation control first loses the rest of its plan: @@ -838,6 +846,8 @@ mod tests { name: name.to_owned(), value: None, enabled: true, + focusable: false, + viewport_fixed: false, visible: true, selected: false, bounds, @@ -978,8 +988,19 @@ mod tests { #[test] fn component_inventory_is_not_button_only() { for role in [ - "button", "checkbox", "combobox", "link", "menuitem", "radio", "slider", "switch", - "tab", "textbox", "treeitem", + "button", + "checkbox", + "combobox", + "link", + "menuitem", + "radio", + "searchbox", + "slider", + "switch", + "tab", + "textarea", + "textbox", + "treeitem", ] { assert!(interactive(&node( 1, @@ -1005,6 +1026,22 @@ mod tests { let mut hidden_option = node(4, "option", "Retained choice", Some([0.0, 0.0, 20.0, 20.0])); hidden_option.visible = false; assert!(!interactive(&hidden_option)); + + let mut sortable_header = node(5, "columnheader", "Name", Some([0.0, 0.0, 80.0, 20.0])); + assert!(!interactive(&sortable_header)); + sortable_header.focusable = true; + assert!(interactive(&sortable_header)); + } + + #[test] + fn component_coverage_requires_a_control_to_be_operable_now() { + let mut control = node(1, "button", "Action", Some([0.0, 0.0, 80.0, 20.0])); + assert!(operable(&control)); + control.enabled = false; + assert!(!operable(&control)); + control.enabled = true; + control.visible = false; + assert!(!operable(&control)); } #[test] diff --git a/crates/ps-qa/src/runner.rs b/crates/ps-qa/src/runner.rs index 4e5f11b..67f7fb8 100644 --- a/crates/ps-qa/src/runner.rs +++ b/crates/ps-qa/src/runner.rs @@ -1053,7 +1053,29 @@ async fn run_component( let descriptor = inspector::discover(descriptor_path.to_str())?; let mut client = Client::connect(&descriptor.socket_path()).await?; client.initialize().await?; - run_qa(&mut client, selector, checks_dir).await + let baseline = inspect(&mut client).await?.0; + let all_checks = qa::checks(checks_dir).map_err(eyre::Report::msg)?; + // Baseline controls belong to the application, not to whichever group the + // caller chose to execute this time. A Home link may be exercised by the + // navigation group while the input group is the current run; restricting + // attribution to the selected group made every isolated group fail on the + // same shared header even though the full manifest covered it. + let uncovered: Vec<&SemanticNode> = baseline + .nodes + .iter() + .filter(|node| reach::operable(node)) + .filter(|node| outcome_check_ids(node, &all_checks).is_empty()) + .collect(); + + for node in &uncovered { + eprintln!( + "uncovered component control: id={} role={} name={:?} visible={} enabled={}", + node.id, node.role, node.name, node.visible, node.enabled + ); + } + + let failed = run_qa(&mut client, selector, checks_dir).await?; + Ok(failed + uncovered.len()) } /// The order a run executes in: the order the files declare. @@ -2096,6 +2118,7 @@ async fn run_qa( client, check, &before.nodes, + before.focused_node, action_target.as_deref(), action_node_id, action_paint_armed, @@ -2136,6 +2159,8 @@ async fn run_qa( &after.nodes, action_target.as_deref(), action_node_id, + before.focused_node, + after.focused_node, ) .map_err(|outcome| format!("{error}; rendered outcome also failed: {outcome}")), Some(error) => Err(error), @@ -2145,6 +2170,8 @@ async fn run_qa( &after.nodes, action_target.as_deref(), action_node_id, + before.focused_node, + after.focused_node, ), } }; @@ -2267,6 +2294,8 @@ fn outcome_verdict( after: &[SemanticNode], action_target: Option<&str>, action_node_id: Option, + before_focused: Option, + after_focused: Option, ) -> std::result::Result<(), String> { if check.expect == qa::Expect::TargetPaints { let Some(subject) = action_target else { @@ -2286,6 +2315,21 @@ fn outcome_verdict( && let Some(node_id) = action_node_id { qa::selection_changed(node_id, before, after) + } else if check.expect == qa::Expect::FocusMoves { + let destination = qa::matching(after, &check.subject) + .into_iter() + .find(|node| qa::paints(node)) + .ok_or_else(|| format!("no painted focus destination matching {:?}", check.subject))?; + if after_focused != Some(destination.id) { + return Err(format!( + "focus ended on {:?}, expected {} for {:?}", + after_focused, destination.id, check.subject + )); + } + if before_focused == after_focused { + return Err(format!("focus did not move from {:?}", before_focused)); + } + Ok(()) } else { qa::verdict(check, before, after) } @@ -2489,6 +2533,7 @@ async fn settle_for_outcome( client: &mut Client, check: &qa::Check, before: &[SemanticNode], + before_focused: Option, action_target: Option<&str>, action_node_id: Option, already_armed: bool, @@ -2527,8 +2572,16 @@ async fn settle_for_outcome( }; iterations += 1; let now = tokio::time::Instant::now(); - let mut passing = - outcome_verdict(check, before, &after.nodes, action_target, action_node_id).is_ok(); + let mut passing = outcome_verdict( + check, + before, + &after.nodes, + action_target, + action_node_id, + before_focused, + after.focused_node, + ) + .is_ok(); 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 { @@ -2544,8 +2597,16 @@ async fn settle_for_outcome( // what keeps this from serialising the document every turn. after = inspect(client).await?.0; last_full_probe = Some(now); - passing = - outcome_verdict(check, before, &after.nodes, action_target, action_node_id).is_ok(); + passing = outcome_verdict( + check, + before, + &after.nodes, + action_target, + action_node_id, + before_focused, + after.focused_node, + ) + .is_ok(); scope = if passing { // Navigation can replace the active pane. Adopt that new // subtree when it owns the declared subject; a portal-owned @@ -3948,6 +4009,7 @@ fn outcome_check_ids(node: &SemanticNode, checks: &[qa::Check]) -> Vec { check.hover.as_ref().map(qa::Hover::target), check.after_prepare_hover.as_ref().map(qa::Hover::target), check.click.as_deref(), + check.setup_type_into.as_deref(), check.type_into.as_deref(), check.key_on.as_deref(), check.scroll_over.as_deref(), @@ -3959,10 +4021,12 @@ fn outcome_check_ids(node: &SemanticNode, checks: &[qa::Check]) -> Vec { .covers .iter() .any(|selector| selector_matches_node(node, selector)); + let focus_destination = check.expect == qa::Expect::FocusMoves + && exact_selector_matches_node(node, &check.subject); let disabled_outcome = !node.enabled && matches!(check.expect, qa::Expect::Disabled) && coverage_action_matches_node(node, &check.subject); - driven || family || disabled_outcome + driven || family || focus_destination || disabled_outcome }) .map(|check| check.id.clone()) .collect() @@ -4017,6 +4081,8 @@ fn saved_control_node(control: &SavedControl) -> SemanticNode { name: control.name.clone(), value: None, enabled: !control.classification.contains("disabled"), + focusable: false, + viewport_fixed: false, visible: !control.classification.contains("unreachable"), selected: false, bounds: Some([0.0, 0.0, 1.0, 1.0]), @@ -6010,7 +6076,9 @@ mod tests { use crate::app::{AppProfile, SurfaceSpec}; use crate::interaction::parse_key_chord; use crate::qa::{Check, Expect}; - use crate::target::{exact_selector_matches_node, retain_exact_candidates, viewport_for_node}; + use crate::target::{ + exact_selector_matches_node, offscreen, retain_exact_candidates, viewport_for_node, + }; use blitz_control_protocol::{AgentSnapshot, CapturedImage, SemanticNode, WindowComposition}; use std::collections::HashSet; use std::time::Duration; @@ -6099,6 +6167,8 @@ mod tests { name: name.into(), value: None, enabled, + focusable: true, + viewport_fixed: false, visible, selected: false, bounds: Some([0.0, 0.0, 20.0, 20.0]), @@ -6163,6 +6233,37 @@ mod tests { assert_eq!(viewport_for_node(&snapshot, 22), (64.0, 960.0)); } + #[test] + fn fixed_content_uses_the_window_viewport_after_its_dom_parent_scrolls() { + let mut root = component("", true, true); + root.id = 30; + root.role = "generic".into(); + root.bounds = Some([0.0, -11000.0, 1344.0, 12000.0]); + + let mut main = component("", true, true); + main.id = 31; + main.parent = Some(root.id); + main.role = "main".into(); + main.bounds = Some([0.0, -10942.0, 1344.0, 11942.0]); + + let mut fixed = component("Return to coverage", true, true); + fixed.id = 32; + fixed.parent = Some(main.id); + fixed.viewport_fixed = true; + fixed.bounds = Some([16.0, 16.0, 180.0, 40.0]); + + let snapshot = AgentSnapshot { + nodes: vec![root, main, fixed], + viewport: Some([0.0, 0.0, 1344.0, 960.0]), + ..AgentSnapshot::default() + }; + assert_eq!(viewport_for_node(&snapshot, 32), (0.0, 960.0)); + assert!(!offscreen( + snapshot.nodes[2].bounds.expect("fixed bounds"), + viewport_for_node(&snapshot, 32) + )); + } + #[test] fn pixel_stability_reports_a_changed_rendered_pixel() { use base64::Engine as _; @@ -6667,6 +6768,8 @@ mod tests { name: "Saved".into(), value: None, enabled: true, + focusable: false, + viewport_fixed: false, visible: true, selected: false, bounds: Some([0.0, 0.0, 90.0, 24.0]), @@ -6707,9 +6810,10 @@ mod tests { ); let settled = std::time::Instant::now(); - let (_, error, _) = settle_for_outcome(&mut client, &check, &[], None, None, true) - .await - .expect("the outcome is judged from the tree"); + let (_, error, _) = + settle_for_outcome(&mut client, &check, &[], None, None, None, true) + .await + .expect("the outcome is judged from the tree"); assert!(error.is_none(), "{error:?}"); assert!( settled.elapsed() < budget / 2, @@ -6739,6 +6843,8 @@ mod tests { name: "Filter".into(), value: Some(value.into()), enabled: true, + focusable: false, + viewport_fixed: false, visible: true, selected: false, bounds: Some([0.0, 0.0, 240.0, 28.0]), @@ -7198,7 +7304,7 @@ mod tests { check.expect = Expect::NameChanges; let before = component("Refresh generation 1", true, true); let after = before.clone(); - assert!(outcome_verdict(&check, &[before], &[after], None, None).is_err()); + assert!(outcome_verdict(&check, &[before], &[after], None, None, None, None).is_err()); } #[test] @@ -7207,7 +7313,55 @@ mod tests { check.expect = Expect::NameChanges; let before = component("Refresh generation 1", true, true); let after = component("Refresh generation 2", true, true); - assert!(outcome_verdict(&check, &[before], &[after], None, None).is_ok()); + assert!(outcome_verdict(&check, &[before], &[after], None, None, None, None).is_ok()); + } + + #[test] + fn focus_outcome_requires_the_named_destination_and_a_real_move() { + let mut check = check("toolbar-next", None, "button:Second tool"); + check.expect = Expect::FocusMoves; + let mut first = component("First tool", true, true); + first.role = "button".into(); + let mut second = component("Second tool", true, true); + second.id = 2; + second.role = "button".into(); + + assert!( + outcome_verdict( + &check, + &[first.clone(), second.clone()], + &[first.clone(), second.clone()], + None, + None, + Some(first.id), + Some(second.id), + ) + .is_ok() + ); + assert!( + outcome_verdict( + &check, + &[first.clone(), second.clone()], + &[first.clone(), second.clone()], + None, + None, + Some(first.id), + Some(first.id), + ) + .is_err() + ); + assert!( + outcome_verdict( + &check, + &[first, second.clone()], + &[second.clone()], + None, + None, + Some(second.id), + Some(second.id), + ) + .is_err() + ); } #[test] @@ -7230,6 +7384,8 @@ mod tests { &[reset, after_slider], Some("Reset to default"), Some(1), + None, + None, ) .is_ok() ); diff --git a/crates/ps-qa/src/sweep.rs b/crates/ps-qa/src/sweep.rs index 53155b4..58694b9 100644 --- a/crates/ps-qa/src/sweep.rs +++ b/crates/ps-qa/src/sweep.rs @@ -378,6 +378,8 @@ mod tests { name: name.to_owned(), value: None, enabled: true, + focusable: false, + viewport_fixed: false, visible: true, selected: false, bounds: Some([0.0, 0.0, 10.0, 10.0]), diff --git a/crates/ps-qa/src/target.rs b/crates/ps-qa/src/target.rs index 96825bc..1071653 100644 --- a/crates/ps-qa/src/target.rs +++ b/crates/ps-qa/src/target.rs @@ -131,6 +131,15 @@ pub(crate) fn viewport_of_nodes(nodes: &[SemanticNode]) -> (f64, f64) { /// scroll coordinates as window-visible sends pointer events behind the tab /// strip instead of revealing the row inside its panel. pub(crate) fn viewport_for_node(snapshot: &AgentSnapshot, node_id: u64) -> (f64, f64) { + if snapshot + .nodes + .iter() + .find(|node| node.id == node_id) + .is_some_and(|node| node.viewport_fixed) + && let Some(bounds) = snapshot.viewport + { + return (bounds[1], bounds[1] + bounds[3]); + } let inferred = viewport_for_node_in(&snapshot.nodes, node_id); if let Some(bounds) = snapshot.viewport { let window = (bounds[1], bounds[1] + bounds[3]); @@ -550,6 +559,8 @@ mod tests { name: name.into(), value: None, enabled: true, + focusable: false, + viewport_fixed: false, visible: true, selected: false, bounds: Some([0.0, 0.0, 20.0, 20.0]), From 32c90eb46e40ac7120b9fca3dcc1b7d62ed2d85e Mon Sep 17 00:00:00 2001 From: meh Date: Sat, 12 Sep 2026 16:59:15 +0700 Subject: [PATCH 8/9] fix(qa): attribute measured control outcomes --- crates/ps-qa/src/runner.rs | 128 +++++++++++++++++++++++++++---------- 1 file changed, 96 insertions(+), 32 deletions(-) diff --git a/crates/ps-qa/src/runner.rs b/crates/ps-qa/src/runner.rs index 67f7fb8..fadc3e7 100644 --- a/crates/ps-qa/src/runner.rs +++ b/crates/ps-qa/src/runner.rs @@ -4013,6 +4013,7 @@ fn outcome_check_ids(node: &SemanticNode, checks: &[qa::Check]) -> Vec { check.type_into.as_deref(), check.key_on.as_deref(), check.scroll_over.as_deref(), + check.pointer_drag.as_ref().map(|drag| drag.from.as_str()), ] .into_iter() .flatten() @@ -4023,10 +4024,14 @@ fn outcome_check_ids(node: &SemanticNode, checks: &[qa::Check]) -> Vec { .any(|selector| selector_matches_node(node, selector)); let focus_destination = check.expect == qa::Expect::FocusMoves && exact_selector_matches_node(node, &check.subject); + let measured_outcome = matches!( + check.expect, + qa::Expect::ValueChanges | qa::Expect::SelectionChanges | qa::Expect::NameChanges + ) && exact_selector_matches_node(node, &check.subject); let disabled_outcome = !node.enabled && matches!(check.expect, qa::Expect::Disabled) && coverage_action_matches_node(node, &check.subject); - driven || family || focus_destination || disabled_outcome + driven || family || focus_destination || measured_outcome || disabled_outcome }) .map(|check| check.id.clone()) .collect() @@ -4204,6 +4209,22 @@ fn inventory_outcome_failures(unverified: usize, isolated: usize, required: bool } } +fn inventory_outcome_declared(class: InventoryClass, matched_checks: &[String]) -> bool { + class != InventoryClass::Manual + && class != InventoryClass::Isolated + && !matched_checks.is_empty() +} + +fn inventory_control_unverified(class: InventoryClass, matched_checks: &[String]) -> bool { + match class { + InventoryClass::Manual => false, + // These need a disposable-process lifecycle verdict. Merely naming an + // isolated control in the shared suite cannot prove it completed. + InventoryClass::Isolated => true, + _ => matched_checks.is_empty(), + } +} + fn validate_surface_filter_against(only: Option<&str>, surfaces: &[reach::Surface]) -> Result<()> { let Some(want) = only else { return Ok(()); @@ -4491,30 +4512,12 @@ async fn run_inventory( let outcome_declared = classes .iter() .zip(&declared) - .filter(|(class, matches)| { - matches!( - class, - InventoryClass::Reachable - | InventoryClass::Disabled - | InventoryClass::Unreachable - ) && !matches.is_empty() - }) + .filter(|(class, matches)| inventory_outcome_declared(**class, matches)) .count(); let unverified = classes .iter() .zip(&declared) - .filter(|(class, matches)| match class { - InventoryClass::Reachable | InventoryClass::Disabled => matches.is_empty(), - // These must run in disposable processes; declaration in the - // shared suite cannot turn them green. - InventoryClass::Isolated => true, - InventoryClass::Manual - | InventoryClass::MissingId - | InventoryClass::UnstableId - | InventoryClass::DuplicateId - | InventoryClass::Anonymous - | InventoryClass::Unreachable => false, - }) + .filter(|(class, matches)| inventory_control_unverified(**class, matches)) .count(); for (node, matched_checks) in components.iter().zip(&declared) { let manual = reach::requires_manual_release_check(&node.name); @@ -4537,16 +4540,7 @@ async fn run_inventory( if !matched_checks.is_empty() { counts[10] += 1; } - let is_unverified = match class { - InventoryClass::Reachable | InventoryClass::Disabled => matched_checks.is_empty(), - InventoryClass::Isolated => true, - InventoryClass::Manual - | InventoryClass::MissingId - | InventoryClass::UnstableId - | InventoryClass::DuplicateId - | InventoryClass::Anonymous - | InventoryClass::Unreachable => false, - }; + let is_unverified = inventory_control_unverified(class, matched_checks); if is_unverified { counts[11] += 1; } @@ -6075,7 +6069,7 @@ mod tests { }; use crate::app::{AppProfile, SurfaceSpec}; use crate::interaction::parse_key_chord; - use crate::qa::{Check, Expect}; + use crate::qa::{Check, Expect, PointerDrag}; use crate::target::{ exact_selector_matches_node, offscreen, retain_exact_candidates, viewport_for_node, }; @@ -7289,6 +7283,40 @@ mod tests { ); } + #[test] + fn a_control_measured_by_a_state_outcome_receives_coverage_credit() { + let mut selection = check( + "select-option", + Some("option:Beta"), + "combobox:Coverage combo box", + ); + selection.expect = Expect::ValueChanges; + let mut input = component("Coverage combo box", true, true); + input.role = "combobox".into(); + + assert_eq!( + outcome_check_ids(&input, &[selection]), + vec!["select-option"] + ); + } + + #[test] + fn pointer_drag_targets_receive_coverage_credit() { + let mut drag = check("drag-hue", None, "slider:Hue"); + drag.expect = Expect::ValueChanges; + drag.pointer_drag = Some(PointerDrag { + from: "slider:Hue".into(), + dx: 20.0, + dy: 0.0, + steps: 2, + cancel: false, + }); + let mut slider = component("Hue", true, true); + slider.role = "slider".into(); + + assert_eq!(outcome_check_ids(&slider, &[drag]), vec!["drag-hue"]); + } + #[test] fn an_explicit_family_selector_credits_repeated_component_rows() { let mut check = check("offer-model", Some("Offer Default"), "Offer Default"); @@ -7460,6 +7488,42 @@ mod tests { assert_eq!(super::inventory_outcome_failures(3, 1, false), 0); } + #[test] + fn identity_failures_do_not_hide_missing_outcomes() { + let none: Vec = Vec::new(); + let declared = vec!["save-works".into()]; + + for class in [ + InventoryClass::MissingId, + InventoryClass::UnstableId, + InventoryClass::DuplicateId, + InventoryClass::Anonymous, + InventoryClass::Unreachable, + ] { + assert!(super::inventory_control_unverified(class, &none)); + assert!(!super::inventory_outcome_declared(class, &none)); + assert!(!super::inventory_control_unverified(class, &declared)); + assert!(super::inventory_outcome_declared(class, &declared)); + } + + assert!(!super::inventory_control_unverified( + InventoryClass::Manual, + &none + )); + assert!(!super::inventory_outcome_declared( + InventoryClass::Manual, + &declared + )); + assert!(super::inventory_control_unverified( + InventoryClass::Isolated, + &declared + )); + assert!(!super::inventory_outcome_declared( + InventoryClass::Isolated, + &declared + )); + } + /// A bare word is a substring, because that is how a control is recalled. #[test] fn a_bare_pattern_is_a_substring() { From e472b425ba000c87fa37bbf53f4a24594624a107 Mon Sep 17 00:00:00 2001 From: meh Date: Sat, 12 Sep 2026 18:37:49 +0700 Subject: [PATCH 9/9] fix(qa): keep tests after runtime items --- crates/ps-qa/src/interaction.rs | 44 ++++++++++++++++----------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/crates/ps-qa/src/interaction.rs b/crates/ps-qa/src/interaction.rs index 62ca8e6..3d6b62e 100644 --- a/crates/ps-qa/src/interaction.rs +++ b/crates/ps-qa/src/interaction.rs @@ -240,28 +240,6 @@ fn find_text_field<'a>(nodes: &'a [SemanticNode], want: &str) -> Option<&'a Sema fields.into_iter().find(matches_name) } -#[cfg(test)] -mod tests { - use super::is_text_field_role; - - #[test] - fn editable_aria_roles_are_literal_text_targets() { - for role in [ - "textbox", - "textarea", - "input", - "combobox", - "searchbox", - "spinbutton", - ] { - assert!(is_text_field_role(role), "{role}"); - } - for role in ["button", "listbox", "option", "slider"] { - assert!(!is_text_field_role(role), "{role}"); - } - } -} - /// Drive real key events into a focused text field and price them. /// /// Typing is the interaction the composer autosizes on: it writes @@ -534,3 +512,25 @@ fn report_frames( report::show("after", after); report::show_delta(before, after, actions); } + +#[cfg(test)] +mod tests { + use super::is_text_field_role; + + #[test] + fn editable_aria_roles_are_literal_text_targets() { + for role in [ + "textbox", + "textarea", + "input", + "combobox", + "searchbox", + "spinbutton", + ] { + assert!(is_text_field_role(role), "{role}"); + } + for role in ["button", "listbox", "option", "slider"] { + assert!(!is_text_field_role(role), "{role}"); + } + } +}