chrome-devtools 0.4.0

Profile-aware CLI for running Chrome DevTools MCP with isolated Chrome user data directories
use std::fs;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpListener;
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};

struct TestEnv {
    home: PathBuf,
    daemon: Option<Child>,
}

impl TestEnv {
    fn new(tag: &str) -> Self {
        let home =
            std::env::temp_dir().join(format!("chrome-devtools-it-{tag}-{}", std::process::id()));
        let _ = fs::remove_dir_all(&home);
        fs::create_dir_all(home.join(".config/chrome-devtools")).unwrap();
        fs::create_dir_all(home.join(".cache/chrome-devtools/daemons")).unwrap();
        fs::write(
            home.join(".config/chrome-devtools/config.toml"),
            "[[profiles]]\nname = \"default\"\n",
        )
        .unwrap();

        let devtools_port = spawn_fake_devtools();
        fs::write(
            home.join(".cache/chrome-devtools/daemons/default.port"),
            devtools_port.to_string(),
        )
        .unwrap();

        Self { home, daemon: None }
    }

    fn cli(&self) -> Command {
        let mut command = Command::new(env!("CARGO_BIN_EXE_chrome-devtools"));
        command
            .env("HOME", &self.home)
            .env("CHROME_DEVTOOLS_MCP_COMMAND", fake_mcp_path())
            .env("CHROME", "/nonexistent-chrome-for-tests");
        command
    }

    fn start_daemon(&mut self) {
        let child = self
            .cli()
            .args(["daemon", "run", "--profile", "default"])
            .stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .spawn()
            .unwrap();
        self.daemon = Some(child);
        let socket = self.socket_path();
        let started = Instant::now();
        while !socket.exists() {
            assert!(
                started.elapsed() < Duration::from_secs(10),
                "daemon socket did not appear within 10s"
            );
            thread::sleep(Duration::from_millis(100));
        }
    }

    fn socket_path(&self) -> PathBuf {
        self.home
            .join(".cache/chrome-devtools/daemons/default.sock")
    }

    fn connect(&self) -> UnixStream {
        UnixStream::connect(self.socket_path()).unwrap()
    }

    fn run_cli(&self, args: &[&str]) -> (bool, String) {
        let output = self.cli().args(args).output().unwrap();
        let combined = format!(
            "{}{}",
            String::from_utf8_lossy(&output.stdout),
            String::from_utf8_lossy(&output.stderr)
        );
        (output.status.success(), combined)
    }

    fn create_session(&self) -> String {
        let (ok, output) = self.run_cli(&["session", "create", "--profile", "default"]);
        assert!(ok, "session create failed: {output}");
        output
            .split_whitespace()
            .find_map(|part| part.strip_prefix("session="))
            .expect("session id in output")
            .to_string()
    }
}

impl Drop for TestEnv {
    fn drop(&mut self) {
        if let Some(child) = self.daemon.as_mut() {
            let _ = child.kill();
            let _ = child.wait();
        }
        let _ = fs::remove_dir_all(&self.home);
    }
}

fn fake_mcp_path() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/fake-mcp.sh")
}

fn spawn_fake_devtools() -> u16 {
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
    let port = listener.local_addr().unwrap().port();
    thread::spawn(move || {
        for stream in listener.incoming() {
            let Ok(mut stream) = stream else { continue };
            let mut buffer = [0u8; 1024];
            let _ = stream.read(&mut buffer);
            let _ = stream.write_all(
                b"HTTP/1.1 200 OK\r\nContent-Length: 18\r\nConnection: close\r\n\r\n{\"Browser\":\"fake\"}",
            );
        }
    });
    port
}

fn control_roundtrip(stream: &mut UnixStream, command: &str) -> String {
    stream
        .write_all(format!("__chrome_devtools_daemon__:{command}\n").as_bytes())
        .unwrap();
    stream.flush().unwrap();
    let mut reader = BufReader::new(stream.try_clone().unwrap());
    let mut line = String::new();
    reader.read_line(&mut line).unwrap();
    line.trim_end().to_string()
}

#[test]
fn smoke_session_create_and_batch() {
    let mut env = TestEnv::new("smoke");
    env.start_daemon();
    let session = env.create_session();

    let script = env.home.join("batch.json");
    fs::write(
        &script,
        r#"[{"type":"tool","name":"take_snapshot","args":{}}]"#,
    )
    .unwrap();
    let (ok, output) = env.run_cli(&[
        "mcp",
        "batch",
        "--profile",
        "default",
        "--session",
        &session,
        "--script",
        script.to_str().unwrap(),
    ]);
    assert!(ok, "batch failed: {output}");
    assert!(
        output.contains("\"text\": \"ok\""),
        "batch output: {output}"
    );
}

#[test]
fn batch_failure_emits_json_error_on_stdout() {
    let mut env = TestEnv::new("batcherr");
    env.start_daemon();
    let script = env.home.join("batch.json");
    fs::write(
        &script,
        r#"[{"type":"tool","name":"take_snapshot","args":{}}]"#,
    )
    .unwrap();
    let output = env
        .cli()
        .args([
            "mcp",
            "batch",
            "--profile",
            "default",
            "--session",
            "sess-bogus",
            "--script",
            script.to_str().unwrap(),
        ])
        .output()
        .unwrap();
    assert!(!output.status.success());
    let stdout = String::from_utf8_lossy(&output.stdout);
    let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("stdout is JSON");
    assert_eq!(parsed[0]["type"], "error");
    assert!(parsed[0]["error"]
        .as_str()
        .unwrap()
        .contains("unknown session"));
}

#[test]
fn client_disconnect_does_not_kill_daemon() {
    let mut env = TestEnv::new("disconnect");
    env.start_daemon();
    let session = env.create_session();

    {
        let mut stream = env.connect();
        let bound = control_roundtrip(&mut stream, &format!("bind session={session}"));
        assert_eq!(bound, format!("bound={session}"));
        stream
            .write_all(
                br#"{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"click","arguments":{}}}"#,
            )
            .unwrap();
        stream.write_all(b"\n").unwrap();
        stream.flush().unwrap();
    }

    thread::sleep(Duration::from_secs(1));
    let mut stream = env.connect();
    let status = control_roundtrip(&mut stream, "status");
    assert!(
        status.contains("daemon=ready"),
        "daemon died after client disconnect: {status}"
    );

    let (ok, output) = env.run_cli(&["session", "list", "--profile", "default"]);
    assert!(ok, "session list failed: {output}");
    assert!(
        output.contains(&session),
        "session lost after another client's disconnect: {output}"
    );
}

#[test]
fn string_jsonrpc_id_roundtrips_through_daemon() {
    let mut env = TestEnv::new("stringid");
    env.start_daemon();
    let session = env.create_session();

    let mut stream = env.connect();
    let bound = control_roundtrip(&mut stream, &format!("bind session={session}"));
    assert_eq!(bound, format!("bound={session}"));
    stream
        .write_all(br#"{"jsonrpc":"2.0","id":"client-a","method":"tools/list","params":{}}"#)
        .unwrap();
    stream.write_all(b"\n").unwrap();
    stream.flush().unwrap();

    let mut reader = BufReader::new(stream);
    let mut line = String::new();
    reader.read_line(&mut line).unwrap();
    let value: serde_json::Value = serde_json::from_str(line.trim_end()).unwrap();
    assert_eq!(value["id"], "client-a");
    assert_eq!(value["result"]["tools"], serde_json::json!([]));
}

#[test]
fn unbound_tool_call_is_rejected() {
    let mut env = TestEnv::new("unbound");
    env.start_daemon();

    let mut stream = env.connect();
    stream
        .write_all(
            br#"{"jsonrpc":"2.0","id":"client-a","method":"tools/call","params":{"name":"click","arguments":{}}}"#,
        )
        .unwrap();
    stream.write_all(b"\n").unwrap();
    stream.flush().unwrap();

    let mut reader = BufReader::new(stream);
    let mut line = String::new();
    reader.read_line(&mut line).unwrap();
    let value: serde_json::Value = serde_json::from_str(line.trim_end()).unwrap();
    assert_eq!(value["id"], "client-a");
    assert_eq!(
        value["error"]["message"],
        "session bind required for MCP forwarding"
    );
}

#[test]
fn control_commands_respond_while_client_bound() {
    let mut env = TestEnv::new("busy");
    env.start_daemon();
    let session = env.create_session();

    let mut holder = env.connect();
    let bound = control_roundtrip(&mut holder, &format!("bind session={session}"));
    assert_eq!(bound, format!("bound={session}"));

    let mut stream = env.connect();
    stream
        .set_read_timeout(Some(Duration::from_secs(5)))
        .unwrap();
    stream
        .write_all(b"__chrome_devtools_daemon__:session_create\n")
        .unwrap();
    stream.flush().unwrap();
    let mut reader = BufReader::new(stream);
    let mut line = String::new();
    reader
        .read_line(&mut line)
        .expect("session_create should answer within 5s while another client is bound");
    assert!(line.starts_with("session="), "unexpected response: {line}");
}

#[test]
fn stop_refused_while_sessions_active() {
    let mut env = TestEnv::new("stopguard");
    env.start_daemon();
    let session = env.create_session();

    let (ok, output) = env.run_cli(&["daemon", "stop", "--profile", "default"]);
    assert!(!ok, "stop should be refused: {output}");
    assert!(output.contains("active session(s)"), "output: {output}");

    let (ok, output) = env.run_cli(&["profile", "stop", "--profile", "default"]);
    assert!(!ok, "profile stop should be refused: {output}");
    assert!(output.contains("is running"), "output: {output}");

    let (ok, output) = env.run_cli(&[
        "session",
        "close",
        "--profile",
        "default",
        "--session",
        &session,
    ]);
    assert!(ok, "session close failed: {output}");
    let (ok, output) = env.run_cli(&["daemon", "stop", "--profile", "default"]);
    assert!(ok, "stop after close failed: {output}");
}

#[test]
fn status_reports_version_sessions_and_health() {
    let mut env = TestEnv::new("status");
    env.start_daemon();
    env.create_session();

    let (ok, output) = env.run_cli(&["daemon", "status", "--profile", "default"]);
    assert!(ok, "status failed: {output}");
    assert!(
        output.contains(&format!("version={}", env!("CARGO_PKG_VERSION"))),
        "output: {output}"
    );
    assert!(output.contains("sessions=1"), "output: {output}");
    assert!(output.contains("chrome=ready"), "output: {output}");

    let (ok, output) = env.run_cli(&["daemon", "stop", "--profile", "default", "--force"]);
    assert!(ok, "forced stop failed: {output}");
    let started = Instant::now();
    while env.socket_path().exists() && started.elapsed() < Duration::from_secs(5) {
        thread::sleep(Duration::from_millis(100));
    }

    let (ok, output) = env.run_cli(&["daemon", "status", "--profile", "default"]);
    assert!(ok, "status after stop failed: {output}");
    assert!(output.contains("daemon=stopped"), "output: {output}");
    assert!(output.contains("exit=ok"), "output: {output}");
}