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