eoka-cli 0.1.8

Browser automation CLI for AI agents and pentesting
//! `eoka batch` — run a sequence of commands from JSON (argv, file, or stdin).

use std::io::Read;

use serde_json::{json, Value};

use crate::client;
use crate::launch_spec::LaunchSpec;
use crate::protocol::{Request, Response};

pub(crate) async fn run_batch(
    session_name: &str,
    spec: LaunchSpec,
    input: Option<&str>,
    file: Option<&std::path::PathBuf>,
    bail: bool,
) -> Result<Response, String> {
    let source = read_batch_source(input, file)?;
    let requests = parse_batch_requests(&source)?;
    let mut responses = Vec::with_capacity(requests.len());
    let mut first_error = None;
    let mut shutdown_daemon = false;

    for request in requests {
        let request_cmd = request.cmd.clone();
        let response = client::send_command(session_name, request, spec.clone())
            .await
            .map_err(|e| e.to_string())?;
        if !response.ok && first_error.is_none() {
            first_error = response.error.clone();
        }
        let step = batch_step_effect(&request_cmd, &response, bail);
        shutdown_daemon |= step.shutdown_daemon;
        responses.push(response);
        if step.stop {
            break;
        }
    }

    if shutdown_daemon {
        let _ = client::kill_daemon(session_name);
    }

    let all_ok = first_error.is_none();
    let data = serde_json::to_value(responses).map_err(|e| e.to_string())?;
    Ok(Response {
        ok: all_ok,
        data: Some(data),
        error: first_error,
    })
}

fn read_batch_source(
    input: Option<&str>,
    file: Option<&std::path::PathBuf>,
) -> Result<String, String> {
    if let Some(path) = file {
        return std::fs::read_to_string(path)
            .map_err(|e| format!("Failed to read batch file '{}': {}", path.display(), e));
    }
    if let Some(input) = input {
        return Ok(input.to_string());
    }

    let mut source = String::new();
    std::io::stdin()
        .read_to_string(&mut source)
        .map_err(|e| format!("Failed to read batch JSON from stdin: {}", e))?;
    if source.trim().is_empty() {
        return Err("Batch JSON is empty. Pass JSON as an argument, via --file, or stdin.".into());
    }
    Ok(source)
}

fn parse_batch_requests(source: &str) -> Result<Vec<Request>, String> {
    let value: Value =
        serde_json::from_str(source).map_err(|e| format!("Invalid batch JSON: {}", e))?;
    let steps = match value {
        Value::Array(steps) => steps,
        other => vec![other],
    };

    steps
        .into_iter()
        .enumerate()
        .map(|(idx, step)| batch_step_to_request(idx, step))
        .collect()
}

fn batch_step_to_request(idx: usize, step: Value) -> Result<Request, String> {
    let mut obj = match step {
        Value::Object(obj) => obj,
        _ => return Err(format!("Batch step {} must be an object", idx + 1)),
    };

    if let Some(cmd) = obj.remove("cmd") {
        let cmd = cmd
            .as_str()
            .ok_or_else(|| format!("Batch step {} field 'cmd' must be a string", idx + 1))?;
        let cmd = normalize_batch_cmd(cmd);
        let args = obj.remove("args").unwrap_or_else(|| json!({}));
        return Ok(Request {
            args: normalize_batch_args(idx, &cmd, args)?,
            cmd,
        });
    }

    if obj.len() != 1 {
        return Err(format!(
            "Batch step {} must contain either {{\"cmd\":...}} or exactly one shorthand command",
            idx + 1
        ));
    }

    let (cmd, value) = obj.into_iter().next().expect("len checked");
    let cmd = normalize_batch_cmd(&cmd);
    let args = normalize_batch_args(idx, &cmd, value)?;
    Ok(Request { cmd, args })
}

fn normalize_batch_cmd(cmd: &str) -> String {
    match cmd.replace('-', "_").as_str() {
        "double_click" => "dblclick".into(),
        "set_cookie" => "set_cookie".into(),
        "delete_cookie" => "delete_cookie".into(),
        "clear_cookies" => "clear_cookies".into(),
        "set_storage" => "set_storage".into(),
        "dump_storage" => "dump_storage".into(),
        "save_state" => "save_state".into(),
        "load_state" => "load_state".into(),
        "captcha_inject" => "captcha_inject".into(),
        "spa_info" => "spa_info".into(),
        "spa_navigate" => "spa_navigate".into(),
        "fake_camera" => "fake_camera".into(),
        other => other.into(),
    }
}

fn normalize_batch_args(idx: usize, cmd: &str, value: Value) -> Result<Value, String> {
    if value.is_null() {
        return Ok(json!({}));
    }
    if value.is_object() {
        return Ok(value);
    }

    match cmd {
        "open" | "fetch" => string_arg(idx, cmd, value, "url"),
        "eval" | "exec" => string_arg(idx, cmd, value, "code"),
        "click" | "dblclick" | "hover" | "scroll" => string_arg(idx, cmd, value, "target"),
        "key" => string_arg(idx, cmd, value, "key"),
        "find" => string_arg(idx, cmd, value, "text"),
        "captcha_inject" => string_arg(idx, cmd, value, "token"),
        "spa_navigate" => string_arg(idx, cmd, value, "path"),
        "tab_new" => string_arg(idx, cmd, value, "url"),
        "tab_switch" | "tab_close" | "tab_attach" => string_arg(idx, cmd, value, "tab_id"),
        "wait" => wait_arg(idx, value),
        "fill" => pair_arg(idx, cmd, value, "target", "text"),
        "select" => pair_arg(idx, cmd, value, "target", "value"),
        _ => Err(format!(
            "Batch step {} command '{}' requires object args",
            idx + 1,
            cmd
        )),
    }
}

fn string_arg(idx: usize, cmd: &str, value: Value, key: &str) -> Result<Value, String> {
    value.as_str().map(|v| json!({ key: v })).ok_or_else(|| {
        format!(
            "Batch step {} command '{}' requires string or object args",
            idx + 1,
            cmd
        )
    })
}

fn wait_arg(idx: usize, value: Value) -> Result<Value, String> {
    if let Some(ms) = value.as_u64() {
        return Ok(json!({ "ms": ms }));
    }
    if let Some(text) = value.as_str() {
        return Ok(json!({ "text": text }));
    }
    Err(format!(
        "Batch step {} command 'wait' requires milliseconds, text, or object args",
        idx + 1
    ))
}

fn pair_arg(
    idx: usize,
    cmd: &str,
    value: Value,
    first_key: &str,
    second_key: &str,
) -> Result<Value, String> {
    let values = value.as_array().ok_or_else(|| {
        format!(
            "Batch step {} command '{}' requires [\"{}\", \"{}\"] or object args",
            idx + 1,
            cmd,
            first_key,
            second_key
        )
    })?;
    if values.len() != 2 {
        return Err(format!(
            "Batch step {} command '{}' requires exactly two array values",
            idx + 1,
            cmd
        ));
    }
    let first = values[0].as_str().ok_or_else(|| {
        format!(
            "Batch step {} command '{}' first array value must be a string",
            idx + 1,
            cmd
        )
    })?;
    let second = values[1].as_str().ok_or_else(|| {
        format!(
            "Batch step {} command '{}' second array value must be a string",
            idx + 1,
            cmd
        )
    })?;
    Ok(json!({ first_key: first, second_key: second }))
}

struct BatchStepEffect {
    stop: bool,
    shutdown_daemon: bool,
}

fn batch_step_effect(cmd: &str, response: &Response, bail: bool) -> BatchStepEffect {
    let shutdown_daemon = cmd == "close" && response.ok;
    BatchStepEffect {
        stop: shutdown_daemon || (bail && !response.ok),
        shutdown_daemon,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::cli::test_support::parsed_command;
    use crate::cli::Command;

    #[test]
    fn batch_cli_accepts_inline_json_argument() {
        let (_cli, command) = parsed_command(&["eoka", "batch", r#"[{"info":null}]"#]);

        match command {
            Command::Batch { input, file, bail } => {
                assert_eq!(input.as_deref(), Some(r#"[{"info":null}]"#));
                assert!(file.is_none());
                assert!(!bail);
            }
            _ => panic!("expected batch command"),
        }
    }

    #[test]
    fn batch_shorthand_matches_recreation_flow_shape() {
        let requests = parse_batch_requests(
            r#"[
                {"open":"https://www.recreation.gov/camping/campsites/71576?start_date=2026-08-02&end_date=2026-08-03"},
                {"wait":3000},
                {"eval":"\"patched\""},
                {"click":"Add to Cart"},
                {"wait":5000},
                {"info":null}
            ]"#,
        )
        .unwrap();

        assert_eq!(requests.len(), 6);
        assert_eq!(requests[0].cmd, "open");
        assert_eq!(
            requests[0].args["url"],
            "https://www.recreation.gov/camping/campsites/71576?start_date=2026-08-02&end_date=2026-08-03"
        );
        assert_eq!(requests[1].cmd, "wait");
        assert_eq!(requests[1].args["ms"], 3000);
        assert_eq!(requests[2].cmd, "eval");
        assert_eq!(requests[2].args["code"], "\"patched\"");
        assert_eq!(requests[3].cmd, "click");
        assert_eq!(requests[3].args["target"], "Add to Cart");
        assert_eq!(requests[5].cmd, "info");
        assert_eq!(requests[5].args, json!({}));
    }

    #[test]
    fn batch_accepts_canonical_cmd_args_steps() {
        let requests =
            parse_batch_requests(r#"[{"cmd":"eval","args":{"code":"window.location.href"}}]"#)
                .unwrap();

        assert_eq!(requests.len(), 1);
        assert_eq!(requests[0].cmd, "eval");
        assert_eq!(requests[0].args["code"], "window.location.href");
    }

    #[test]
    fn batch_canonical_hyphen_aliases_use_normalized_args() {
        let requests = parse_batch_requests(r#"[{"cmd":"spa-navigate","args":"/cart"}]"#).unwrap();

        assert_eq!(requests.len(), 1);
        assert_eq!(requests[0].cmd, "spa_navigate");
        assert_eq!(requests[0].args["path"], "/cart");
    }

    #[test]
    fn batch_accepts_captcha_inject_shorthand() {
        let requests = parse_batch_requests(r#"[{"captcha-inject":"token-123"}]"#).unwrap();

        assert_eq!(requests.len(), 1);
        assert_eq!(requests[0].cmd, "captcha_inject");
        assert_eq!(requests[0].args["token"], "token-123");
    }

    #[test]
    fn batch_close_stops_and_marks_daemon_for_shutdown() {
        let effect = batch_step_effect("close", &Response::ok_text("Browser closed"), false);

        assert!(effect.stop);
        assert!(effect.shutdown_daemon);
    }

    #[test]
    fn batch_bail_still_stops_on_error_without_daemon_shutdown() {
        let effect = batch_step_effect("click", &Response::err("not found"), true);

        assert!(effect.stop);
        assert!(!effect.shutdown_daemon);
    }

    #[test]
    fn batch_rejects_ambiguous_shorthand_step() {
        let err = parse_batch_requests(r#"[{"click":"A","eval":"B"}]"#).unwrap_err();

        assert!(err.contains("exactly one shorthand command"));
    }

    #[test]
    fn batch_rejects_scalar_step() {
        let err = parse_batch_requests(r#"["click Add"]"#).unwrap_err();

        assert!(err.contains("must be an object"));
    }
}