diff --git a/crates/ps-qa/src/app.rs b/crates/ps-qa/src/app.rs index e93eb5e..63c8990 100644 --- a/crates/ps-qa/src/app.rs +++ b/crates/ps-qa/src/app.rs @@ -70,12 +70,27 @@ 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` /// 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 @@ -326,7 +341,9 @@ 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()), }], permanent_surfaces: vec!["Dashboard".to_owned()], @@ -353,6 +370,11 @@ 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") + ); 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..301c508 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 @@ -873,7 +924,9 @@ mod tests { let surface = Surface { name: "settings".into(), opener: "Settings".into(), + via: vec![], marker: Some("Search settings".into()), + inventory_root: None, reveal_with: None, }; let profile = crate::app::AppProfile { @@ -975,7 +1028,9 @@ 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, }; let scope = on_surface_subtree(&nodes, &surface); @@ -985,6 +1040,62 @@ 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(), + via: vec![], + 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(), + via: vec![], + 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..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(); @@ -4459,9 +4464,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() @@ -4659,6 +4666,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); @@ -4749,7 +4767,7 @@ async fn open_surface(client: &mut Client, surface: &reach::Surface) -> Result Result