From f6b4ba0e5fa1b6d198af3b22ee2ba7f0a7f1c565 Mon Sep 17 00:00:00 2001 From: meh Date: Sun, 13 Sep 2026 01:19:39 +0700 Subject: [PATCH 1/3] feat(qa): let applications declare inventory roots --- crates/ps-qa/src/app.rs | 13 +++++ crates/ps-qa/src/reach.rs | 107 +++++++++++++++++++++++++++++++++++++ crates/ps-qa/src/runner.rs | 11 ++-- 3 files changed, 128 insertions(+), 3 deletions(-) diff --git a/crates/ps-qa/src/app.rs b/crates/ps-qa/src/app.rs index e93eb5e..7cbcc5e 100644 --- a/crates/ps-qa/src/app.rs +++ b/crates/ps-qa/src/app.rs @@ -76,6 +76,14 @@ pub struct SurfaceSpec { /// or `#dashboard`. An unqualified name matches text in any semantic role. #[serde(default)] pub marker: Option, + /// An authored root used only to decide which controls inventory owns. + /// + /// Outcome polling continues to use `marker`, whose narrower semantic + /// subtree excludes unrelated animation. This selector lets inventory + /// count global controls that belong to the page but sit outside that + /// outcome subtree. + #[serde(default)] + pub inventory_root: Option, /// A text field whose query causes this surface to mount deferred rows. /// /// ps-qa writes a temporary query and clears it immediately. The @@ -327,6 +335,7 @@ mod tests { name: "dashboard".to_owned(), opener: "Dashboard".to_owned(), marker: Some("Overview heading".to_owned()), + inventory_root: Some("#surface-root".to_owned()), reveal_with: Some("Search dashboard".to_owned()), }], permanent_surfaces: vec!["Dashboard".to_owned()], @@ -353,6 +362,10 @@ mod tests { let text = ron::to_string(&profile).expect("serialises"); let back: AppProfile = ron::from_str(&text).expect("parses"); assert_eq!(back.surfaces.len(), 1); + assert_eq!( + back.surfaces[0].inventory_root.as_deref(), + Some("#surface-root") + ); assert_eq!(back.sections, vec!["Records".to_owned()]); assert_eq!(back.transcript_region.as_deref(), Some("Message history")); assert_eq!(back.document_openers, vec!["QA document".to_owned()]); diff --git a/crates/ps-qa/src/reach.rs b/crates/ps-qa/src/reach.rs index 38c1d21..9268174 100644 --- a/crates/ps-qa/src/reach.rs +++ b/crates/ps-qa/src/reach.rs @@ -488,6 +488,57 @@ pub fn on_surface_subtree(nodes: &[SemanticNode], surface: &Surface) -> Vec best } +/// The semantic subtree an inventory assigns to a surface. +/// +/// Most applications use the same marker-derived subtree for interaction +/// outcomes and inventory. An application whose global page controls are +/// siblings of that subtree can name one authored root for inventory without +/// widening outcome polling to unrelated animation. +pub fn inventory_surface_subtree( + nodes: &[SemanticNode], + surface: &Surface, +) -> Result, String> { + let Some(selector) = surface.inventory_root.as_deref() else { + return Ok(on_surface_subtree(nodes, surface)); + }; + let roots: Vec<_> = nodes + .iter() + .filter(|node| selector_matches_node(node, selector)) + .collect(); + let root = match roots.as_slice() { + [root] => *root, + [] => { + return Err(format!( + "inventory root {selector:?} was not found on surface {:?}", + surface.name + )); + } + _ => { + return Err(format!( + "inventory root {selector:?} matched {} nodes on surface {:?}", + roots.len(), + surface.name + )); + } + }; + + let mut children: HashMap> = HashMap::new(); + for node in nodes { + if let Some(parent) = node.parent { + children.entry(parent).or_default().push(node.id); + } + } + let mut owned = Vec::new(); + let mut stack = vec![root.id]; + while let Some(id) = stack.pop() { + owned.push(id); + if let Some(descendants) = children.get(&id) { + stack.extend(descendants.iter().copied()); + } + } + Ok(owned) +} + /// Whether the application reserves this control for its manual release pass. /// /// The profile owns the exact prefixes and records why each one is manual. The @@ -874,6 +925,7 @@ mod tests { name: "settings".into(), opener: "Settings".into(), marker: Some("Search settings".into()), + inventory_root: None, reveal_with: None, }; let profile = crate::app::AppProfile { @@ -976,6 +1028,7 @@ mod tests { name: "deep".to_owned(), opener: "Deep".to_owned(), marker: Some("Surface marker".to_owned()), + inventory_root: None, reveal_with: None, }; let scope = on_surface_subtree(&nodes, &surface); @@ -985,6 +1038,60 @@ mod tests { assert!(!scope.contains(&21)); } + #[test] + fn authored_inventory_root_does_not_widen_outcome_scope() { + let mut document = node(1, "generic", "", Some([0.0, 0.0, 800.0, 600.0])); + document.dom_id = Some("document".into()); + let mut app = node(2, "region", "PathScale", Some([0.0, 0.0, 800.0, 600.0])); + app.dom_id = Some("surface-root".into()); + app.parent = Some(1); + let mut main = node(3, "main", "", Some([0.0, 0.0, 800.0, 600.0])); + main.parent = Some(2); + let mut navigation = node(4, "navigation", "", Some([0.0, 0.0, 800.0, 80.0])); + navigation.parent = Some(3); + let mut marker = node(5, "button", "Page 1", Some([20.0, 20.0, 40.0, 24.0])); + marker.parent = Some(4); + let mut hero = node(6, "button", "Get started", Some([20.0, 160.0, 120.0, 40.0])); + hero.parent = Some(3); + let mut chat = node(7, "button", "Open chat", Some([720.0, 520.0, 48.0, 48.0])); + chat.parent = Some(2); + let mut foreign = node(8, "button", "Browser control", Some([0.0, 0.0, 20.0, 20.0])); + foreign.parent = Some(1); + let nodes = [document, app, main, navigation, marker, hero, chat, foreign]; + let surface = Surface { + name: "home".into(), + opener: "PathScale".into(), + marker: Some("Page 1".into()), + inventory_root: Some("#surface-root".into()), + reveal_with: None, + }; + + let outcome = on_surface_subtree(&nodes, &surface); + assert!(outcome.contains(&5)); + assert!(!outcome.contains(&6)); + assert!(!outcome.contains(&7)); + + let inventory = inventory_surface_subtree(&nodes, &surface).unwrap(); + assert!(inventory.contains(&5)); + assert!(inventory.contains(&6)); + assert!(inventory.contains(&7)); + assert!(!inventory.contains(&8)); + } + + #[test] + fn a_missing_authored_inventory_root_is_an_error() { + let surface = Surface { + name: "home".into(), + opener: "PathScale".into(), + marker: Some("Page 1".into()), + inventory_root: Some("#missing".into()), + reveal_with: None, + }; + let error = inventory_surface_subtree(&[], &surface).unwrap_err(); + assert!(error.contains("#missing")); + assert!(error.contains("home")); + } + #[test] fn component_inventory_is_not_button_only() { for role in [ diff --git a/crates/ps-qa/src/runner.rs b/crates/ps-qa/src/runner.rs index fadc3e7..abdae0c 100644 --- a/crates/ps-qa/src/runner.rs +++ b/crates/ps-qa/src/runner.rs @@ -4459,9 +4459,11 @@ async fn run_inventory( continue; } }; - let mine: std::collections::HashSet = reach::on_surface_subtree(&tree.nodes, surface) - .into_iter() - .collect(); + let mine: std::collections::HashSet = + reach::inventory_surface_subtree(&tree.nodes, surface) + .map_err(eyre::Report::msg)? + .into_iter() + .collect(); let components: Vec<_> = tree .nodes .iter() @@ -6450,6 +6452,7 @@ mod tests { name: "settings".into(), opener: "Settings".into(), marker: Some("Search settings".into()), + inventory_root: None, reveal_with: Some("Search settings".into()), }; @@ -7039,6 +7042,7 @@ mod tests { name: "settings".into(), opener: "Settings".into(), marker: Some("Search settings".into()), + inventory_root: None, reveal_with: None, }]; assert!(validate_surface_filter_against(Some("Settings"), &surfaces).is_ok()); @@ -7148,6 +7152,7 @@ mod tests { name: "project".into(), opener: crate::reach::DYNAMIC_DOCUMENT.into(), marker: Some("Send".into()), + inventory_root: None, reveal_with: None, }; let nodes = [component("Send", true, true)]; From 560e8ab079c53ed4069050215955b4c160568d58 Mon Sep 17 00:00:00 2001 From: meh Date: Sun, 13 Sep 2026 02:08:28 +0700 Subject: [PATCH 2/3] feat(qa): navigate nested inventory surfaces --- crates/ps-qa/src/app.rs | 9 +++++++++ crates/ps-qa/src/reach.rs | 4 ++++ crates/ps-qa/src/runner.rs | 18 ++++++++++++++++-- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/ps-qa/src/app.rs b/crates/ps-qa/src/app.rs index 7cbcc5e..63c8990 100644 --- a/crates/ps-qa/src/app.rs +++ b/crates/ps-qa/src/app.rs @@ -70,6 +70,13 @@ pub struct SurfaceSpec { /// first user-named document, resolved at run time when fixture names are /// not stable. pub opener: String, + /// Controls that must be activated, in order, before `opener` exists. + /// + /// This keeps nested navigation in application data. For example, a + /// component lab may require `Components`, then `Surfaces`, before its + /// final `Complete Coverage` link is mounted. + #[serde(default)] + pub via: Vec, /// A control unique to this surface, used to prove it is in front and to /// scope coverage to its semantic subtree. /// Accepts the same selectors as check subjects, for example `link:version` @@ -334,6 +341,7 @@ mod tests { surfaces: vec![SurfaceSpec { name: "dashboard".to_owned(), opener: "Dashboard".to_owned(), + via: vec!["Products".to_owned()], marker: Some("Overview heading".to_owned()), inventory_root: Some("#surface-root".to_owned()), reveal_with: Some("Search dashboard".to_owned()), @@ -362,6 +370,7 @@ mod tests { let text = ron::to_string(&profile).expect("serialises"); let back: AppProfile = ron::from_str(&text).expect("parses"); assert_eq!(back.surfaces.len(), 1); + assert_eq!(back.surfaces[0].via, ["Products"]); assert_eq!( back.surfaces[0].inventory_root.as_deref(), Some("#surface-root") diff --git a/crates/ps-qa/src/reach.rs b/crates/ps-qa/src/reach.rs index 9268174..301c508 100644 --- a/crates/ps-qa/src/reach.rs +++ b/crates/ps-qa/src/reach.rs @@ -924,6 +924,7 @@ mod tests { let surface = Surface { name: "settings".into(), opener: "Settings".into(), + via: vec![], marker: Some("Search settings".into()), inventory_root: None, reveal_with: None, @@ -1027,6 +1028,7 @@ mod tests { let surface = Surface { name: "deep".to_owned(), opener: "Deep".to_owned(), + via: vec![], marker: Some("Surface marker".to_owned()), inventory_root: None, reveal_with: None, @@ -1061,6 +1063,7 @@ mod tests { let surface = Surface { name: "home".into(), opener: "PathScale".into(), + via: vec![], marker: Some("Page 1".into()), inventory_root: Some("#surface-root".into()), reveal_with: None, @@ -1083,6 +1086,7 @@ mod tests { let surface = Surface { name: "home".into(), opener: "PathScale".into(), + via: vec![], marker: Some("Page 1".into()), inventory_root: Some("#missing".into()), reveal_with: None, diff --git a/crates/ps-qa/src/runner.rs b/crates/ps-qa/src/runner.rs index abdae0c..518324a 100644 --- a/crates/ps-qa/src/runner.rs +++ b/crates/ps-qa/src/runner.rs @@ -4661,6 +4661,17 @@ async fn run_inventory( } /// Navigate to a surface, and say whether it opened. +async fn click_surface_path( + client: &mut Client, + surface: &reach::Surface, + opener: &str, +) -> Result<()> { + for step in surface.via.iter().map(String::as_str).chain([opener]) { + click_named_quiet(client, step).await?; + } + Ok(()) +} + async fn open_surface(client: &mut Client, surface: &reach::Surface) -> Result { if surface.opener.is_empty() { return Ok(true); @@ -4751,7 +4762,7 @@ async fn open_surface(client: &mut Client, surface: &reach::Surface) -> Result Result Date: Sun, 13 Sep 2026 03:05:48 +0700 Subject: [PATCH 3/3] fix(qa): honor manual controls in hosted coverage --- crates/ps-qa/src/runner.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/ps-qa/src/runner.rs b/crates/ps-qa/src/runner.rs index 518324a..87e2368 100644 --- a/crates/ps-qa/src/runner.rs +++ b/crates/ps-qa/src/runner.rs @@ -1064,6 +1064,11 @@ async fn run_component( .nodes .iter() .filter(|node| reach::operable(node)) + // A profile's manual controls are deliberately outside native + // automation (for example, links that leave the application). The + // site-wide inventory already reports them explicitly; counting them + // again as uncovered makes a fully reconciled qa-hosted run fail. + .filter(|node| !reach::requires_manual_release_check(&node.name)) .filter(|node| outcome_check_ids(node, &all_checks).is_empty()) .collect();