eoka-agent 0.1.4

AI agent interaction layer for browser automation — MCP server, observe/act loop
Documentation
//! DOM enumeration — finds all interactive elements on the page.

use eoka::{Page, Result};
use serde::Deserialize;

use crate::InteractiveElement;

#[derive(Deserialize)]
struct RawElement {
    tag: String,
    role: Option<String>,
    text: String,
    placeholder: Option<String>,
    input_type: Option<String>,
    selector: String,
    checked: bool,
    value: String,
    x: f64,
    y: f64,
    width: f64,
    height: f64,
}

/// JavaScript that enumerates all interactive elements on the page.
const OBSERVE_JS: &str = include_str!("js/observe.js");

/// Parse raw JSON from observe script into InteractiveElement list.
pub fn parse_raw_elements(json_str: &str) -> std::result::Result<Vec<InteractiveElement>, String> {
    let raw: Vec<RawElement> =
        serde_json::from_str(json_str).map_err(|e| format!("observe parse error: {}", e))?;

    Ok(raw
        .into_iter()
        .enumerate()
        .map(|(i, r)| {
            let fingerprint = InteractiveElement::compute_fingerprint(
                &r.tag,
                &r.text,
                r.role.as_deref(),
                r.input_type.as_deref(),
                r.placeholder.as_deref(),
                &r.selector,
            );
            InteractiveElement {
                index: i,
                tag: r.tag,
                role: r.role,
                text: r.text,
                placeholder: r.placeholder,
                input_type: r.input_type,
                selector: r.selector,
                checked: r.checked,
                value: if r.value.is_empty() {
                    None
                } else {
                    Some(r.value)
                },
                bbox: eoka::BoundingBox {
                    x: r.x,
                    y: r.y,
                    width: r.width,
                    height: r.height,
                },
                fingerprint,
            }
        })
        .collect())
}

/// Run the observe script and return parsed interactive elements.
pub async fn observe(page: &Page, viewport_only: bool) -> Result<Vec<InteractiveElement>> {
    let js = format!(
        "var __eoka_viewport_only = {}; {}",
        viewport_only, OBSERVE_JS
    );
    let json_str: String = page.evaluate(&js).await?;

    parse_raw_elements(&json_str).map_err(eoka::Error::CdpSimple)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_single_button() {
        let json = r#"[{
            "tag": "button",
            "role": null,
            "text": "Submit",
            "placeholder": null,
            "input_type": null,
            "selector": "button.primary",
            "checked": false,
            "value": "",
            "x": 100,
            "y": 200,
            "width": 80,
            "height": 30
        }]"#;
        let elements = parse_raw_elements(json).unwrap();
        assert_eq!(elements.len(), 1);
        assert_eq!(elements[0].index, 0);
        assert_eq!(elements[0].tag, "button");
        assert_eq!(elements[0].text, "Submit");
        assert_eq!(elements[0].selector, "button.primary");
        assert!(elements[0].value.is_none());
    }

    #[test]
    fn parse_input_with_value() {
        let json = r#"[{
            "tag": "input",
            "role": null,
            "text": "Email",
            "placeholder": "Enter email",
            "input_type": "email",
            "selector": "input[name=\"email\"]",
            "checked": false,
            "value": "user@test.com",
            "x": 10,
            "y": 20,
            "width": 200,
            "height": 40
        }]"#;
        let elements = parse_raw_elements(json).unwrap();
        assert_eq!(elements[0].input_type.as_deref(), Some("email"));
        assert_eq!(elements[0].placeholder.as_deref(), Some("Enter email"));
        assert_eq!(elements[0].value.as_deref(), Some("user@test.com"));
    }

    #[test]
    fn sequential_index_assignment() {
        let json = r#"[
            {"tag":"a","role":null,"text":"Link 1","placeholder":null,"input_type":null,"selector":"a:nth-of-type(1)","checked":false,"value":"","x":0,"y":0,"width":50,"height":20},
            {"tag":"a","role":null,"text":"Link 2","placeholder":null,"input_type":null,"selector":"a:nth-of-type(2)","checked":false,"value":"","x":0,"y":30,"width":50,"height":20},
            {"tag":"button","role":null,"text":"Click","placeholder":null,"input_type":null,"selector":"button","checked":false,"value":"","x":0,"y":60,"width":50,"height":20}
        ]"#;
        let elements = parse_raw_elements(json).unwrap();
        assert_eq!(elements.len(), 3);
        assert_eq!(elements[0].index, 0);
        assert_eq!(elements[1].index, 1);
        assert_eq!(elements[2].index, 2);
    }

    #[test]
    fn empty_array() {
        let elements = parse_raw_elements("[]").unwrap();
        assert!(elements.is_empty());
    }

    #[test]
    fn invalid_json() {
        assert!(parse_raw_elements("not json").is_err());
        assert!(parse_raw_elements("{\"bad\": true}").is_err());
    }

    #[test]
    fn fingerprint_consistency() {
        let json = r#"[{
            "tag": "button",
            "role": null,
            "text": "Submit",
            "placeholder": null,
            "input_type": null,
            "selector": "button.primary",
            "checked": false,
            "value": "",
            "x": 100,
            "y": 200,
            "width": 80,
            "height": 30
        }]"#;
        let e1 = parse_raw_elements(json).unwrap();
        let e2 = parse_raw_elements(json).unwrap();
        assert_eq!(e1[0].fingerprint, e2[0].fingerprint);
        assert_ne!(e1[0].fingerprint, 0);
    }
}