Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions crates/ps-qa/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<String>,
/// 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<String>,
/// A text field whose query causes this surface to mount deferred rows.
///
/// ps-qa writes a temporary query and clears it immediately. The
Expand Down Expand Up @@ -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()],
Expand All @@ -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()]);
Expand Down
111 changes: 111 additions & 0 deletions crates/ps-qa/src/reach.rs
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,57 @@ pub fn on_surface_subtree(nodes: &[SemanticNode], surface: &Surface) -> Vec<u64>
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<Vec<u64>, 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<u64, Vec<u64>> = 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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand All @@ -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 [
Expand Down
34 changes: 29 additions & 5 deletions crates/ps-qa/src/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -4459,9 +4464,11 @@ async fn run_inventory(
continue;
}
};
let mine: std::collections::HashSet<u64> = reach::on_surface_subtree(&tree.nodes, surface)
.into_iter()
.collect();
let mine: std::collections::HashSet<u64> =
reach::inventory_surface_subtree(&tree.nodes, surface)
.map_err(eyre::Report::msg)?
.into_iter()
.collect();
let components: Vec<_> = tree
.nodes
.iter()
Expand Down Expand Up @@ -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<bool> {
if surface.opener.is_empty() {
return Ok(true);
Expand Down Expand Up @@ -4749,7 +4767,7 @@ async fn open_surface(client: &mut Client, surface: &reach::Surface) -> Result<b
node_id: id,
}))
.await?;
} else if click_named_quiet(client, &opener).await.is_err() {
} else if click_surface_path(client, surface, &opener).await.is_err() {
return Ok(false);
}
if settle_on(client, surface).await? {
Expand All @@ -4765,7 +4783,7 @@ async fn open_surface(client: &mut Client, surface: &reach::Surface) -> Result<b
if let Some(home) = reach::profile().home_opener.as_deref() {
let _ = click_named_quiet(client, home).await;
}
if click_named_quiet(client, &opener).await.is_err() {
if click_surface_path(client, surface, &opener).await.is_err() {
return Ok(false);
}
settle_on(client, surface).await
Expand Down Expand Up @@ -6449,7 +6467,9 @@ mod tests {
let settings = SurfaceSpec {
name: "settings".into(),
opener: "Settings".into(),
via: vec![],
marker: Some("Search settings".into()),
inventory_root: None,
reveal_with: Some("Search settings".into()),
};

Expand Down Expand Up @@ -7038,7 +7058,9 @@ mod tests {
let surfaces = [SurfaceSpec {
name: "settings".into(),
opener: "Settings".into(),
via: vec![],
marker: Some("Search settings".into()),
inventory_root: None,
reveal_with: None,
}];
assert!(validate_surface_filter_against(Some("Settings"), &surfaces).is_ok());
Expand Down Expand Up @@ -7147,7 +7169,9 @@ mod tests {
let project = SurfaceSpec {
name: "project".into(),
opener: crate::reach::DYNAMIC_DOCUMENT.into(),
via: vec![],
marker: Some("Send".into()),
inventory_root: None,
reveal_with: None,
};
let nodes = [component("Send", true, true)];
Expand Down
Loading