eoka-protocol 0.6.2

Protocol and tool catalog definitions for eoka browser automation
Documentation
use super::*;
use tokio::io::AsyncWriteExt;
use tokio::net::{UnixListener, UnixStream};

fn temp_socket_path(name: &str) -> std::path::PathBuf {
    std::env::temp_dir().join(format!(
        "eoka-protocol-test-{}-{}.sock",
        std::process::id(),
        name
    ))
}

#[tokio::test]
async fn round_trip_over_unix_socket() {
    let sock_path = temp_socket_path("roundtrip");
    let _ = std::fs::remove_file(&sock_path);
    let listener = UnixListener::bind(&sock_path).unwrap();

    let server = tokio::spawn(async move {
        let (stream, _) = listener.accept().await.unwrap();
        let (mut reader, mut writer) = stream.into_split();
        let req: Request = read_msg(&mut reader).await.unwrap();
        assert_eq!(req.cmd(), "click");
        assert_eq!(req.args_json(), serde_json::json!({ "target": "@e1" }));
        write_msg(&mut writer, &Response::ok_text("pong"))
            .await
            .unwrap();
    });

    let stream = UnixStream::connect(&sock_path).await.unwrap();
    let (mut reader, mut writer) = stream.into_split();
    write_msg(
        &mut writer,
        &Request::Click(TargetArgs {
            operation: Default::default(),
            target: "@e1".into(),
        }),
    )
    .await
    .unwrap();
    let response: Response = read_msg(&mut reader).await.unwrap();

    server.await.unwrap();
    let _ = std::fs::remove_file(&sock_path);

    assert!(response.ok);
    assert_eq!(
        response.data,
        Some(serde_json::Value::String("pong".into()))
    );
    assert_eq!(response.error, None);
}

#[tokio::test]
async fn read_msg_rejects_oversized_length_prefix() {
    let sock_path = temp_socket_path("oversized");
    let _ = std::fs::remove_file(&sock_path);
    let listener = UnixListener::bind(&sock_path).unwrap();

    let server = tokio::spawn(async move {
        let (stream, _) = listener.accept().await.unwrap();
        let (mut reader, _writer) = stream.into_split();
        let result: std::io::Result<Request> = read_msg(&mut reader).await;
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("too large"));
    });

    let mut stream = UnixStream::connect(&sock_path).await.unwrap();
    let oversized_len: u32 = 65 * 1024 * 1024;
    stream
        .write_all(&oversized_len.to_be_bytes())
        .await
        .unwrap();

    server.await.unwrap();
    let _ = std::fs::remove_file(&sock_path);
}

#[test]
fn typed_network_request_round_trips_with_cmd_and_args() {
    let request = Request::NetworkWait(NetworkWaitArgs {
        pattern: Some("*/api/*".into()),
        method: Some("POST".into()),
        status: Some(201),
        timeout: Some(5000),
        since: Some(10),
        include_existing: false,
    });

    let value = serde_json::to_value(&request).unwrap();
    assert_eq!(value["cmd"], "network_wait");
    assert_eq!(value["args"]["pattern"], "*/api/*");

    let parsed: Request = serde_json::from_value(value).unwrap();
    assert_eq!(parsed.cmd(), "network_wait");
    assert_eq!(parsed.args_json()["status"], 201);
}

#[test]
fn typed_request_rejects_unknown_command() {
    let result: Result<Request, _> =
        serde_json::from_value(serde_json::json!({ "cmd": "not_real", "args": {} }));

    assert!(result.is_err());
}

#[test]
fn operation_catalog_maps_stable_paths_to_protocol_commands() {
    let tab = operation_by_path("tab.new").unwrap();
    assert_eq!(tab.cmd, "tab_new");
    assert_eq!(tab.exposure, OperationExposure::DefaultAgent);

    let request =
        request_from_operation_path("tab.new", serde_json::json!({"url":"https://example.com"}))
            .unwrap();
    assert_eq!(request.cmd(), "tab_new");
    assert_eq!(request.args_json()["url"], "https://example.com");
}

#[test]
fn default_catalog_excludes_opt_in_and_lifecycle_operations() {
    let paths: Vec<&str> = default_agent_operations()
        .map(|operation| operation.path)
        .collect();

    assert!(paths.contains(&"open"));
    assert!(paths.contains(&"mouse.down"));
    assert!(paths.contains(&"key.down"));
    assert!(paths.contains(&"release_all_inputs"));
    assert!(paths.contains(&"tab.list"));
    assert!(!paths.contains(&"network.log"));
    assert!(!paths.contains(&"close"));
}

#[test]
fn operation_catalog_has_unique_paths_and_commands() {
    let mut paths = std::collections::BTreeSet::new();
    let mut commands = std::collections::BTreeSet::new();
    let mut ids = std::collections::BTreeSet::new();

    for operation in all_operations() {
        assert!(ids.insert(operation.id), "duplicate id {:?}", operation.id);
        assert!(
            paths.insert(operation.path),
            "duplicate path {}",
            operation.path
        );
        assert!(
            commands.insert(operation.cmd),
            "duplicate cmd {}",
            operation.cmd
        );
        assert_eq!(operation_by_path(operation.path).unwrap().id, operation.id);
        assert_eq!(operation_by_cmd(operation.cmd).unwrap().id, operation.id);
        assert_eq!(
            request_from_operation_path(operation.path, operation.representative_input_for_test())
                .unwrap()
                .cmd(),
            operation.cmd
        );
    }
}

#[test]
fn manifest_uses_catalog_metadata_and_schemas() {
    let default_manifest = manifest_for_operations("eoka", false);
    let paths: std::collections::BTreeSet<&str> = default_manifest
        .iter()
        .map(|entry| entry.path.as_str())
        .collect();

    assert!(paths.contains("eoka.open"));
    assert!(paths.contains("eoka.mouse.down"));
    assert!(paths.contains("eoka.key.down"));
    assert!(paths.contains("eoka.release_all_inputs"));
    assert!(paths.contains("eoka.tab.list"));
    assert!(!paths.contains("eoka.network.log"));
    assert!(!paths.contains("eoka.close"));

    let open = default_manifest
        .iter()
        .find(|entry| entry.path == "eoka.open")
        .unwrap();
    assert_eq!(open.capability, "navigation");
    assert_eq!(open.exposure, "defaultAgent");
    assert_eq!(open.tags, vec!["eoka", "navigation"]);
    assert!(open.input_schema.get("properties").is_some());

    let all_manifest = manifest_for_operations("eoka", true);
    let all_paths: std::collections::BTreeSet<&str> = all_manifest
        .iter()
        .map(|entry| entry.path.as_str())
        .collect();
    assert!(all_paths.contains("eoka.network.log"));
    assert!(!all_paths.contains("eoka.close"));
}

#[test]
fn shared_mcp_inputs_keep_schema_descriptions() {
    let click_schema = input_schema_for_cmd("click");
    assert_eq!(
        click_schema["properties"]["target"]["description"],
        "Target element. Supports index, snapshot refs like @e1, text:Submit, placeholder:Email, role:button, css:form button, id:my-btn, or plain text search."
    );

    let tab_schema = input_schema_for_cmd("tab_switch");
    assert_eq!(
        tab_schema["properties"]["tab_id"]["description"],
        "Tab ID from list_tabs."
    );
}

impl OperationSpec {
    fn representative_input_for_test(&self) -> serde_json::Value {
        match self.cmd {
            "action_command" => {
                serde_json::json!({"operation_id":"a116d158-2cc5-4473-b73c-616b4b73cb0e","command":"reload","arguments":{}})
            }
            "agent_step" => serde_json::json!({"action":{"kind":"press_key","key":"Tab"}}),
            "action_perform" => {
                serde_json::json!({"operation_id":"a116d158-2cc5-4473-b73c-616b4b73cb0e","action":{"kind":"reload"}})
            }
            "operation_get" | "operation_wait" | "operation_cancel" => {
                serde_json::json!({"id":"operation"})
            }
            "open" => serde_json::json!({"url":"about:blank"}),
            "find" => serde_json::json!({"text":"needle"}),
            "click" | "dblclick" | "hover" | "scroll" => {
                serde_json::json!({"target":"body"})
            }
            "fill" => serde_json::json!({"target":"body","text":"value"}),
            "select" => serde_json::json!({"target":"select","value":"A"}),
            "key" | "key_down" | "key_up" => serde_json::json!({"key":"Enter"}),
            "mouse_down" | "mouse_up" => {
                serde_json::json!({"x":10.0,"y":20.0,"button":"left"})
            }
            "mouse_move" => serde_json::json!({"x":10.0,"y":20.0}),
            "eval" | "exec" => serde_json::json!({"code":"return 1"}),
            "frame_eval" => serde_json::json!({"frame":"iframe","code":"1+1"}),
            "frame_click" => serde_json::json!({"frame":"iframe","target":"button"}),
            "frame_fill" => {
                serde_json::json!({"frame":"iframe","target":"input","text":"value"})
            }
            "emulate" => serde_json::json!({"width":390,"height":844}),
            "download_start" => serde_json::json!({"click":"@e1","output":"/tmp/download"}),
            "download_get" | "download_cancel" | "download_wait" => {
                serde_json::json!({"id":"job"})
            }
            "fetch" => serde_json::json!({"url":"https://example.com"}),
            "set_cookie" => serde_json::json!({"name":"a","value":"b"}),
            "delete_cookie" => serde_json::json!({"name":"a"}),
            "storage" => serde_json::json!({"key":"a"}),
            "set_storage" => serde_json::json!({"key":"a","value":"b"}),
            "save_state" | "load_state" | "network_har" | "network_export" => {
                serde_json::json!({"path":"/tmp/eoka-state.json"})
            }
            "headers" => serde_json::json!({"headers_json":"{}"}),
            "console" | "errors" => serde_json::json!({"clear":false}),
            "tab_new" => serde_json::json!({"url":"about:blank"}),
            "tab_switch" | "tab_close" | "tab_attach" => serde_json::json!({"tab_id":"1"}),
            "wait" => serde_json::json!({"text":"ready"}),
            "spa_navigate" => serde_json::json!({"path":"/"}),
            "fake_camera" => serde_json::json!({"file":"/tmp/video.y4m"}),
            "wasm_read" => serde_json::json!({"addr":"0","len":1}),
            "wasm_write" => serde_json::json!({"addr":"0","hex":"00"}),
            "wasm_find" => serde_json::json!({"pattern":"00"}),
            "network_record_start" => serde_json::json!({"patterns":["*"]}),
            "network_log" => serde_json::json!({"limit":10}),
            "network_show" => serde_json::json!({"id":1}),
            "intercept_remove" => serde_json::json!({"id":"1"}),
            "network_wait" => serde_json::json!({"pattern":"*"}),
            "intercept_add" => serde_json::json!({"url_pattern":"*"}),
            "intercept_log" => serde_json::json!({"clear":false}),
            "js_mode" => serde_json::json!({"mode":"block"}),
            "js_allow" | "js_block" | "js_remove" => {
                serde_json::json!({"domain":"example.com"})
            }
            "clone_from" => serde_json::json!({"source":"9222"}),
            "captcha_solve" => serde_json::json!({
                "api_key":"key",
                "captcha_type":"hcaptcha",
                "website_url":"https://example.com",
                "website_key":"site"
            }),
            "captcha_detect" => serde_json::json!({}),
            "captcha_inject" => serde_json::json!({"token":"token"}),
            _ => serde_json::json!({}),
        }
    }
}