#![cfg(unix)]
use std::net::SocketAddr;
use std::os::unix::net::UnixListener;
use std::path::PathBuf;
use std::time::Duration;
use arcature::{Application, Routes, get};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::task::JoinHandle;
const HANG_GUARD: Duration = Duration::from_secs(10);
struct RunningApp {
addr: SocketAddr,
shutdown: tokio::sync::oneshot::Sender<()>,
join: JoinHandle<()>,
}
impl RunningApp {
async fn start(app: Application<()>) -> RunningApp {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral TCP listener");
let addr = listener.local_addr().expect("read bound address");
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
let join = tokio::spawn(async move {
app.serve_with_shutdown(listener, async {
let _ = shutdown_rx.await;
})
.await
.expect("application served without engine error");
});
RunningApp {
addr,
shutdown: shutdown_tx,
join,
}
}
fn addr(&self) -> SocketAddr {
self.addr
}
async fn stop(self) {
self.shutdown
.send(())
.expect("server task still alive to receive shutdown");
tokio::time::timeout(HANG_GUARD, self.join)
.await
.expect("server did not hang on shutdown")
.expect("server task did not panic");
}
}
struct MockViteIpc {
path: PathBuf,
join: JoinHandle<()>,
}
impl MockViteIpc {
async fn start() -> MockViteIpc {
let path = std::env::temp_dir().join(format!(
"arcature-dev-proxy-test-{}-{}.sock",
std::process::id(),
rand_suffix()
));
let _ = std::fs::remove_file(&path);
let listener = UnixListener::bind(&path).expect("bind Unix socket");
listener
.set_nonblocking(true)
.expect("set nonblocking for tokio");
let tokio_listener = tokio::net::UnixListener::from_std(listener)
.expect("convert std UnixListener to tokio");
let join = tokio::spawn(async move {
loop {
let (mut stream, _) = match tokio_listener.accept().await {
Ok(pair) => pair,
Err(_) => break,
};
tokio::spawn(async move {
let mut buf = vec![0u8; 4096];
let _ = tokio::time::timeout(HANG_GUARD, stream.read(&mut buf)).await;
let response = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 18\r\nConnection: close\r\n\r\nvite-mock-response";
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.shutdown().await;
});
}
});
MockViteIpc { path, join }
}
fn stop(self) {
self.join.abort();
let _ = std::fs::remove_file(&self.path);
}
}
async fn http_get(addr: SocketAddr, path: &str) -> String {
let mut stream = tokio::time::timeout(HANG_GUARD, TcpStream::connect(addr))
.await
.expect("connect did not hang")
.expect("connect succeeds");
let request = format!("GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n");
stream
.write_all(request.as_bytes())
.await
.expect("write request");
let mut buffer = Vec::new();
tokio::time::timeout(HANG_GUARD, stream.read_to_end(&mut buffer))
.await
.expect("read did not hang")
.expect("read succeeds");
String::from_utf8_lossy(&buffer).into_owned()
}
fn status_line(response: &str) -> &str {
response.split("\r\n").next().unwrap_or(response)
}
fn body(response: &str) -> &str {
response
.split_once("\r\n\r\n")
.map(|(_, b)| b)
.unwrap_or("")
}
fn rand_suffix() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0)
}
async fn app_handler() -> &'static str {
"app-response"
}
fn app_with_dev_proxy(endpoint: PathBuf) -> Application<()> {
Application::new()
.routes(Routes::new().route("/", get(app_handler)))
.dev_proxy_endpoint(Some(endpoint))
.build()
}
fn app_without_dev_proxy() -> Application<()> {
Application::new()
.routes(Routes::new().route("/", get(app_handler)))
.build()
}
#[tokio::test]
async fn vite_request_forwarded_to_ipc() {
let mock = MockViteIpc::start().await;
let app = app_with_dev_proxy(mock.path.clone());
let server = RunningApp::start(app).await;
let response = http_get(server.addr(), "/@vite/client").await;
assert_eq!(
status_line(&response),
"HTTP/1.1 200 OK",
"vite request should be forwarded to the mock IPC server"
);
assert_eq!(
body(&response),
"vite-mock-response",
"the body should come from the mock Vite IPC server, not the app"
);
server.stop().await;
mock.stop();
}
#[tokio::test]
async fn source_module_forwarded_to_ipc() {
let mock = MockViteIpc::start().await;
let app = app_with_dev_proxy(mock.path.clone());
let server = RunningApp::start(app).await;
let response = http_get(server.addr(), "/src/app.tsx").await;
assert_eq!(status_line(&response), "HTTP/1.1 200 OK");
assert_eq!(body(&response), "vite-mock-response");
server.stop().await;
mock.stop();
}
#[tokio::test]
async fn app_request_delegated_to_router() {
let mock = MockViteIpc::start().await;
let app = app_with_dev_proxy(mock.path.clone());
let server = RunningApp::start(app).await;
let response = http_get(server.addr(), "/").await;
assert_eq!(
status_line(&response),
"HTTP/1.1 200 OK",
"app request should be served by the router"
);
assert_eq!(
body(&response),
"app-response",
"the body should come from the application handler, not the mock IPC"
);
server.stop().await;
mock.stop();
}
#[tokio::test]
async fn unmatched_app_request_returns_404() {
let mock = MockViteIpc::start().await;
let app = app_with_dev_proxy(mock.path.clone());
let server = RunningApp::start(app).await;
let response = http_get(server.addr(), "/api/users").await;
assert_eq!(
status_line(&response),
"HTTP/1.1 404 Not Found",
"unmatched app request should return the app's 404, not be forwarded"
);
assert!(
!body(&response).contains("vite-mock-response"),
"the response should not come from the mock IPC server"
);
server.stop().await;
mock.stop();
}
#[tokio::test]
async fn dev_proxy_inactive_when_no_endpoint() {
let app = app_without_dev_proxy();
let server = RunningApp::start(app).await;
let response = http_get(server.addr(), "/@vite/client").await;
assert_eq!(
status_line(&response),
"HTTP/1.1 404 Not Found",
"vite request with no IPC endpoint should fall through to the app's 404"
);
server.stop().await;
}