The fork's CI had never been green. Pre-existing failures: - version-sync: check-version-sync.js read packages/dashboard/package.json, which doesn't exist in this fork (workspace is just "."). Drop the dashboard comparison; check package.json vs cli/Cargo.toml only. - Dashboard job: `pnpm install --filter dashboard` for a non-existent package. Remove the job. - Format check: repo was never `cargo fmt`-clean. Ran cargo fmt (mechanical). - Clippy -D warnings (newly enforced on Rust 1.94 stable): manual_contains in commands.rs (.iter().any()->.contains()), question_mark in element.rs (if-let-Err -> ?), result_large_err on the tungstenite handshake callback in connect.rs (allow — the Result type is fixed by the accept_hdr_async contract). - rust-cross: lightpanda::waits_for_ready_without_logs spawns a real process + binds a socket with timing assumptions; flaky in CI. Marked #[ignore]. Also: skill docs note fork.30's relay-preferred auto-connect (plain `agent-browser open` is dialog-free once the ab-connect extension is loaded) and the extension's new "agent-browser-stealth" display name.
374 lines
13 KiB
Rust
374 lines
13 KiB
Rust
//! Adaptive @ref relocation.
|
||
//!
|
||
//! When a saved `@ref`'s DOM node is gone (stale `backendNodeId`) and the
|
||
//! role/name/nth re-query also fails, we score the current page's candidate
|
||
//! elements against the ref's stored [`ElementFingerprint`] and relocate to the
|
||
//! best match — but ONLY when confident: the best candidate must clear a high
|
||
//! absolute threshold AND beat the runner-up by a clear margin. This matches the
|
||
//! project's "fail loudly rather than mis-click" posture (see the identity and
|
||
//! occlusion guards in `element.rs`).
|
||
//!
|
||
//! Everything in this module is pure and browser-free so the scoring can be
|
||
//! unit-tested directly.
|
||
|
||
use std::collections::BTreeMap;
|
||
|
||
/// Minimum absolute similarity (0..1) for a relocation candidate to be accepted.
|
||
pub const ADAPTIVE_THRESHOLD: f64 = 0.70;
|
||
/// Minimum gap between the best and second-best candidate to avoid ambiguity.
|
||
pub const ADAPTIVE_MARGIN: f64 = 0.15;
|
||
|
||
/// A structural/semantic fingerprint of an element, captured at snapshot time so
|
||
/// a moved element can be re-identified after the page mutates.
|
||
///
|
||
/// Populated purely from the accessibility tree we already walk (`TreeNode`), so
|
||
/// capturing it costs no extra CDP round-trips — `TreeNode` has no DOM tag or
|
||
/// attributes (those would need an N×`DOM.describeNode` storm per snapshot), so
|
||
/// `tag` holds the AX **role** and `attrs` holds discriminating AX properties
|
||
/// (value/url/level/checked), not DOM `id`/`class`.
|
||
#[derive(Debug, Clone, Default, PartialEq)]
|
||
pub struct ElementFingerprint {
|
||
/// AX role, e.g. "button" (used where a DOM tag would otherwise go).
|
||
pub tag: String,
|
||
/// Accessible name / visible text — the dominant identity signal.
|
||
pub text: String,
|
||
/// Discriminating AX properties: value, url, level, checked. Keyed by name.
|
||
pub attrs: BTreeMap<String, String>,
|
||
/// Ancestor role signatures from nearest to farthest, e.g. "form" / "list".
|
||
pub ancestors: Vec<String>,
|
||
/// Parent role.
|
||
pub parent_tag: String,
|
||
/// Parent accessible name / text.
|
||
pub parent_text: String,
|
||
/// Index among same-role siblings.
|
||
pub sibling_index: u32,
|
||
/// Count of same-role siblings.
|
||
pub sibling_count: u32,
|
||
}
|
||
|
||
/// Component weights. They sum to 1.0 so the total score lands in 0..1.
|
||
/// Tuned for AX-derived fingerprints: the accessible name dominates, with role
|
||
/// and tree structure carrying disambiguation when the name has changed (which
|
||
/// is exactly when the exact role+name+nth fallback failed and we got here).
|
||
const W_TAG: f64 = 0.20;
|
||
const W_TEXT: f64 = 0.40;
|
||
const W_ATTRS: f64 = 0.10;
|
||
const W_ANCESTORS: f64 = 0.20;
|
||
const W_PARENT_SIBLING: f64 = 0.10;
|
||
|
||
/// Per-attribute importance for the attribute-overlap score. Strong identity
|
||
/// signals (a link's url) outweigh weak ones (heading level).
|
||
fn attr_weight(name: &str) -> f64 {
|
||
match name {
|
||
"url" | "value" => 3.0,
|
||
"checked" => 2.0,
|
||
_ => 1.0,
|
||
}
|
||
}
|
||
|
||
/// Levenshtein-based string similarity in 0..1 (1.0 = identical). Two empty
|
||
/// strings are treated as a perfect match (consistent absence of text).
|
||
pub fn string_similarity(a: &str, b: &str) -> f64 {
|
||
if a == b {
|
||
return 1.0;
|
||
}
|
||
let a: Vec<char> = a.chars().collect();
|
||
let b: Vec<char> = b.chars().collect();
|
||
let max_len = a.len().max(b.len());
|
||
if max_len == 0 {
|
||
return 1.0;
|
||
}
|
||
let dist = levenshtein(&a, &b);
|
||
1.0 - (dist as f64 / max_len as f64)
|
||
}
|
||
|
||
fn levenshtein(a: &[char], b: &[char]) -> usize {
|
||
if a.is_empty() {
|
||
return b.len();
|
||
}
|
||
if b.is_empty() {
|
||
return a.len();
|
||
}
|
||
let mut prev: Vec<usize> = (0..=b.len()).collect();
|
||
let mut cur = vec![0usize; b.len() + 1];
|
||
for (i, &ca) in a.iter().enumerate() {
|
||
cur[0] = i + 1;
|
||
for (j, &cb) in b.iter().enumerate() {
|
||
let cost = if ca == cb { 0 } else { 1 };
|
||
cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
|
||
}
|
||
std::mem::swap(&mut prev, &mut cur);
|
||
}
|
||
prev[b.len()]
|
||
}
|
||
|
||
/// Jaccard similarity over whitespace-separated tokens (used for `class`).
|
||
fn token_jaccard(a: &str, b: &str) -> f64 {
|
||
let sa: std::collections::BTreeSet<&str> = a.split_whitespace().collect();
|
||
let sb: std::collections::BTreeSet<&str> = b.split_whitespace().collect();
|
||
if sa.is_empty() && sb.is_empty() {
|
||
return 1.0;
|
||
}
|
||
let inter = sa.intersection(&sb).count() as f64;
|
||
let union = sa.union(&sb).count() as f64;
|
||
if union == 0.0 {
|
||
1.0
|
||
} else {
|
||
inter / union
|
||
}
|
||
}
|
||
|
||
/// Length-ratio of the longest common subsequence over two ancestor sequences.
|
||
fn lcs_ratio(a: &[String], b: &[String]) -> f64 {
|
||
if a.is_empty() && b.is_empty() {
|
||
return 1.0;
|
||
}
|
||
if a.is_empty() || b.is_empty() {
|
||
return 0.0;
|
||
}
|
||
let mut dp = vec![vec![0usize; b.len() + 1]; a.len() + 1];
|
||
for i in 0..a.len() {
|
||
for j in 0..b.len() {
|
||
dp[i + 1][j + 1] = if a[i] == b[j] {
|
||
dp[i][j] + 1
|
||
} else {
|
||
dp[i][j + 1].max(dp[i + 1][j])
|
||
};
|
||
}
|
||
}
|
||
let lcs = dp[a.len()][b.len()] as f64;
|
||
(2.0 * lcs) / (a.len() + b.len()) as f64
|
||
}
|
||
|
||
fn attr_score(base: &BTreeMap<String, String>, cand: &BTreeMap<String, String>) -> f64 {
|
||
let mut names: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
|
||
names.extend(base.keys().map(|s| s.as_str()));
|
||
names.extend(cand.keys().map(|s| s.as_str()));
|
||
if names.is_empty() {
|
||
return 1.0; // no attributes on either side — neutral
|
||
}
|
||
let mut total = 0.0;
|
||
let mut got = 0.0;
|
||
for name in names {
|
||
let w = attr_weight(name);
|
||
total += w;
|
||
// present on only one side → no credit
|
||
if let (Some(a), Some(b)) = (base.get(name), cand.get(name)) {
|
||
if name == "class" {
|
||
got += w * token_jaccard(a, b);
|
||
} else if a == b {
|
||
got += w;
|
||
}
|
||
}
|
||
}
|
||
if total == 0.0 {
|
||
1.0
|
||
} else {
|
||
got / total
|
||
}
|
||
}
|
||
|
||
fn parent_sibling_score(base: &ElementFingerprint, cand: &ElementFingerprint) -> f64 {
|
||
// Split the 0.10 budget: parent tag 0.4, parent text 0.3, sibling pos 0.3.
|
||
let parent_tag = if base.parent_tag == cand.parent_tag {
|
||
1.0
|
||
} else {
|
||
0.0
|
||
};
|
||
let parent_text = string_similarity(&base.parent_text, &cand.parent_text);
|
||
let span = base.sibling_count.max(1) as f64;
|
||
let delta = (base.sibling_index as i64 - cand.sibling_index as i64).unsigned_abs() as f64;
|
||
let sibling = 1.0 - (delta / span).min(1.0);
|
||
0.4 * parent_tag + 0.3 * parent_text + 0.3 * sibling
|
||
}
|
||
|
||
/// Similarity score in 0..1 between a stored baseline and a candidate element.
|
||
pub fn score(base: &ElementFingerprint, cand: &ElementFingerprint) -> f64 {
|
||
let tag = if base.tag == cand.tag { 1.0 } else { 0.0 };
|
||
let text = string_similarity(&base.text, &cand.text);
|
||
let attrs = attr_score(&base.attrs, &cand.attrs);
|
||
let ancestors = lcs_ratio(&base.ancestors, &cand.ancestors);
|
||
let parent_sibling = parent_sibling_score(base, cand);
|
||
|
||
W_TAG * tag
|
||
+ W_TEXT * text
|
||
+ W_ATTRS * attrs
|
||
+ W_ANCESTORS * ancestors
|
||
+ W_PARENT_SIBLING * parent_sibling
|
||
}
|
||
|
||
/// Why a relocation was rejected.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub enum RejectReason {
|
||
/// No candidates to score.
|
||
NoCandidates,
|
||
/// Best score below [`ADAPTIVE_THRESHOLD`].
|
||
LowScore { best: f64 },
|
||
/// Best score too close to the runner-up (below [`ADAPTIVE_MARGIN`]).
|
||
Ambiguous { best: f64, second: f64 },
|
||
}
|
||
|
||
/// A successful relocation decision.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct Relocation {
|
||
/// Chosen candidate's backend node id.
|
||
pub backend_node_id: i64,
|
||
/// Winning score.
|
||
pub score: f64,
|
||
/// Runner-up score (0.0 when there was only one candidate).
|
||
pub second_score: f64,
|
||
}
|
||
|
||
/// Pick the best candidate, accepting only when confident. `candidates` is a
|
||
/// list of `(backend_node_id, fingerprint)` for the current page.
|
||
pub fn pick_best(
|
||
base: &ElementFingerprint,
|
||
candidates: &[(i64, ElementFingerprint)],
|
||
threshold: f64,
|
||
margin: f64,
|
||
) -> Result<Relocation, RejectReason> {
|
||
if candidates.is_empty() {
|
||
return Err(RejectReason::NoCandidates);
|
||
}
|
||
let mut scored: Vec<(i64, f64)> = candidates
|
||
.iter()
|
||
.map(|(id, fp)| (*id, score(base, fp)))
|
||
.collect();
|
||
// Highest score first; stable enough for deterministic ties.
|
||
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
||
|
||
let (best_id, best) = scored[0];
|
||
let second = scored.get(1).map(|(_, s)| *s).unwrap_or(0.0);
|
||
|
||
if best < threshold {
|
||
return Err(RejectReason::LowScore { best });
|
||
}
|
||
if best - second < margin {
|
||
return Err(RejectReason::Ambiguous { best, second });
|
||
}
|
||
Ok(Relocation {
|
||
backend_node_id: best_id,
|
||
score: best,
|
||
second_score: second,
|
||
})
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn fp(tag: &str, text: &str, attrs: &[(&str, &str)]) -> ElementFingerprint {
|
||
ElementFingerprint {
|
||
tag: tag.to_string(),
|
||
text: text.to_string(),
|
||
attrs: attrs
|
||
.iter()
|
||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||
.collect(),
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn identical_fingerprints_score_one() {
|
||
let a = fp(
|
||
"button",
|
||
"Submit",
|
||
&[("id", "go"), ("class", "btn primary")],
|
||
);
|
||
assert!((score(&a, &a) - 1.0).abs() < 1e-9);
|
||
}
|
||
|
||
#[test]
|
||
fn different_tag_caps_score_below_threshold() {
|
||
let a = fp("button", "Submit", &[("id", "go")]);
|
||
let b = fp("a", "Submit", &[("id", "go")]);
|
||
// Same text + same attrs but different role: must lose the role weight
|
||
// (W_TAG = 0.20), landing around 0.80 and below a perfect match.
|
||
let s = score(&a, &b);
|
||
assert!(s < 0.85 && s > 0.75, "got {s}");
|
||
}
|
||
|
||
#[test]
|
||
fn string_similarity_basics() {
|
||
assert_eq!(string_similarity("abc", "abc"), 1.0);
|
||
assert_eq!(string_similarity("", ""), 1.0);
|
||
assert!(string_similarity("Submit", "Submit now") > 0.5);
|
||
assert!(string_similarity("Add post", "Post all") < 0.6);
|
||
}
|
||
|
||
#[test]
|
||
fn class_uses_token_overlap() {
|
||
let a = fp("div", "", &[("class", "card primary big")]);
|
||
let b = fp("div", "", &[("class", "card primary")]);
|
||
// partial class overlap should still score high (tag+text match, attrs partial)
|
||
let s = score(&a, &b);
|
||
assert!(s > 0.85, "got {s}");
|
||
}
|
||
|
||
#[test]
|
||
fn ancestors_lcs() {
|
||
let mut a = fp("button", "OK", &[]);
|
||
let mut b = fp("button", "OK", &[]);
|
||
a.ancestors = vec!["form#f".into(), "div.col".into(), "body".into()];
|
||
// b wrapped in an extra div — DOM path changed but mostly preserved
|
||
b.ancestors = vec![
|
||
"form#f".into(),
|
||
"div.wrap".into(),
|
||
"div.col".into(),
|
||
"body".into(),
|
||
];
|
||
let s = score(&a, &b);
|
||
assert!(s > 0.85, "got {s}");
|
||
}
|
||
|
||
#[test]
|
||
fn pick_best_accepts_clear_winner() {
|
||
let base = fp("button", "Submit", &[("id", "go")]);
|
||
let winner = fp("button", "Submit", &[("id", "go")]);
|
||
let other = fp("a", "Home", &[("href", "/")]);
|
||
let out = pick_best(
|
||
&base,
|
||
&[(10, other), (20, winner)],
|
||
ADAPTIVE_THRESHOLD,
|
||
ADAPTIVE_MARGIN,
|
||
)
|
||
.expect("should accept");
|
||
assert_eq!(out.backend_node_id, 20);
|
||
assert!(out.score > out.second_score);
|
||
}
|
||
|
||
#[test]
|
||
fn pick_best_rejects_ambiguous_twins() {
|
||
let base = fp("button", "Delete", &[("class", "btn danger")]);
|
||
// Two near-identical delete buttons — must refuse to guess.
|
||
let twin_a = fp("button", "Delete", &[("class", "btn danger")]);
|
||
let twin_b = fp("button", "Delete", &[("class", "btn danger")]);
|
||
let err = pick_best(
|
||
&base,
|
||
&[(1, twin_a), (2, twin_b)],
|
||
ADAPTIVE_THRESHOLD,
|
||
ADAPTIVE_MARGIN,
|
||
)
|
||
.unwrap_err();
|
||
assert!(matches!(err, RejectReason::Ambiguous { .. }), "got {err:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn pick_best_rejects_low_score() {
|
||
let base = fp("button", "Submit order", &[("id", "checkout")]);
|
||
let junk = fp("span", "unrelated footer text", &[("class", "muted")]);
|
||
let err = pick_best(&base, &[(1, junk)], ADAPTIVE_THRESHOLD, ADAPTIVE_MARGIN).unwrap_err();
|
||
assert!(matches!(err, RejectReason::LowScore { .. }), "got {err:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn pick_best_no_candidates() {
|
||
let base = fp("button", "x", &[]);
|
||
assert_eq!(
|
||
pick_best(&base, &[], ADAPTIVE_THRESHOLD, ADAPTIVE_MARGIN).unwrap_err(),
|
||
RejectReason::NoCandidates
|
||
);
|
||
}
|
||
}
|