omni-dev 0.46.0

AI-powered git commit rewriter, PR generator, and MCP server for Jira, Confluence, Datadog, Gmail, and Drive.
Documentation
//! End-to-end integration test for the `omni-dev daemon` command tree.
//!
//! Spawns the real `daemon run` process on a temp socket and drives it through
//! the library [`DaemonClient`] (ping → status → shutdown), proving the CLI
//! wiring and graceful-shutdown path without touching any per-user location.

#![allow(clippy::unwrap_used, clippy::expect_used)]

use std::path::Path;
use std::process::{Child, Command};
use std::time::Duration;

use omni_dev::daemon::client::DaemonClient;

/// Spawns `omni-dev daemon run` on `socket`, with its request log in `log`.
///
/// The daemon's `github` service tallies the request log at startup and again
/// in `shutdown()` — which the process awaits before it can exit — and does so
/// by parsing the **whole** file. Left to default, that file is the
/// developer's real `log.jsonl`, which grows without bound (4.1 GB on the
/// machine that surfaced #1653) and took 50-75s per pass. That, not host
/// load, is what failed these tests past every fixed deadline (#1632, #1653);
/// CI never saw it because a fresh runner has no log. Pointing the daemon at a
/// file of the test's own makes the run independent of the machine's history,
/// so route every spawn through here rather than building the command by hand.
fn spawn_daemon(socket: &Path, log: &Path) -> Child {
    Command::new(env!("CARGO_BIN_EXE_omni-dev"))
        .arg("daemon")
        .arg("run")
        .arg("--socket")
        .arg(socket)
        // Bind the bridge's TCP planes to random free ports so the test never
        // collides with a real daemon (or another test) on the default 9998/9999.
        .arg("--bridge-control-port")
        .arg("0")
        .arg("--bridge-ws-port")
        .arg("0")
        // Stay headless even when the binary is built with `--features menu-bar`,
        // so this test never tries to open a macOS tray in a headless run.
        .arg("--no-menu")
        .env("OMNI_DEV_LOG_FILE", log)
        .spawn()
        .expect("failed to spawn `omni-dev daemon run`")
}

/// Polls `f` until it returns `true` or the deadline passes.
///
/// Every caller waits for a **process to exit** after a graceful shutdown
/// (readiness has its own loops), so the deadline is generous on purpose:
/// a daemon hosting four services routinely drains for longer than a few
/// seconds — 9.6s was measured by hand on a developer machine — and the
/// old 5s budget failed the two lifecycle tests on real machines while CI
/// stayed green (#1632).
///
/// The asymmetry is what makes a large value free: this returns as soon as
/// `f` is true, so raising the ceiling costs nothing when the daemon exits
/// promptly and only extends the *failing* path. These tests assert that
/// shutdown is graceful, not that it is fast; no requirement anywhere binds
/// it to a deadline, so the bound exists purely to stop a hung daemon
/// hanging the suite forever.
///
/// It is **not** a knob for slow hosts. The slowness #1653 chased was the
/// daemon scanning the real request log (see [`spawn_daemon`]), which no
/// ceiling can absorb because the log only grows; with the log isolated a
/// daemon exits in a few seconds even on a busy machine. If these tests time
/// out again, look for what the daemon is doing before raising this.
async fn wait_for<F>(mut f: F) -> bool
where
    F: FnMut() -> bool,
{
    let deadline = Duration::from_secs(30);
    let interval = Duration::from_millis(25);
    let attempts = deadline.as_millis() / interval.as_millis();
    for _ in 0..attempts {
        if f() {
            return true;
        }
        tokio::time::sleep(interval).await;
    }
    false
}

#[tokio::test]
async fn daemon_run_status_stop_roundtrip() {
    let dir = tempfile::tempdir().unwrap();
    let socket = dir.path().join("d.sock");

    // Bind the bridge's TCP planes to random free ports so the test never
    // collides with a real daemon (or another test) on the default 9998/9999.
    let mut child = spawn_daemon(&socket, &dir.path().join("log.jsonl"));

    let client = DaemonClient::new(&socket);

    // The control socket comes up.
    let mut ready = false;
    for _ in 0..200 {
        if client.ping().await.is_ok() {
            ready = true;
            break;
        }
        tokio::time::sleep(Duration::from_millis(25)).await;
    }
    if !ready {
        let _ = child.kill();
    }
    assert!(ready, "daemon did not become ready");

    // Status reports the registered browser-bridge service.
    let report = client.status().await.expect("status request failed");
    assert!(
        report
            .services
            .iter()
            .any(|s| s.name == "browser-bridge" && s.healthy),
        "expected a healthy browser-bridge service, got {report:?}"
    );

    // Graceful shutdown over the socket.
    client.shutdown().await.expect("shutdown request failed");

    // The process exits on its own (clean exit 0).
    let exited = wait_for(|| matches!(child.try_wait(), Ok(Some(_)))).await;
    if !exited {
        let _ = child.kill();
    }
    assert!(exited, "daemon did not exit after shutdown");

    let status = child.wait().unwrap();
    assert!(status.success(), "daemon exited with failure: {status:?}");
}

/// SIGHUP is a graceful-shutdown signal like SIGTERM/SIGINT (#1112): the
/// daemon drains and exits 0 instead of dying to the default disposition.
#[cfg(unix)]
#[tokio::test]
async fn daemon_run_shuts_down_gracefully_on_sighup() {
    use nix::sys::signal::{kill, Signal};
    use nix::unistd::Pid;

    let dir = tempfile::tempdir().unwrap();
    let socket = dir.path().join("d.sock");

    let mut child = spawn_daemon(&socket, &dir.path().join("log.jsonl"));

    let client = DaemonClient::new(&socket);
    let ready = {
        let client = &client;
        let mut ok = false;
        for _ in 0..200 {
            if client.ping().await.is_ok() {
                ok = true;
                break;
            }
            tokio::time::sleep(Duration::from_millis(25)).await;
        }
        ok
    };
    if !ready {
        let _ = child.kill();
    }
    assert!(ready, "daemon did not become ready");

    kill(
        Pid::from_raw(child.id().try_into().unwrap()),
        Signal::SIGHUP,
    )
    .expect("failed to send SIGHUP");

    let exited = wait_for(|| matches!(child.try_wait(), Ok(Some(_)))).await;
    if !exited {
        let _ = child.kill();
    }
    assert!(exited, "daemon did not exit after SIGHUP");

    let status = child.wait().unwrap();
    assert!(status.success(), "SIGHUP exit was not graceful: {status:?}");
    // Graceful shutdown unlinks the self-bound control socket.
    assert!(!socket.exists(), "socket was not unlinked on shutdown");
}

/// `daemon start` off macOS must leave a daemon that has fully left the
/// launcher's session (#1112): its own session id, and stdout/stderr appended
/// to a `0600` `daemon.log` beside the socket. Linux-only because `start` on
/// macOS installs a real launchd LaunchAgent, and because the session-id probe
/// reads `/proc`.
#[cfg(target_os = "linux")]
#[tokio::test]
async fn daemon_start_detaches_into_its_own_session() {
    use std::os::unix::fs::PermissionsExt;

    use nix::sys::signal::{kill, Signal};
    use nix::unistd::Pid;

    /// Session id from `/proc/<pid>/stat` field 6 (pid, comm, state, ppid,
    /// pgrp, session). `comm` may contain spaces, so split after the last `)`.
    fn session_id(pid: &str) -> i32 {
        let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).unwrap();
        let after_comm = &stat[stat.rfind(')').unwrap() + 2..];
        after_comm
            .split_whitespace()
            .nth(3)
            .unwrap()
            .parse()
            .unwrap()
    }

    let dir = tempfile::tempdir().unwrap();
    let socket = dir.path().join("d.sock");

    // The launcher spawns the detached daemon, waits for readiness, and exits.
    // NOTE: the spawned `daemon run` uses the default bridge TCP ports; fine in
    // CI, but on a workstation a resident daemon may make its bridge fail —
    // the control socket (what this test exercises) still comes up.
    let launcher = Command::new(env!("CARGO_BIN_EXE_omni-dev"))
        .arg("daemon")
        .arg("start")
        .arg("--socket")
        .arg(&socket)
        // Force the detached-spawn path this test asserts on: without it, a host
        // with a systemd user manager would install a real unit under the
        // developer's `~/.config` and socket-activate the daemon instead (#1174).
        .env("OMNI_DEV_DAEMON_DISABLE_SYSTEMD", "1")
        // The detached daemon inherits this; see `spawn_daemon` for why it
        // must not read the developer's real request log.
        .env("OMNI_DEV_LOG_FILE", dir.path().join("log.jsonl"))
        .status()
        .expect("failed to run `omni-dev daemon start`");
    assert!(launcher.success(), "daemon start failed: {launcher:?}");

    let client = DaemonClient::new(&socket);
    // The launcher has already exited, yet the daemon keeps answering.
    client.ping().await.expect("detached daemon is not alive");

    // Find the daemon: the process whose argv carries our socket path.
    let socket_str = socket.to_str().unwrap();
    let daemon_pid = std::fs::read_dir("/proc")
        .unwrap()
        .filter_map(|e| {
            let name = e.ok()?.file_name().into_string().ok()?;
            name.chars().all(|c| c.is_ascii_digit()).then_some(name)
        })
        .find(|pid| {
            std::fs::read(format!("/proc/{pid}/cmdline")).is_ok_and(|argv| {
                let argv = String::from_utf8_lossy(&argv);
                argv.contains("daemon\0run\0") && argv.contains(socket_str)
            })
        })
        .expect("could not find the detached daemon process");

    // Detached: the daemon leads its own session, distinct from this test's.
    let daemon_sid = session_id(&daemon_pid);
    assert_eq!(
        daemon_sid,
        daemon_pid.parse::<i32>().unwrap(),
        "daemon is not a session leader"
    );
    assert_ne!(
        daemon_sid,
        session_id("self"),
        "daemon shares the launcher's session"
    );

    // Its stdio landed in a 0600 daemon.log beside the socket.
    let log = dir.path().join("daemon.log");
    assert!(
        log.is_file(),
        "daemon.log was not created beside the socket"
    );
    assert_eq!(
        std::fs::metadata(&log).unwrap().permissions().mode() & 0o777,
        0o600,
        "daemon.log is not 0600"
    );

    // Graceful stop; SIGKILL as a last-resort cleanup so a failure here can't
    // leak a daemon into the CI runner.
    let stopped = client.shutdown().await;
    let pid = Pid::from_raw(daemon_pid.parse().unwrap());
    let gone = wait_for(|| kill(pid, None).is_err()).await;
    if !gone {
        let _ = kill(pid, Signal::SIGKILL);
    }
    stopped.expect("shutdown request failed");
    assert!(gone, "daemon did not exit after shutdown");
}