eoka-cli 0.1.6

Browser automation CLI for AI agents and pentesting
//! Target resolution and action retry logic.

use std::collections::HashMap;

use eoka::Page;
use eoka_agent::{observe, target, Target};
use serde_json::json;

use super::state::TabState;

pub struct ResolvedTarget {
    pub selector: String,
    pub desc: String,
}

/// Shared selector-generation JS (used by ref resolution).
const SELECTOR_JS: &str = include_str!("../js/selector.js");

/// Resolve a snapshot ref (@eN) to a CSS selector via CDP.
async fn resolve_ref(
    page: &Page,
    snapshot_refs: &HashMap<String, i64>,
    label: &str,
) -> Result<String, String> {
    let stale = |detail: &str| format!("Ref {} {}. Take a new snapshot.", label, detail);

    let backend_id = snapshot_refs.get(label).ok_or_else(|| stale("not found"))?;

    let resolve_result: serde_json::Value = page
        .session()
        .send("DOM.resolveNode", &json!({ "backendNodeId": backend_id }))
        .await
        .map_err(|_| stale("no longer exists"))?;

    let object_id = resolve_result
        .get("object")
        .and_then(|o| o.get("objectId"))
        .and_then(|v| v.as_str())
        .ok_or_else(|| stale("could not be resolved"))?;

    let call_result: serde_json::Value = page
        .session()
        .send(
            "Runtime.callFunctionOn",
            &json!({
                "objectId": object_id,
                "functionDeclaration": SELECTOR_JS,
                "arguments": [{ "objectId": object_id }],
                "returnByValue": true
            }),
        )
        .await
        .map_err(|_| stale("selector generation failed"))?;

    call_result
        .get("result")
        .and_then(|r| r.get("value"))
        .and_then(|v| v.as_str())
        .map(|s| s.to_string())
        .ok_or_else(|| stale("selector returned null"))
}

/// Resolve target string to a selector + description.
pub async fn resolve_target(tab: &TabState, target_str: &str) -> Result<ResolvedTarget, String> {
    match Target::parse(target_str) {
        Target::Index(idx) => {
            let el = tab.elements.get(idx).ok_or_else(|| {
                format!("Index {} out of range (have {})", idx, tab.elements.len())
            })?;
            Ok(ResolvedTarget {
                selector: el.selector.clone(),
                desc: el.to_string(),
            })
        }
        Target::Ref(label) => {
            let selector = resolve_ref(&tab.page, &tab.snapshot_refs, &label).await?;
            Ok(ResolvedTarget {
                desc: format!("ref {}", label),
                selector,
            })
        }
        Target::Live(pattern) => {
            let r = target::resolve(&tab.page, &pattern)
                .await
                .map_err(|e| e.to_string())?;
            if !r.found {
                return Err(r
                    .error
                    .unwrap_or_else(|| format!("{} not found", target_str)));
            }
            Ok(ResolvedTarget {
                selector: r.selector,
                desc: format!("<{}> \"{}\"", r.tag, r.text),
            })
        }
    }
}

fn is_stale_element_error(msg: &str) -> bool {
    const NEEDLES: &[&str] = &[
        "not found",
        "not visible",
        "node not connected",
        "stale element",
        "detached",
    ];
    NEEDLES.iter().any(|n| msg.contains(n))
}

/// Auto-observe if target is an index and element cache is empty.
pub async fn auto_observe_if_needed(
    tab: &mut TabState,
    target_str: &str,
    viewport_only: bool,
) -> Result<(), String> {
    if matches!(Target::parse(target_str), Target::Index(_)) && tab.elements.is_empty() {
        tab.elements = observe::observe(&tab.page, viewport_only)
            .await
            .map_err(|e| e.to_string())?;
    }
    Ok(())
}

enum Action<'a> {
    Click,
    Fill(&'a str),
}

impl<'a> Action<'a> {
    async fn perform(&self, page: &Page, selector: &str) -> Result<(), String> {
        match self {
            Action::Click => page.click(selector).await.map_err(|e| e.to_string()),
            Action::Fill(text) => page.fill(selector, text).await.map_err(|e| e.to_string()),
        }
    }
}

async fn act_with_retry(
    tab: &mut TabState,
    target_str: &str,
    viewport_only: bool,
    action: Action<'_>,
) -> Result<String, String> {
    let resolved = resolve_target(tab, target_str).await?;
    match action.perform(&tab.page, &resolved.selector).await {
        Ok(_) => return Ok(resolved.desc),
        Err(e) if is_stale_element_error(&e) => {}
        Err(e) => return Err(e),
    }
    tab.elements = observe::observe(&tab.page, viewport_only)
        .await
        .map_err(|e| e.to_string())?;
    let resolved = resolve_target(tab, target_str).await?;
    action.perform(&tab.page, &resolved.selector).await?;
    Ok(resolved.desc)
}

pub async fn click_with_retry(
    tab: &mut TabState,
    target_str: &str,
    viewport_only: bool,
) -> Result<String, String> {
    act_with_retry(tab, target_str, viewport_only, Action::Click).await
}

pub async fn fill_with_retry(
    tab: &mut TabState,
    target_str: &str,
    text: &str,
    viewport_only: bool,
) -> Result<String, String> {
    act_with_retry(tab, target_str, viewport_only, Action::Fill(text)).await
}

/// Give a just-navigated document a brief chance to install.
///
/// Do not poll `document.readyState` here. Some SPAs keep their document in a
/// loading state while streaming or refreshing long-lived resources; probing
/// it through CDP can then consume the full command timeout and block the
/// daemon. Commands that need a usable element already resolve and retry their
/// selector, which is the meaningful readiness condition.
pub async fn wait_for_stable(page: &Page) -> Result<(), String> {
    let _ = page;
    tokio::time::sleep(std::time::Duration::from_millis(200)).await;
    Ok(())
}

/// Get title without blocking on busy JS thread.
pub async fn title_nonblocking(page: &Page) -> String {
    page.evaluate_sync("document.title || ''")
        .await
        .unwrap_or_default()
}

/// JSON-serialize a string value (for embedding in JS). Never fails on &str.
pub fn json_str(s: &str) -> String {
    serde_json::to_string(s).expect("string serialization is infallible")
}