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");
}