arcature 2026.2.0

Arcature application framework: a high-level Application facade over the certified Arcature subsystems, with the low-level Axum/Tower escape hatch preserved.
Documentation
//! AP2.1-3: the one-port dev proxy integration test (Unix-only).
//!
//! Proves the core topology invariant: a real `Application` serves on one
//! TCP listener, and the `dev-proxy` layer (active when an IPC endpoint is
//! configured) forwards Vite-looking requests (`/@vite/`, `/src/...`) to a
//! mock Vite server over a Unix socket, while application requests (`/`,
//! `/api/`) are delegated to the Axum router.
//!
//! This is a real integration test: a real `Application` bound to a real TCP
//! listener, a real mock IPC server on a real Unix socket, real HTTP/1.1
//! requests over both transports. No `IntoResponse` calls, no fakes
//! (AGENTS.md §27).
//!
//! The endpoint is injected via the typed builder seam
//! (`ApplicationBuilder::dev_proxy_endpoint`), not via environment-variable
//! mutation — `std::env::set_var` is `unsafe` in Rust 2024 and the workspace
//! lints forbid `unsafe_code` even in test binaries. The builder method is
//! the explicit, resolved-configuration path (AGENTS.md §21).

#![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);

/// A running `Application` server on an ephemeral TCP address.
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");
    }
}

/// A mock Vite IPC server on a Unix socket.
///
/// Responds to every HTTP/1.1 request with a `200 OK` body of
/// `vite-mock-response`. The test asserts a Vite-looking request forwarded
/// through the dev proxy receives this body, proving the request reached
/// the IPC server.
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()
        ));
        // Remove any stale socket from a prior run.
        let _ = std::fs::remove_file(&path);
        // `UnixListener::bind` is `std::os::unix::net` (blocking). We need
        // `tokio::net::UnixListener` for async accept, but tokio's bind is
        // not available on all targets — use the std listener and convert.
        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 {
                    // Read the request (until empty line). We do not parse
                    // it — the mock responds unconditionally.
                    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 }
    }

    /// Abort the accept loop and clean up the socket file.
    fn stop(self) {
        self.join.abort();
        let _ = std::fs::remove_file(&self.path);
    }
}

/// Send a raw HTTP/1.1 GET request over TCP and return the full response.
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()
}

/// Extract the status line from a raw HTTP response.
fn status_line(response: &str) -> &str {
    response.split("\r\n").next().unwrap_or(response)
}

/// Extract the body (everything after the blank line) from a raw HTTP response.
fn body(response: &str) -> &str {
    response
        .split_once("\r\n\r\n")
        .map(|(_, b)| b)
        .unwrap_or("")
}

/// A cheap deterministic suffix for the socket path (no `rand` dev-dep).
fn rand_suffix() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_nanos() as u64)
        .unwrap_or(0)
}

/// The application handler — returns a fixed body so the test can
/// distinguish a delegated request from a forwarded one.
async fn app_handler() -> &'static str {
    "app-response"
}

/// Build an `Application` whose dev proxy forwards to `endpoint`. The
/// endpoint is injected via the typed builder seam — no env mutation.
fn app_with_dev_proxy(endpoint: PathBuf) -> Application<()> {
    Application::new()
        .routes(Routes::new().route("/", get(app_handler)))
        .dev_proxy_endpoint(Some(endpoint))
        .build()
}

/// Build an `Application` with no dev-proxy endpoint (the proxy is
/// inactive — pass-through). No env mutation.
fn app_without_dev_proxy() -> Application<()> {
    Application::new()
        .routes(Routes::new().route("/", get(app_handler)))
        .build()
}

/// Prove a Vite-looking request (`/@vite/client`) is forwarded to the mock
/// IPC server, not delegated to the application router.
#[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();
}

/// Prove a source-module request (`/src/app.tsx`) is forwarded to the mock
/// IPC server.
#[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();
}

/// Prove an application request (`/`) is delegated to the router, not
/// forwarded to the IPC server.
#[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();
}

/// Prove an unmatched application request (`/api/users`) is delegated and
/// returns the app's 404, not a forwarded response.
#[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();
}

/// Prove the dev proxy is inactive when no endpoint is configured —
/// Vite-looking requests fall through to the app's router (404). No env
/// mutation; the endpoint is simply not set on the builder.
#[tokio::test]
async fn dev_proxy_inactive_when_no_endpoint() {
    let app = app_without_dev_proxy();
    let server = RunningApp::start(app).await;

    // A Vite-looking request with no IPC endpoint → delegated to the app.
    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;
}