servile-cli 0.1.0

Static file server with live-reload
Documentation
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tempfile::TempDir;
use tokio::net::TcpListener;

async fn spawn_server(dir: &std::path::Path) -> SocketAddr {
    spawn_server_with_debounce(dir, 50).await
}

async fn spawn_server_with_debounce(dir: &std::path::Path, debounce_ms: u64) -> SocketAddr {
    let app = servile_cli::build_router(dir, debounce_ms);
    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    tokio::spawn(async move {
        axum::serve(listener, app).await.unwrap();
    });
    addr
}

#[tokio::test]
async fn serves_static_file() {
    let tmp = TempDir::new().unwrap();
    std::fs::write(tmp.path().join("hello.txt"), "hello world").unwrap();

    let addr = spawn_server(tmp.path()).await;
    let resp = reqwest::get(format!("http://{addr}/hello.txt")).await.unwrap();

    assert_eq!(resp.status(), 200);
    assert_eq!(resp.text().await.unwrap(), "hello world");
}

#[tokio::test]
async fn servile_js_returns_reload_script() {
    let tmp = TempDir::new().unwrap();
    let addr = spawn_server(tmp.path()).await;
    let resp = reqwest::get(format!("http://{addr}/servile.js")).await.unwrap();

    assert_eq!(resp.status(), 200);
    let ct = resp.headers().get("content-type").unwrap().to_str().unwrap().to_string();
    assert!(ct.contains("javascript"), "expected javascript content-type, got: {ct}");
    let body = resp.text().await.unwrap();
    assert!(body.contains("/servile-reload"), "script should reference /servile-reload endpoint");
    assert!(body.contains("window.location.reload()"), "script should trigger page reload");
}

#[tokio::test]
async fn index_html_resolution() {
    let tmp = TempDir::new().unwrap();
    std::fs::write(tmp.path().join("index.html"), "<h1>home</h1>").unwrap();

    let addr = spawn_server(tmp.path()).await;
    let resp = reqwest::get(format!("http://{addr}/")).await.unwrap();

    assert_eq!(resp.status(), 200);
    let body = resp.text().await.unwrap();
    assert!(body.contains("<h1>home</h1>"));
}

#[tokio::test]
async fn html_responses_get_script_injected() {
    let tmp = TempDir::new().unwrap();
    std::fs::write(
        tmp.path().join("page.html"),
        "<html><body><p>content</p></body></html>",
    ).unwrap();

    let addr = spawn_server(tmp.path()).await;
    let resp = reqwest::get(format!("http://{addr}/page.html")).await.unwrap();

    assert_eq!(resp.status(), 200);
    let body = resp.text().await.unwrap();
    assert!(body.contains(r#"<script src="/servile.js"></script>"#), "HTML should have injected script tag");
    assert!(body.contains("<p>content</p>"), "original content should be preserved");
}

#[tokio::test]
async fn reload_resolves_after_file_change() {
    let tmp = TempDir::new().unwrap();
    std::fs::write(tmp.path().join("index.html"), "<h1>v1</h1>").unwrap();

    let addr = spawn_server_with_debounce(tmp.path(), 50).await;

    let reload_addr = addr;
    let reload_handle = tokio::spawn(async move {
        reqwest::get(format!(
            "http://{reload_addr}/servile-reload?path=/index.html"
        ))
        .await
        .unwrap()
    });

    tokio::time::sleep(Duration::from_millis(100)).await;
    std::fs::write(tmp.path().join("index.html"), "<h1>v2</h1>").unwrap();

    let resp = tokio::time::timeout(Duration::from_secs(5), reload_handle)
        .await
        .expect("reload should resolve within 5s")
        .unwrap();

    assert_eq!(resp.status(), 200);
}

#[tokio::test]
async fn reload_waits_for_html_file_to_exist() {
    let tmp = TempDir::new().unwrap();
    let html_path = tmp.path().join("page.html");
    std::fs::write(&html_path, "<h1>original</h1>").unwrap();

    let addr = spawn_server_with_debounce(tmp.path(), 50).await;

    let reload_handle = tokio::spawn({
        let addr = addr;
        async move {
            reqwest::get(format!("http://{addr}/servile-reload?path=/page.html"))
                .await
                .unwrap()
        }
    });

    tokio::time::sleep(Duration::from_millis(100)).await;

    std::fs::remove_file(&html_path).unwrap();
    std::fs::write(tmp.path().join("other.txt"), "trigger").unwrap();

    tokio::time::sleep(Duration::from_millis(200)).await;
    assert!(!reload_handle.is_finished(), "should not resolve while html file missing");

    std::fs::write(&html_path, "<h1>back</h1>").unwrap();

    let resp = tokio::time::timeout(Duration::from_secs(5), reload_handle)
        .await
        .expect("reload should resolve after html file reappears")
        .unwrap();

    assert_eq!(resp.status(), 200);
}

#[tokio::test]
async fn logs_full_resolved_url() {
    let tmp = TempDir::new().unwrap();
    std::fs::write(tmp.path().join("index.html"), "<h1>hi</h1>").unwrap();

    let log_buf: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
    let buf = log_buf.clone();

    let app = servile_cli::build_router_with_logger(tmp.path(), 50, move |url: &str| {
        buf.lock().unwrap().push(url.to_string());
    });
    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });

    reqwest::get(format!("http://{addr}/index.html")).await.unwrap();

    let logs = log_buf.lock().unwrap();
    assert_eq!(logs.len(), 1);
    assert_eq!(
        logs[0],
        format!("http://{addr}/index.html")
    );
}

#[tokio::test]
async fn non_html_responses_not_injected() {
    let tmp = TempDir::new().unwrap();
    std::fs::write(tmp.path().join("data.json"), r#"{"key": "value"}"#).unwrap();

    let addr = spawn_server(tmp.path()).await;
    let resp = reqwest::get(format!("http://{addr}/data.json")).await.unwrap();

    assert_eq!(resp.status(), 200);
    let body = resp.text().await.unwrap();
    assert!(!body.contains("servile.js"), "non-HTML should not be injected");
}