forte-cli 0.3.12

CLI for the Forte fullstack web framework
use assert_cmd::cargo;
use std::io::{BufRead, BufReader};
use std::net::TcpStream;
use std::process::{Child, Stdio};
use std::sync::mpsc;
use std::time::Duration;

fn get_forte_bin_path() -> std::path::PathBuf {
    cargo::cargo_bin!("forte").to_path_buf()
}

struct DevServer {
    child: Child,
    port: u16,
    vite_ssr_port: Option<u16>,
    _stdout_thread: std::thread::JoinHandle<()>,
}

impl DevServer {
    fn start(project_dir: &std::path::Path) -> Self {
        let forte_bin = get_forte_bin_path();

        let mut child = std::process::Command::new(&forte_bin)
            .args(["dev"])
            .current_dir(project_dir)
            .stdout(Stdio::piped())
            .stderr(Stdio::inherit())
            .spawn()
            .expect("Failed to start forte dev");

        let stdout = child.stdout.take().expect("Failed to get stdout");
        let (tx, rx) = mpsc::channel::<(u16, Option<u16>)>();

        let stdout_thread = std::thread::spawn(move || {
            let reader = BufReader::new(stdout);
            let mut forte_port: Option<u16> = None;
            let mut vite_ssr_port: Option<u16> = None;
            let mut sent = false;

            for line in reader.lines() {
                let Ok(line) = line else { break };
                eprintln!("[dev server] {}", line);

                if line.contains("[vite-ssr] Vite SSR adapter ready on port")
                    && let Some(port_str) = line.split_whitespace().last()
                {
                    vite_ssr_port = port_str.parse().ok();
                }

                if line.contains("listening on")
                    && let Some(port_str) = line.split(':').next_back()
                {
                    forte_port = port_str.trim().parse().ok();
                }

                if !sent && let Some(forte_port) = forte_port {
                    let _ = tx.send((forte_port, vite_ssr_port));
                    sent = true;
                }
            }
        });

        let (port, vite_ssr_port) = rx
            .recv_timeout(Duration::from_secs(120))
            .expect("Timeout waiting for server to start");

        Self {
            child,
            port,
            vite_ssr_port,
            _stdout_thread: stdout_thread,
        }
    }

    fn url(&self) -> String {
        format!("http://127.0.0.1:{}", self.port)
    }
}

impl Drop for DevServer {
    fn drop(&mut self) {
        let _ = self.child.kill();
        let _ = self.child.wait();
    }
}

fn init_project(temp_dir: &std::path::Path, name: &str) -> std::path::PathBuf {
    cargo::cargo_bin_cmd!("forte")
        .args(["init", name])
        .current_dir(temp_dir)
        .assert()
        .success();

    temp_dir.join(name)
}

fn install_npm_deps(project_dir: &std::path::Path) {
    std::process::Command::new("npm")
        .arg("install")
        .current_dir(project_dir.join("fe"))
        .stdout(Stdio::inherit())
        .stderr(Stdio::inherit())
        .status()
        .expect("Failed to run npm install");
}

#[test]
fn test_dev_server_starts_and_responds() {
    let temp = tempfile::tempdir().unwrap();
    let project_dir = init_project(temp.path(), "test-app");

    install_npm_deps(&project_dir);

    let server = DevServer::start(&project_dir);

    std::thread::sleep(Duration::from_secs(1));

    let response = reqwest::blocking::get(server.url());

    match response {
        Ok(resp) => {
            assert!(
                resp.status().is_success(),
                "Expected success status, got {}",
                resp.status()
            );
            let body = resp.text().unwrap();
            assert!(body.contains("html"), "Expected HTML response");
        }
        Err(e) => {
            panic!("Failed to connect to dev server: {}", e);
        }
    }
}

#[test]
fn test_dev_auto_selects_port_if_busy() {
    let temp = tempfile::tempdir().unwrap();
    let project_dir = init_project(temp.path(), "test-app-2");

    install_npm_deps(&project_dir);

    let _listener = std::net::TcpListener::bind("127.0.0.1:3000").unwrap();

    let server = DevServer::start(&project_dir);

    std::thread::sleep(Duration::from_secs(1));

    assert_ne!(server.port, 3000, "Should have selected a different port");

    let response = reqwest::blocking::get(server.url());
    assert!(response.is_ok(), "Server should respond on alternate port");
}

fn is_port_open(port: u16) -> bool {
    use std::net::SocketAddr;
    let addr = SocketAddr::from(([127, 0, 0, 1], port));
    TcpStream::connect_timeout(&addr, Duration::from_millis(100)).is_ok()
}

#[test]
fn test_vite_ssr_exits_when_forte_dies() {
    let temp = tempfile::tempdir().unwrap();
    let project_dir = init_project(temp.path(), "test-app-vite-ssr-exit");

    install_npm_deps(&project_dir);

    let mut server = DevServer::start(&project_dir);

    std::thread::sleep(Duration::from_secs(1));

    let vite_ssr_port = server
        .vite_ssr_port
        .expect("Vite SSR port should be captured from logs");

    assert!(
        is_port_open(vite_ssr_port),
        "Vite SSR adapter should be running on port {}",
        vite_ssr_port
    );

    server.child.kill().expect("Failed to kill forte");
    server.child.wait().expect("Failed to wait for forte");

    let mut vite_ssr_closed = false;
    for _ in 0..30 {
        std::thread::sleep(Duration::from_millis(100));
        if !is_port_open(vite_ssr_port) {
            vite_ssr_closed = true;
            break;
        }
    }

    assert!(
        vite_ssr_closed,
        "Vite SSR adapter should have exited after forte died (port {} still open)",
        vite_ssr_port
    );
}