1use std::fmt::Write as FmtWrite;
4
5use alef_core::hash::{self, CommentStyle};
6
7use crate::config::E2eConfig;
8use crate::escape::rust_raw_string;
9use crate::fixture::Fixture;
10
11pub fn render_mock_server_setup(out: &mut String, fixture: &Fixture, e2e_config: &E2eConfig) {
17 let mock = match fixture.mock_response.as_ref() {
18 Some(m) => m,
19 None => return,
20 };
21
22 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
24 let path = call_config.path.as_deref().unwrap_or("/");
25 let method = call_config.method.as_deref().unwrap_or("POST");
26
27 let status = mock.status;
28
29 let mut header_entries: Vec<(&String, &String)> = mock.headers.iter().collect();
31 header_entries.sort_by(|a, b| a.0.cmp(b.0));
32 let render_headers = |out: &mut String| {
33 let _ = writeln!(out, " headers: vec![");
34 for (name, value) in &header_entries {
35 let n = rust_raw_string(name);
36 let v = rust_raw_string(value);
37 let _ = writeln!(out, " ({n}.to_string(), {v}.to_string()),");
38 }
39 let _ = writeln!(out, " ],");
40 };
41
42 if let Some(chunks) = &mock.stream_chunks {
43 let _ = writeln!(out, " let mock_route = MockRoute {{");
45 let _ = writeln!(out, " path: \"{path}\",");
46 let _ = writeln!(out, " method: \"{method}\",");
47 let _ = writeln!(out, " status: {status},");
48 let _ = writeln!(out, " body: String::new(),");
49 let _ = writeln!(out, " stream_chunks: vec![");
50 for chunk in chunks {
51 let chunk_str = match chunk {
52 serde_json::Value::String(s) => rust_raw_string(s),
53 other => {
54 let s = serde_json::to_string(other).unwrap_or_default();
55 rust_raw_string(&s)
56 }
57 };
58 let _ = writeln!(out, " {chunk_str}.to_string(),");
59 }
60 let _ = writeln!(out, " ],");
61 render_headers(out);
62 let _ = writeln!(out, " }};");
63 } else {
64 let body_str = match &mock.body {
66 Some(b) => {
67 let s = serde_json::to_string(b).unwrap_or_default();
68 rust_raw_string(&s)
69 }
70 None => rust_raw_string("{}"),
71 };
72 let _ = writeln!(out, " let mock_route = MockRoute {{");
73 let _ = writeln!(out, " path: \"{path}\",");
74 let _ = writeln!(out, " method: \"{method}\",");
75 let _ = writeln!(out, " status: {status},");
76 let _ = writeln!(out, " body: {body_str}.to_string(),");
77 let _ = writeln!(out, " stream_chunks: vec![],");
78 render_headers(out);
79 let _ = writeln!(out, " }};");
80 }
81
82 let _ = writeln!(out, " let mock_server = MockServer::start(vec![mock_route]).await;");
83}
84
85pub fn render_mock_server_module() -> String {
87 hash::header(CommentStyle::DoubleSlash)
90 + r#"//
91// Minimal axum-based mock HTTP server for e2e tests.
92
93use std::net::SocketAddr;
94use std::sync::Arc;
95
96use axum::Router;
97use axum::body::Body;
98use axum::extract::State;
99use axum::http::{Request, StatusCode};
100use axum::response::{IntoResponse, Response};
101use tokio::net::TcpListener;
102
103/// A single mock route: match by path + method, return a configured response.
104#[derive(Clone, Debug)]
105pub struct MockRoute {
106 /// URL path to match, e.g. `"/v1/chat/completions"`.
107 pub path: &'static str,
108 /// HTTP method to match, e.g. `"POST"` or `"GET"`.
109 pub method: &'static str,
110 /// HTTP status code to return.
111 pub status: u16,
112 /// Response body JSON string (used when `stream_chunks` is empty).
113 pub body: String,
114 /// Ordered SSE data payloads for streaming responses.
115 /// Each entry becomes `data: <chunk>\n\n` in the response.
116 /// A final `data: [DONE]\n\n` is always appended.
117 pub stream_chunks: Vec<String>,
118 /// Response headers to apply (name, value) pairs.
119 /// Multiple entries with the same name produce multiple header lines.
120 pub headers: Vec<(String, String)>,
121}
122
123struct ServerState {
124 routes: Vec<MockRoute>,
125}
126
127pub struct MockServer {
128 /// Base URL of the mock server, e.g. `"http://127.0.0.1:54321"`.
129 pub url: String,
130 handle: tokio::task::JoinHandle<()>,
131}
132
133impl MockServer {
134 /// Start a mock server with the given routes. Binds to a random port on
135 /// localhost and returns immediately once the server is listening.
136 pub async fn start(routes: Vec<MockRoute>) -> Self {
137 let state = Arc::new(ServerState { routes });
138
139 let app = Router::new().fallback(handle_request).with_state(state);
140
141 let listener = TcpListener::bind("127.0.0.1:0")
142 .await
143 .expect("Failed to bind mock server port");
144 let addr: SocketAddr = listener.local_addr().expect("Failed to get local addr");
145 let url = format!("http://{addr}");
146
147 let handle = tokio::spawn(async move {
148 axum::serve(listener, app).await.expect("Mock server failed");
149 });
150
151 MockServer { url, handle }
152 }
153
154 /// Stop the mock server.
155 pub fn shutdown(self) {
156 self.handle.abort();
157 }
158}
159
160impl Drop for MockServer {
161 fn drop(&mut self) {
162 self.handle.abort();
163 }
164}
165
166async fn handle_request(State(state): State<Arc<ServerState>>, req: Request<Body>) -> Response {
167 let path = req.uri().path().to_owned();
168 let method = req.method().as_str().to_uppercase();
169
170 for route in &state.routes {
171 // Match on method and either exact path or path prefix (route.path is a prefix of the
172 // request path, separated by a '/' boundary). This allows a single route registered at
173 // "/v1/batches" to match requests to "/v1/batches/abc123" or
174 // "/v1/batches/abc123/cancel".
175 let path_matches = path == route.path
176 || (path.starts_with(route.path)
177 && path.as_bytes().get(route.path.len()) == Some(&b'/'));
178 if path_matches && route.method.to_uppercase() == method {
179 let status =
180 StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
181
182 if !route.stream_chunks.is_empty() {
183 // Build SSE body: data: <chunk>\n\n ... data: [DONE]\n\n
184 let mut sse = String::new();
185 for chunk in &route.stream_chunks {
186 sse.push_str("data: ");
187 sse.push_str(chunk);
188 sse.push_str("\n\n");
189 }
190 sse.push_str("data: [DONE]\n\n");
191
192 let mut builder = Response::builder()
193 .status(status)
194 .header("content-type", "text/event-stream")
195 .header("cache-control", "no-cache");
196 for (name, value) in &route.headers {
197 builder = builder.header(name, value);
198 }
199 return builder.body(Body::from(sse)).unwrap().into_response();
200 }
201
202 let mut builder =
203 Response::builder().status(status).header("content-type", "application/json");
204 for (name, value) in &route.headers {
205 builder = builder.header(name, value);
206 }
207 return builder.body(Body::from(route.body.clone())).unwrap().into_response();
208 }
209 }
210
211 // No matching route → 404.
212 Response::builder()
213 .status(StatusCode::NOT_FOUND)
214 .body(Body::from(format!("No mock route for {method} {path}")))
215 .unwrap()
216 .into_response()
217}
218"#
219}
220
221pub fn render_mock_server_binary() -> String {
233 hash::header(CommentStyle::DoubleSlash)
234 + r#"//
235// Standalone mock HTTP server binary for cross-language e2e tests.
236// Reads fixture JSON files and serves mock responses on /fixtures/{fixture_id}.
237//
238// Usage: mock-server [fixtures-dir]
239// fixtures-dir defaults to "../../fixtures"
240//
241// Prints `MOCK_SERVER_URL=http://127.0.0.1:<port>` to stdout once listening,
242// then blocks until stdin is closed (parent process exit triggers cleanup).
243
244use std::collections::HashMap;
245use std::io::{self, BufRead};
246use std::net::SocketAddr;
247use std::path::Path;
248use std::sync::Arc;
249
250use axum::Router;
251use axum::body::Body;
252use axum::extract::State;
253use axum::http::{Request, StatusCode};
254use axum::response::{IntoResponse, Response};
255use serde::Deserialize;
256use tokio::net::TcpListener;
257
258// ---------------------------------------------------------------------------
259// Fixture types (mirrors alef-e2e's fixture.rs for runtime deserialization)
260// Supports both schemas:
261// liter-llm: mock_response: { status, body, stream_chunks }
262// spikard: http.expected_response: { status_code, body, headers }
263// ---------------------------------------------------------------------------
264
265#[derive(Debug, Deserialize)]
266struct MockResponse {
267 status: u16,
268 #[serde(default)]
269 body: Option<serde_json::Value>,
270 #[serde(default)]
271 stream_chunks: Option<Vec<serde_json::Value>>,
272 #[serde(default)]
273 headers: HashMap<String, String>,
274}
275
276#[derive(Debug, Deserialize)]
277struct HttpExpectedResponse {
278 status_code: u16,
279 #[serde(default)]
280 body: Option<serde_json::Value>,
281 #[serde(default)]
282 headers: HashMap<String, String>,
283}
284
285#[derive(Debug, Deserialize)]
286struct HttpFixture {
287 expected_response: HttpExpectedResponse,
288}
289
290#[derive(Debug, Deserialize)]
291struct Fixture {
292 id: String,
293 #[serde(default)]
294 mock_response: Option<MockResponse>,
295 #[serde(default)]
296 http: Option<HttpFixture>,
297}
298
299impl Fixture {
300 /// Bridge both schemas into a unified MockResponse.
301 fn as_mock_response(&self) -> Option<MockResponse> {
302 if let Some(mock) = &self.mock_response {
303 return Some(MockResponse {
304 status: mock.status,
305 body: mock.body.clone(),
306 stream_chunks: mock.stream_chunks.clone(),
307 headers: mock.headers.clone(),
308 });
309 }
310 if let Some(http) = &self.http {
311 return Some(MockResponse {
312 status: http.expected_response.status_code,
313 body: http.expected_response.body.clone(),
314 stream_chunks: None,
315 headers: http.expected_response.headers.clone(),
316 });
317 }
318 None
319 }
320}
321
322// ---------------------------------------------------------------------------
323// Route table
324// ---------------------------------------------------------------------------
325
326#[derive(Clone, Debug)]
327struct MockRoute {
328 status: u16,
329 body: String,
330 stream_chunks: Vec<String>,
331 headers: Vec<(String, String)>,
332}
333
334type RouteTable = Arc<HashMap<String, MockRoute>>;
335
336// ---------------------------------------------------------------------------
337// Axum handler
338// ---------------------------------------------------------------------------
339
340async fn handle_request(State(routes): State<RouteTable>, req: Request<Body>) -> Response {
341 let path = req.uri().path().to_owned();
342
343 // Try exact match first
344 if let Some(route) = routes.get(&path) {
345 return serve_route(route);
346 }
347
348 // Try prefix match: find a route that is a prefix of the request path
349 // This allows /fixtures/basic_chat/v1/chat/completions to match /fixtures/basic_chat
350 for (route_path, route) in routes.iter() {
351 if path.starts_with(route_path) && (path.len() == route_path.len() || path.as_bytes()[route_path.len()] == b'/') {
352 return serve_route(route);
353 }
354 }
355
356 Response::builder()
357 .status(StatusCode::NOT_FOUND)
358 .body(Body::from(format!("No mock route for {path}")))
359 .unwrap()
360 .into_response()
361}
362
363fn serve_route(route: &MockRoute) -> Response {
364 let status = StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
365
366 if !route.stream_chunks.is_empty() {
367 let mut sse = String::new();
368 for chunk in &route.stream_chunks {
369 sse.push_str("data: ");
370 sse.push_str(chunk);
371 sse.push_str("\n\n");
372 }
373 sse.push_str("data: [DONE]\n\n");
374
375 let mut builder = Response::builder()
376 .status(status)
377 .header("content-type", "text/event-stream")
378 .header("cache-control", "no-cache");
379 for (name, value) in &route.headers {
380 builder = builder.header(name, value);
381 }
382 return builder.body(Body::from(sse)).unwrap().into_response();
383 }
384
385 // Only set the default content-type if the fixture does not override it.
386 // Use application/json when the body looks like JSON (starts with { or [),
387 // otherwise fall back to text/plain to avoid clients failing JSON-decode.
388 let has_content_type = route.headers.iter().any(|(k, _)| k.to_lowercase() == "content-type");
389 let mut builder = Response::builder().status(status);
390 if !has_content_type {
391 let trimmed = route.body.trim_start();
392 let default_ct = if trimmed.starts_with('{') || trimmed.starts_with('[') {
393 "application/json"
394 } else {
395 "text/plain"
396 };
397 builder = builder.header("content-type", default_ct);
398 }
399 for (name, value) in &route.headers {
400 // Skip content-encoding headers — the mock server returns uncompressed bodies.
401 // Sending a content-encoding without actually encoding the body would cause
402 // clients to fail decompression.
403 if name.to_lowercase() == "content-encoding" {
404 continue;
405 }
406 // The <<absent>> sentinel means this header must NOT be present in the
407 // real server response — do not emit it from the mock server either.
408 if value == "<<absent>>" {
409 continue;
410 }
411 // Replace the <<uuid>> sentinel with a real UUID v4 so clients can
412 // assert the header value matches the UUID pattern.
413 if value == "<<uuid>>" {
414 let uuid = format!(
415 "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
416 rand_u32(),
417 rand_u16(),
418 rand_u16() & 0x0fff,
419 (rand_u16() & 0x3fff) | 0x8000,
420 rand_u48(),
421 );
422 builder = builder.header(name, uuid);
423 continue;
424 }
425 builder = builder.header(name, value);
426 }
427 builder.body(Body::from(route.body.clone())).unwrap().into_response()
428}
429
430/// Generate a pseudo-random u32 using the current time nanoseconds.
431fn rand_u32() -> u32 {
432 use std::time::{SystemTime, UNIX_EPOCH};
433 let ns = SystemTime::now()
434 .duration_since(UNIX_EPOCH)
435 .map(|d| d.subsec_nanos())
436 .unwrap_or(0);
437 ns ^ (ns.wrapping_shl(13)) ^ (ns.wrapping_shr(17))
438}
439
440fn rand_u16() -> u16 {
441 (rand_u32() & 0xffff) as u16
442}
443
444fn rand_u48() -> u64 {
445 ((rand_u32() as u64) << 16) | (rand_u16() as u64)
446}
447
448// ---------------------------------------------------------------------------
449// Fixture loading
450// ---------------------------------------------------------------------------
451
452fn load_routes(fixtures_dir: &Path) -> HashMap<String, MockRoute> {
453 let mut routes = HashMap::new();
454 load_routes_recursive(fixtures_dir, &mut routes);
455 routes
456}
457
458fn load_routes_recursive(dir: &Path, routes: &mut HashMap<String, MockRoute>) {
459 let entries = match std::fs::read_dir(dir) {
460 Ok(e) => e,
461 Err(err) => {
462 eprintln!("warning: cannot read directory {}: {err}", dir.display());
463 return;
464 }
465 };
466
467 let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
468 paths.sort();
469
470 for path in paths {
471 if path.is_dir() {
472 load_routes_recursive(&path, routes);
473 } else if path.extension().is_some_and(|ext| ext == "json") {
474 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
475 if filename == "schema.json" || filename.starts_with('_') {
476 continue;
477 }
478 let content = match std::fs::read_to_string(&path) {
479 Ok(c) => c,
480 Err(err) => {
481 eprintln!("warning: cannot read {}: {err}", path.display());
482 continue;
483 }
484 };
485 let fixtures: Vec<Fixture> = if content.trim_start().starts_with('[') {
486 match serde_json::from_str(&content) {
487 Ok(v) => v,
488 Err(err) => {
489 eprintln!("warning: cannot parse {}: {err}", path.display());
490 continue;
491 }
492 }
493 } else {
494 match serde_json::from_str::<Fixture>(&content) {
495 Ok(f) => vec![f],
496 Err(err) => {
497 eprintln!("warning: cannot parse {}: {err}", path.display());
498 continue;
499 }
500 }
501 };
502
503 for fixture in fixtures {
504 if let Some(mock) = fixture.as_mock_response() {
505 let route_path = format!("/fixtures/{}", fixture.id);
506 let body = mock
507 .body
508 .as_ref()
509 .map(|b| match b {
510 // Plain strings (e.g. text/plain bodies) are stored as JSON strings in
511 // fixtures. Return the raw value so clients receive the string itself,
512 // not its JSON-encoded form with extra surrounding quotes.
513 serde_json::Value::String(s) => s.clone(),
514 other => serde_json::to_string(other).unwrap_or_default(),
515 })
516 .unwrap_or_default();
517 let stream_chunks = mock
518 .stream_chunks
519 .unwrap_or_default()
520 .into_iter()
521 .map(|c| match c {
522 serde_json::Value::String(s) => s,
523 other => serde_json::to_string(&other).unwrap_or_default(),
524 })
525 .collect();
526 let mut headers: Vec<(String, String)> =
527 mock.headers.into_iter().collect();
528 headers.sort_by(|a, b| a.0.cmp(&b.0));
529 routes.insert(route_path, MockRoute { status: mock.status, body, stream_chunks, headers });
530 }
531 }
532 }
533 }
534}
535
536// ---------------------------------------------------------------------------
537// Entry point
538// ---------------------------------------------------------------------------
539
540#[tokio::main]
541async fn main() {
542 let fixtures_dir_arg = std::env::args().nth(1).unwrap_or_else(|| "../../fixtures".to_string());
543 let fixtures_dir = Path::new(&fixtures_dir_arg);
544
545 let routes = load_routes(fixtures_dir);
546 eprintln!("mock-server: loaded {} routes from {}", routes.len(), fixtures_dir.display());
547
548 let route_table: RouteTable = Arc::new(routes);
549 let app = Router::new().fallback(handle_request).with_state(route_table);
550
551 let listener = TcpListener::bind("127.0.0.1:0")
552 .await
553 .expect("mock-server: failed to bind port");
554 let addr: SocketAddr = listener.local_addr().expect("mock-server: failed to get local addr");
555
556 // Print the URL so the parent process can read it.
557 println!("MOCK_SERVER_URL=http://{addr}");
558 // Flush stdout explicitly so the parent does not block waiting.
559 use std::io::Write;
560 std::io::stdout().flush().expect("mock-server: failed to flush stdout");
561
562 // Spawn the server in the background.
563 tokio::spawn(async move {
564 axum::serve(listener, app).await.expect("mock-server: server error");
565 });
566
567 // Block until stdin is closed — the parent process controls lifetime.
568 let stdin = io::stdin();
569 let mut lines = stdin.lock().lines();
570 while lines.next().is_some() {}
571}
572"#
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578
579 #[test]
580 fn render_mock_server_module_contains_struct_definition() {
581 let out = render_mock_server_module();
582 assert!(out.contains("pub struct MockRoute"));
583 assert!(out.contains("pub struct MockServer"));
584 }
585
586 #[test]
587 fn render_mock_server_binary_contains_main() {
588 let out = render_mock_server_binary();
589 assert!(out.contains("async fn main()"));
590 assert!(out.contains("MOCK_SERVER_URL=http://"));
591 }
592}