Skip to main content

alef_e2e/
fixture.rs

1//! Fixture loading, validation, and grouping for e2e test generation.
2
3use anyhow::{Context, Result, bail};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::path::Path;
7
8/// Mock HTTP response for testing HTTP clients.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct MockResponse {
11    /// HTTP status code.
12    pub status: u16,
13    /// JSON response body (for non-streaming responses).
14    #[serde(default)]
15    pub body: Option<serde_json::Value>,
16    /// SSE stream chunks (for streaming responses).
17    /// Each chunk is a JSON object sent as `data: <chunk>\n\n`.
18    #[serde(default)]
19    pub stream_chunks: Option<Vec<serde_json::Value>>,
20    /// Response headers to apply to the mock response.
21    /// Bridged from `http.expected_response.headers` for consumer-style fixtures.
22    #[serde(default)]
23    pub headers: HashMap<String, String>,
24}
25
26/// Visitor specification for visitor pattern tests.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct VisitorSpec {
29    /// Map of callback method name to action.
30    pub callbacks: HashMap<String, CallbackAction>,
31}
32
33/// Action a visitor callback should take.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(tag = "action")]
36pub enum CallbackAction {
37    /// Return VisitResult::Skip.
38    #[serde(rename = "skip")]
39    Skip,
40    /// Return VisitResult::Continue.
41    #[serde(rename = "continue")]
42    Continue,
43    /// Return VisitResult::PreserveHtml.
44    #[serde(rename = "preserve_html")]
45    PreserveHtml,
46    /// Return VisitResult::Custom with static output.
47    #[serde(rename = "custom")]
48    Custom {
49        /// The static replacement string.
50        output: String,
51    },
52    /// Return VisitResult::Custom with template interpolation.
53    #[serde(rename = "custom_template")]
54    CustomTemplate {
55        /// Template with placeholders like {text}, {href}.
56        template: String,
57        /// How the generated visitor returns the rendered template to the host.
58        /// `Dict` (default) returns `{"custom": "..."}` (or per-language equivalent)
59        /// to hit the structured-result code path; `BareString` returns the raw
60        /// rendered string to hit the string-result code path. Both must produce
61        /// `VisitResult::Custom`.
62        #[serde(default)]
63        return_form: TemplateReturnForm,
64    },
65}
66
67/// How a `CustomTemplate` action returns its rendered value from the visitor.
68#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case")]
70pub enum TemplateReturnForm {
71    /// Return a host-native structured value (e.g. dict, hash, array, object)
72    /// carrying the rendered string under a `custom` key.
73    #[default]
74    Dict,
75    /// Return the rendered string directly, with no wrapper.
76    BareString,
77}
78
79/// Environment variable requirements for a smoke/live test fixture.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct FixtureEnv {
82    /// Name of the env var that holds the API key (e.g. `"OPENAI_API_KEY"`).
83    #[serde(default)]
84    pub api_key_var: Option<String>,
85}
86
87/// A single e2e test fixture.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct Fixture {
90    /// Unique identifier (used as test function name).
91    pub id: String,
92    /// Optional category (defaults to parent directory name).
93    #[serde(default)]
94    pub category: Option<String>,
95    /// Human-readable description.
96    pub description: String,
97    /// Optional tags for filtering.
98    #[serde(default)]
99    pub tags: Vec<String>,
100    /// Skip directive.
101    #[serde(default)]
102    pub skip: Option<SkipDirective>,
103    /// Environment variable requirements (used by smoke/live tests).
104    #[serde(default)]
105    pub env: Option<FixtureEnv>,
106    /// Named call config to use (references `[e2e.calls.<name>]`).
107    /// When omitted, uses the default `[e2e.call]`.
108    #[serde(default)]
109    pub call: Option<String>,
110    /// Input data passed to the function under test.
111    #[serde(default)]
112    pub input: serde_json::Value,
113    /// Optional mock HTTP response for testing HTTP clients.
114    #[serde(default)]
115    pub mock_response: Option<MockResponse>,
116    /// Optional visitor specification for visitor pattern tests.
117    #[serde(default)]
118    pub visitor: Option<VisitorSpec>,
119    /// List of assertions to check.
120    #[serde(default)]
121    pub assertions: Vec<Assertion>,
122    /// Source file path (populated during loading).
123    #[serde(skip)]
124    pub source: String,
125    /// HTTP server test specification. When present, this fixture tests
126    /// an HTTP handler rather than a function call.
127    #[serde(default)]
128    pub http: Option<HttpFixture>,
129}
130
131/// HTTP server test specification.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct HttpFixture {
134    /// Handler/route definition.
135    pub handler: HttpHandler,
136    /// The HTTP request to send.
137    pub request: HttpRequest,
138    /// Expected response.
139    pub expected_response: HttpExpectedResponse,
140}
141
142/// Handler/route definition for HTTP server tests.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct HttpHandler {
145    /// Route pattern (e.g., "/users/{user_id}").
146    pub route: String,
147    /// HTTP method (GET, POST, PUT, etc.).
148    pub method: String,
149    /// JSON Schema for request body validation.
150    #[serde(default)]
151    pub body_schema: Option<serde_json::Value>,
152    /// Parameter schemas by source (path, query, header, cookie).
153    #[serde(default)]
154    pub parameters: HashMap<String, HashMap<String, serde_json::Value>>,
155    /// Middleware configuration.
156    #[serde(default)]
157    pub middleware: Option<HttpMiddleware>,
158}
159
160/// HTTP request to send in a server test.
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct HttpRequest {
163    pub method: String,
164    pub path: String,
165    #[serde(default)]
166    pub headers: HashMap<String, String>,
167    #[serde(default)]
168    pub query_params: HashMap<String, serde_json::Value>,
169    #[serde(default)]
170    pub cookies: HashMap<String, String>,
171    #[serde(default)]
172    pub body: Option<serde_json::Value>,
173    #[serde(default)]
174    pub content_type: Option<String>,
175}
176
177/// Expected HTTP response specification.
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct HttpExpectedResponse {
180    pub status_code: u16,
181    /// Exact body match.
182    #[serde(default)]
183    pub body: Option<serde_json::Value>,
184    /// Partial body match (only check specified fields).
185    #[serde(default)]
186    pub body_partial: Option<serde_json::Value>,
187    /// Header expectations. Special tokens: `<<uuid>>`, `<<present>>`, `<<absent>>`.
188    #[serde(default)]
189    pub headers: HashMap<String, String>,
190    /// Expected validation errors (for 422 responses).
191    #[serde(default)]
192    pub validation_errors: Option<Vec<ValidationErrorExpectation>>,
193}
194
195/// Expected validation error entry.
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct ValidationErrorExpectation {
198    pub loc: Vec<String>,
199    pub msg: String,
200    #[serde(rename = "type")]
201    pub error_type: String,
202}
203
204/// CORS policy configuration for HTTP handler tests.
205#[derive(Debug, Clone, Default, Serialize, Deserialize)]
206pub struct CorsConfig {
207    /// Allowed origins (e.g. `["https://example.com"]`). Empty means deny all.
208    #[serde(default)]
209    pub allow_origins: Vec<String>,
210    /// Allowed HTTP methods (e.g. `["GET", "POST"]`). Empty means deny all.
211    #[serde(default)]
212    pub allow_methods: Vec<String>,
213    /// Allowed request headers (e.g. `["Content-Type"]`). Empty means deny all.
214    #[serde(default)]
215    pub allow_headers: Vec<String>,
216    /// Exposed response headers (e.g. `["X-Total-Count"]`).
217    #[serde(default)]
218    pub expose_headers: Vec<String>,
219    /// `Access-Control-Max-Age` value in seconds.
220    #[serde(default)]
221    pub max_age: Option<u64>,
222    /// Whether to allow credentials.
223    #[serde(default)]
224    pub allow_credentials: bool,
225}
226
227/// A single static file entry for the static-files middleware.
228#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct StaticFile {
230    /// Relative path within the served directory (e.g. `"hello.txt"`).
231    pub path: String,
232    /// File content (plain text or HTML string).
233    pub content: String,
234}
235
236/// Static-files middleware configuration for HTTP handler tests.
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct StaticFilesConfig {
239    /// URL route prefix (e.g. `"/public"`).
240    pub route_prefix: String,
241    /// Files to write to the temporary directory.
242    #[serde(default)]
243    pub files: Vec<StaticFile>,
244    /// Whether to serve `index.html` for directory requests.
245    #[serde(default)]
246    pub index_file: bool,
247    /// `Cache-Control` header value to apply.
248    #[serde(default)]
249    pub cache_control: Option<String>,
250}
251
252/// Middleware configuration for HTTP handler tests.
253#[derive(Debug, Clone, Default, Serialize, Deserialize)]
254pub struct HttpMiddleware {
255    #[serde(default)]
256    pub jwt_auth: Option<serde_json::Value>,
257    #[serde(default)]
258    pub api_key_auth: Option<serde_json::Value>,
259    #[serde(default)]
260    pub compression: Option<serde_json::Value>,
261    #[serde(default)]
262    pub rate_limit: Option<serde_json::Value>,
263    #[serde(default)]
264    pub request_timeout: Option<serde_json::Value>,
265    #[serde(default)]
266    pub request_id: Option<serde_json::Value>,
267    /// CORS policy to apply via tower-http `CorsLayer`.
268    #[serde(default)]
269    pub cors: Option<CorsConfig>,
270    /// Static-files configuration to serve via tower-http `ServeDir`.
271    #[serde(default)]
272    pub static_files: Option<Vec<StaticFilesConfig>>,
273}
274
275/// Returns true for paths that the crawler fetches from the host root rather than under a
276/// fixture-namespaced prefix.  Mirrors the identical predicate in the standalone mock-server
277/// binary (`codegen/rust/mock_server.rs`).
278fn is_host_root_path(path: &str) -> bool {
279    path.starts_with("/robots") || path.starts_with("/sitemap")
280}
281
282impl Fixture {
283    /// Returns true if this is an HTTP server test fixture.
284    pub fn is_http_test(&self) -> bool {
285        self.http.is_some()
286    }
287
288    /// Returns true if this fixture requires a mock HTTP server.
289    /// This is true when either `mock_response` (liter-llm shape),
290    /// `http.expected_response` (consumer shape), or the kreuzcrawl-style
291    /// `input.mock_responses` array is non-empty.
292    pub fn needs_mock_server(&self) -> bool {
293        if self.mock_response.is_some() || self.http.is_some() {
294            return true;
295        }
296        // kreuzcrawl-style: input.mock_responses array with at least one entry
297        self.input
298            .get("mock_responses")
299            .and_then(|v| v.as_array())
300            .map(|arr| !arr.is_empty())
301            .unwrap_or(false)
302    }
303
304    /// Returns the effective mock response for this fixture, bridging both schemas:
305    /// - liter-llm shape: `mock_response: { status, body, stream_chunks }`
306    /// - consumer shape: `http.expected_response: { status_code, body, headers }`
307    ///
308    /// Returns `None` if neither schema is present.
309    pub fn as_mock_response(&self) -> Option<MockResponse> {
310        if let Some(mock) = &self.mock_response {
311            return Some(mock.clone());
312        }
313        if let Some(http) = &self.http {
314            return Some(MockResponse {
315                status: http.expected_response.status_code,
316                body: http.expected_response.body.clone(),
317                stream_chunks: None,
318                headers: http.expected_response.headers.clone(),
319            });
320        }
321        None
322    }
323
324    /// Returns true if the mock response uses streaming (SSE).
325    pub fn is_streaming_mock(&self) -> bool {
326        self.mock_response
327            .as_ref()
328            .and_then(|m| m.stream_chunks.as_ref())
329            .map(|c| !c.is_empty())
330            .unwrap_or(false)
331    }
332
333    /// Returns true if any of this fixture's mock response paths are host-root paths —
334    /// i.e. paths the crawler fetches from the host root rather than under a
335    /// fixture-namespaced prefix.  Mirrors the `is_host_root_path` predicate in the
336    /// standalone mock-server binary (`codegen/rust/mock_server.rs`).
337    ///
338    /// Host-root fixtures get a dedicated per-fixture listener and their base URL is
339    /// published in the `MOCK_SERVERS={"fixture_id":"http://..."}` JSON line.
340    pub fn has_host_root_route(&self) -> bool {
341        // Array schema: input.mock_responses[*].path
342        if let Some(arr) = self.input.get("mock_responses").and_then(|v| v.as_array()) {
343            // Direct host-root paths (/robots*, /sitemap*).
344            if arr.iter().any(|entry| {
345                entry
346                    .get("path")
347                    .and_then(|v| v.as_str())
348                    .map(is_host_root_path)
349                    .unwrap_or(false)
350            }) {
351                return true;
352            }
353            // Any response that triggers an intra-fixture redirect to a host-root path:
354            // the engine resolves the redirect target against the origin (not the
355            // /fixtures/<id>/ namespace), so the fixture must serve at host root for the
356            // follow-up GET to hit the correct route. Three trigger shapes are detected:
357            //   - 3xx with Location: /...
358            //   - any status with Refresh: <s>;url=/...
359            //   - 200 HTML with <meta http-equiv="refresh" content="...url=/...">
360            return arr.iter().any(|entry| {
361                let status = entry.get("status_code").and_then(|v| v.as_u64()).unwrap_or(0);
362                let headers = entry.get("headers").and_then(|v| v.as_object());
363                let location_redirect = (300..400).contains(&status)
364                    && headers
365                        .map(|hdrs| {
366                            hdrs.iter().any(|(name, value)| {
367                                name.eq_ignore_ascii_case("location")
368                                    && value.as_str().is_some_and(|s| s.starts_with('/'))
369                            })
370                        })
371                        .unwrap_or(false);
372                let refresh_redirect = headers
373                    .map(|hdrs| {
374                        hdrs.iter().any(|(name, value)| {
375                            if !name.eq_ignore_ascii_case("refresh") {
376                                return false;
377                            }
378                            value
379                                .as_str()
380                                .and_then(|s| s.to_ascii_lowercase().find("url=").map(|i| (s.to_owned(), i)))
381                                .map(|(s, idx)| s[idx + 4..].trim_start().starts_with('/'))
382                                .unwrap_or(false)
383                        })
384                    })
385                    .unwrap_or(false);
386                let meta_refresh = entry
387                    .get("body_inline")
388                    .and_then(|v| v.as_str())
389                    .map(|body| {
390                        let lower = body.to_ascii_lowercase();
391                        lower
392                            .split("http-equiv=\"refresh\"")
393                            .nth(1)
394                            .and_then(|s| s.split("content=").nth(1))
395                            .map(|s| s.trim_start_matches(['"', '\'']).contains("url=/"))
396                            .unwrap_or(false)
397                    })
398                    .unwrap_or(false);
399                // Inline HTML anchor with host-absolute target (`<a href="/page1">`)
400                // — same trigger as the runtime mock-server `has_inline_host_link`
401                // detection.  Without this, language-specific e2e codegens for
402                // multi-page crawl fixtures emit the shared `/fixtures/<id>/` URL
403                // and the crawl engine then resolves linked `/page` paths against
404                // the host root, 404'ing against the namespaced shared listener.
405                let inline_host_link = entry
406                    .get("body_inline")
407                    .and_then(|v| v.as_str())
408                    .map(|body| body.contains("href=\"/") || body.contains("href='/"))
409                    .unwrap_or(false);
410                location_redirect || refresh_redirect || meta_refresh || inline_host_link
411            });
412        }
413        false
414    }
415
416    /// Get the resolved category (explicit or from source directory).
417    pub fn resolved_category(&self) -> String {
418        self.category.clone().unwrap_or_else(|| {
419            Path::new(&self.source)
420                .parent()
421                .and_then(|p| p.file_name())
422                .and_then(|n| n.to_str())
423                .unwrap_or("default")
424                .to_string()
425        })
426    }
427}
428
429/// Skip directive for conditionally excluding fixtures.
430#[derive(Debug, Clone, Serialize, Deserialize)]
431pub struct SkipDirective {
432    /// Languages to skip (empty means skip all).
433    #[serde(default)]
434    pub languages: Vec<String>,
435    /// Human-readable reason for skipping.
436    #[serde(default)]
437    pub reason: Option<String>,
438}
439
440impl SkipDirective {
441    /// Check if this fixture should be skipped for a given language.
442    pub fn should_skip(&self, language: &str) -> bool {
443        self.languages.is_empty() || self.languages.iter().any(|l| l == language)
444    }
445}
446
447/// A single assertion in a fixture.
448#[derive(Debug, Clone, Default, Serialize, Deserialize)]
449pub struct Assertion {
450    /// Assertion type (equals, contains, not_empty, error, etc.).
451    #[serde(rename = "type")]
452    pub assertion_type: String,
453    /// Field path to access on the result (dot-separated).
454    #[serde(default)]
455    pub field: Option<String>,
456    /// Expected value (string, number, bool, or array depending on type).
457    #[serde(default)]
458    pub value: Option<serde_json::Value>,
459    /// Expected values (for contains_all, contains_any).
460    #[serde(default)]
461    pub values: Option<Vec<serde_json::Value>>,
462    /// Method name to call on the result (for method_result assertions).
463    #[serde(default)]
464    pub method: Option<String>,
465    /// Assertion check type for the method result (equals, is_true, is_false, greater_than_or_equal, count_min).
466    #[serde(default)]
467    pub check: Option<String>,
468    /// Arguments to pass to the method call (for method_result assertions).
469    #[serde(default)]
470    pub args: Option<serde_json::Value>,
471    /// Return type hint for C method_result codegen.
472    ///
473    /// Supported values:
474    /// - `"string"` — the method returns a heap-allocated `char*` that must be
475    ///   freed with `free()` after the assertion.  The generator emits
476    ///   `char* _r = call(); assert(...); free(_r);`.
477    ///
478    /// Defaults to primitive integer dispatch when absent.
479    #[serde(default)]
480    pub return_type: Option<String>,
481}
482
483/// A group of fixtures sharing the same category.
484#[derive(Debug, Clone)]
485pub struct FixtureGroup {
486    pub category: String,
487    pub fixtures: Vec<Fixture>,
488}
489
490/// Load all fixtures from a directory recursively.
491pub fn load_fixtures(dir: &Path) -> Result<Vec<Fixture>> {
492    let mut fixtures = Vec::new();
493    load_fixtures_recursive(dir, dir, &mut fixtures)?;
494
495    // Validate: check for duplicate IDs
496    let mut seen: HashMap<String, String> = HashMap::new();
497    for f in &fixtures {
498        if let Some(prev_source) = seen.get(&f.id) {
499            bail!(
500                "duplicate fixture ID '{}': found in '{}' and '{}'",
501                f.id,
502                prev_source,
503                f.source
504            );
505        }
506        seen.insert(f.id.clone(), f.source.clone());
507    }
508
509    // Sort by (category, id) for deterministic output
510    fixtures.sort_by(|a, b| {
511        let cat_cmp = a.resolved_category().cmp(&b.resolved_category());
512        cat_cmp.then_with(|| a.id.cmp(&b.id))
513    });
514
515    Ok(fixtures)
516}
517
518fn load_fixtures_recursive(base: &Path, dir: &Path, fixtures: &mut Vec<Fixture>) -> Result<()> {
519    let entries =
520        std::fs::read_dir(dir).with_context(|| format!("failed to read fixture directory: {}", dir.display()))?;
521
522    let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
523    paths.sort();
524
525    for path in paths {
526        if path.is_dir() {
527            load_fixtures_recursive(base, &path, fixtures)?;
528        } else if path.extension().is_some_and(|ext| ext == "json") {
529            let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
530            // Skip schema files and files starting with _
531            if filename == "schema.json" || filename.starts_with('_') {
532                continue;
533            }
534            let content = std::fs::read_to_string(&path)
535                .with_context(|| format!("failed to read fixture: {}", path.display()))?;
536            let relative = path.strip_prefix(base).unwrap_or(&path).to_string_lossy().to_string();
537
538            // Try parsing as array first, then as single fixture
539            let parsed: Vec<Fixture> = if content.trim_start().starts_with('[') {
540                serde_json::from_str(&content)
541                    .with_context(|| format!("failed to parse fixture array: {}", path.display()))?
542            } else {
543                let single: Fixture = serde_json::from_str(&content)
544                    .with_context(|| format!("failed to parse fixture: {}", path.display()))?;
545                vec![single]
546            };
547
548            for mut fixture in parsed {
549                fixture.source = relative.clone();
550                // Expand template expressions (e.g. `{{ repeat 'x' 10000 times }}`)
551                // in all JSON string values so generators emit the expanded values.
552                expand_json_templates(&mut fixture.input);
553                if let Some(ref mut http) = fixture.http {
554                    for (_, v) in http.request.headers.iter_mut() {
555                        *v = crate::escape::expand_fixture_templates(v);
556                    }
557                    if let Some(ref mut body) = http.request.body {
558                        expand_json_templates(body);
559                    }
560                }
561                normalize_assertions(&mut fixture);
562                fixtures.push(fixture);
563            }
564        }
565    }
566    Ok(())
567}
568
569/// Group fixtures by their resolved category.
570pub fn group_fixtures(fixtures: &[Fixture]) -> Vec<FixtureGroup> {
571    let mut groups: HashMap<String, Vec<Fixture>> = HashMap::new();
572    for f in fixtures {
573        groups.entry(f.resolved_category()).or_default().push(f.clone());
574    }
575    let mut result: Vec<FixtureGroup> = groups
576        .into_iter()
577        .map(|(category, fixtures)| FixtureGroup { category, fixtures })
578        .collect();
579    result.sort_by(|a, b| a.category.cmp(&b.category));
580    result
581}
582
583/// Normalize alias-style assertion fields that are not direct struct-field
584/// accesses on the call's result type but have well-defined semantic meanings.
585///
586/// Currently rewrites:
587/// - `pages_crawled` (with optional `crawl.` namespace prefix) → `count_equals`
588///   on the `pages` field. Semantically "the crawl produced N pages", which
589///   maps to `len(result.pages) == N` when the call's `result_fields` contain
590///   `pages` (i.e. the call returns a `CrawlResult`-shaped value).
591/// - `min_pages` (with optional `crawl.` namespace prefix) → `count_min` on the
592///   `pages` field. Used with `greater_than_or_equal` to assert a lower bound on
593///   the number of pages crawled.
594///
595/// This rewrite operates on the assertion *before* per-language codegen, so
596/// every backend benefits without per-backend changes. When the call's
597/// `result_fields` do not contain `pages`, the rewritten assertion falls
598/// through to the standard `is_valid_for_result` skip path, just like any
599/// other field that does not exist on the result type.
600fn normalize_assertions(fixture: &mut Fixture) {
601    for assertion in fixture.assertions.iter_mut() {
602        let Some(field) = assertion.field.as_deref() else {
603            continue;
604        };
605        // Tolerate both the bare alias and a `crawl.` namespace prefix that
606        // fixtures commonly use to group crawl-related assertions.
607        let bare = field.strip_prefix("crawl.").unwrap_or(field);
608        match bare {
609            "pages_crawled" => {
610                assertion.field = Some("pages".to_string());
611                match assertion.assertion_type.as_str() {
612                    "equals" => assertion.assertion_type = "count_equals".to_string(),
613                    "greater_than_or_equal" => assertion.assertion_type = "count_min".to_string(),
614                    "less_than_or_equal" => assertion.assertion_type = "count_max".to_string(),
615                    _ => {}
616                }
617            }
618            "min_pages" => {
619                assertion.field = Some("pages".to_string());
620                if assertion.assertion_type == "greater_than_or_equal" {
621                    assertion.assertion_type = "count_min".to_string();
622                }
623            }
624            _ => {}
625        }
626    }
627}
628
629/// Recursively expand fixture template expressions in all string values of a JSON tree.
630fn expand_json_templates(value: &mut serde_json::Value) {
631    match value {
632        serde_json::Value::String(s) => {
633            let expanded = crate::escape::expand_fixture_templates(s);
634            if expanded != *s {
635                *s = expanded;
636            }
637        }
638        serde_json::Value::Array(arr) => {
639            for item in arr {
640                expand_json_templates(item);
641            }
642        }
643        serde_json::Value::Object(map) => {
644            for (_, v) in map.iter_mut() {
645                expand_json_templates(v);
646            }
647        }
648        _ => {}
649    }
650}
651
652#[cfg(test)]
653mod tests {
654    use super::*;
655
656    #[test]
657    fn test_fixture_with_mock_response() {
658        let json = r#"{
659            "id": "test_chat",
660            "description": "Test chat",
661            "call": "chat",
662            "input": {"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}]},
663            "mock_response": {
664                "status": 200,
665                "body": {"choices": [{"message": {"content": "hello"}}]}
666            },
667            "assertions": [{"type": "not_error"}]
668        }"#;
669        let fixture: Fixture = serde_json::from_str(json).unwrap();
670        assert!(fixture.needs_mock_server());
671        assert!(!fixture.is_streaming_mock());
672        assert_eq!(fixture.mock_response.unwrap().status, 200);
673    }
674
675    #[test]
676    fn test_fixture_with_streaming_mock_response() {
677        let json = r#"{
678            "id": "test_stream",
679            "description": "Test streaming",
680            "input": {},
681            "mock_response": {
682                "status": 200,
683                "stream_chunks": [{"delta": "hello"}, {"delta": " world"}]
684            },
685            "assertions": []
686        }"#;
687        let fixture: Fixture = serde_json::from_str(json).unwrap();
688        assert!(fixture.needs_mock_server());
689        assert!(fixture.is_streaming_mock());
690    }
691
692    fn make_fixture_with_assertion(assertion_json: &str) -> Fixture {
693        let json = format!(
694            r#"{{
695                "id": "x",
696                "description": "x",
697                "input": {{}},
698                "assertions": [{assertion_json}]
699            }}"#,
700        );
701        serde_json::from_str(&json).unwrap()
702    }
703
704    #[test]
705    fn normalize_assertions_rewrites_pages_crawled_to_count_equals_on_pages() {
706        let mut fixture =
707            make_fixture_with_assertion(r#"{"type": "equals", "field": "crawl.pages_crawled", "value": 3}"#);
708        normalize_assertions(&mut fixture);
709        assert_eq!(fixture.assertions[0].assertion_type, "count_equals");
710        assert_eq!(fixture.assertions[0].field.as_deref(), Some("pages"));
711    }
712
713    #[test]
714    fn normalize_assertions_rewrites_bare_pages_crawled() {
715        let mut fixture = make_fixture_with_assertion(r#"{"type": "equals", "field": "pages_crawled", "value": 5}"#);
716        normalize_assertions(&mut fixture);
717        assert_eq!(fixture.assertions[0].assertion_type, "count_equals");
718        assert_eq!(fixture.assertions[0].field.as_deref(), Some("pages"));
719    }
720
721    #[test]
722    fn normalize_assertions_rewrites_pages_crawled_gte_to_count_min() {
723        let mut fixture = make_fixture_with_assertion(
724            r#"{"type": "greater_than_or_equal", "field": "crawl.pages_crawled", "value": 3}"#,
725        );
726        normalize_assertions(&mut fixture);
727        assert_eq!(fixture.assertions[0].assertion_type, "count_min");
728        assert_eq!(fixture.assertions[0].field.as_deref(), Some("pages"));
729    }
730
731    #[test]
732    fn normalize_assertions_rewrites_pages_crawled_lte_to_count_max() {
733        let mut fixture = make_fixture_with_assertion(
734            r#"{"type": "less_than_or_equal", "field": "crawl.pages_crawled", "value": 7}"#,
735        );
736        normalize_assertions(&mut fixture);
737        assert_eq!(fixture.assertions[0].assertion_type, "count_max");
738        assert_eq!(fixture.assertions[0].field.as_deref(), Some("pages"));
739    }
740
741    #[test]
742    fn normalize_assertions_rewrites_min_pages_to_count_min_on_pages() {
743        let mut fixture =
744            make_fixture_with_assertion(r#"{"type": "greater_than_or_equal", "field": "crawl.min_pages", "value": 2}"#);
745        normalize_assertions(&mut fixture);
746        assert_eq!(fixture.assertions[0].assertion_type, "count_min");
747        assert_eq!(fixture.assertions[0].field.as_deref(), Some("pages"));
748    }
749
750    #[test]
751    fn normalize_assertions_leaves_unrelated_fields_unchanged() {
752        let mut fixture = make_fixture_with_assertion(r#"{"type": "equals", "field": "content", "value": "hi"}"#);
753        normalize_assertions(&mut fixture);
754        assert_eq!(fixture.assertions[0].assertion_type, "equals");
755        assert_eq!(fixture.assertions[0].field.as_deref(), Some("content"));
756    }
757
758    #[test]
759    fn test_fixture_without_mock_response() {
760        let json = r#"{
761            "id": "test_no_mock",
762            "description": "No mock",
763            "input": {},
764            "assertions": []
765        }"#;
766        let fixture: Fixture = serde_json::from_str(json).unwrap();
767        assert!(!fixture.needs_mock_server());
768        assert!(!fixture.is_streaming_mock());
769    }
770
771    #[test]
772    fn has_host_root_route_true_for_robots_path() {
773        let json = r#"{
774            "id": "robots_disallow_path",
775            "description": "Robots fixture",
776            "input": {
777                "mock_responses": [
778                    {"path": "/robots.txt", "status_code": 200, "body_inline": "User-agent: *\nDisallow: /"},
779                    {"path": "/", "status_code": 200, "body_inline": "<html/>"}
780                ]
781            },
782            "assertions": []
783        }"#;
784        let fixture: Fixture = serde_json::from_str(json).unwrap();
785        assert!(fixture.has_host_root_route(), "expected true for /robots.txt path");
786    }
787
788    #[test]
789    fn has_host_root_route_true_for_sitemap_path() {
790        let json = r#"{
791            "id": "sitemap_index",
792            "description": "Sitemap fixture",
793            "input": {
794                "mock_responses": [
795                    {"path": "/sitemap.xml", "status_code": 200, "body_inline": "<?xml version='1.0'?>"},
796                    {"path": "/", "status_code": 200, "body_inline": "<html/>"}
797                ]
798            },
799            "assertions": []
800        }"#;
801        let fixture: Fixture = serde_json::from_str(json).unwrap();
802        assert!(fixture.has_host_root_route(), "expected true for /sitemap.xml path");
803    }
804
805    #[test]
806    fn has_host_root_route_false_for_data_json_path() {
807        let json = r#"{
808            "id": "data_endpoint",
809            "description": "Non-host-root fixture",
810            "input": {
811                "mock_responses": [
812                    {"path": "/data.json", "status_code": 200, "body_inline": "{}"}
813                ]
814            },
815            "assertions": []
816        }"#;
817        let fixture: Fixture = serde_json::from_str(json).unwrap();
818        assert!(!fixture.has_host_root_route(), "expected false for /data.json path");
819    }
820
821    #[test]
822    fn has_host_root_route_false_for_single_mock_response_schema() {
823        // Single mock_response schema (no input.mock_responses array) is never host-root.
824        let json = r#"{
825            "id": "basic_chat",
826            "description": "Basic chat",
827            "mock_response": {"status": 200, "body": {}},
828            "input": {},
829            "assertions": []
830        }"#;
831        let fixture: Fixture = serde_json::from_str(json).unwrap();
832        assert!(
833            !fixture.has_host_root_route(),
834            "expected false for single mock_response schema"
835        );
836    }
837
838    #[test]
839    fn has_host_root_route_false_for_empty_mock_responses() {
840        let json = r#"{
841            "id": "empty_responses",
842            "description": "No mock_responses",
843            "input": {},
844            "assertions": []
845        }"#;
846        let fixture: Fixture = serde_json::from_str(json).unwrap();
847        assert!(!fixture.has_host_root_route(), "expected false when no mock_responses");
848    }
849}