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, var_name: &str) {
23 let mut routes = Vec::new();
25
26 if let Some(mock_responses) = fixture.input.get("mock_responses").and_then(|v| v.as_array()) {
27 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
29 let default_path = call_config.path.as_deref().unwrap_or("/");
30 let default_method = call_config.method.as_deref().unwrap_or("POST");
31
32 for response in mock_responses {
33 if let Ok(obj) = serde_json::from_value::<serde_json::Map<String, serde_json::Value>>(response.clone()) {
34 let path = obj
35 .get("path")
36 .and_then(|v| v.as_str())
37 .unwrap_or(default_path)
38 .to_string();
39 let method = obj
40 .get("method")
41 .and_then(|v| v.as_str())
42 .unwrap_or(default_method)
43 .to_string();
44 let status: u16 = obj.get("status_code").and_then(|v| v.as_u64()).unwrap_or(200) as u16;
45
46 let headers: Vec<(String, String)> = obj
47 .get("headers")
48 .and_then(|v| v.as_object())
49 .map(|h| {
50 let mut entries: Vec<_> = h
51 .iter()
52 .map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string()))
53 .collect();
54 entries.sort_by(|a, b| a.0.cmp(&b.0));
55 entries
56 })
57 .unwrap_or_default();
58
59 let body_str = if let Some(body_inline) = obj.get("body_inline").and_then(|v| v.as_str()) {
60 rust_raw_string(body_inline)
61 } else {
62 rust_raw_string("{}")
65 };
66
67 let delay_ms = obj.get("delay_ms").and_then(|v| v.as_u64());
68
69 routes.push((path, method, status, body_str, headers, delay_ms));
70 }
71 }
72 } else if let Some(mock) = fixture.mock_response.as_ref() {
73 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
75 let path = call_config.path.as_deref().unwrap_or("/");
76 let method = call_config.method.as_deref().unwrap_or("POST");
77
78 let status = mock.status;
79
80 let mut header_entries: Vec<(&String, &String)> = mock.headers.iter().collect();
82 header_entries.sort_by(|a, b| a.0.cmp(b.0));
83 let header_tuples: Vec<(String, String)> = header_entries
84 .into_iter()
85 .map(|(k, v)| (k.clone(), v.clone()))
86 .collect();
87
88 let body_str = match &mock.body {
89 Some(b) => {
90 let s = serde_json::to_string(b).unwrap_or_default();
91 rust_raw_string(&s)
92 }
93 None => rust_raw_string("{}"),
94 };
95
96 if let Some(chunks) = &mock.stream_chunks {
98 let _ = writeln!(out, " let mock_route = MockRoute {{");
100 let _ = writeln!(out, " path: \"{path}\",");
101 let _ = writeln!(out, " method: \"{method}\",");
102 let _ = writeln!(out, " status: {status},");
103 let _ = writeln!(out, " body: String::new(),");
104 let _ = writeln!(out, " is_streaming: true,");
105 let _ = writeln!(out, " stream_chunks: vec![");
106 for chunk in chunks {
107 let chunk_str = match chunk {
108 serde_json::Value::String(s) => rust_raw_string(s),
109 other => {
110 let s = serde_json::to_string(other).unwrap_or_default();
111 rust_raw_string(&s)
112 }
113 };
114 let _ = writeln!(out, " {chunk_str}.to_string(),");
115 }
116 let _ = writeln!(out, " ],");
117 let _ = writeln!(out, " headers: vec![");
118 for (name, value) in &header_tuples {
119 let n = rust_raw_string(name);
120 let v = rust_raw_string(value);
121 let _ = writeln!(out, " ({n}.to_string(), {v}.to_string()),");
122 }
123 let _ = writeln!(out, " ],");
124 let _ = writeln!(out, " delay_ms: None,");
125 let _ = writeln!(out, " }};");
126 let _ = writeln!(out, " let {var_name} = MockServer::start(vec![mock_route]).await;");
127 return;
128 }
129
130 routes.push((
131 path.to_string(),
132 method.to_string(),
133 status,
134 body_str,
135 header_tuples,
136 None,
137 ));
138 } else {
139 return;
140 }
141
142 if routes.len() == 1 {
144 let (path, method, status, body_str, header_entries, delay_ms) = routes.pop().unwrap();
145 let delay_literal = match delay_ms {
146 Some(ms) => format!("Some({ms})"),
147 None => "None".to_string(),
148 };
149 let _ = writeln!(out, " let mock_route = MockRoute {{");
150 let _ = writeln!(out, " path: \"{path}\",");
151 let _ = writeln!(out, " method: \"{method}\",");
152 let _ = writeln!(out, " status: {status},");
153 let _ = writeln!(out, " body: {body_str}.to_string(),");
154 let _ = writeln!(out, " is_streaming: false,");
155 let _ = writeln!(out, " stream_chunks: vec![],");
156 let _ = writeln!(out, " headers: vec![");
157 for (name, value) in &header_entries {
158 let n = rust_raw_string(name);
159 let v = rust_raw_string(value);
160 let _ = writeln!(out, " ({n}.to_string(), {v}.to_string()),");
161 }
162 let _ = writeln!(out, " ],");
163 let _ = writeln!(out, " delay_ms: {delay_literal},");
164 let _ = writeln!(out, " }};");
165 let _ = writeln!(out, " let {var_name} = MockServer::start(vec![mock_route]).await;");
166 } else {
167 let _ = writeln!(out, " let mut mock_routes = vec![];");
169 for (path, method, status, body_str, header_entries, delay_ms) in routes {
170 let delay_literal = match delay_ms {
171 Some(ms) => format!("Some({ms})"),
172 None => "None".to_string(),
173 };
174 let _ = writeln!(out, " mock_routes.push(MockRoute {{");
175 let _ = writeln!(out, " path: \"{path}\",");
176 let _ = writeln!(out, " method: \"{method}\",");
177 let _ = writeln!(out, " status: {status},");
178 let _ = writeln!(out, " body: {body_str}.to_string(),");
179 let _ = writeln!(out, " is_streaming: false,");
180 let _ = writeln!(out, " stream_chunks: vec![],");
181 let _ = writeln!(out, " headers: vec![");
182 for (name, value) in &header_entries {
183 let n = rust_raw_string(name);
184 let v = rust_raw_string(value);
185 let _ = writeln!(out, " ({n}.to_string(), {v}.to_string()),");
186 }
187 let _ = writeln!(out, " ],");
188 let _ = writeln!(out, " delay_ms: {delay_literal},");
189 let _ = writeln!(out, " }});");
190 }
191 let _ = writeln!(out, " let {var_name} = MockServer::start(mock_routes).await;");
192 }
193}
194
195pub fn render_mock_server_module() -> String {
197 hash::header(CommentStyle::DoubleSlash)
209 + r#"//
210// Minimal axum-based mock HTTP server for e2e tests.
211
212#![allow(dead_code)]
213
214use std::net::SocketAddr;
215use std::sync::Arc;
216
217use axum::Router;
218use axum::body::Body;
219use axum::extract::State;
220use axum::http::{Request, StatusCode};
221use axum::response::{IntoResponse, Response};
222use tokio::net::TcpListener;
223
224/// A single mock route: match by path + method, return a configured response.
225#[derive(Clone, Debug)]
226pub struct MockRoute {
227 /// URL path to match, e.g. `"/v1/chat/completions"`.
228 pub path: &'static str,
229 /// HTTP method to match, e.g. `"POST"` or `"GET"`.
230 pub method: &'static str,
231 /// HTTP status code to return.
232 pub status: u16,
233 /// Response body JSON string (used when `is_streaming` is false).
234 pub body: String,
235 /// Whether this route serves a streaming SSE response.
236 /// True even when `stream_chunks` is empty (e.g. an empty stream still sends
237 /// `data: [DONE]\n\n` with the correct `content-type: text/event-stream` header).
238 pub is_streaming: bool,
239 /// Ordered SSE data payloads for streaming responses.
240 /// Each entry becomes `data: <chunk>\n\n` in the response.
241 /// A final `data: [DONE]\n\n` is always appended.
242 pub stream_chunks: Vec<String>,
243 /// Response headers to apply (name, value) pairs.
244 /// Multiple entries with the same name produce multiple header lines.
245 pub headers: Vec<(String, String)>,
246 /// Optional artificial response delay in milliseconds. Used by timeout-error
247 /// fixtures to force a client-side request timeout.
248 pub delay_ms: Option<u64>,
249}
250
251struct ServerState {
252 routes: Vec<MockRoute>,
253}
254
255pub struct MockServer {
256 /// Base URL of the mock server, e.g. `"http://127.0.0.1:54321"`.
257 pub url: String,
258 handle: tokio::task::JoinHandle<()>,
259}
260
261impl MockServer {
262 /// Start a mock server with the given routes. Binds to a random port on
263 /// localhost and returns immediately once the server is listening.
264 pub async fn start(routes: Vec<MockRoute>) -> Self {
265 let state = Arc::new(ServerState { routes });
266
267 let app = Router::new().fallback(handle_request).with_state(state);
268
269 let listener = TcpListener::bind("127.0.0.1:0")
270 .await
271 .expect("Failed to bind mock server port");
272 let addr: SocketAddr = listener.local_addr().expect("Failed to get local addr");
273 let url = format!("http://{addr}");
274
275 let handle = tokio::spawn(async move {
276 axum::serve(listener, app).await.expect("Mock server failed");
277 });
278
279 MockServer { url, handle }
280 }
281
282 /// Stop the mock server.
283 pub fn shutdown(self) {
284 self.handle.abort();
285 }
286}
287
288impl Drop for MockServer {
289 fn drop(&mut self) {
290 self.handle.abort();
291 }
292}
293
294async fn handle_request(State(state): State<Arc<ServerState>>, req: Request<Body>) -> Response {
295 let path = req.uri().path().to_owned();
296 let method = req.method().as_str().to_uppercase();
297
298 for route in &state.routes {
299 // Match on method and either exact path or path prefix (route.path is a prefix of the
300 // request path, separated by a '/' boundary). This allows a single route registered at
301 // "/v1/batches" to match requests to "/v1/batches/abc123" or
302 // "/v1/batches/abc123/cancel".
303 let path_matches = path == route.path
304 || (path.starts_with(route.path)
305 && path.as_bytes().get(route.path.len()) == Some(&b'/'));
306 if path_matches && route.method.to_uppercase() == method {
307 if let Some(delay_ms) = route.delay_ms {
308 tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
309 }
310 let status =
311 StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
312
313 if route.is_streaming {
314 // Build SSE body: data: <chunk>\n\n ... data: [DONE]\n\n
315 // Note: stream_chunks may be empty for an empty-stream fixture; we still
316 // emit `data: [DONE]\n\n` with the correct SSE headers so clients do not hang.
317 let mut sse = String::new();
318 for chunk in &route.stream_chunks {
319 sse.push_str("data: ");
320 sse.push_str(chunk);
321 sse.push_str("\n\n");
322 }
323 sse.push_str("data: [DONE]\n\n");
324
325 let mut builder = Response::builder()
326 .status(status)
327 .header("content-type", "text/event-stream")
328 .header("cache-control", "no-cache");
329 for (name, value) in &route.headers {
330 builder = builder.header(name, value);
331 }
332 return builder.body(Body::from(sse)).unwrap().into_response();
333 }
334
335 let mut builder =
336 Response::builder().status(status).header("content-type", "application/json");
337 for (name, value) in &route.headers {
338 builder = builder.header(name, value);
339 }
340 return builder.body(Body::from(route.body.clone())).unwrap().into_response();
341 }
342 }
343
344 // No matching route → 404.
345 Response::builder()
346 .status(StatusCode::NOT_FOUND)
347 .body(Body::from(format!("No mock route for {method} {path}")))
348 .unwrap()
349 .into_response()
350}
351"#
352}
353
354pub fn render_common_module() -> String {
368 hash::header(CommentStyle::DoubleSlash)
376 + r#"//
377// Auto-spawned mock server setup for e2e tests.
378// This module is auto-generated and should not be edited manually.
379
380#![allow(dead_code)]
381
382use std::sync::OnceLock;
383
384static MOCK_SERVER_URL: OnceLock<String> = OnceLock::new();
385
386/// Get the mock server URL, spawning the server if not already running.
387///
388/// The server is spawned once per test process and reused by all tests.
389/// On first call, this function:
390/// - Spawns the `target/release/mock-server` binary
391/// - Reads `MOCK_SERVER_URL=http://...` from its stdout
392/// - Parses `MOCK_SERVERS={...}` JSON and sets env vars for per-fixture servers
393/// - Sets `MOCK_SERVER_URL` env var globally
394/// - Drains remaining stdout in a background thread
395///
396/// Subsequent calls return the cached URL without spawning again.
397pub fn mock_server_url() -> &'static str {
398 MOCK_SERVER_URL.get_or_init(|| {
399 let mock_server_bin = concat!(
400 env!("CARGO_MANIFEST_DIR"),
401 "/target/release/mock-server"
402 );
403 let fixtures_dir = concat!(
404 env!("CARGO_MANIFEST_DIR"),
405 "/../../fixtures"
406 );
407
408 // Spawn the mock-server binary with fixtures directory as argument.
409 let mut child = std::process::Command::new(mock_server_bin)
410 .arg(fixtures_dir)
411 .stdout(std::process::Stdio::piped())
412 .stdin(std::process::Stdio::piped())
413 .spawn()
414 .expect("Failed to spawn mock-server binary");
415
416 let stdout = child.stdout.take().expect("Failed to get stdout");
417 let stdin = child.stdin.take().expect("Failed to get stdin");
418
419 let mut url = String::new();
420 let mut line_buffer = String::new();
421 let mut line_count = 0;
422
423 // Read startup lines from the mock server.
424 // Expected: MOCK_SERVER_URL=http://... then MOCK_SERVERS={...json...}
425 // The server prints one line per loaded fixture before the markers, so the
426 // ceiling has to be high enough to clear hundreds of "loaded route" lines.
427 // We bail on the first `MOCK_SERVERS=` line (always emitted last) rather than
428 // relying on the line cap.
429 use std::io::BufRead;
430 let mut reader = std::io::BufReader::new(stdout);
431
432 while line_count < 2048 {
433 line_buffer.clear();
434 match reader.read_line(&mut line_buffer) {
435 Ok(0) => break, // EOF
436 Ok(_) => {
437 let line = line_buffer.trim();
438 if line.starts_with("MOCK_SERVER_URL=") {
439 url = line.strip_prefix("MOCK_SERVER_URL=")
440 .unwrap_or("")
441 .to_string();
442 } else if line.starts_with("MOCK_SERVERS=") {
443 let json_str = line.strip_prefix("MOCK_SERVERS=")
444 .unwrap_or("{}");
445 // Parse the JSON map and set env vars for each entry.
446 if let Ok(servers) = serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(json_str) {
447 for (fid, furl) in servers {
448 if let serde_json::Value::String(url_str) = furl {
449 let env_key = format!("MOCK_SERVER_{}", fid.to_uppercase());
450 std::env::set_var(&env_key, &url_str);
451 }
452 }
453 }
454 std::env::set_var("MOCK_SERVERS", json_str);
455 // We have seen both lines; stop reading.
456 break;
457 }
458 line_count += 1;
459 }
460 Err(_) => break,
461 }
462 }
463
464 // Set the main URL env var globally.
465 std::env::set_var("MOCK_SERVER_URL", &url);
466
467 // Drain remaining stdout in a background thread to prevent the server from blocking.
468 std::thread::spawn(move || {
469 let _ = std::io::copy(&mut reader.into_inner(), &mut std::io::sink());
470 });
471
472 // Keep stdin alive for the test process lifetime — the mock-server treats
473 // stdin EOF as the parent's shutdown signal, so dropping the handle would
474 // make it exit before any test connects.
475 Box::leak(Box::new(stdin));
476
477 // Return the URL for this process.
478 url
479 }).as_str()
480}
481"#
482}
483
484pub fn render_mock_server_binary() -> String {
499 hash::header(CommentStyle::DoubleSlash)
500 + r#"//
501// Standalone mock HTTP server binary for cross-language e2e tests.
502// Reads fixture JSON files and serves mock responses on /fixtures/{fixture_id}.
503//
504// Usage: mock-server [fixtures-dir]
505// fixtures-dir defaults to "../../fixtures"
506//
507// Prints `MOCK_SERVER_URL=http://127.0.0.1:<port>` to stdout once listening,
508// then optionally `MOCK_SERVERS={...}` with per-fixture URLs for host-root fixtures,
509// then blocks until stdin is closed (parent process exit triggers cleanup).
510
511use std::collections::HashMap;
512use std::io::{self, BufRead};
513use std::net::SocketAddr;
514use std::path::Path;
515use std::sync::Arc;
516
517use axum::Router;
518use axum::body::Body;
519use axum::extract::State;
520use axum::http::{Request, StatusCode};
521use axum::response::{IntoResponse, Response};
522use serde::Deserialize;
523use tokio::net::TcpListener;
524
525// ---------------------------------------------------------------------------
526// Fixture types (mirrors alef-e2e's fixture.rs for runtime deserialization)
527// Supports both schemas:
528// liter-llm: mock_response: { status, body, stream_chunks }
529// spikard: http.expected_response: { status_code, body, headers }
530// ---------------------------------------------------------------------------
531
532#[derive(Debug, Deserialize)]
533struct MockResponse {
534 status: u16,
535 #[serde(default)]
536 body: Option<serde_json::Value>,
537 #[serde(default)]
538 stream_chunks: Option<Vec<serde_json::Value>>,
539 #[serde(default)]
540 headers: HashMap<String, String>,
541 /// Optional artificial delay (milliseconds) applied before sending the response.
542 /// Used by timeout-error fixtures to force the client request to time out.
543 #[serde(default)]
544 delay_ms: Option<u64>,
545}
546
547#[derive(Debug, Deserialize)]
548struct HttpExpectedResponse {
549 status_code: u16,
550 #[serde(default)]
551 body: Option<serde_json::Value>,
552 #[serde(default)]
553 headers: HashMap<String, String>,
554}
555
556#[derive(Debug, Deserialize)]
557struct HttpFixture {
558 expected_response: HttpExpectedResponse,
559}
560
561#[derive(Debug, Deserialize)]
562struct Fixture {
563 id: String,
564 #[serde(default)]
565 mock_response: Option<MockResponse>,
566 #[serde(default)]
567 http: Option<HttpFixture>,
568 /// Array-form fixture schema. `input.mock_responses[i] = { path?, status_code, headers, body_inline | body_file }`.
569 /// Used by kreuzcrawl-style fixtures that mock multiple URLs per fixture (e.g. a page +
570 /// `/robots.txt` + `/sitemap.xml`).
571 #[serde(default)]
572 input: Option<serde_json::Value>,
573}
574
575/// A single resolved mock response with its serving path and whether it is a host-root path.
576struct ResolvedRoute {
577 /// The namespaced path under which this route is registered in the shared server,
578 /// e.g. `/fixtures/robots_disallow_path` or `/fixtures/robots_disallow_path/assets/style.css`.
579 path: String,
580 /// The original fixture-declared path (before namespacing), e.g. `/robots.txt` or `/assets/style.css`.
581 original_path: String,
582 response: MockResponse,
583 /// Body bytes (pre-loaded from body_file or body_inline).
584 body_bytes: Vec<u8>,
585}
586
587/// Returns true for paths that the crawler fetches from the host root rather than
588/// under a fixture-namespaced prefix. These require a dedicated per-fixture listener.
589fn is_host_root_path(path: &str) -> bool {
590 path.starts_with("/robots") || path.starts_with("/sitemap")
591}
592
593impl Fixture {
594 /// Bridge both schemas into a unified MockResponse.
595 fn as_mock_response(&self) -> Option<MockResponse> {
596 if let Some(mock) = &self.mock_response {
597 return Some(MockResponse {
598 status: mock.status,
599 body: mock.body.clone(),
600 stream_chunks: mock.stream_chunks.clone(),
601 headers: mock.headers.clone(),
602 delay_ms: mock.delay_ms,
603 });
604 }
605 if let Some(http) = &self.http {
606 return Some(MockResponse {
607 status: http.expected_response.status_code,
608 body: http.expected_response.body.clone(),
609 stream_chunks: None,
610 headers: http.expected_response.headers.clone(),
611 delay_ms: None,
612 });
613 }
614 None
615 }
616
617 /// Resolve every mock response this fixture defines.
618 ///
619 /// Returns single-element output for the legacy `mock_response` / `http` schemas, and one
620 /// element per array entry for the kreuzcrawl-style `input.mock_responses` schema. For the
621 /// array schema, each element may declare its own `path` (defaulting to `/fixtures/{id}`),
622 /// and the body source can be either `body_inline` (string) or `body_file` (path relative
623 /// to the fixtures dir, loaded at startup).
624 ///
625 /// Paths that are NOT host-root paths (e.g. `/robots.txt`, `/sitemap.xml`) are namespaced
626 /// under `/fixtures/{id}` so that fixtures sharing common paths (like `/`, `/a`, `/b`) do
627 /// not collide in the shared route table.
628 fn as_routes(&self, fixtures_dir: &Path) -> Vec<ResolvedRoute> {
629 let mut routes = Vec::new();
630 let default_path = format!("/fixtures/{}", self.id);
631
632 if let Some(mock) = self.as_mock_response() {
633 let body_bytes = mock
634 .body
635 .as_ref()
636 .map(|b| match b {
637 serde_json::Value::String(s) => s.as_bytes().to_vec(),
638 other => serde_json::to_string(other).unwrap_or_default().into_bytes(),
639 })
640 .unwrap_or_default();
641 routes.push(ResolvedRoute {
642 path: default_path.clone(),
643 original_path: default_path.clone(),
644 response: mock,
645 body_bytes,
646 });
647 }
648
649 if let Some(input) = &self.input {
650 if let Some(arr) = input.get("mock_responses").and_then(|v| v.as_array()) {
651 for entry in arr {
652 let original_path = entry
653 .get("path")
654 .and_then(|v| v.as_str())
655 .unwrap_or("/")
656 .to_string();
657
658 // Namespace under /fixtures/<id> unless this is a host-root path.
659 let namespaced_path = if is_host_root_path(&original_path) {
660 original_path.clone()
661 } else if original_path == "/" {
662 format!("/fixtures/{}", self.id)
663 } else {
664 format!("/fixtures/{}{}", self.id, original_path)
665 };
666
667 let status: u16 = entry.get("status_code").and_then(|v| v.as_u64()).unwrap_or(200) as u16;
668 let headers: HashMap<String, String> = entry
669 .get("headers")
670 .and_then(|v| v.as_object())
671 .map(|h| {
672 h.iter()
673 .map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string()))
674 .collect()
675 })
676 .unwrap_or_default();
677
678 // Load body bytes — use read() not read_to_string() to support binary files.
679 let body_bytes: Vec<u8> = if let Some(inline) = entry.get("body_inline").and_then(|v| v.as_str()) {
680 inline.as_bytes().to_vec()
681 } else if let Some(file) = entry.get("body_file").and_then(|v| v.as_str()) {
682 // body_file is resolved relative to `<fixtures>/responses/` first,
683 // falling back to `<fixtures>/` for projects that store body assets at
684 // the fixtures root rather than under a `responses/` subdir.
685 let candidates = [fixtures_dir.join("responses").join(file), fixtures_dir.join(file)];
686 let mut loaded = None;
687 for abs in &candidates {
688 if let Ok(bytes) = std::fs::read(abs) {
689 loaded = Some(bytes);
690 break;
691 }
692 }
693 match loaded {
694 Some(b) => b,
695 None => {
696 eprintln!(
697 "warning: cannot read body_file {} (tried {} and {})",
698 file,
699 candidates[0].display(),
700 candidates[1].display()
701 );
702 Vec::new()
703 }
704 }
705 } else {
706 Vec::new()
707 };
708
709 let delay_ms = entry.get("delay_ms").and_then(|v| v.as_u64());
710
711 routes.push(ResolvedRoute {
712 path: namespaced_path,
713 original_path,
714 response: MockResponse {
715 status,
716 body: None,
717 stream_chunks: None,
718 headers,
719 delay_ms,
720 },
721 body_bytes,
722 });
723 }
724 }
725 }
726
727 routes
728 }
729}
730
731// ---------------------------------------------------------------------------
732// Route table
733// ---------------------------------------------------------------------------
734
735#[derive(Clone, Debug)]
736struct MockRoute {
737 status: u16,
738 body: Vec<u8>,
739 /// Whether this route serves a streaming SSE response.
740 /// True even when `stream_chunks` is empty (e.g. an empty stream still sends
741 /// `data: [DONE]\n\n` with the correct `content-type: text/event-stream` header).
742 is_streaming: bool,
743 stream_chunks: Vec<String>,
744 headers: Vec<(String, String)>,
745 /// Optional artificial delay applied before the handler returns. When set,
746 /// the handler `tokio::time::sleep`s for this many milliseconds before
747 /// constructing the response — used by timeout-error fixtures to force
748 /// client-side request timeouts.
749 delay_ms: Option<u64>,
750}
751
752type RouteTable = Arc<HashMap<String, MockRoute>>;
753
754// ---------------------------------------------------------------------------
755// Axum handler
756// ---------------------------------------------------------------------------
757
758async fn handle_request(State(routes): State<RouteTable>, req: Request<Body>) -> Response {
759 let path = req.uri().path().to_owned();
760
761 // Try exact match first
762 if let Some(route) = routes.get(&path) {
763 if let Some(delay_ms) = route.delay_ms {
764 tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
765 }
766 return serve_route(route);
767 }
768
769 // Try prefix match: find a route that is a prefix of the request path
770 // This allows /fixtures/basic_chat/v1/chat/completions to match /fixtures/basic_chat
771 for (route_path, route) in routes.iter() {
772 if path.starts_with(route_path) && (path.len() == route_path.len() || path.as_bytes()[route_path.len()] == b'/') {
773 if let Some(delay_ms) = route.delay_ms {
774 tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
775 }
776 return serve_route(route);
777 }
778 }
779
780 Response::builder()
781 .status(StatusCode::NOT_FOUND)
782 .body(Body::from(format!("No mock route for {path}")))
783 .unwrap()
784 .into_response()
785}
786
787fn serve_route(route: &MockRoute) -> Response {
788 let status = StatusCode::from_u16(route.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
789
790 if route.is_streaming {
791 // Note: stream_chunks may be empty for an empty-stream fixture; we still emit
792 // `data: [DONE]\n\n` with the correct SSE headers so clients do not hang.
793 let mut sse = String::new();
794 for chunk in &route.stream_chunks {
795 sse.push_str("data: ");
796 sse.push_str(chunk);
797 sse.push_str("\n\n");
798 }
799 sse.push_str("data: [DONE]\n\n");
800
801 let mut builder = Response::builder()
802 .status(status)
803 .header("content-type", "text/event-stream")
804 .header("cache-control", "no-cache");
805 for (name, value) in &route.headers {
806 builder = builder.header(name, value);
807 }
808 return builder.body(Body::from(sse)).unwrap().into_response();
809 }
810
811 // Only set the default content-type if the fixture does not override it.
812 // Inspect the first non-whitespace byte to detect JSON vs binary vs plain text.
813 let has_content_type = route.headers.iter().any(|(k, _)| k.to_lowercase() == "content-type");
814 let mut builder = Response::builder().status(status);
815 if !has_content_type {
816 let first_nonws = route.body.iter().find(|&&b| b != b' ' && b != b'\t' && b != b'\n' && b != b'\r');
817 let default_ct = match first_nonws {
818 Some(&b'{') | Some(&b'[') => "application/json",
819 _ => "text/plain",
820 };
821 builder = builder.header("content-type", default_ct);
822 }
823 for (name, value) in &route.headers {
824 // Skip content-encoding headers — the mock server returns uncompressed bodies.
825 // Sending a content-encoding without actually encoding the body would cause
826 // clients to fail decompression.
827 if name.to_lowercase() == "content-encoding" {
828 continue;
829 }
830 // The <<absent>> sentinel means this header must NOT be present in the
831 // real server response — do not emit it from the mock server either.
832 if value == "<<absent>>" {
833 continue;
834 }
835 // Replace the <<uuid>> sentinel with a real UUID v4 so clients can
836 // assert the header value matches the UUID pattern.
837 if value == "<<uuid>>" {
838 let uuid = format!(
839 "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
840 rand_u32(),
841 rand_u16(),
842 rand_u16() & 0x0fff,
843 (rand_u16() & 0x3fff) | 0x8000,
844 rand_u48(),
845 );
846 builder = builder.header(name, uuid);
847 continue;
848 }
849 builder = builder.header(name, value);
850 }
851 builder.body(Body::from(route.body.clone())).unwrap().into_response()
852}
853
854/// Generate a pseudo-random u32 using the current time nanoseconds.
855fn rand_u32() -> u32 {
856 use std::time::{SystemTime, UNIX_EPOCH};
857 let ns = SystemTime::now()
858 .duration_since(UNIX_EPOCH)
859 .map(|d| d.subsec_nanos())
860 .unwrap_or(0);
861 ns ^ (ns.wrapping_shl(13)) ^ (ns.wrapping_shr(17))
862}
863
864fn rand_u16() -> u16 {
865 (rand_u32() & 0xffff) as u16
866}
867
868fn rand_u48() -> u64 {
869 ((rand_u32() as u64) << 16) | (rand_u16() as u64)
870}
871
872// ---------------------------------------------------------------------------
873// Fixture loading
874// ---------------------------------------------------------------------------
875
876/// Intermediate fixture-loading result: shared route table plus per-fixture host-root data.
877struct LoadedRoutes {
878 /// Routes namespaced under /fixtures/<id> for the shared listener.
879 shared: HashMap<String, MockRoute>,
880 /// For each fixture that has host-root routes: fixture_id → route table at host root.
881 per_fixture: HashMap<String, HashMap<String, MockRoute>>,
882}
883
884fn load_routes(fixtures_dir: &Path) -> LoadedRoutes {
885 let mut shared = HashMap::new();
886 let mut per_fixture: HashMap<String, HashMap<String, MockRoute>> = HashMap::new();
887 load_routes_recursive(fixtures_dir, fixtures_dir, &mut shared, &mut per_fixture);
888 LoadedRoutes { shared, per_fixture }
889}
890
891fn load_routes_recursive(
892 dir: &Path,
893 fixtures_root: &Path,
894 shared: &mut HashMap<String, MockRoute>,
895 per_fixture: &mut HashMap<String, HashMap<String, MockRoute>>,
896) {
897 let entries = match std::fs::read_dir(dir) {
898 Ok(e) => e,
899 Err(err) => {
900 eprintln!("warning: cannot read directory {}: {err}", dir.display());
901 return;
902 }
903 };
904
905 let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
906 paths.sort();
907
908 for path in paths {
909 if path.is_dir() {
910 load_routes_recursive(&path, fixtures_root, shared, per_fixture);
911 } else if path.extension().is_some_and(|ext| ext == "json") {
912 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
913 if filename == "schema.json" || filename.starts_with('_') {
914 continue;
915 }
916 let content = match std::fs::read_to_string(&path) {
917 Ok(c) => c,
918 Err(err) => {
919 eprintln!("warning: cannot read {}: {err}", path.display());
920 continue;
921 }
922 };
923 let fixtures: Vec<Fixture> = if content.trim_start().starts_with('[') {
924 match serde_json::from_str(&content) {
925 Ok(v) => v,
926 Err(err) => {
927 eprintln!("warning: cannot parse {}: {err}", path.display());
928 continue;
929 }
930 }
931 } else {
932 match serde_json::from_str::<Fixture>(&content) {
933 Ok(f) => vec![f],
934 Err(err) => {
935 eprintln!("warning: cannot parse {}: {err}", path.display());
936 continue;
937 }
938 }
939 };
940
941 for fixture in fixtures {
942 let resolved_routes = fixture.as_routes(fixtures_root);
943 // A fixture needs host-root routing if either:
944 // 1. it serves a path the crawler fetches at host root (/robots*, /sitemap*), OR
945 // 2. it returns a 3xx Location header pointing to a host-root path inside the
946 // same fixture (the engine resolves the Location against the host, not the
947 // /fixtures/<id>/ namespace, so host-root serving is required for the
948 // follow-up GET to hit the correct route).
949 let has_intra_fixture_redirect = resolved_routes.iter().any(|r| {
950 // 3xx with relative Location header
951 let location_redirect = (300..400).contains(&r.response.status)
952 && r.response.headers.iter().any(|(name, value)| {
953 name.eq_ignore_ascii_case("location") && value.starts_with('/')
954 });
955 // Refresh header with url=/...
956 let refresh_redirect = r.response.headers.iter().any(|(name, value)| {
957 if !name.eq_ignore_ascii_case("refresh") {
958 return false;
959 }
960 let lower = value.to_ascii_lowercase();
961 lower
962 .find("url=")
963 .map(|idx| value[idx + 4..].trim_start().starts_with('/'))
964 .unwrap_or(false)
965 });
966 // HTML meta-refresh tag pointing to /...
967 let body_lower_lossy = String::from_utf8_lossy(&r.body_bytes).to_ascii_lowercase();
968 let meta_refresh = body_lower_lossy
969 .split("http-equiv=\"refresh\"")
970 .nth(1)
971 .and_then(|s| s.split("content=").nth(1))
972 .map(|s| {
973 let trimmed = s.trim_start_matches(['"', '\'']);
974 trimmed.contains("url=/")
975 })
976 .unwrap_or(false);
977 location_redirect || refresh_redirect || meta_refresh
978 });
979 let has_host_root = has_intra_fixture_redirect
980 || resolved_routes.iter().any(|r| is_host_root_path(&r.original_path));
981
982 for resolved in resolved_routes {
983 let is_streaming = resolved.response.stream_chunks.is_some();
984 let stream_chunks = resolved.response
985 .stream_chunks
986 .unwrap_or_default()
987 .into_iter()
988 .map(|c| match c {
989 serde_json::Value::String(s) => s,
990 other => serde_json::to_string(&other).unwrap_or_default(),
991 })
992 .collect();
993 let mut headers: Vec<(String, String)> = resolved.response.headers.into_iter().collect();
994 headers.sort_by(|a, b| a.0.cmp(&b.0));
995
996 let mock_route = MockRoute {
997 status: resolved.response.status,
998 body: resolved.body_bytes,
999 is_streaming,
1000 stream_chunks,
1001 headers,
1002 delay_ms: resolved.response.delay_ms,
1003 };
1004
1005 // Always insert into the shared namespaced table.
1006 shared.insert(resolved.path.clone(), mock_route.clone());
1007
1008 // For fixtures with host-root routes, also build a per-fixture table
1009 // where routes are mounted at their original (un-namespaced) paths.
1010 if has_host_root {
1011 per_fixture
1012 .entry(fixture.id.clone())
1013 .or_default()
1014 .insert(resolved.original_path.clone(), mock_route);
1015 }
1016 }
1017 }
1018 }
1019 }
1020}
1021
1022// ---------------------------------------------------------------------------
1023// Entry point
1024// ---------------------------------------------------------------------------
1025
1026#[tokio::main]
1027async fn main() {
1028 let fixtures_dir_arg = std::env::args().nth(1).unwrap_or_else(|| "../../fixtures".to_string());
1029 let fixtures_dir = Path::new(&fixtures_dir_arg);
1030
1031 let loaded = load_routes(fixtures_dir);
1032 eprintln!("mock-server: loaded {} shared routes from {}", loaded.shared.len(), fixtures_dir.display());
1033
1034 // Shared namespaced server.
1035 let shared_table: RouteTable = Arc::new(loaded.shared);
1036 let shared_app = Router::new().fallback(handle_request).with_state(shared_table);
1037
1038 let shared_listener = TcpListener::bind("127.0.0.1:0")
1039 .await
1040 .expect("mock-server: failed to bind shared port");
1041 let shared_addr: SocketAddr = shared_listener.local_addr().expect("mock-server: failed to get shared local addr");
1042
1043 // Per-fixture listeners for host-root routes (robots.txt, sitemap.xml, etc.).
1044 // Sorted by fixture_id for deterministic output.
1045 let mut fixture_ids: Vec<String> = loaded.per_fixture.keys().cloned().collect();
1046 fixture_ids.sort();
1047
1048 let mut fixture_urls: HashMap<String, String> = HashMap::new();
1049 for fixture_id in &fixture_ids {
1050 let routes = loaded.per_fixture[fixture_id].clone();
1051 eprintln!("mock-server: fixture {} has {} host-root routes", fixture_id, routes.len());
1052 let table: RouteTable = Arc::new(routes);
1053 let app = Router::new().fallback(handle_request).with_state(table);
1054 let listener = TcpListener::bind("127.0.0.1:0")
1055 .await
1056 .expect("mock-server: failed to bind per-fixture port");
1057 let addr: SocketAddr = listener.local_addr().expect("mock-server: failed to get per-fixture local addr");
1058 fixture_urls.insert(fixture_id.clone(), format!("http://{addr}"));
1059 tokio::spawn(async move {
1060 axum::serve(listener, app).await.expect("mock-server: per-fixture server error");
1061 });
1062 }
1063
1064 // Print the shared URL so the parent process can read it.
1065 println!("MOCK_SERVER_URL=http://{shared_addr}");
1066
1067 // Always print MOCK_SERVERS=... (empty `{}` when there are no host-root
1068 // fixtures) so parent parsers — which read until they see this sentinel
1069 // line — never block on a readline that never comes.
1070 let mut sorted_pairs: Vec<(&String, &String)> = fixture_urls.iter().collect();
1071 sorted_pairs.sort_by_key(|(k, _)| k.as_str());
1072 let json_entries: Vec<String> = sorted_pairs
1073 .iter()
1074 .map(|(k, v)| format!("\"{}\":\"{}\"", k, v))
1075 .collect();
1076 println!("MOCK_SERVERS={{{}}}", json_entries.join(","));
1077
1078 // Flush stdout explicitly so the parent does not block waiting.
1079 use std::io::Write;
1080 std::io::stdout().flush().expect("mock-server: failed to flush stdout");
1081
1082 // Spawn the shared server in the background.
1083 tokio::spawn(async move {
1084 axum::serve(shared_listener, shared_app).await.expect("mock-server: shared server error");
1085 });
1086
1087 // Block until stdin is closed — the parent process controls lifetime.
1088 let stdin = io::stdin();
1089 let mut lines = stdin.lock().lines();
1090 while lines.next().is_some() {}
1091}
1092"#
1093}
1094
1095#[cfg(test)]
1096mod tests {
1097 use super::*;
1098
1099 #[test]
1100 fn render_mock_server_module_contains_struct_definition() {
1101 let out = render_mock_server_module();
1102 assert!(out.contains("pub struct MockRoute"));
1103 assert!(out.contains("pub struct MockServer"));
1104 }
1105
1106 #[test]
1107 fn render_mock_server_binary_contains_main() {
1108 let out = render_mock_server_binary();
1109 assert!(out.contains("async fn main()"));
1110 assert!(out.contains("MOCK_SERVER_URL=http://"));
1111 }
1112
1113 #[test]
1114 fn render_common_module_has_expected_symbols() {
1115 let src = render_common_module();
1116 assert!(src.contains("pub fn mock_server_url"), "missing mock_server_url");
1117 assert!(src.contains("OnceLock"), "missing OnceLock");
1118 assert!(src.contains("MOCK_SERVER_URL"), "missing MOCK_SERVER_URL");
1119 assert!(src.contains("MOCK_SERVERS"), "missing MOCK_SERVERS");
1120 assert!(src.contains("serde_json"), "missing serde_json parsing");
1121 }
1122}