use std::path::PathBuf;
use std::process::ExitCode;
use crate::CliError;
use crate::act;
use smix_screen::A11yNode;
#[derive(Debug, Clone)]
pub struct SelectorCandidate {
pub spec: String,
pub role: String,
pub bounds: (f64, f64, f64, f64),
}
pub fn suggest_selectors(tree: &A11yNode, partial: &str) -> Vec<SelectorCandidate> {
let mut out = Vec::new();
let (field, pattern) = parse_partial(partial);
walk_tree(tree, &mut |node| {
if matches_partial(node, field, pattern) {
let role_name = node
.role
.as_ref()
.map(|r| format!("{r:?}"))
.unwrap_or_else(|| node.raw_type.clone());
if let Some(id) = &node.identifier {
out.push(SelectorCandidate {
spec: format!("id: {id}"),
role: role_name.clone(),
bounds: bounds_tuple(node),
});
}
if let Some(text) = node.text.as_deref().or(node.value.as_deref()) {
out.push(SelectorCandidate {
spec: format!("text: \"{text}\""),
role: role_name.clone(),
bounds: bounds_tuple(node),
});
}
if let Some(label) = &node.label {
out.push(SelectorCandidate {
spec: format!("label: \"{label}\""),
role: role_name,
bounds: bounds_tuple(node),
});
}
}
});
out
}
fn bounds_tuple(n: &A11yNode) -> (f64, f64, f64, f64) {
(n.bounds.x, n.bounds.y, n.bounds.w, n.bounds.h)
}
fn parse_partial(partial: &str) -> (&'static str, &str) {
if let Some((k, v)) = partial.split_once(':') {
let k = k.trim();
let v = v.trim();
match k {
"id" => ("id", v),
"text" => ("text", v),
"label" => ("label", v),
_ => ("text", partial),
}
} else {
("text", partial)
}
}
fn matches_partial(node: &A11yNode, field: &str, pattern: &str) -> bool {
let hay: Option<&str> = match field {
"id" => node.identifier.as_deref(),
"text" => node
.text
.as_deref()
.or(node.value.as_deref())
.or(node.title.as_deref()),
"label" => node.label.as_deref(),
_ => None,
};
let Some(hay) = hay else { return false };
if pattern == "*" {
return true;
}
if let Some(stripped) = pattern.strip_suffix('*') {
return hay.to_lowercase().starts_with(&stripped.to_lowercase());
}
if let Some(stripped) = pattern.strip_prefix('*') {
return hay.to_lowercase().ends_with(&stripped.to_lowercase());
}
hay.to_lowercase().contains(&pattern.to_lowercase())
}
fn walk_tree<F: FnMut(&A11yNode)>(node: &A11yNode, visit: &mut F) {
visit(node);
for c in &node.children {
walk_tree(c, visit);
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TreeDiff {
pub missing: Vec<String>,
pub extra: Vec<String>,
pub drifted: Vec<TreeDrift>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TreeDrift {
pub key: String,
pub baseline: String,
pub current: String,
}
impl TreeDiff {
pub fn is_clean(&self) -> bool {
self.missing.is_empty() && self.extra.is_empty() && self.drifted.is_empty()
}
}
pub fn diff_a11y_trees(baseline: &A11yNode, current: &A11yNode) -> TreeDiff {
let mut base_keys = std::collections::BTreeMap::new();
let mut curr_keys = std::collections::BTreeMap::new();
collect_keyed(baseline, &mut base_keys);
collect_keyed(current, &mut curr_keys);
let mut missing = Vec::new();
let mut extra = Vec::new();
let mut drifted = Vec::new();
for (k, base_content) in &base_keys {
match curr_keys.get(k) {
None => missing.push(k.clone()),
Some(curr_content) if curr_content != base_content => {
drifted.push(TreeDrift {
key: k.clone(),
baseline: base_content.clone(),
current: curr_content.clone(),
});
}
_ => {}
}
}
for k in curr_keys.keys() {
if !base_keys.contains_key(k) {
extra.push(k.clone());
}
}
TreeDiff {
missing,
extra,
drifted,
}
}
fn collect_keyed(node: &A11yNode, out: &mut std::collections::BTreeMap<String, String>) {
let role = node
.role
.as_ref()
.map(|r| format!("{r:?}"))
.unwrap_or_else(|| node.raw_type.clone());
let key = match (&node.identifier, &node.label) {
(Some(id), _) => format!("{role}#{id}"),
(None, Some(lbl)) => format!("{role}@{lbl}"),
_ => role.clone(),
};
let content = node
.text
.as_deref()
.or(node.value.as_deref())
.or(node.title.as_deref())
.unwrap_or("")
.to_string();
let dedup_key = if out.contains_key(&key) {
format!(
"{key}@bounds({},{},{}x{})",
node.bounds.x as i32, node.bounds.y as i32, node.bounds.w as i32, node.bounds.h as i32
)
} else {
key
};
out.insert(dedup_key, content);
for c in &node.children {
collect_keyed(c, out);
}
}
pub async fn cmd_suggest(port: u16, partial: String) -> Result<ExitCode, CliError> {
let tree_json = act::fetch_tree_json(port)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
let tree: A11yNode = serde_json::from_value(tree_json)
.map_err(|e| CliError::Other(format!("parse tree: {e}")))?;
let candidates = suggest_selectors(&tree, &partial);
if candidates.is_empty() {
eprintln!("no candidates found for `{partial}`");
return Ok(ExitCode::from(1));
}
for c in candidates.iter().take(50) {
println!(
" - {} (role={}, bounds=({:.0},{:.0},{:.0}x{:.0}))",
c.spec, c.role, c.bounds.0, c.bounds.1, c.bounds.2, c.bounds.3
);
}
Ok(ExitCode::SUCCESS)
}
pub async fn cmd_diff_tree(port: u16, baseline: PathBuf) -> Result<ExitCode, CliError> {
let base_bytes =
std::fs::read(&baseline).map_err(|e| CliError::Other(format!("read baseline: {e}")))?;
let base: A11yNode = serde_json::from_slice(&base_bytes)
.map_err(|e| CliError::Other(format!("parse baseline: {e}")))?;
let curr_json = act::fetch_tree_json(port)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
let curr: A11yNode = serde_json::from_value(curr_json)
.map_err(|e| CliError::Other(format!("parse current tree: {e}")))?;
let diff = diff_a11y_trees(&base, &curr);
if diff.is_clean() {
println!("tree: clean (no missing / extra / drifted)");
Ok(ExitCode::SUCCESS)
} else {
for m in &diff.missing {
println!(" MISSING: {m}");
}
for e in &diff.extra {
println!(" EXTRA: {e}");
}
for d in &diff.drifted {
println!(" DRIFT: {} \"{}\" → \"{}\"", d.key, d.baseline, d.current);
}
Ok(ExitCode::from(2))
}
}
pub async fn cmd_record_session(
port: u16,
duration_secs: u64,
interval_ms: u64,
output: PathBuf,
) -> Result<ExitCode, CliError> {
let end = std::time::Instant::now() + std::time::Duration::from_secs(duration_secs);
let mut samples: Vec<A11yNode> = Vec::new();
while std::time::Instant::now() < end {
let tree_json = act::fetch_tree_json(port)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
let tree: A11yNode = serde_json::from_value(tree_json)
.map_err(|e| CliError::Other(format!("parse: {e}")))?;
samples.push(tree);
tokio::time::sleep(std::time::Duration::from_millis(interval_ms)).await;
}
let mut stable_ids: std::collections::BTreeMap<String, usize> =
std::collections::BTreeMap::new();
let n_samples = samples.len();
for s in &samples {
let mut seen = std::collections::BTreeSet::new();
walk_tree(s, &mut |node| {
if let Some(id) = &node.identifier
&& seen.insert(id.clone())
{
*stable_ids.entry(id.clone()).or_insert(0) += 1;
}
});
}
let mut yaml = String::new();
yaml.push_str("# v1.0 Phase E4 — auto-generated session recording\n");
yaml.push_str("appId: com.example # update to your app\n");
yaml.push_str("---\n");
for (id, count) in &stable_ids {
if *count >= n_samples * 3 / 4 {
yaml.push_str(&format!("- assertVisible:\n id: {id}\n"));
}
}
std::fs::write(&output, yaml)
.map_err(|e| CliError::Other(format!("write {}: {e}", output.display())))?;
eprintln!(
"recorded {n_samples} samples over {duration_secs}s; wrote yaml scaffold to {}",
output.display()
);
Ok(ExitCode::SUCCESS)
}
pub async fn cmd_capture_tree(port: u16, out: PathBuf) -> Result<ExitCode, CliError> {
let tree_json = act::fetch_tree_json(port)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
let bytes = serde_json::to_vec_pretty(&tree_json)
.map_err(|e| CliError::Other(format!("serialize: {e}")))?;
std::fs::write(&out, &bytes)
.map_err(|e| CliError::Other(format!("write {}: {e}", out.display())))?;
eprintln!("captured a11y tree: {}", out.display());
Ok(ExitCode::SUCCESS)
}
#[cfg(test)]
mod tests {
use super::*;
use smix_screen::{A11yNode, Rect, Role};
fn node(id: Option<&str>, text: Option<&str>) -> A11yNode {
A11yNode {
raw_type: "any".into(),
role: Some(Role::Button),
identifier: id.map(String::from),
label: None,
title: None,
placeholder_value: None,
value: None,
text: text.map(String::from),
bounds: Rect {
x: 0.0,
y: 0.0,
w: 100.0,
h: 40.0,
},
enabled: true,
selected: false,
has_focus: false,
visible: true,
children: vec![],
}
}
#[test]
fn suggest_id_wildcard() {
let root = A11yNode {
children: vec![
node(Some("qa-chip-a"), Some("Chip A")),
node(Some("qa-chip-b"), Some("Chip B")),
node(Some("other"), Some("Other")),
],
..node(None, None)
};
let out = suggest_selectors(&root, "id: qa-*");
assert!(out.iter().any(|c| c.spec == "id: qa-chip-a"));
assert!(out.iter().any(|c| c.spec == "id: qa-chip-b"));
assert!(!out.iter().any(|c| c.spec == "id: other"));
}
#[test]
fn suggest_text_case_insensitive() {
let root = A11yNode {
children: vec![node(None, Some("Sign In")), node(None, Some("Log Out"))],
..node(None, None)
};
let out = suggest_selectors(&root, "sign");
assert!(out.iter().any(|c| c.spec == "text: \"Sign In\""));
}
#[test]
fn diff_clean_when_identical() {
let root = A11yNode {
children: vec![node(Some("a"), Some("hi"))],
..node(None, None)
};
let diff = diff_a11y_trees(&root, &root);
assert!(diff.is_clean());
}
#[test]
fn diff_reports_missing() {
let base = A11yNode {
children: vec![node(Some("a"), Some("hi")), node(Some("b"), Some("bye"))],
..node(None, None)
};
let curr = A11yNode {
children: vec![node(Some("a"), Some("hi"))],
..node(None, None)
};
let diff = diff_a11y_trees(&base, &curr);
assert!(!diff.is_clean());
assert!(diff.missing.iter().any(|k| k.contains("#b")));
}
#[test]
fn diff_reports_drift() {
let base = A11yNode {
children: vec![node(Some("a"), Some("hi"))],
..node(None, None)
};
let curr = A11yNode {
children: vec![node(Some("a"), Some("hello"))],
..node(None, None)
};
let diff = diff_a11y_trees(&base, &curr);
assert!(!diff.is_clean());
assert_eq!(diff.drifted.len(), 1);
assert_eq!(diff.drifted[0].baseline, "hi");
assert_eq!(diff.drifted[0].current, "hello");
}
}