use pagesift_extract::locate::{Fingerprint, Located, Target, locate, robust_selector};
use pagesift_html::{Html, NodeRef, Selector};
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(super) struct PlanStep {
pub step: Value,
pub target: Option<Target>,
pub role: String,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(super) struct Plan {
pub steps: Vec<PlanStep>,
pub answer: bool,
}
pub(super) fn capture_target(html: &str, path: &str) -> Option<(Target, bool)> {
let doc = Html::parse(html);
let selector = Selector::parse(path).ok()?;
let mut hits = doc.root().select(&selector);
let node = hits.next()?;
if hits.next().is_some() {
return None;
}
let robust = robust_selector(node, doc.root()).unwrap_or_else(|| path.to_string());
let target = Target::from_node(node, &robust, None).ok()?;
let password = node
.as_element()
.and_then(|e| e.attr("type"))
.is_some_and(|t| t.eq_ignore_ascii_case("password"));
Some((target, password))
}
fn same_text(node: NodeRef<'_>, target: &Target) -> bool {
Fingerprint::of(node).is_some_and(|f| f.text == target.fingerprint.text)
}
fn text_overlaps(node: NodeRef<'_>, target: &Target) -> bool {
let want = &target.fingerprint.text;
Fingerprint::of(node)
.is_some_and(|f| (f.text.is_empty() && want.is_empty()) || !f.text.is_disjoint(want))
}
fn find<'a>(root: NodeRef<'a>, target: &Target) -> Option<(NodeRef<'a>, bool)> {
let node = match locate(root, target) {
Located::Exact(n) if same_text(n, target) => return Some((n, false)),
Located::Exact(_) => {
let mut blind = target.clone();
blind.selector.clear();
locate(root, &blind).node()?
}
other => other.node()?,
};
text_overlaps(node, target).then_some((node, true))
}
pub(super) fn resolve(html: &str, plan: &mut Plan) -> Option<Vec<usize>> {
let doc = Html::parse(html);
let root = doc.root();
let mut healed = Vec::new();
for (i, s) in plan.steps.iter_mut().enumerate() {
let Some(target) = &mut s.target else {
continue;
};
let (node, relocated) = find(root, target)?;
if relocated {
target.selector = robust_selector(node, root)?;
target.fingerprint = Fingerprint::of(node)?;
healed.push(i);
}
}
Some(healed)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn page(body: &str) -> String {
let nav: String = (0..12)
.map(|i| format!("<a href='/c{i}'>Category number {i}</a>"))
.collect();
format!(
"<html><body><nav>{nav}</nav><main><h1>Shop</h1><p>Some words about the shop, long enough to count.</p>{body}</main></body></html>"
)
}
fn plan_for(html: &str, path: &str) -> Plan {
let (target, _) = capture_target(html, path).unwrap();
Plan {
steps: vec![
PlanStep {
step: json!({"action": "click"}),
target: Some(target),
role: "button".into(),
name: "Buy now".into(),
},
PlanStep {
step: json!({"action": "press", "key": "Enter"}),
target: None,
role: String::new(),
name: String::new(),
},
],
answer: false,
}
}
const PATH: &str = "html > body > main > button:nth-of-type(1)";
#[test]
fn captures_a_stable_selector_and_notices_password_fields() {
let html = page("<button id='buy'>Buy now</button>");
let (t, password) = capture_target(&html, PATH).unwrap();
assert!(t.selector.contains("buy"), "{}", t.selector);
assert!(!password);
let html = page("<input name='pw' type='Password'>");
let path = "html > body > main > input:nth-of-type(1)";
assert!(capture_target(&html, path).unwrap().1);
assert!(capture_target(&html, "section").is_none());
}
#[test]
fn an_unchanged_page_needs_no_healing() {
let html = page("<button id='buy'>Buy now</button>");
let mut plan = plan_for(&html, PATH);
let before = plan.clone();
assert_eq!(resolve(&html, &mut plan), Some(vec![]));
assert_eq!(plan, before);
}
#[test]
fn a_renamed_element_is_found_by_its_fingerprint() {
let old = page(
"<button id='buy' class='cta primary'>Buy now</button><button id='wish'>Add to wishlist</button>",
);
let new = page(
"<div><button id='purchase-1' class='cta primary'>Buy now</button></div><button id='wish'>Add to wishlist</button>",
);
let mut plan = plan_for(&old, PATH);
assert_eq!(resolve(&new, &mut plan), Some(vec![0]));
let t = plan.steps[0].target.as_ref().unwrap();
assert_ne!(t.selector, "#buy");
assert_eq!(resolve(&new, &mut plan), Some(vec![]));
}
#[test]
fn a_selector_that_now_means_something_else_does_not_count() {
let old = page("<button id='act'>Buy now</button>");
let new = page("<button id='act'>Delete my account</button>");
let mut plan = plan_for(&old, PATH);
assert_eq!(resolve(&new, &mut plan), None);
let reworded = page("<button id='act'>Buy it now</button>");
let mut plan = plan_for(&old, PATH);
assert_eq!(resolve(&reworded, &mut plan), Some(vec![0]));
}
#[test]
fn a_missing_element_voids_the_plan() {
let old = page("<button id='buy' class='cta'>Buy now</button>");
let new = page("<p>Sold out, nothing to do here today.</p>");
let mut plan = plan_for(&old, PATH);
assert_eq!(resolve(&new, &mut plan), None);
}
}