use mini_static::Server;
use std::fs;
use std::time::Duration;
use tempfile::TempDir;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::time::timeout;
async fn read_until(conn: &mut TcpStream, needle: &str, bound: Duration) -> String {
let mut buf = Vec::new();
let mut chunk = [0u8; 4096];
timeout(bound, async {
loop {
let n = conn.read(&mut chunk).await.expect("read failed");
assert!(n > 0, "connection closed before {needle:?} appeared");
buf.extend_from_slice(&chunk[..n]);
if String::from_utf8_lossy(&buf).contains(needle) {
break;
}
}
})
.await
.unwrap_or_else(|_| {
panic!(
"timed out waiting for {needle:?}, got so far: {}",
String::from_utf8_lossy(&buf)
)
});
String::from_utf8_lossy(&buf).into_owned()
}
#[tokio::test]
async fn live_reload_disabled_by_default_serves_plain_html_and_404s_reload_endpoint() {
let root = TempDir::new().unwrap();
fs::write(
root.path().join("index.html"),
b"<html><body>hi</body></html>",
)
.unwrap();
let server = Server::new(root.path()).unwrap();
let (port, handle) = server.run_ephemeral().await.unwrap();
let addr = format!("127.0.0.1:{port}");
let mut conn = TcpStream::connect(&addr).await.unwrap();
conn.write_all(b"GET /index.html HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut response_data = Vec::new();
conn.read_to_end(&mut response_data).await.unwrap();
let response = String::from_utf8_lossy(&response_data);
assert!(response.contains("HTTP/1.1 200"));
assert!(
!response.contains("EventSource"),
"no live-reload script should be injected when with_live_reload() was never called"
);
let mut conn = TcpStream::connect(&addr).await.unwrap();
let request = format!(
"GET {} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
mini_static::LIVE_RELOAD_PATH
);
conn.write_all(request.as_bytes()).await.unwrap();
let mut response_data = Vec::new();
conn.read_to_end(&mut response_data).await.unwrap();
let response = String::from_utf8_lossy(&response_data);
assert!(
response.contains("HTTP/1.1 404"),
"reload endpoint should not be routed when live-reload is disabled, got: {response}"
);
handle.shutdown().await;
}
#[tokio::test]
async fn live_reload_enabled_injects_script_into_html_with_correct_content_length() {
let root = TempDir::new().unwrap();
fs::write(
root.path().join("index.html"),
b"<html><body>hi</body></html>",
)
.unwrap();
let server = Server::new(root.path()).unwrap().with_live_reload();
let (port, handle) = server.run_ephemeral().await.unwrap();
let addr = format!("127.0.0.1:{port}");
let mut conn = TcpStream::connect(&addr).await.unwrap();
conn.write_all(b"GET /index.html HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut response_data = Vec::new();
conn.read_to_end(&mut response_data).await.unwrap();
let response = String::from_utf8_lossy(&response_data);
assert!(response.contains("HTTP/1.1 200"));
assert!(response.contains(mini_static::LIVE_RELOAD_PATH));
assert!(
response.find("EventSource").unwrap() < response.find("</body>").unwrap(),
"script must be injected before the closing body tag, got: {response}"
);
let content_length: usize = response
.lines()
.find(|l| l.to_lowercase().starts_with("content-length:"))
.and_then(|l| l.split(':').nth(1))
.and_then(|v| v.trim().parse().ok())
.expect("Content-Length header present");
let body = response.split("\r\n\r\n").nth(1).unwrap();
assert_eq!(
content_length,
body.len(),
"Content-Length must reflect the injected body, not the on-disk file size"
);
handle.shutdown().await;
}
#[tokio::test]
async fn live_reload_enabled_leaves_non_html_files_byte_identical() {
let root = TempDir::new().unwrap();
let css = b"body { color: red; }".to_vec();
fs::write(root.path().join("style.css"), &css).unwrap();
let server = Server::new(root.path()).unwrap().with_live_reload();
let (port, handle) = server.run_ephemeral().await.unwrap();
let addr = format!("127.0.0.1:{port}");
let mut conn = TcpStream::connect(&addr).await.unwrap();
conn.write_all(b"GET /style.css HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut response_data = Vec::new();
conn.read_to_end(&mut response_data).await.unwrap();
let response = String::from_utf8_lossy(&response_data);
assert!(response.contains("HTTP/1.1 200"));
assert!(response.ends_with(&String::from_utf8(css).unwrap()));
handle.shutdown().await;
}
#[tokio::test]
async fn live_reload_sse_endpoint_broadcasts_file_change_to_connected_client() {
let src = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
fs::write(out.path().join("index.html"), b"<html></html>").unwrap();
let server = Server::new(out.path())
.unwrap()
.with_live_reload()
.with_source_folder(src.path())
.unwrap();
let (port, handle) = server.run_ephemeral().await.unwrap();
let addr = format!("127.0.0.1:{port}");
let mut conn = TcpStream::connect(&addr).await.unwrap();
let request = format!(
"GET {} HTTP/1.1\r\nHost: localhost\r\n\r\n",
mini_static::LIVE_RELOAD_PATH
);
conn.write_all(request.as_bytes()).await.unwrap();
let headers = read_until(&mut conn, "\r\n\r\n", Duration::from_secs(2)).await;
assert!(headers.contains("HTTP/1.1 200"));
assert!(headers
.to_lowercase()
.contains("content-type: text/event-stream"));
tokio::time::sleep(Duration::from_millis(700)).await;
fs::write(src.path().join("style.css"), b"body{}").unwrap();
let frame = read_until(&mut conn, "\n\n", Duration::from_secs(2)).await;
assert!(
frame.contains("event: css"),
"expected an SSE frame for the new .css source file, got: {frame}"
);
handle.shutdown().await;
}
#[tokio::test]
async fn live_reload_sse_connection_survives_past_header_timeout() {
let src = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
fs::write(out.path().join("index.html"), b"<html></html>").unwrap();
let header_timeout = Duration::from_millis(100);
let server = Server::new(out.path())
.unwrap()
.with_live_reload()
.with_source_folder(src.path())
.unwrap();
let (port, handle) = server.run(header_timeout).await.unwrap();
let addr = format!("127.0.0.1:{port}");
let mut conn = TcpStream::connect(&addr).await.unwrap();
let request = format!(
"GET {} HTTP/1.1\r\nHost: localhost\r\n\r\n",
mini_static::LIVE_RELOAD_PATH
);
conn.write_all(request.as_bytes()).await.unwrap();
let headers = read_until(&mut conn, "\r\n\r\n", Duration::from_secs(2)).await;
assert!(headers.contains("HTTP/1.1 200"));
tokio::time::sleep(header_timeout * 8).await;
fs::write(src.path().join("style.css"), b"body{}").unwrap();
let frame = read_until(&mut conn, "\n\n", Duration::from_secs(2)).await;
assert!(
frame.contains("event: css"),
"SSE connection should still be alive long after header_timeout elapsed, got: {frame}"
);
drop(conn);
tokio::time::timeout(Duration::from_secs(2), handle.shutdown())
.await
.expect("shutdown should complete promptly once the client has disconnected");
}
#[tokio::test]
async fn live_reload_shutdown_aborts_a_still_open_sse_connection_after_the_drain_timeout() {
let root = TempDir::new().unwrap();
fs::write(root.path().join("index.html"), b"<html></html>").unwrap();
let server = Server::new(root.path()).unwrap().with_live_reload();
let (port, handle) = server.run_ephemeral().await.unwrap();
let addr = format!("127.0.0.1:{port}");
let mut conn = TcpStream::connect(&addr).await.unwrap();
let request = format!(
"GET {} HTTP/1.1\r\nHost: localhost\r\n\r\n",
mini_static::LIVE_RELOAD_PATH
);
conn.write_all(request.as_bytes()).await.unwrap();
let headers = read_until(&mut conn, "\r\n\r\n", Duration::from_secs(2)).await;
assert!(
headers.contains("HTTP/1.1 200"),
"SSE connection should be established, got: {headers}"
);
tokio::time::timeout(
Duration::from_secs(2),
handle.shutdown_with_timeout(Duration::from_millis(200)),
)
.await
.expect(
"shutdown_with_timeout should abort the open connection at its drain timeout, not hang",
);
}