use crate::core::hash::{self, CommentStyle};
pub fn render_mock_server_module() -> String {
hash::header(CommentStyle::DoubleSlash)
+ r#"//
// Minimal axum-based mock HTTP server for e2e tests.
#![allow(dead_code)]
use std::net::SocketAddr;
use std::sync::Arc;
use axum::Router;
use axum::body::Body;
use axum::extract::State;
use axum::http::{Request, StatusCode};
use axum::response::{IntoResponse, Response};
use tokio::net::TcpListener;
/// A single mock route: match by path + method, return a configured response.
#[derive(Clone, Debug)]
pub struct MockRoute {
/// URL path to match, e.g. `"/v1/chat/completions"`.
pub path: &'static str,
/// HTTP method to match, e.g. `"POST"` or `"GET"`.
pub method: &'static str,
/// HTTP status code to return.
pub status: u16,
/// Response body JSON string (used when `is_streaming` is false).
pub body: String,
/// Whether this route serves a streaming SSE response.
/// True even when `stream_chunks` is empty (e.g. an empty stream still sends
/// `data: [DONE]\n\n` with the correct `content-type: text/event-stream` header).
pub is_streaming: bool,
/// Ordered SSE data payloads for streaming responses.
/// Each entry becomes `data: <chunk>\n\n` in the response.
/// A final `data: [DONE]\n\n` is always appended.
pub stream_chunks: Vec<String>,
/// Response headers to apply (name, value) pairs.
/// Multiple entries with the same name produce multiple header lines.
pub headers: Vec<(String, String)>,
/// Optional artificial response delay in milliseconds. Used by timeout-error
/// fixtures to force a client-side request timeout.
pub delay_ms: Option<u64>,
}
struct ServerState {
routes: Vec<MockRoute>,
}
pub struct MockServer {
/// Base URL of the mock server, e.g. `"http://127.0.0.1:54321"`.
pub url: String,
handle: tokio::task::JoinHandle<()>,
}
impl MockServer {
/// Start a mock server with the given routes. Binds to a random port on
/// localhost and returns immediately once the server is listening.
pub async fn start(routes: Vec<MockRoute>) -> Self {
let state = Arc::new(ServerState { routes });
let app = Router::new().fallback(handle_request).with_state(state);
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("Failed to bind mock server port");
let addr: SocketAddr = listener.local_addr().expect("Failed to get local addr");
let url = format!("http://{addr}");
let handle = tokio::spawn(async move {
axum::serve(listener, app).await.expect("Mock server failed");
});
MockServer { url, handle }
}
/// Stop the mock server.
pub fn shutdown(self) {
self.handle.abort();
}
}
impl Drop for MockServer {
fn drop(&mut self) {
self.handle.abort();
}
}
async fn handle_request(State(state): State<Arc<ServerState>>, req: Request<Body>) -> Response {
let path = req.uri().path().to_owned();
let method = req.method().as_str().to_uppercase();
for route in &state.routes {
// Match on method and either exact path or path prefix (route.path is a prefix of the
// request path, separated by a '/' boundary). This allows a single route registered at
// "/v1/batches" to match requests to "/v1/batches/abc123" or
// "/v1/batches/abc123/cancel".
let path_matches = path == route.path
|| (path.starts_with(route.path)
&& path.as_bytes().get(route.path.len()) == Some(&b'/'));
if path_matches && route.method.to_uppercase() == method {
if let Some(delay_ms) = route.delay_ms {
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
}
let status =
StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
if route.is_streaming {
// Build SSE body: data: <chunk>\n\n ... data: [DONE]\n\n
// Note: stream_chunks may be empty for an empty-stream fixture; we still
// emit `data: [DONE]\n\n` with the correct SSE headers so clients do not hang.
let mut sse = String::new();
for chunk in &route.stream_chunks {
sse.push_str("data: ");
sse.push_str(chunk);
sse.push_str("\n\n");
}
sse.push_str("data: [DONE]\n\n");
let mut builder = Response::builder()
.status(status)
.header("content-type", "text/event-stream")
.header("cache-control", "no-cache");
for (name, value) in &route.headers {
builder = builder.header(name, value);
}
return builder.body(Body::from(sse)).unwrap().into_response();
}
let mut builder =
Response::builder().status(status).header("content-type", "application/json");
for (name, value) in &route.headers {
builder = builder.header(name, value);
}
return builder.body(Body::from(route.body.clone())).unwrap().into_response();
}
}
// No matching route → 404.
Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from(format!("No mock route for {method} {path}")))
.unwrap()
.into_response()
}
"#
}